Newer
Older
gradle-lectures / buildSrc / src / main / java / nz / stanger / lecture / GenerateImageFromPlantumlTask.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 net.sourceforge.plantuml.FileFormat;
import net.sourceforge.plantuml.FileFormatOption;
import net.sourceforge.plantuml.SourceStringReader;
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.provider.Property;
import org.gradle.api.tasks.Internal;
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, PNG, or SVG output from PlantUML source.
 *
 * See {@link https://plantuml.com/api} for details.
 */
abstract public class GenerateImageFromPlantumlTask extends GenerateImageFromSourceTask {

    /**
     * Whether this task includes Salt diagrams (default false). This is an
     * explicit property rather than inferred from the setter to avoid calling
     * an overridable method in the constructor.
     */
    private final Property<Boolean> salt = getProject().getObjects().property(Boolean.class);

    /**
     * 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("*.pu", "*.puml", "*.plantuml");

    /**
     * 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 PlantUML input.
     */
    public GenerateImageFromPlantumlTask() {
        DirectoryProperty imageDir = getProject().getExtensions().getByType(LectureExtension.class).getImageDir();
        getSource().setDir(imageDir);
        include(INCLUDE_FILES);
        getOutputDir().convention(imageDir);
        getOutputFormat().convention(ImageFormat.SVG);
        salt.convention(false);
        setGroup(Lecture.LECTURE_TASK_GROUP);
    }

    /**
     * Returns whether or not this task includes Salt diagrams.
     *
     * @return true if this task includes Salt diagrams, otherwise false
     */
    @Internal
    public Property<Boolean> getSalt() {
        return salt;
    }

    /**
     * Runs this task.
     *
     * @param inputChanges the inputs that have changed
     */
    @TaskAction
    public void runTask(InputChanges inputChanges) {
        /**
         * Workaround for relative !include directives in PlantUML source.
         *
         * Because the PlantUML source is rendered from a string variable, it
         * has no real reference to the file system. Directives like {@code !include
         * foo.pu} that assume {@code foo.pu} is in the same directory will thus
         * fail.
         *
         * PlantUML provides the system property {@code plantuml.include.path}
         * to set the include search path
         * ({@link https://plantuml.com/preprocessing}, "Search path"). Normally
         * system properties are set in {@code gradle.properties}, but that
         * would hard code the name of the image source directory, breaking
         * configurability.
         *
         * We can't set it in either the root or sub-project
         * {@code gradle.properties} anyway, as the path needs to be relative to
         * the sub-project, not the root project, and "In a multi project build,
         * 'systemProp.' properties set in any project except the root will be
         * ignored."
         * ({@link https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_system_properties})
         *
         * The solution is to explicitly set {@code plantuml.include.path} at
         * run time to the absolute path of the sub-project's images source
         * directory.
         */
        System.setProperty("plantuml.include.path", getSource().getDir().getAbsolutePath());

        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 ( ByteArrayOutputStream svgOutputStream = new ByteArrayOutputStream();) {
                    // Generate SVG regardless as as all other formats are transcoded
                    // from it anyway.
                    File outputFile = getOutputDir().file(outputFilename).get().getAsFile();

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

                    SourceStringReader reader = new SourceStringReader(plantUmlSource);
                    reader.outputImage(svgOutputStream, new FileFormatOption(FileFormat.SVG));
                    String svgSource = new String(svgOutputStream.toByteArray(), StandardCharsets.UTF_8);

                    /**
                     * Workaround for lack of font support in Salt diagrams.
                     *
                     * Salt diagrams have no way (yet) to specify the font used.
                     * It comes out as "sans-serif" in the SVG, which will
                     * probably become something like Helvetica in the PDF, but
                     * who knows?. It may look awful as a result. As a hacky
                     * workaround we can just replace the "sans-serif" font
                     * family specification in the generated SVG before doing
                     * anything else.
                     *
                     * Maybe there's a way to tell FOP to do this for us?
                     */
                    if (salt.get()) {
                        // I'd like to use Roboto here, but it somehow loses the bold face :(.
                        svgSource = svgSource.replaceAll("font-family=\"sans-serif\"", "font-family=\"Open Sans, Helvetica, Verdana, Arial, sans-serif\"");
                    }

                    // If we're just doing SVG, we can stop here.
                    if (getOutputFormat().get() == ImageFormat.SVG) {
                        Files.writeString(outputFile.toPath(), svgSource, StandardCharsets.UTF_8);
                        return;
                    }

                    // 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();
                }
            }
        });
    }

}