package aima.core.util.math.geom;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Stack;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamConstants;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
import aima.core.util.math.geom.shapes.Circle2D;
import aima.core.util.math.geom.shapes.Ellipse2D;
import aima.core.util.math.geom.shapes.IGeometric2D;
import aima.core.util.math.geom.shapes.Line2D;
import aima.core.util.math.geom.shapes.Point2D;
import aima.core.util.math.geom.shapes.Polyline2D;
import aima.core.util.math.geom.shapes.Rect2D;
import aima.core.util.math.geom.shapes.TransformMatrix2D;
/**
* This class implements {@link IGroupParser} for a SVG map.
* the "g" element is used to define the group(s) that should be parsed.<br/>
*
* The parser only recognizes the following basic shapes:
* <ul>
* <li>rect</li>
* <li>line</li>
* <li>circle</li>
* <li>ellipse</li>
* <li>polyline</li>
* <li>polygon</li>
* </ul>
* In addition any number of grouping elements are allowed.<br/>
* For all elements only the coordinates and the transform attribute are used. This means that rounded corners etc. are ignored.
* Every element/shape can use the transform attribute. The following transformations may be used:<br/><br/>
* <ul>
* <li>translate</li>
* <li>scale</li>
* <li>rotate</li>
* </ul>
* To use the svg map with {@code CartesianPlot2D} it has to contain a "g" element with an id.<br/>
* See <a href="https://www.w3.org/TR/SVG/expanded-toc.html">w3c® SVG standard definition</a> for more information.<br/>
* <br/>
* During the process of parsing most of the time is spent in the {@link XMLStreamReader}.
* A known issue is a <code><!DOCTYPE ></code> element in the file. Removing this element can speed up the parsing significantly.
*
* @author Arno von Borries
* @author Jan Phillip Kretzschmar
* @author Andreas Walscheid
*
*/
public class SVGGroupParser implements IGroupParser {
private static final String GROUP_ELEMENT = "g";
private static final String CIRCLE_ELEMENT = "circle";
private static final String ELLIPSE_ELEMENT = "ellipse";
private static final String LINE_ELEMENT = "line";
private static final String POLYLINE_ELEMENT = "polyline";
private static final String POLYGON_ELEMENT = "polygon";
private static final String RECT_ELEMENT = "rect";
private static final String ID_ATTRIBUTE = "id";
private static final String TRANSFORM_ATTRIBUTE = "transform";
private static final String X_ATTRIBUTE = "x";
private static final String Y_ATTRIBUTE = "y";
private static final String CX_ATTRIBUTE = "cx";
private static final String CY_ATTRIBUTE = "cy";
private static final String X1_ATTRIBUTE = "x1";
private static final String Y1_ATTRIBUTE = "y1";
private static final String X2_ATTRIBUTE = "x2";
private static final String Y2_ATTRIBUTE = "y2";
private static final String R_ATTRIBUTE = "r";
private static final String RX_ATTRIBUTE = "rx";
private static final String RY_ATTRIBUTE = "ry";
private static final String WIDTH_ATTRIBUTE = "width";
private static final String HEIGHT_ATTRIBUTE = "height";
private static final String POINTS_ATTRIBUTE = "points";
private static final String TRANSLATE_TRANSFORM = "translate";
private static final String SCALE_TRANSFORM = "scale";
private static final String ROTATE_TRANSFORM = "rotate";
private static final String POINTS_REGEX = "[,\\s]+";
private static final String TRANSFORM_REGEX1 = "[a-zA-Z]*\\([0-9.,Ee\\+\\-\\s]*\\)";
private static final String TRANSFORM_REGEX2 = "([a-zA-Z]+)|([0-9\\.Ee\\+\\-]*[eEmMxXpPiInNcCtT%]*[^\\,\\(\\)\\s]+)";
private static final String NUMBER_REGEX = "([\\+\\-]?[0-9]+\\.?[0-9]*[Ee]?[\\+\\-]?[0-9]*\\.?[0-9]*)|em|ex|px|in|cm|mm|pt|pc|\\%";
private static final Pattern NUMBER_PATTERN = Pattern.compile(NUMBER_REGEX);
private static final Pattern TRANSFORM_PATTERN1 = Pattern.compile(TRANSFORM_REGEX1);
private static final Pattern TRANSFORM_PATTERN2 = Pattern.compile(TRANSFORM_REGEX2);
private static final XMLInputFactory FACTORY = XMLInputFactory.newInstance();
private XMLStreamReader reader;
private ArrayList<IGeometric2D> shapes;
private Stack<TransformMatrix2D> transformations = new Stack<TransformMatrix2D>();
private TransformMatrix2D currentMatrix;
/**
* Parses the given {@link InputStream} into a group of geometric shapes.
* @param input the given input stream.
* @param groupID the identifier for the group.
* @throws XMLStreamException if a syntax error is found in the input.
* @return the constructed list of geometric shapes.
*/
@Override
public ArrayList<IGeometric2D> parse(InputStream input, String groupID) throws XMLStreamException {
if(input == null || groupID == null) throw new NullPointerException();
reader = FACTORY.createXMLStreamReader(input);
shapes = new ArrayList<IGeometric2D>();
transformations.clear();
currentMatrix = TransformMatrix2D.UNITY_MATRIX;
while(reader.hasNext()) {
final int event = reader.next();
if (event == XMLStreamConstants.START_ELEMENT) {
applyTransform();
if(reader.getLocalName().equalsIgnoreCase(GROUP_ELEMENT)) {
final String element = reader.getAttributeValue(null, ID_ATTRIBUTE);
if(element != null) {
if(element.equalsIgnoreCase(groupID)) {
parseGroup();
break;
}
}
}
} else if(event == XMLStreamConstants.END_ELEMENT) {
applyTransformEnd();
}
}
return shapes;
}
/**
* Parses the specified group.
* @throws XMLStreamException if an syntax error was encountered in the file.
*/
private void parseGroup() throws XMLStreamException {
int groupCounter = 1;
while (reader.hasNext()) {
int event = reader.next();
if (event == XMLStreamConstants.START_ELEMENT) {
applyTransform();
final String elementName = reader.getLocalName();
if(elementName.equalsIgnoreCase(CIRCLE_ELEMENT)) parseCircle();
else if(elementName.equalsIgnoreCase(ELLIPSE_ELEMENT)) parseEllipse();
else if(elementName.equalsIgnoreCase(LINE_ELEMENT)) parseLine();
else if(elementName.equalsIgnoreCase(POLYLINE_ELEMENT)) parsePolyline();
else if(elementName.equalsIgnoreCase(POLYGON_ELEMENT)) parsePolygon();
else if(elementName.equalsIgnoreCase(RECT_ELEMENT)) parseRect();
} else if(event == XMLStreamConstants.END_ELEMENT) {
applyTransformEnd();
if (reader.getLocalName().equalsIgnoreCase(GROUP_ELEMENT)) {
groupCounter--;
if(groupCounter == 0) break;
}
}
}
}
/**
* Checks the current element for a transform attribute and adds that attribute to the current transform matrix.
* Manages the transformation stack.
*/
private void applyTransform() {
String value = reader.getAttributeValue(null, TRANSFORM_ATTRIBUTE);
transformations.push(currentMatrix);
currentMatrix = currentMatrix.multiply(parseTransform(value));
}
/**
* Sets the current transform matrix to the matrix of the underlying element when leaving an element.
* Manages the transformation stack.
*/
private void applyTransformEnd() {
currentMatrix = transformations.pop();
}
/**
* This method parses a transform attribute into a {@link TransformMatrix2D}.<br/>
* @param string the string of the transform attribute.
* @return the parsed transform matrix.
*/
private TransformMatrix2D parseTransform(String string) {
TransformMatrix2D result = TransformMatrix2D.UNITY_MATRIX;
if(string != null) {
Matcher matcher1 = TRANSFORM_PATTERN1.matcher(string);
int transformCount1 = 0;
while(matcher1.lookingAt()) transformCount1++;
for(int j=1;j<=transformCount1;j++) {
Matcher matcher2 = TRANSFORM_PATTERN2.matcher(matcher1.group(j));
int transformCount2 = 0;
while(matcher1.lookingAt()) transformCount2++;
for(int i=1;i<transformCount2;) {
if(matcher2.group(i).equalsIgnoreCase(TRANSLATE_TRANSFORM)) {
double tx = parseNumber(matcher2.group(++i));
double ty = 0.0d;
i++;
try {
ty = parseNumber(matcher2.group(i));
i++;
} catch(NumberFormatException e) {
e.printStackTrace();
}
result = result.multiply(TransformMatrix2D.translate(tx, ty));
} else if(matcher2.group(i).equalsIgnoreCase(SCALE_TRANSFORM)) {
double sx = parseNumber(matcher2.group(++i));
double sy = sx;
i++;
try {
sy = parseNumber(matcher2.group(i));
i++;
} catch(NumberFormatException e) {
e.printStackTrace();
}
result = result.multiply(TransformMatrix2D.scale(sx, sy));
} else if(matcher2.group(i).equalsIgnoreCase(ROTATE_TRANSFORM)) {
double angle = Math.toRadians(parseNumber(matcher2.group(++i)));
double cx = 0.0d;
double cy = 0.0d;
i++;
try {
cx = parseNumber(matcher2.group(i));
i++;
cy = parseNumber(matcher2.group(i));
i++;
} catch(NumberFormatException e) {
e.printStackTrace();
}
if(cx != 0 && cy != 0) {
result = result.multiply(TransformMatrix2D.translate(cx,cy));
}
result = result.multiply(TransformMatrix2D.rotate(angle));
if(cx != 0 && cy != 0) {
result = result.multiply(TransformMatrix2D.translate(-cx,-cy));
}
} else {
i++;
}
}
}
}
return result;
}
/**
* Parses the current element as a rectangle. This rectangle is added to the {@code shapes} if it is rendered.
*/
private void parseRect() {
String value = reader.getAttributeValue(null, X_ATTRIBUTE);
final double x = parseNumber(value);
value = reader.getAttributeValue(null, Y_ATTRIBUTE);
final double y = parseNumber(value);
value = reader.getAttributeValue(null, WIDTH_ATTRIBUTE);
final double width = parseNumber(value);
value = reader.getAttributeValue(null, HEIGHT_ATTRIBUTE);
final double height = parseNumber(value);
if(width != 0.0d && height != 0.0d) {
//SVG standard specifies that both width and height are forced to have a value. Otherwise the rendering for this element is disabled.
IGeometric2D rect = new Rect2D(x,y,x+width,y+height).transform(currentMatrix);
shapes.add(rect);
}
}
/**
* Parses the current element as a circle. This circle is added to the {@code shapes} if it is rendered.
*/
private void parseCircle() {
String value = reader.getAttributeValue(null, CX_ATTRIBUTE);
final double cx = parseNumber(value);
value = reader.getAttributeValue(null, CY_ATTRIBUTE);
final double cy = parseNumber(value);
value = reader.getAttributeValue(null, R_ATTRIBUTE);
final double r = parseNumber(value);
if(r != 0.0d) {
//SVG standard specifies that the radius is forced to have a value. Otherwise the rendering for this element is disabled.
IGeometric2D circle = new Circle2D(new Point2D(cx,cy),r).transform(currentMatrix);
shapes.add(circle);
}
}
/**
* Parses the current element as an ellipse. This ellipse is added to the {@code shapes} if it is rendered.
*/
private void parseEllipse() {
String value = reader.getAttributeValue(null, CX_ATTRIBUTE);
final double cx = parseNumber(value);
value = reader.getAttributeValue(null, CY_ATTRIBUTE);
final double cy = parseNumber(value);
value = reader.getAttributeValue(null, RX_ATTRIBUTE);
final double rx = parseNumber(value);
value = reader.getAttributeValue(null, RY_ATTRIBUTE);
final double ry = parseNumber(value);
if(rx != 0.0d && ry!= 0.0d) {
//SVG standard specifies that the radius is forced to have a value. Otherwise the rendering for this element is disabled.
IGeometric2D elipse = new Ellipse2D(new Point2D(cx,cy),rx,ry).transform(currentMatrix);
shapes.add(elipse);
}
}
/**
* Parses the current element as a line.
*/
private void parseLine() {
String value = reader.getAttributeValue(null, X1_ATTRIBUTE);
final double x1 = parseNumber(value);
value = reader.getAttributeValue(null, Y1_ATTRIBUTE);
final double y1 = parseNumber(value);
value = reader.getAttributeValue(null, X2_ATTRIBUTE);
final double x2 = parseNumber(value);
value = reader.getAttributeValue(null, Y2_ATTRIBUTE);
final double y2 = parseNumber(value);
IGeometric2D line = new Line2D(x1,y1,x2,y2).transform(currentMatrix);
shapes.add(line);
}
/**
* Parses the current element as a polyline. This polyline is added to the {@code shapes} if it contains a valid points list.
*/
private void parsePolyline() {
String value = reader.getAttributeValue(null, POINTS_ATTRIBUTE);
if(value != null) {
String[] coords = value.split(POINTS_REGEX);
if(coords.length >= 2 && coords.length % 2 == 0) {
//otherwise something is wrong with the points list!
Point2D[] vertexes = new Point2D[coords.length / 2];
for(int i=0; i<coords.length; i = i + 2) {
vertexes[(i/2)-1] = new Point2D(parseNumber(coords[i]),parseNumber(coords[i+1]));
}
IGeometric2D polyline = new Polyline2D(vertexes,false).transform(currentMatrix);
shapes.add(polyline);
}
}
}
/**
* Parses the current element as a polygon. This polygon is added to the {@code shapes} if it contains a valid points list.
*/
private void parsePolygon() {
String value = reader.getAttributeValue(null, POINTS_ATTRIBUTE);
if(value != null) {
String[] coords = value.split(POINTS_REGEX);
if(coords.length >= 2 && coords.length % 2 == 0) {
//otherwise something is wrong with the points list!
Point2D[] vertexes = new Point2D[coords.length / 2];
for(int i=1; i<coords.length; i = i + 2) {
vertexes[(i-1)/2] = new Point2D(parseNumber(coords[i-1]),parseNumber(coords[i]));
}
IGeometric2D polygon = new Polyline2D(vertexes,true).transform(currentMatrix);
shapes.add(polygon);
}
}
}
/**
* Parses a given string as a number. The valid format of the number is specified in the SVG standard. It is parsed through the regular expression {@code NUMBER_PATTERN}.
* @param string the string containing the number.
* @return the number as a double.
*/
private double parseNumber(String string) {
if(string == null) return 0.0d;
Matcher matcher = NUMBER_PATTERN.matcher(string);
if(!matcher.lookingAt()) return 0.0d;
final String group = matcher.group(1);
if(group == null) return 0.0d;
return Double.valueOf(group);
}
}