Map-Projections/src/utils/SVGMap.java
Justin Kunimune 9ae6c2a668 Put the scissors down!
I (finally) made it automatically snip paths that are obviously crossing
interruptions. This should make vector maps look world better straight
out of the program, and will be crucial to mass-producing vector maps.
There's some room for improvement regarding the handling of closepaths,
but all in all, this is a big step up.

I also got a new input, fixed some minor issues with the new
Cahill-Keyes (did you know it's pronounced /kais/?), and might have
fixed the sporadic BufferOverflowExceptions, but I'm not sure about the
last one, as that problem is extremely difficult to reproduce, but I
have a strong suspicion it has something to do with threads, and I added
a bunch of Platform.runLater()s, so we'll see.
2018-02-09 22:43:58 -10:00

499 lines
16 KiB
Java

/**
* MIT License
*
* Copyright (c) 2017 Justin Kunimune
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package utils;
import java.io.BufferedInputStream;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Stack;
import java.util.function.DoubleConsumer;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.AttributesImpl;
import org.xml.sax.helpers.DefaultHandler;
/**
* An input equirectangular map based on an SVG file
*
* @author jkunimune
*/
public class SVGMap implements Iterable<SVGMap.Path> {
public static final double[] NULL_TRANSFORM = {1, 1, 0, 0};
private List<Path> paths; //the set of closed curves in this image
private List<String> format; //the stuff that goes between the curve descriptions, probably important for something.
private double vbMinX, vbMinY, vbWidth, vbHeight; //the SVG viewBox
private double svgWidth, svgHeight; //the actual SVG dimensions
private int length; //the total number of path commands, for optimization purposes
private static final Map<String, String> ATTRIBUTE_PLACEHOLDERS = Map.of(
"width","hmxMLwhWHeqMA8Ba", "height","VlMBunXsmQUtmCw4", "viewBox","UrFo1q9niPDkKSNC"); //attributes of the SVG object to change
public SVGMap(File file) throws IOException, SAXException, ParserConfigurationException {
paths = new LinkedList<Path>();
format = new LinkedList<String>();
final SAXParser parser = SAXParserFactory.newInstance().newSAXParser();
final DefaultHandler handler = new DefaultHandler() {
private Stack<double[]> transformStack = new Stack<double[]>();
private String currentFormatString = "";
@Override
public InputSource resolveEntity(String publicId, String systemId) {
return new InputSource(new StringReader("")); //ignore all external references - we don't need to validate
}
@Override
public void startElement(
String uri, String localName, String qName, Attributes attributes) throws SAXException {
currentFormatString += "<"+qName;
if (attributes.getIndex("transform") >= 0)
parseTransform(attributes.getValue("transform"));
else
parseTransform();
if (qName.equals("path")) {
try {
parsePath(attributes.getValue("d"));
} catch (Exception e) {
throw new SAXException(e.getLocalizedMessage(), null);
}
}
if (qName.equals("svg")) {
attributes = parseViewBox(attributes);
}
for (int i = 0; i < attributes.getLength(); i ++)
if (!attributes.getQName(i).equals("d") && //d is already taken care of
!attributes.getQName(i).equals("transform")) //there shall be no transforms in the final output
currentFormatString +=
" "+attributes.getQName(i)+"=\""+attributes.getValue(i)+"\"";
currentFormatString += ">";
}
@Override
public void endElement(String uri, String localName, String qName) {
currentFormatString += "</"+qName+">";
transformStack.pop();
}
@Override
public void characters(char[] ch, int start, int length) {
for (int i = 0; i < length; i ++) {
if (ch[start+i] >= 128)
currentFormatString += "&#" + (int)ch[start+i] + ";";
else
currentFormatString += ch[start+i];
}
}
@Override
public void endDocument() {
format.add(currentFormatString);
}
private Attributes parseViewBox(Attributes attrs) {
if (attrs.getValue("width") != null)
svgWidth = Double.parseDouble(attrs.getValue("width"));
else
svgWidth = 360.;
if (attrs.getValue("height") != null)
svgHeight = Double.parseDouble(attrs.getValue("height"));
else
svgHeight = 180.;
if (attrs.getValue("viewBox") != null) {
String[] values = attrs.getValue("viewBox").split("\\s", 4);
vbMinX = Double.parseDouble(values[0]);
vbMinY = Double.parseDouble(values[1]);
vbWidth = Double.parseDouble(values[2]);
vbHeight = Double.parseDouble(values[3]);
}
else {
vbWidth = svgWidth;
vbHeight = svgHeight;
vbMinX = 0;
vbMinY = 0;
}
AttributesImpl modAttrs = new AttributesImpl(attrs); //now insert the placeholders for the format string
for (String qName: ATTRIBUTE_PLACEHOLDERS.keySet()) {
if (modAttrs.getIndex(qName) >= 0)
modAttrs.setValue(modAttrs.getIndex(qName), ATTRIBUTE_PLACEHOLDERS.get(qName));
else
modAttrs.addAttribute("", "", qName, "", ATTRIBUTE_PLACEHOLDERS.get(qName));
}
return modAttrs;
}
private void parseTransform() {
if (transformStack.isEmpty())
transformStack.push(NULL_TRANSFORM);
else
transformStack.push(transformStack.peek());
}
private void parseTransform(String transString) {
double xScale = 1, yScale = 1, xTrans = 0, yTrans = 0;
int i;
while ((i = transString.indexOf('(')) >= 0) {
int j = transString.indexOf(')');
String type = transString.substring(0, i).trim();
String argString = transString.substring(i+1, j);
String[] args = argString.split("[,\\s]+");
if (type.equals("matrix")) {
xScale = Double.parseDouble(args[0]);
yScale = Double.parseDouble(args[3]);
xTrans = Double.parseDouble(args[4]);
yTrans = Double.parseDouble(args[5]);
}
else if (type.equals("translate")) {
if (args.length == 1) {
xTrans = yTrans = Double.parseDouble(args[0]);
}
else {
xTrans = Double.parseDouble(args[0]);
yTrans = Double.parseDouble(args[1]);
}
}
else if (type.equals("scale")) {
if (args.length == 1) {
xScale = yScale = Double.parseDouble(args[0]);
}
else {
xScale = Double.parseDouble(args[0]);
yScale = Double.parseDouble(args[1]);
}
} //I'm not worrying about shear and rotation because I don't want to
transString = transString.substring(j+1);
}
transformStack.push(new double[] {xScale, yScale, xTrans, yTrans});
}
private void parsePath(String d) throws Exception {
currentFormatString += " d=\"";
format.add(currentFormatString);
paths.add(new Path(d, transformStack.peek(), vbMinX, vbMinY, vbWidth, vbHeight));
currentFormatString = "\"";
length += paths.get(paths.size()-1).size();
}
};
parser.parse(new BufferedInputStream(new FileInputStream(file)), handler);
}
private SVGMap(List<Path> paths, List<String> format, double vbMinX, double vbMinY,
double vbWidth, double vbHeight, double svgWidth, double svgHeight, int size) {
this.paths = paths;
this.format = format;
this.vbMinX = vbMinX;
this.vbMinY = vbMinY;
this.vbWidth = vbWidth;
this.vbHeight = vbHeight;
this.svgWidth = svgWidth;
this.svgHeight = svgHeight;
this.length = size;
}
public int length() {
return this.length;
}
public int numCurves() {
return paths.size();
}
@Override
public Iterator<Path> iterator() {
return paths.iterator();
}
/**
* @return the result of replacing all instances of target in the format strings
*/
public SVGMap replace(CharSequence target, CharSequence replacement) {
List<String> newFormat = new LinkedList<String>();
for (String f: this.format)
newFormat.add(f.replace(target, replacement));
return new SVGMap(
paths, newFormat, vbMinX, vbMinY, vbWidth, vbHeight, svgWidth, svgHeight, length);
}
public void save(List<Path> paths, File file, double inMinX, double inMaxY, double inWidth,
double inHeight, DoubleConsumer tracker) throws IOException {
BufferedWriter out = new BufferedWriter(new FileWriter(file));
int i = 0;
final Iterator<String> formatIterator = format.iterator();
final Iterator<Path> curveIterator = paths.iterator();
out.write(replacePlaceholders(formatIterator.next(), inWidth/inHeight));
while (curveIterator.hasNext()) {
out.write(breakWraps(curveIterator.next()).toString(inMinX, inMaxY, vbMinX, vbMinY,
Math.max(vbWidth, vbHeight)/Math.max(inWidth, inHeight)));
out.write(formatIterator.next());
tracker.accept((double)i/paths.size());
i ++;
}
out.close();
}
private String replacePlaceholders(String str, double desiredAspectRatio) { //change the width, height, and viewBox attributes to match the new aspect ratio
double outVBWidth, outVBHeight, outSVGWidth, outSVGHeight;
if (desiredAspectRatio > 1) {
outVBWidth = Math.max(this.vbWidth, this.vbHeight);
outVBHeight = outVBWidth/desiredAspectRatio;
outSVGWidth = Math.max(this.svgWidth, this.svgHeight);
outSVGHeight = outSVGWidth/desiredAspectRatio;
}
else {
outVBHeight = Math.max(this.vbWidth, this.vbHeight);
outVBWidth = outVBHeight*desiredAspectRatio;
outSVGHeight = Math.max(this.svgWidth, this.svgHeight);
outSVGWidth = outSVGHeight*desiredAspectRatio;
}
String viewBoxString = formatDouble(vbMinX) + " " + formatDouble(vbMinY) + " "
+ formatDouble(outVBWidth) + " " + formatDouble(outVBHeight);
str = str.replace(ATTRIBUTE_PLACEHOLDERS.get("viewBox"), viewBoxString);
str = str.replace(ATTRIBUTE_PLACEHOLDERS.get("width"), formatDouble(outSVGWidth));
str = str.replace(ATTRIBUTE_PLACEHOLDERS.get("height"), formatDouble(outSVGHeight));
return str;
}
private Path breakWraps(Path continuous) { //break excessively long commands, as they are likely wrapping over a discontinuity
if (continuous.size() <= 2) return continuous;
Path broken = new Path();
double[] lens = {Double.NaN, Double.NaN, Double.NaN}; //the revolving array of command lengths
for (int i = 0; i < continuous.size(); i ++) {
if (i < continuous.size()-1 && continuous.get(i+1).type != 'M')
lens[2] = Math.hypot( //compute this next length
continuous.get(i+1).args[0] - continuous.get(i).args[0],
continuous.get(i+1).args[1] - continuous.get(i).args[1]);
else
lens[2] = Double.NaN;
char type = continuous.get(i).type;
if ((Double.isNaN(lens[0]) || lens[1] > 20*lens[0]) //and compare it to the last two lengths
&& (Double.isNaN(lens[2]) || lens[1] > 20*lens[2]))
type = 'M';
broken.add(new Command(type, continuous.get(i).args.clone()));
lens[0] = lens[1];
lens[1] = lens[2];
}
return broken;
}
private static boolean isNonELetter(char c) {
return (c >= 'A' && c <= 'Z' && c != 'E') || (c >= 'a' && c <= 'z' && c != 'e');
}
private static String formatDouble(double d) { //format numbers just the way I want them
String str = String.format("%04d", (int)Math.round(d*1000));
str = str.substring(0, str.length()-3) + "." + str.substring(str.length()-3);
return str.replaceFirst("\\.?0*$", "");
}
/**
* An svg path String, stored in a modifiable form
* @author jkunimune
*/
public static class Path extends ArrayList<Command> {
private static final long serialVersionUID = 8635895911857570332L;
public Path() {
super();
}
public Path(Command... commands) {
this();
this.addAll(Arrays.asList(commands));
}
public Path(String d, double vbWidth, double vbHeight) throws Exception {
this(d, new double[] {1,1,0,0}, 0, 0, vbWidth, vbHeight);
}
public Path(String d, double[] transform,
double vbMinX, double vbMinY, double vbWidth, double vbHeight) throws Exception { //I don't know if this is bad coding practice, but I don't really want to find a way to gracefully catch all possible errors into some more specific Exception class
super();
int i = 0;
double[] lastMove = {0, 0}; //for closepaths
double[] last = {0, 0}; //for relative coordinates
while (i < d.length()) {
char type = d.charAt(i);
String argString = "";
while (i+1 < d.length() && !isNonELetter(d.charAt(i+1))) {
i ++;
argString += d.charAt(i);
}
argString = argString.replaceAll("([0-9\\.])-", "$1,-"); //this is necessary because some Adobe products leave out delimiters between negative numbers
i ++;
String[] argStrings;
if (argString.trim().isEmpty()) argStrings = new String[0];
else argStrings = argString.trim().split("[\\s,]+");
final double[] args;
if (type == 'a' || type == 'A') {
type += (type == 'a') ? 'l' : 'L'; //change this to a line; I don't want to deal with arcs
argStrings = new String[] {argStrings[3], argStrings[4]};
}
if (type == 'h' || type == 'H' || type == 'v' || type == 'V') { //convert these to 'L'
final int direcIdx = (type%32 == 8) ? 0 : 1;
args = new double[] {last[0], last[1]};
if (type <= 'Z') args[direcIdx] = Double.parseDouble(argStrings[0]); //uppercase (absolute)
else args[direcIdx] += Double.parseDouble(argStrings[0]); //lowercase (relative)
last[direcIdx] = args[direcIdx];
type = 'L';
}
else if (type == 'z' || type == 'Z') { //change this to 'L', too
args = new double[] {lastMove[0], lastMove[1]};
type = 'L';
}
else {
args = new double[argStrings.length];
for (int j = 0; j < args.length; j ++) {
args[j] = Double.parseDouble(argStrings[j]); //parse the coordinate
if (type >= 'a')
args[j] += last[j%2]; //account for relative commands
last[j%2] = args[j];
}
if (type >= 'a') //make all letters uppercase
type -= 32;
}
if (type == 'M') { //make note, so we can interpret closepaths properly
lastMove[0] = args[args.length-2];
lastMove[1] = args[args.length-1];
}
for (int j = 0; j < args.length; j ++) {
if (!Double.isFinite(args[j]))
throw new IllegalArgumentException("uhh... "+type+argString);
if (j%2 == 0) {
args[j] = args[j]*transform[0] + transform[2]; //apply the transformation
args[j] = Math2.linInterp(args[j], vbMinX, vbMinX+vbWidth,
-Math.PI, Math.PI); //scale to radians
}
else {
args[j] = args[j]*transform[1] + transform[3];
args[j] = Math2.linInterp(args[j], vbMinY+vbHeight, vbMinY, //keep in mind that these are paired longitude-latitude
-Math.PI/2, Math.PI/2); //not latitude-longitude, as they are elsewhere
}
}
this.add(new Command(type, args));
}
}
public Path(Command command) {
// TODO: Implement this
}
public String toString(
double inMinX, double inMaxY, double outMinX, double outMinY, double outScale) {
String s = "";
for (Command c: this)
s += c.toString(inMinX, inMaxY, outMinX, outMinY, outScale)+" ";
return s;
}
}
/**
* An SVG command, like line or bezier curve or whatever
* @author jkunimune
*/
public static class Command {
final public char type; //M, L, C, etc. This will never be lowercase
final public double[] args; //the absolute coordinates that go with it
public Command(char type, double[] args) {
this.type = type;
this.args = args;
}
public Command(Command command) {
this(command.type, command.args.clone());
}
public String toString() {
return this.toString(-1, -1, 0, 0, 1);
}
public String toString(
double inMinX, double inMaxY, double outMinX, double outMinY, double outScale) {
String s = Character.toString(type);
for (int i = 0; i < args.length; i ++) {
if (i%2 == 0)
s += formatDouble(outMinX + (args[i]-inMinX)*outScale)+",";
else
s += formatDouble(outMinY + (inMaxY-args[i])*outScale)+",";
}
return s.substring(0, s.length()-1);
}
}
}