com.googlecode.blaisemath.style.AttributeSetCoder Maven / Gradle / Ivy
Show all versions of blaise-common Show documentation
package com.googlecode.blaisemath.style;
/*-
* #%L
* blaise-common
* --
* Copyright (C) 2014 - 2019 Elisha Peterson
* --
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* #L%
*/
import com.googlecode.blaisemath.primitive.Marker;
import com.googlecode.blaisemath.primitive.Anchor;
import com.google.common.base.Joiner;
import com.google.common.base.Splitter;
import com.google.common.collect.Maps;
import com.google.common.primitives.Doubles;
import com.google.common.primitives.Ints;
import com.googlecode.blaisemath.encode.ColorCoder;
import com.googlecode.blaisemath.util.Colors;
import com.googlecode.blaisemath.encode.FontCoder;
import com.googlecode.blaisemath.encode.InsetsCoder;
import com.googlecode.blaisemath.encode.Point2DCoder;
import com.googlecode.blaisemath.encode.PointCoder;
import com.googlecode.blaisemath.encode.Rectangle2DCoder;
import com.googlecode.blaisemath.encode.RectangleCoder;
import com.googlecode.blaisemath.encode.StringDecoder;
import com.googlecode.blaisemath.encode.StringEncoder;
import com.googlecode.blaisemath.encode.TypeConverter;
import java.awt.Color;
import java.awt.Font;
import java.awt.Insets;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import static java.util.Collections.emptyMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static java.util.Objects.requireNonNull;
import java.util.function.Function;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.checkerframework.checker.nullness.qual.Nullable;
/**
* Encode/decodes {@link AttributeSet}s as strings. The key-value pairs in the
* attribute set are encoded using colons and semicolons as separators, e.g. "a:2; b:3".
*
* The string is intended to be
* compatible with html/css, but some features of the {@code AttributeSet} cannot
* be encoded this way, so the operation is not invertible. Conversion to a
* string uses the following rules:
*
*
* - The attribute name is not used for conversion.
* - Only values of type Number, String, Color, Marker, and Anchor are supported.
* - Number, String, and Anchor values are converted in the obvious way.
* - Colors are converted to #RRGGBB or #AARRGGBB notation, using {@link Colors#encode(Color)}.
* - Marker values are persisted using their class name.
* - Null values are converted to the string "none".
*
*
* Conversion from a string uses the following rules:
*
*
* - The attribute name is not used for conversion.
* - If the value matches #RGB, #RRGGBB, or #AARRGGBB it is converted to a color.
* - A string value "none" is converted to a null value.
* - If a value can be parsed as an integer or double, it is converted to that type.
* - Otherwise, values are left as strings.
*
*
* Note that values of type Marker and Anchor are deserialized as strings rather
* than their previous type. Blaise supports having string values for these attributes
* wherever they are used.
*
* @author Elisha Peterson
*/
public class AttributeSetCoder implements StringEncoder, StringDecoder {
private static final Logger LOG = Logger.getLogger(AttributeSetCoder.class.getName());
//region CONFIGS
/** String used to represent null explicitly. */
private static final String NULL_STRING = "none";
/** Joins values into the result string */
private static final Joiner.MapJoiner CODER_JOINER = Joiner.on("; ")
.withKeyValueSeparator(":");
/** Functions used to encode specific types. Listed in order of type checks for encoding. */
private static final CoderMap CODERS = new CoderMap()
.put(Color.class, Colors::encode)
.put(Font.class, new FontCoder()::encode)
.put(Insets.class, new InsetsCoder()::encode)
.put(Point.class, new PointCoder()::encode)
.put(Point2D.class, new Point2DCoder()::encode)
.put(Rectangle.class, new RectangleCoder()::encode)
.put(Rectangle2D.class, new Rectangle2DCoder()::encode)
.put(Marker.class, v -> v.getClass().getSimpleName());
/** Splits key-value pairs in a string to decode */
private static final Splitter DECODER_PAIR_SPLITTER = Splitter.on(";")
.omitEmptyStrings().trimResults();
/** Splits key from value */
private static final Splitter DECODER_KEY_SPLITTER = Splitter.on(":")
.omitEmptyStrings().trimResults();
/** Functions used to encode specific types. Listed in order of type checks for decoding. */
private static final DecoderMap DECODERS = new DecoderMap()
.put(Integer.class, Integer::valueOf)
.put(Float.class, Float::valueOf)
.put(Double.class, Double::valueOf)
.put(Boolean.class, Boolean::valueOf)
.put(Anchor.class, Anchor::valueOf)
.put(String.class, s -> s)
.put(Color.class, Colors::decode)
.put(Font.class, Font::decode)
.put(Insets.class, new InsetsCoder()::decode)
.put(Point.class, new PointCoder()::decode)
.put(Point2D.class, new Point2DCoder()::decode)
.put(Rectangle.class, new RectangleCoder()::decode)
.put(Rectangle2D.class, new Rectangle2DCoder()::decode);
//endregion
/** Used in deserialization for custom type mapping */
private final Map> types;
/**
* Get default coder instance.
*/
public AttributeSetCoder() {
this(null);
}
/**
* Get coder instance where particular keys are associated with particular types,
* which allows decoding operations to generate the correct types in more cases.
* @param types types associated with keys
*/
public AttributeSetCoder(Map> types) {
this.types = types == null ? emptyMap() : types;
}
@Override
public String encode(AttributeSet style) {
requireNonNull(style);
Map props = Maps.newTreeMap();
style.getAttributes().forEach(s -> tryPut(props, s, style.get(s)));
return CODER_JOINER.join(props);
}
@Override
public AttributeSet decode(String str) {
requireNonNull(str);
// perform two separate splits instead of using MapSplitter to allow for duplicate keys
AttributeSet res = new AttributeSet();
List pairs = DECODER_PAIR_SPLITTER.splitToList(str);
for (String p : pairs) {
List kv = DECODER_KEY_SPLITTER.splitToList(p);
if (kv.size() != 2) {
LOG.log(Level.WARNING, "Invalid attribute string: {0}", str);
return res;
}
String key = kv.get(0);
String sval = kv.get(1);
Object val = NULL_STRING.equals(sval) ? null
: types.containsKey(key) ? decodeValue(sval, types.get(key))
: decodeValue(sval, Object.class);
res.put(key, val);
}
return res;
}
//region VALUE CONVERSION UTILS
/**
* Converts value from one type to another, with optional default.
* @param value value to convert
* @param targetType target type
* @param def default value
* @return value of target type if possible, else default; may return null if def is null
*/
static @Nullable X convertValue(@Nullable Object value, Class targetType, @Nullable X def) {
return TypeConverter.convert(value, targetType, def);
}
//endregion
//region ENCODE UTILS
/** Attempt to convert given value to a string and add to target map */
private static void tryPut(Map props, String key, Object value) {
try {
props.put(key, encodeValue(value));
} catch (UnsupportedOperationException x) {
LOG.log(Level.WARNING, "Cannot convert value " + value + " to string.", x);
}
}
/**
* Converts values to strings.
* @param val value to encode
* @return encoded value, or null if unable to encode
*/
static @Nullable String encodeValue(Object val) {
try {
if (val == null) {
return NULL_STRING;
} else if (CODERS.containsKey(val.getClass())) {
return (String) CODERS.get(val.getClass()).apply(val);
} else {
for (Class c : CODERS.keySet()) {
if (c.isAssignableFrom(val.getClass())) {
return (String) CODERS.get(c).apply(val);
}
}
}
return val+"";
} catch (Exception x) {
LOG.log(Level.WARNING, "Unable to convert "+val, x);
return null;
}
}
//endregion
//region DECODE UTILS
/**
* Decodes a string value as a target type.
* @param decoded type
* @param val string value
* @param type decoded type
* @return decoded value, or null if unable to decode
*/
@SuppressWarnings("unchecked")
static @Nullable X decodeValue(String val, Class type) {
requireNonNull(val);
String trim = val.trim();
try {
if (NULL_STRING.equals(val)) {
return null;
} else if (DECODERS.containsKey(type)) {
return DECODERS.apply(type, val);
} else if (ColorCoder.decodable(trim)) {
return (X) DECODERS.apply(Color.class, trim);
} else if (trim.matches("\\((.*),(.*)\\)") && trim.contains(".")) {
return (X) DECODERS.apply(Point2D.class, trim);
} else if (trim.matches("\\((.*),(.*)\\)")) {
return (X) DECODERS.apply(Point.class, trim);
} else if (trim.matches("rectangle\\((.*)\\)")) {
return (X) DECODERS.apply(Rectangle.class, trim);
} else if (trim.matches("rectangle2d\\((.*)\\)")) {
return (X) DECODERS.apply(Rectangle2D.class, trim);
}
Integer i = Ints.tryParse(trim);
if (type.isInstance(i)) {
return (X) i;
}
Double d = Doubles.tryParse(trim);
if (type.isInstance(d)) {
return (X) d;
}
if (type.isInstance(val)) {
return (X) val;
}
} catch (Exception x) {
LOG.log(Level.WARNING, "Unable to decode "+val+" as "+type, x);
return null;
}
LOG.log(Level.WARNING, "Unable to decode {0} as {1}", new Object[]{val, type});
return null;
}
//endregion
/** Utility type for storing coders */
private static class CoderMap extends LinkedHashMap {
private CoderMap put(Class type, Function toStr) {
super.put(type, toStr);
return this;
}
}
/** Utility type for storing decoders */
private static class DecoderMap extends LinkedHashMap {
private DecoderMap put(Class type, Function fromStr) {
super.put(type, fromStr);
return this;
}
private X apply(Class type, String key) {
return (X) get(type).apply(key);
}
}
}