Newer
Older
gradle-lectures / buildSrc / src / main / java / nz / stanger / lecture / GenerateImageFromSvgTask.java
package nz.stanger.lecture;

import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Map;
import java.util.Set;
import org.apache.batik.anim.dom.SAXSVGDocumentFactory;
import org.apache.batik.transcoder.SVGAbstractTranscoder;
import org.apache.batik.transcoder.Transcoder;
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.PNGTranscoder;
import org.apache.batik.util.XMLResourceDescriptor;
import org.apache.commons.io.FilenameUtils;
import org.apache.fop.activity.ContainerUtil;
import org.apache.fop.configuration.Configuration;
import org.apache.fop.configuration.ConfigurationException;
import org.apache.fop.configuration.DefaultConfigurationBuilder;
import org.apache.fop.svg.PDFTranscoder;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.FileType;
import org.gradle.api.tasks.TaskAction;
import org.gradle.work.ChangeType;
import org.gradle.work.InputChanges;
import org.w3c.dom.svg.SVGDocument;
import org.w3c.dom.svg.SVGElement;

/**
 * Generate PDF or PNG output from SVG source.
 *
 * See {@link https://plantuml.com/api} for details.
 */
abstract public class GenerateImageFromSvgTask extends GenerateImageFromSourceTask {

    /**
     * Files to include in this task. These are Ant-style include and exclude
     * patterns as per {@code PatternFilterable}. They can be modified by
     * further calls to {@code include} or {@code exclude}, or replaced entirely
     * by calling {@code setIncludes}.
     *
     * @see org.gradle.api.tasks.util.PatternFilterable
     */
    private static final Set<String> INCLUDE_FILES = Set.of("*.svg");

    /**
     * Maps image formats to corresponding Batik transcoders.
     */
    private static final Map<ImageFormat, Transcoder> TRANSCODERS = Map.of(
            ImageFormat.PDF, new PDFTranscoder(),
            ImageFormat.PNG, new PNGTranscoder()
    );

    /**
     * Constructs a task to generate images from SVG input.
     */
    public GenerateImageFromSvgTask() {
        DirectoryProperty imageDir = getProject().getExtensions().getByType(LectureExtension.class).getImageDir();
        getSource().setDir(imageDir);
        include(INCLUDE_FILES);
        getOutputDir().convention(imageDir);
        getOutputFormat().convention(ImageFormat.PDF);
        setGroup(Lecture.LECTURE_TASK_GROUP);
    }

    /**
     * Runs this task.
     *
     * @param inputChanges the inputs that have changed
     */
    @TaskAction
    public void runTask(InputChanges inputChanges) {
        inputChanges.getFileChanges(getSource()).forEach(change -> {
            if (change.getFileType() != FileType.DIRECTORY && change.getChangeType() != ChangeType.REMOVED) {
                String inputFilename = change.getNormalizedPath();
                String outputFilename = FilenameUtils.removeExtension(inputFilename) + getOutputFormat().get().getFileSuffix();

                try {
                    File outputFile = getOutputDir().file(outputFilename).get().getAsFile();

                    String svgSource = Files.readString(change.getFile().toPath(), StandardCharsets.UTF_8);

                    // https://xmlgraphics.apache.org/batik/using/dom-api.html
                    String parser = XMLResourceDescriptor.getXMLParserClassName();
                    SAXSVGDocumentFactory factory = new SAXSVGDocumentFactory(parser);

                    // https://www.programcreek.com/java-api-examples/?api=org.apache.batik.dom.svg.SAXSVGDocumentFactory
                    SVGDocument svgDocument = factory.createSVGDocument(null, new StringReader(svgSource));

                    // https://thinktibits.blogspot.com/2012/12/batik-convert-svg-to-pdf-java-example.html
                    // Step 1: read input SVG document into TranscoderInput.
                    TranscoderInput inputSvgImage = new TranscoderInput(svgDocument);

                    // Step 2: define OutputStream to target image and attach to TranscoderOutput
                    ByteArrayOutputStream imageOutputStream = new ByteArrayOutputStream();
                    TranscoderOutput imageOutput = new TranscoderOutput(imageOutputStream);

                    // Step 3: create appropriate image Transcoder.
                    Transcoder transcoder = TRANSCODERS.get(getOutputFormat().get());

                    switch (getOutputFormat().get()) {
                        case PNG -> {
                            // The raster images that PlantUML generates look like shit
                            // because they're only 72 DPI, and PlantUML provides no way
                            // to specify higher DPI. The solution is to rasterise the
                            // SVG at higher DPI and scale the width and height accordingly.
                            SVGElement svgRoot = svgDocument.getRootElement();
                            // The width and height attributes may have units,
                            // most likely "px". Just strip out anything that's
                            // not a number
                            Float scaledWidth = Float.valueOf(svgRoot.getAttribute("width").replaceAll("[^\\d]+", "")) * SCALE_BY_RESOLUTION;
                            Float scaledHeight = Float.valueOf(svgRoot.getAttribute("height").replaceAll("[^\\d]+", "")) * SCALE_BY_RESOLUTION;
                            transcoder.addTranscodingHint(ImageTranscoder.KEY_BACKGROUND_COLOR, Color.WHITE);
                            transcoder.addTranscodingHint(ImageTranscoder.KEY_WIDTH, scaledWidth);
                            transcoder.addTranscodingHint(ImageTranscoder.KEY_HEIGHT, scaledHeight);
                            transcoder.addTranscodingHint(ImageTranscoder.KEY_PIXEL_UNIT_TO_MILLIMETER, PIXEL_UNIT_TO_MM);
                        }
                        case PDF -> {
                            // This seems to be necessary to ensure properly
                            // embedded fonts when generating PDFs.
                            transcoder.addTranscodingHint(SVGAbstractTranscoder.KEY_DEFAULT_FONT_FAMILY, "Open Sans, Helvetica, Verdana, Arial, sans serif");
                            // See https://stackoverflow.com/q/47884893 and https://stackoverflow.com/a/72807651
                            DefaultConfigurationBuilder configBuilder = new DefaultConfigurationBuilder();
                            // See https://stackoverflow.com/a/20389418
                            Configuration config = configBuilder.build(getClass().getResourceAsStream("/" + FOP_CONFIG_FILENAME));
                            ContainerUtil.configure((PDFTranscoder) transcoder, config);
                        }
                        default -> throw new TranscoderException("invalid output format: " + getOutputFormat().get().toString());
                    }

                    // Step 4: write output to target format
                    transcoder.transcode(inputSvgImage, imageOutput);

                    // Step 5: close and flush output stream
                    imageOutputStream.flush();
                    imageOutputStream.close();
                    try ( OutputStream fileOutputStream = new FileOutputStream(outputFile);) {
                        imageOutputStream.writeTo(fileOutputStream);
                    }
                } catch (IOException | TranscoderException | ConfigurationException e) {
                    System.out.println("failed to generate " + outputFilename);
//                    e.printStackTrace();
                }
            }
        });
    }

}