fr.avianey.androidsvgdrawable.SvgDrawablePlugin.java Source code

Java tutorial

Introduction

Here is the source code for fr.avianey.androidsvgdrawable.SvgDrawablePlugin.java

Source

/*
 * Copyright 2013, 2014 Antoine Vianey
 * 
 * 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.
 */
package fr.avianey.androidsvgdrawable;

import java.awt.Color;
import java.awt.Graphics;
import java.awt.Rectangle;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileFilter;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Reader;
import java.lang.reflect.Type;
import java.net.MalformedURLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import javax.imageio.ImageIO;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.xpath.XPathExpressionException;

import org.apache.batik.bridge.BridgeContext;
import org.apache.batik.bridge.DocumentLoader;
import org.apache.batik.bridge.GVTBuilder;
import org.apache.batik.bridge.UserAgent;
import org.apache.batik.dom.svg.SAXSVGDocumentFactory;
import org.apache.batik.gvt.GraphicsNode;
import org.apache.batik.parser.UnitProcessor;
import org.apache.batik.transcoder.TranscoderException;
import org.apache.batik.transcoder.TranscoderInput;
import org.apache.batik.transcoder.TranscoderOutput;
import org.apache.batik.transcoder.image.ImageTranscoder;
import org.apache.batik.transcoder.image.JPEGTranscoder;
import org.apache.batik.util.XMLResourceDescriptor;
import org.apache.commons.io.FilenameUtils;
import org.w3c.dom.svg.SVGDocument;
import org.w3c.dom.svg.SVGLength;
import org.w3c.dom.svg.SVGSVGElement;
import org.xml.sax.SAXException;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner;
import com.google.common.base.Preconditions;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;

import fr.avianey.androidsvgdrawable.NinePatch.Zone;
import fr.avianey.androidsvgdrawable.batik.DensityAwareUserAgent;
import fr.avianey.androidsvgdrawable.util.Constants;
import fr.avianey.androidsvgdrawable.util.Log;

/**
 * Generates drawable from Scalable Vector Graphics (SVG) files.
 *
 * @author antoine vianey
 */
public class SvgDrawablePlugin {

    public static interface Parameters {

        public static final Integer DEFAULT_JPG_BACKGROUND_COLOR = -1;
        public static final Integer DEFAULT_JPG_QUALITY = 85;
        public static final OutputFormat DEFAULT_OUTPUT_FORMAT = OutputFormat.PNG;
        public static final OutputType DEFAULT_OUTPUT_TYPE = OutputType.drawable;
        public static final BoundsType DEFAULT_BOUNDS_TYPE = BoundsType.sensitive;
        public static final OverrideMode DEFAULT_OVERRIDE_MODE = OverrideMode.always;
        public static final Boolean DEFAULT_CREATE_MISSING_DIRECTORIES = true;

        File getFrom();

        File getTo();

        boolean isCreateMissingDirectories();

        OverrideMode getOverrideMode();

        Density[] getTargetedDensities();

        Map<String, String> getRename();

        String getHighResIcon();

        File getNinePatchConfig();

        File getSvgMaskDirectory();

        File getSvgMaskResourcesDirectory();

        File getSvgMaskedSvgOutputDirectory();

        boolean isUseSameSvgOnlyOnceInMask();

        OutputFormat getOutputFormat();

        OutputType getOutputType();

        int getJpgQuality();

        int getJpgBackgroundColor();

        BoundsType getSvgBoundsType();

    }

    // log
    private final Log log;
    private final Parameters parameters;

    public SvgDrawablePlugin(final Parameters parameters, final Log log) {
        this.parameters = parameters;
        this.log = log;
    }

    private Log getLog() {
        return this.log;
    }

    public void execute() {
        // validating target densities specified in pom.xml
        // untargetted densities will be ignored except for the fallback density if specified
        final Set<Density> targetDensities = new HashSet<>(Arrays.asList(parameters.getTargetedDensities()));
        if (targetDensities.isEmpty()) {
            targetDensities.addAll(EnumSet.allOf(Density.class));
        }
        getLog().info("Targeted densities : " + Joiner.on(", ").join(targetDensities));

        /********************************
         * Load NinePatch configuration *
         ********************************/

        NinePatchMap ninePatchMap = new NinePatchMap();
        if (parameters.getNinePatchConfig() != null && parameters.getNinePatchConfig().isFile()) {
            getLog().info(
                    "Loading NinePatch configuration file " + parameters.getNinePatchConfig().getAbsolutePath());
            try (final Reader reader = new FileReader(parameters.getNinePatchConfig())) {
                Type t = new TypeToken<Set<NinePatch>>() {
                }.getType();
                Set<NinePatch> ninePathSet = (Set<NinePatch>) (new GsonBuilder().create().fromJson(reader, t));
                ninePatchMap = NinePatch.init(ninePathSet);
            } catch (IOException e) {
                getLog().error(e);
            }
        } else {
            getLog().info("No NinePatch configuration file specified");
        }

        /*****************************
         * List input svg to convert *
         *****************************/

        getLog().info("Listing SVG files in " + parameters.getFrom().getAbsolutePath());
        final Collection<QualifiedResource> svgToConvert = listQualifiedResources(parameters.getFrom(), "svg");
        getLog().info("SVG files : " + Joiner.on(", ").join(svgToConvert));

        /*****************************
         * List input svgmask to use *
         *****************************/

        File svgMaskDirectory = parameters.getSvgMaskDirectory();
        File svgMaskResourcesDirectory = parameters.getSvgMaskResourcesDirectory();
        if (svgMaskDirectory == null) {
            svgMaskDirectory = parameters.getFrom();
        }
        if (svgMaskResourcesDirectory == null) {
            svgMaskResourcesDirectory = svgMaskDirectory;
        }
        getLog().info("Listing SVGMASK files in " + svgMaskDirectory.getAbsolutePath());
        final Collection<QualifiedResource> svgMasks = listQualifiedResources(svgMaskDirectory, "svgmask");
        final Collection<QualifiedResource> svgMaskResources = new ArrayList<>();
        getLog().info("SVGMASK files : " + Joiner.on(", ").join(svgMasks));
        if (!svgMasks.isEmpty()) {
            // list masked resources
            if (svgMaskResourcesDirectory.equals(parameters.getFrom())) {
                svgMaskResources.addAll(svgToConvert);
            } else {
                svgMaskResources.addAll(listQualifiedResources(svgMaskResourcesDirectory, "svg"));
            }
            getLog().info("SVG files to mask : " + Joiner.on(", ").join(svgMaskResources));
            // generate masked svg
            for (QualifiedResource maskFile : svgMasks) {
                getLog().info("Generating masked files for " + maskFile);
                try {
                    Collection<QualifiedResource> generatedResources = new SvgMask(maskFile)
                            .generatesMaskedResources(parameters.getSvgMaskedSvgOutputDirectory(), svgMaskResources,
                                    parameters.isUseSameSvgOnlyOnceInMask(), parameters.getOverrideMode());
                    getLog().debug("+ " + Joiner.on(", ").join(generatedResources));
                    svgToConvert.addAll(generatedResources);
                } catch (XPathExpressionException | TransformerException | ParserConfigurationException
                        | SAXException | IOException e) {
                    getLog().error(e);
                }
            }
        } else {
            getLog().info("No SVGMASK file found.");
        }

        QualifiedResource highResIcon = null;

        /*********************************
         * Create svg in res/* folder(s) *
         *********************************/

        for (QualifiedResource svg : svgToConvert) {
            try {
                getLog().info(
                        "Transcoding " + FilenameUtils.getName(svg.getAbsolutePath()) + " to targeted densities");
                Rectangle bounds = extractSVGBounds(svg);
                if (getLog().isDebugEnabled()) {
                    getLog().debug("+ source dimensions [width=" + bounds.getWidth() + " - height="
                            + bounds.getHeight() + "]");
                }
                if (parameters.getHighResIcon() != null && parameters.getHighResIcon().equals(svg.getName())) {
                    highResIcon = svg;
                }
                // for each target density :
                // - find matching destinations :
                //   - matches all extra qualifiers
                //   - no other output with a qualifiers set that is a subset of this output
                // - if no match, create required directories
                for (Density d : targetDensities) {
                    NinePatch ninePatch = ninePatchMap.getBestMatch(svg);
                    File destination = svg.getOutputFor(d, parameters.getTo(),
                            ninePatch == null ? parameters.getOutputType() : OutputType.drawable);
                    if (!destination.exists() && parameters.isCreateMissingDirectories()) {
                        destination.mkdirs();
                    }
                    if (destination.exists()) {
                        getLog().debug("Transcoding " + svg.getName() + " to " + destination.getName());
                        transcode(svg, d, bounds, destination, ninePatch);
                    } else {
                        getLog().info("Qualified output " + destination.getName() + " does not exists. "
                                + "Set 'createMissingDirectories' to true if you want it to be created if missing...");
                    }
                }
            } catch (IOException | TranscoderException | InstantiationException | IllegalAccessException e) {
                getLog().error("Error while converting " + svg, e);
            }
        }

        /******************************************
         * Generates the play store high res icon *
         ******************************************/

        if (highResIcon != null) {
            try {
                // TODO : add a garbage density (NO_DENSITY) for the highResIcon
                // TODO : make highResIcon size configurable
                // TODO : generates other play store assets
                // TODO : parameterized SIZE
                getLog().info("Generating high resolution icon");
                transcode(highResIcon, Density.mdpi, new File("."), 512, 512, null);
            } catch (IOException | TranscoderException | InstantiationException | IllegalAccessException e) {
                getLog().error("Error while converting " + highResIcon, e);
            }
        }
    }

    /**
     * Extract the viewbox of the input SVG
     * @param svg
     * @return
     * @throws MalformedURLException
     * @throws IOException
     */
    @VisibleForTesting
    Rectangle extractSVGBounds(QualifiedResource svg) throws MalformedURLException, IOException {
        // check <svg> attributes first : x, y, width, height
        SVGDocument svgDocument = getSVGDocument(svg);
        SVGSVGElement svgElement = svgDocument.getRootElement();
        if (svgElement.getAttributeNode("width") != null && svgElement.getAttribute("height") != null) {

            UserAgent userAgent = new DensityAwareUserAgent(svg.getDensity().getDpi());
            UnitProcessor.Context context = org.apache.batik.bridge.UnitProcessor
                    .createContext(new BridgeContext(userAgent), svgElement);

            float width = svgLengthInPixels(svgElement.getWidth().getBaseVal(), context);
            float height = svgLengthInPixels(svgElement.getHeight().getBaseVal(), context);
            float x = 0;
            float y = 0;
            // check x and y attributes
            if (svgElement.getX() != null && svgElement.getX().getBaseVal() != null) {
                x = svgLengthInPixels(svgElement.getX().getBaseVal(), context);
            }
            if (svgElement.getY() != null && svgElement.getY().getBaseVal() != null) {
                y = svgLengthInPixels(svgElement.getY().getBaseVal(), context);
            }

            return new Rectangle((int) Math.floor(x), (int) Math.floor(y), (int) Math.ceil(width),
                    (int) Math.ceil(height));
        }

        // use computed bounds
        getLog().warn(
                "Take time to fix desired width and height attributes of the root <svg> node for this file... "
                        + "ROI will be computed by magic using Batik " + parameters.getSvgBoundsType().name()
                        + " bounds");
        return parameters.getSvgBoundsType().getBounds(getGraphicsNode(svgDocument, svg.getDensity().getDpi()));
    }

    /**
     * Convert an {@link SVGLength} to a value in {@link SVGLength#SVG_LENGTHTYPE_PX}
     * @param length
     * @param context
     * @return
     */
    private float svgLengthInPixels(SVGLength length, UnitProcessor.Context context) {
        return UnitProcessor.svgToUserSpace(length.getValueAsString(), "px", UnitProcessor.OTHER_LENGTH, context);
    }

    /**
     * Return the {@link GraphicsNode} of the {@link SVGDocument}
     * @param svgDocument
     * @return
     * @throws MalformedURLException
     * @throws IOException
     */
    private GraphicsNode getGraphicsNode(SVGDocument svgDocument, int dpi)
            throws MalformedURLException, IOException {
        UserAgent userAgent = new DensityAwareUserAgent(dpi);
        DocumentLoader loader = new DocumentLoader(userAgent);
        BridgeContext ctx = new BridgeContext(userAgent, loader);
        ctx.setDynamicState(BridgeContext.DYNAMIC);
        GVTBuilder builder = new GVTBuilder();
        GraphicsNode rootGN = builder.build(ctx, svgDocument);
        return rootGN;
    }

    /**
     * Return the {@link SVGDocument} of the SVG {@link QualifiedResource}
     * @param svg
     * @return
     * @throws MalformedURLException
     * @throws IOException
     */
    @VisibleForTesting
    SVGDocument getSVGDocument(QualifiedResource svg) throws MalformedURLException, IOException {
        String parser = XMLResourceDescriptor.getXMLParserClassName();
        SAXSVGDocumentFactory f = new SAXSVGDocumentFactory(parser);
        return (SVGDocument) f.createDocument(svg.toURI().toURL().toString());
    }

    /**
     * Given it's bounds, transcodes a svg file to a raster image for the desired density
     * @param svg
     * @param targetDensity 
     * @param bounds
     * @param destination
     * @throws IOException
     * @throws TranscoderException
     * @throws IllegalAccessException 
     * @throws InstantiationException 
     */
    @VisibleForTesting
    void transcode(QualifiedResource svg, Density targetDensity, Rectangle bounds, File destination,
            NinePatch ninePatch)
            throws IOException, TranscoderException, InstantiationException, IllegalAccessException {
        transcode(svg, targetDensity, destination,
                new Float(bounds.getWidth() * svg.getDensity().ratio(targetDensity)),
                new Float(bounds.getHeight() * svg.getDensity().ratio(targetDensity)), ninePatch);
    }

    /**
     * Given a desired width and height, transcodes a svg file to a raster image for the desired density
     * @param svg
     * @param targetDensity 
     * @param dest
     * @param targetWidth
     * @param targetHeight
     * @throws IOException
     * @throws TranscoderException
     * @throws IllegalAccessException 
     * @throws InstantiationException 
     */
    private void transcode(QualifiedResource svg, Density targetDensity, File dest, float targetWidth,
            float targetHeight, NinePatch ninePatch)
            throws IOException, TranscoderException, InstantiationException, IllegalAccessException {
        final Float width = Math.max(new Float(Math.floor(targetWidth)), 1);
        final Float height = Math.max(new Float(Math.floor(targetHeight)), 1);
        if (getLog().isDebugEnabled()) {
            getLog().debug("+ target dimensions [width=" + width + " - length=" + height + "]");
        }
        ImageTranscoder t = parameters.getOutputFormat().getTranscoderClass().newInstance();
        if (t instanceof JPEGTranscoder) {
            // custom jpg hints
            t.addTranscodingHint(JPEGTranscoder.KEY_QUALITY,
                    Math.min(1, Math.max(0, parameters.getJpgQuality() / 100f)));
            t.addTranscodingHint(JPEGTranscoder.KEY_BACKGROUND_COLOR,
                    new Color(parameters.getJpgBackgroundColor()));
        }
        t.addTranscodingHint(ImageTranscoder.KEY_WIDTH, width);
        t.addTranscodingHint(ImageTranscoder.KEY_HEIGHT, height);
        TranscoderInput input = new TranscoderInput(svg.toURI().toURL().toString());
        String outputName = svg.getName();
        if (parameters.getRename() != null && parameters.getRename().containsKey(outputName)) {
            if (parameters.getRename().get(outputName) != null
                    && parameters.getRename().get(outputName).matches("\\w+")) {
                outputName = parameters.getRename().get(outputName);
            } else {
                getLog().warn(parameters.getRename().get(outputName) + " is not a valid replacment name for "
                        + outputName);
            }
        }

        // final name
        final String finalName = new StringBuilder(dest.getAbsolutePath())
                .append(System.getProperty("file.separator")).append(outputName)
                .append(ninePatch != null && parameters.getOutputFormat().hasNinePatchSupport() ? ".9" : "")
                .append(".").append(parameters.getOutputFormat().name().toLowerCase()).toString();

        final File finalFile = new File(finalName);

        if (parameters.getOverrideMode().shouldOverride(svg, finalFile, parameters.getNinePatchConfig())) {
            // unit conversion for size not in pixel
            t.addTranscodingHint(ImageTranscoder.KEY_PIXEL_UNIT_TO_MILLIMETER,
                    new Float(Constants.MM_PER_INCH / svg.getDensity().getDpi()));

            if (ninePatch == null || !parameters.getOutputFormat().hasNinePatchSupport()) {
                if (ninePatch != null) {
                    getLog().warn("skipping the nine-patch configuration for the JPG output format !!!");
                }
                // write file directly
                OutputStream ostream = new FileOutputStream(finalName);
                TranscoderOutput output = new TranscoderOutput(ostream);
                t.transcode(input, output);
                ostream.flush();
                ostream.close();
            } else {
                // write in memory
                ByteArrayOutputStream ostream = new ByteArrayOutputStream();
                TranscoderOutput output = new TranscoderOutput(ostream);
                t.transcode(input, output);
                // fill the patch
                ostream.flush();
                InputStream istream = new ByteArrayInputStream(ostream.toByteArray());
                ostream.close();
                ostream = null;
                toNinePatch(istream, finalName, ninePatch, svg.getDensity().ratio(targetDensity));
            }
        } else {
            getLog().debug(finalName + " already exists and is up to date... skiping generation!");
            getLog().debug("+ " + finalName + " last modified on " + new File(finalName).lastModified());
            getLog().debug("+ " + svg.getAbsolutePath() + " last modified on " + svg.lastModified());
            if (ninePatch != null && parameters.getNinePatchConfig() != null /* for tests */) {
                getLog().debug("+ " + parameters.getNinePatchConfig().getAbsolutePath() + " last modified on "
                        + parameters.getNinePatchConfig().lastModified());
            }
        }
    }

    /**
     * Draw the stretch and content area defined by the {@link NinePatch} around the given image
     * @param is
     * @param finalName
     * @param ninePatch
     * @param ratio
     * @throws IOException
     */
    private void toNinePatch(final InputStream is, final String finalName, final NinePatch ninePatch,
            final double ratio) throws IOException {
        BufferedImage image = ImageIO.read(is);
        final int w = image.getWidth();
        final int h = image.getHeight();
        BufferedImage ninePatchImage = new BufferedImage(w + 2, h + 2, BufferedImage.TYPE_INT_ARGB);
        Graphics g = ninePatchImage.getGraphics();
        g.drawImage(image, 1, 1, null);

        // draw patch
        g.setColor(Color.BLACK);

        Zone stretch = ninePatch.getStretch();
        Zone content = ninePatch.getContent();

        if (stretch.getX() == null) {
            if (getLog().isDebugEnabled()) {
                getLog().debug("+ ninepatch stretch(x) [start=0 - size=" + w + "]");
            }
            g.fillRect(1, 0, w, 1);
        } else {
            for (int[] seg : stretch.getX()) {
                final int start = NinePatch.start(seg[0], seg[1], w, ratio);
                final int size = NinePatch.size(seg[0], seg[1], w, ratio);
                if (getLog().isDebugEnabled()) {
                    getLog().debug("+ ninepatch stretch(x) [start=" + start + " - size=" + size + "]");
                }
                g.fillRect(start + 1, 0, size, 1);
            }
        }

        if (stretch.getY() == null) {
            if (getLog().isDebugEnabled()) {
                getLog().debug("+ ninepatch stretch(y) [start=0 - size=" + h + "]");
            }
            g.fillRect(0, 1, 1, h);
        } else {
            for (int[] seg : stretch.getY()) {
                final int start = NinePatch.start(seg[0], seg[1], h, ratio);
                final int size = NinePatch.size(seg[0], seg[1], h, ratio);
                if (getLog().isDebugEnabled()) {
                    getLog().debug("+ ninepatch stretch(y) [start=" + start + " - size=" + size + "]");
                }
                g.fillRect(0, start + 1, 1, size);
            }
        }

        if (content.getX() == null) {
            if (getLog().isDebugEnabled()) {
                getLog().debug("+ ninepatch content(x) [start=0 - size=" + w + "]");
            }
            g.fillRect(1, h + 1, w, 1);
        } else {
            for (int[] seg : content.getX()) {
                final int start = NinePatch.start(seg[0], seg[1], w, ratio);
                final int size = NinePatch.size(seg[0], seg[1], w, ratio);
                if (getLog().isDebugEnabled()) {
                    getLog().debug("+ ninepatch content(x) [start=" + start + " - size=" + size + "]");
                }
                g.fillRect(start + 1, h + 1, size, 1);
            }
        }

        if (content.getY() == null) {
            if (getLog().isDebugEnabled()) {
                getLog().debug("+ ninepatch content(y) [start=0 - size=" + h + "]");
            }
            g.fillRect(w + 1, 1, 1, h);
        } else {
            for (int[] seg : content.getY()) {
                final int start = NinePatch.start(seg[0], seg[1], h, ratio);
                final int size = NinePatch.size(seg[0], seg[1], h, ratio);
                if (getLog().isDebugEnabled()) {
                    getLog().debug("+ ninepatch content(y) [start=" + start + " - size=" + size + "]");
                }
                g.fillRect(w + 1, start + 1, 1, size);
            }
        }

        ImageIO.write(ninePatchImage, "png", new File(finalName));
    }

    /**
     * List {@link QualifiedResource} from an input directory.
     * @param from
     * @param extension
     * @return
     */
    private Collection<QualifiedResource> listQualifiedResources(final File from, final String extension) {
        Preconditions.checkNotNull(extension);
        final Collection<QualifiedResource> resources = new ArrayList<>();
        if (from.isDirectory()) {
            for (File f : from.listFiles(new FileFilter() {
                public boolean accept(File file) {
                    if (file.isFile()
                            && extension.equalsIgnoreCase(FilenameUtils.getExtension(file.getAbsolutePath()))) {
                        try {
                            resources.add(QualifiedResource.fromFile(file));
                            return true;
                        } catch (Exception e) {
                            getLog().error(e);
                        }
                        getLog().warn("Invalid " + extension + " file : " + file.getAbsolutePath());
                    } else {
                        getLog().debug("+ skipping " + file.getAbsolutePath());
                    }
                    return false;
                }
            })) {
                // log matching svgmask inputs
                getLog().debug("+ found " + extension + " file : " + f.getAbsolutePath());
            }
        } else {
            throw new RuntimeException(from.getAbsolutePath() + " is not a directory");
        }
        return resources;
    }

}