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