package org.mapfish.print.processor.map; import com.google.common.base.Strings; import com.google.common.io.Closer; import org.apache.batik.dom.svg.SAXSVGDocumentFactory; import org.apache.batik.dom.svg.SVGDOMImplementation; import org.apache.batik.dom.util.DOMUtilities; import org.apache.batik.util.SVGConstants; import org.apache.batik.util.XMLResourceDescriptor; import org.apache.commons.io.output.FileWriterWithEncoding; import org.mapfish.print.FloatingPointUtil; import org.mapfish.print.http.MfClientHttpRequestFactory; import org.mapfish.print.map.style.json.ColorParser; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpMethod; import org.springframework.http.client.ClientHttpRequest; import org.springframework.http.client.ClientHttpResponse; import org.w3c.dom.DOMImplementation; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.svg.SVGDocument; import org.w3c.dom.svg.SVGElement; import java.awt.Color; import java.awt.Dimension; import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.geom.AffineTransform; import java.awt.image.BufferedImage; import java.io.BufferedInputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.Charset; import javax.imageio.ImageIO; /** * Takes care of scaling and rotating a graphic for the north-arrow. */ public final class NorthArrowGraphic { private static final Logger LOGGER = LoggerFactory.getLogger(NorthArrowGraphic.class); private static final String DEFAULT_GRAPHIC = "NorthArrow_10.svg"; private static final String SVG_NS = SVGDOMImplementation.SVG_NAMESPACE_URI; private NorthArrowGraphic() { } /** * Creates the north-arrow graphic. * * Scales the given graphic to the given size and applies the given * rotation. * * @param targetSize The size of the graphic to create. * @param graphicFile The graphic to use as north-arrow. * @param backgroundColor The background color. * @param rotation The rotation to apply. * @param workingDir The directory in which the graphic is created. * @param clientHttpRequestFactory The request factory. * @return The path to the created graphic. */ public static URI create( final Dimension targetSize, final String graphicFile, final Color backgroundColor, final Double rotation, final File workingDir, final MfClientHttpRequestFactory clientHttpRequestFactory) throws Exception { final Closer closer = Closer.create(); try { final RasterReference input = loadGraphic(graphicFile, clientHttpRequestFactory, closer); if (graphicFile == null || graphicFile.toLowerCase().trim().endsWith("svg")) { return createSvg(targetSize, input, rotation, backgroundColor, workingDir); } else { return createRaster(targetSize, input, rotation, backgroundColor, workingDir); } } finally { closer.close(); } } private static RasterReference loadGraphic(final String graphicFile, final MfClientHttpRequestFactory clientHttpRequestFactory, final Closer closer) throws IOException, URISyntaxException { if (Strings.isNullOrEmpty(graphicFile)) { // if no graphic is set, take a default graphic URL file = NorthArrowGraphic.class.getResource(DEFAULT_GRAPHIC); InputStream inputStream = new BufferedInputStream(new FileInputStream(new File(file.toURI()))); return new RasterReference(closer.register(inputStream), file.toURI()); } // try to load the given graphic final URI uri; if (graphicFile.startsWith("file:")) { uri = new URI(graphicFile.replace("\\", "/")); } else { uri = new URI(graphicFile); } final ClientHttpRequest request = clientHttpRequestFactory.createRequest(uri, HttpMethod.GET); final ClientHttpResponse response = closer.register(request.execute()); return new RasterReference(new BufferedInputStream(response.getBody()), uri); } /** * Renders a given graphic into a new image, scaled to fit the new size and rotated. */ private static URI createRaster(final Dimension targetSize, final RasterReference rasterReference, final Double rotation, final Color backgroundColor, final File workingDir) throws IOException { final File path = File.createTempFile("north-arrow-", ".tiff", workingDir); final BufferedImage newImage = new BufferedImage(targetSize.width, targetSize.height, BufferedImage.TYPE_4BYTE_ABGR); Graphics2D graphics2d = null; try { graphics2d = newImage.createGraphics(); final BufferedImage originalImage = ImageIO.read(rasterReference.inputStream); if (originalImage == null) { LOGGER.warn("Unable to load NorthArrow graphic: " + rasterReference.uri + ", it is not an image format that can be decoded"); throw new IllegalArgumentException(); } // set background color graphics2d.setColor(backgroundColor); graphics2d.fillRect(0, 0, targetSize.width, targetSize.height); // scale the original image to fit the new size int newWidth, newHeight; if (originalImage.getWidth() > originalImage.getHeight()) { newWidth = targetSize.width; newHeight = Math.min( targetSize.height, (int) Math.ceil(newWidth / (originalImage.getWidth() / (double) originalImage.getHeight()))); } else { newHeight = targetSize.height; newWidth = Math.min( targetSize.width, (int) Math.ceil(newHeight / (originalImage.getHeight() / (double) originalImage.getWidth()))); } // position the original image in the center of the new int deltaX = (int) Math.floor((targetSize.width - newWidth) / 2.0); int deltaY = (int) Math.floor((targetSize.height - newHeight) / 2.0); if (!FloatingPointUtil.equals(rotation, 0.0)) { final AffineTransform rotate = AffineTransform.getRotateInstance( rotation, targetSize.width / 2.0, targetSize.height / 2.0); graphics2d.setTransform(rotate); } graphics2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); graphics2d.drawImage(originalImage, deltaX, deltaY, newWidth, newHeight, null); ImageIO.write(newImage, "tiff", path); } finally { if (graphics2d != null) { graphics2d.dispose(); } } return path.toURI(); } /** * With the Batik SVG library it is only possible to create new SVG graphics, * but you can not modify an existing graphic. So, we are loading the SVG file * as plain XML and doing the modifications by hand. */ private static URI createSvg(final Dimension targetSize, final RasterReference rasterReference, final Double rotation, final Color backgroundColor, final File workingDir) throws IOException { // load SVG graphic final SVGElement svgRoot = parseSvg(rasterReference.inputStream); // create a new SVG graphic in which the existing graphic is embedded (scaled and rotated) DOMImplementation impl = SVGDOMImplementation.getDOMImplementation(); Document newDocument = impl.createDocument(SVG_NS, "svg", null); SVGElement newSvgRoot = (SVGElement) newDocument.getDocumentElement(); newSvgRoot.setAttributeNS(null, "width", Integer.toString(targetSize.width)); newSvgRoot.setAttributeNS(null, "height", Integer.toString(targetSize.height)); setSvgBackground(backgroundColor, targetSize, newDocument, newSvgRoot); embedSvgGraphic(svgRoot, newSvgRoot, newDocument, targetSize, rotation); File path = writeSvgToFile(newDocument, workingDir); return path.toURI(); } private static void setSvgBackground(final Color backgroundColor, final Dimension targetSize, final Document newDocument, final SVGElement newSvgRoot) { final Element rect = newDocument.createElementNS(SVG_NS, "rect"); rect.setAttributeNS(null, "x", "0"); rect.setAttributeNS(null, "y", "0"); rect.setAttributeNS(null, "width", Integer.toString(targetSize.width)); rect.setAttributeNS(null, "height", Integer.toString(targetSize.height)); String bgColor = ColorParser.toRGB(backgroundColor); rect.setAttributeNS(null, "fill", bgColor); String opacity = Double.toString(backgroundColor.getAlpha() / 255.0); rect.setAttributeNS(null, "fill-opacity", opacity); newSvgRoot.appendChild(rect); } /** * Embeds the given SVG element into a new SVG element scaling * the graphic to the given dimension and applying the given * rotation. */ private static void embedSvgGraphic(final SVGElement svgRoot, final SVGElement newSvgRoot, final Document newDocument, final Dimension targetSize, final Double rotation) { final String originalWidth = svgRoot.getAttributeNS(null, "width"); final String originalHeight = svgRoot.getAttributeNS(null, "height"); /* * To scale the SVG graphic and to apply the rotation, we distinguish two * cases: width and height is set on the original SVG or not. * * Case 1: Width and height is set * If width and height is set, we wrap the original SVG into 2 new SVG elements * and a container element. * * Example: * Original SVG: * <svg width="100" height="100"></svg> * * New SVG (scaled to 300x300 and rotated by 90 degree): * <svg width="300" height="300"> * <g transform="rotate(90.0 150 150)"> * <svg width="100%" height="100%" viewBox="0 0 100 100"> * <svg width="100" height="100"></svg> * </svg> * </g> * </svg> * * The requested size is set on the outermost <svg>. Then, the rotation is applied to the * <g> container and the scaling is achieved with the viewBox parameter on the 2nd <svg>. * * * Case 2: Width and height is not set * In this case the original SVG is wrapped into just one container and one new SVG element. * The rotation is set on the container, and the scaling happens automatically. * * Example: * Original SVG: * <svg viewBox="0 0 61.06 91.83"></svg> * * New SVG (scaled to 300x300 and rotated by 90 degree): * <svg width="300" height="300"> * <g transform="rotate(90.0 150 150)"> * <svg viewBox="0 0 61.06 91.83"></svg> * </g> * </svg> */ if (!Strings.isNullOrEmpty(originalWidth) && !Strings.isNullOrEmpty(originalHeight)) { Element wrapperContainer = newDocument.createElementNS(SVG_NS, "g"); wrapperContainer.setAttributeNS( null, SVGConstants.SVG_TRANSFORM_ATTRIBUTE, getRotateTransformation(targetSize, rotation)); newSvgRoot.appendChild(wrapperContainer); Element wrapperSvg = newDocument.createElementNS(SVG_NS, "svg"); wrapperSvg.setAttributeNS(null, "width", "100%"); wrapperSvg.setAttributeNS(null, "height", "100%"); wrapperSvg.setAttributeNS(null, "viewBox", "0 0 " + originalWidth + " " + originalHeight); wrapperContainer.appendChild(wrapperSvg); Node svgRootImported = newDocument.importNode(svgRoot, true); wrapperSvg.appendChild(svgRootImported); } else if (Strings.isNullOrEmpty(originalWidth) && Strings.isNullOrEmpty(originalHeight)) { Element wrapperContainer = newDocument.createElementNS(SVG_NS, "g"); wrapperContainer.setAttributeNS( null, SVGConstants.SVG_TRANSFORM_ATTRIBUTE, getRotateTransformation(targetSize, rotation)); newSvgRoot.appendChild(wrapperContainer); Node svgRootImported = newDocument.importNode(svgRoot, true); wrapperContainer.appendChild(svgRootImported); } else { throw new IllegalArgumentException( "Unsupported or invalid north-arrow SVG graphic: The same unit (px, em, %, ...) must be " + "used for `width` and `height`."); } } private static String getRotateTransformation(final Dimension targetSize, final double rotation) { return "rotate(" + Double.toString(Math.toDegrees(rotation)) + " " + Integer.toString(targetSize.width / 2) + " " + Integer.toString(targetSize.height / 2) + ")"; } private static SVGElement parseSvg(final InputStream inputStream) throws IOException { String parser = XMLResourceDescriptor.getXMLParserClassName(); SAXSVGDocumentFactory f = new SAXSVGDocumentFactory(parser); SVGDocument document = (SVGDocument) f.createDocument("", inputStream); return (SVGElement) document.getDocumentElement(); } private static File writeSvgToFile(final Document document, final File workingDir) throws IOException { final File path = File.createTempFile("north-arrow-", ".svg", workingDir); FileWriterWithEncoding fw = null; try { fw = new FileWriterWithEncoding(path, Charset.forName("UTF-8").newEncoder()); DOMUtilities.writeDocument(document, fw); fw.flush(); } finally { if (fw != null) { fw.close(); } } return path; } private static final class RasterReference { private final InputStream inputStream; private final URI uri; public RasterReference( final InputStream inputStream, final URI uri) { this.inputStream = inputStream; this.uri = uri; } } }