RenderPdfMojo.java
package network.ike.docs.plugin;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;
import java.io.File;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Render PDF from intermediate files using an external renderer.
*
* <p>Wraps the five PDF renderers supported by the IKE documentation
* pipeline into a single Maven goal with consistent configuration,
* executable validation, and multi-document support.
*
* <p>Renderers fall into two families:
* <ul>
* <li><b>CSS-based</b> ({@code prince}, {@code ah}, {@code weasyprint})
* — convert print-layout HTML to PDF via CSS Paged Media</li>
* <li><b>FO-based</b> ({@code xep}, {@code fop})
* — convert XSL-FO to PDF</li>
* </ul>
*
* <p>By default, the goal discovers all input files in {@code inputDir}
* and renders each one. To render specific documents, set the
* {@code documents} parameter to a list of base names (without extension).
*
* <p>If the goal is skipped, it produces no log output at all — unlike
* exec-maven-plugin which logs "skipping" for every skipped execution.
*
* <p>Usage in a POM:
* <pre>
* <execution>
* <id>prince-pdf</id>
* <phase>package</phase>
* <goals><goal>render-pdf</goal></goals>
* <configuration>
* <renderer>prince</renderer>
* <inputDir>${asciidoc.output.directory}/pdf-html-prince</inputDir>
* <outputDir>${asciidoc.output.directory}/pdf-prince</outputDir>
* <stylesheet>${asciidoc.output.directory}/pdf-html-prince/ike-print.css</stylesheet>
* </configuration>
* </execution>
* </pre>
*/
@Mojo(name = "render-pdf",
defaultPhase = "package",
projectRequired = true)
public class RenderPdfMojo implements org.apache.maven.api.plugin.Mojo {
@org.apache.maven.api.di.Inject
private org.apache.maven.api.plugin.Log log;
/**
* Access the Maven logger.
*
* @return the logger
*/
protected org.apache.maven.api.plugin.Log getLog() { return log; }
/**
* Renderer to use. One of: {@code prince}, {@code ah},
* {@code weasyprint}, {@code xep}, {@code fop}.
*/
@Parameter(property = "ike.renderer", required = true)
private String renderer;
/**
* Path to the renderer executable. Defaults are renderer-specific:
* {@code prince}, {@code AHFCmd}, {@code weasyprint}, {@code java}.
*/
@Parameter(property = "ike.renderer.executable")
private String executable;
/** Skip this execution entirely (no log output when skipped). */
@Parameter(property = "ike.renderer.skip", defaultValue = "false")
private boolean skip;
/**
* Directory containing intermediate files (HTML for CSS renderers,
* FO for FO renderers).
*/
@Parameter(required = true)
private File inputDir;
/** Directory where rendered PDFs will be written. */
@Parameter(required = true)
private File outputDir;
// ── CSS renderer parameters ──────────────────────────────────────
/** Print stylesheet for CSS renderers (prince, ah, weasyprint). */
@Parameter
private File stylesheet;
/**
* Ordered print stylesheets for CSS renderers. When non-empty these
* are applied in order and take the place of {@link #stylesheet}, each
* becoming a separate {@code --style} (prince), {@code -css} (ah), or
* {@code --stylesheet} (weasyprint) argument — letting one render layer
* an overlay on top of the base stylesheet (e.g. the loose-leaf variant
* passes {@code ike-print.css} then {@code master-folios.css}). If
* empty, {@link #stylesheet} is used.
*/
@Parameter
private List<File> stylesheets;
/**
* PDF profile for CSS renderers.
* Prince uses {@code PDF/UA-1}, AH uses {@code @PDF/UA-1}.
*/
@Parameter(property = "ike.renderer.pdfProfile", defaultValue = "PDF/UA-1")
private String pdfProfile;
// ── FO renderer parameters ───────────────────────────────────────
/** Configuration file for FO renderers (XEP config or FOP xconf). */
@Parameter
private File configFile;
/**
* Classpath for FO renderers that run via {@code java}.
* Colon-separated paths to JAR files.
*/
@Parameter
private String classpath;
// ── Document selection ────────────────────────────────────────────
/**
* Specific documents to render (base names without extension).
* If empty, all input files in {@code inputDir} are rendered.
*/
@Parameter
private List<String> documents;
/**
* Restrict rendering to HTML documents whose {@code <body>} element
* carries this CSS class. Gates a variant by Asciidoctor doctype — e.g.
* {@code book} renders only book-doctype documents (whose body is
* {@code <body class="book">}), skipping articles. Ignored for FO
* renderers. If unset, no filtering is applied.
*/
@Parameter
private String bodyClassFilter;
/**
* Suffix inserted before the {@code .pdf} extension of each output
* file, so a second render of the same inputs produces sibling files
* without collision — e.g. {@code -loose-leaf} turns {@code guide.pdf}
* into {@code guide-loose-leaf.pdf}. Empty by default.
*/
@Parameter
private String outputSuffix;
/** Log file for renderer output. */
@Parameter
private File logFile;
/** Creates this goal instance. */
public RenderPdfMojo() {}
@Override
public void execute() throws MojoException {
if (skip) {
return;
}
RendererType type = resolveRenderer(renderer);
String resolvedExecutable = resolveExecutable(type);
validateExecutable(resolvedExecutable, type);
List<Path> inputs = discoverInputFiles(type);
if (inputs.isEmpty()) {
getLog().info("render-pdf [" + renderer + "]: no input files in "
+ inputDir);
return;
}
try {
Files.createDirectories(outputDir.toPath());
} catch (IOException e) {
throw new MojoException(
"Failed to create output directory: " + outputDir, e);
}
int rendered = 0;
for (Path input : inputs) {
String baseName = stripExtension(input.getFileName().toString());
String suffix = (outputSuffix != null) ? outputSuffix : "";
Path output = outputDir.toPath().resolve(baseName + suffix + ".pdf");
List<String> command = buildCommand(
type, resolvedExecutable, input, output);
try {
int exitCode = invokeRenderer(command);
if (exitCode != 0) {
throw new MojoException(
"Renderer " + renderer + " failed with exit code "
+ exitCode + " for " + input.getFileName());
}
rendered++;
} catch (IOException | InterruptedException e) {
throw new MojoException(
"Failed to invoke " + renderer + " for "
+ input.getFileName(), e);
}
}
getLog().info("render-pdf [" + renderer + "]: rendered " + rendered
+ " document(s) to " + outputDir);
}
// ── Renderer types ───────────────────────────────────────────────
enum RendererType {
/** Prince XML — CSS Paged Media. */
PRINCE("prince", ".html"),
/** Antenna House Formatter — CSS Paged Media. */
AH("AHFCmd", ".html"),
/** WeasyPrint — CSS Paged Media. */
WEASYPRINT("weasyprint", ".html"),
/** RenderX XEP — XSL-FO. */
XEP("java", ".fo"),
/** Apache FOP — XSL-FO. */
FOP("java", ".fo");
final String defaultExecutable;
final String inputExtension;
RendererType(String defaultExecutable, String inputExtension) {
this.defaultExecutable = defaultExecutable;
this.inputExtension = inputExtension;
}
boolean isCssBased() {
return inputExtension.equals(".html");
}
}
// ── Command building ─────────────────────────────────────────────
List<String> buildCommand(RendererType type, String exe,
Path input, Path output) {
return switch (type) {
case PRINCE -> buildPrinceCommand(exe, input, output);
case AH -> buildAhCommand(exe, input, output);
case WEASYPRINT -> buildWeasyprintCommand(exe, input, output);
case XEP -> buildXepCommand(exe, input, output);
case FOP -> buildFopCommand(exe, input, output);
};
}
private List<String> buildPrinceCommand(String exe,
Path input, Path output) {
ArrayList<String> cmd = new ArrayList<String>();
cmd.add(exe);
cmd.add("--silent");
cmd.add(input.toString());
for (File css : effectiveStylesheets()) {
cmd.add("--style");
cmd.add(css.toString());
}
cmd.add("--output");
cmd.add(output.toString());
if (pdfProfile != null && !pdfProfile.isEmpty()) {
cmd.add("--pdf-profile=" + pdfProfile);
}
return cmd;
}
private List<String> buildAhCommand(String exe,
Path input, Path output) {
ArrayList<String> cmd = new ArrayList<String>();
cmd.add(exe);
cmd.add("-cssmode");
for (File css : effectiveStylesheets()) {
cmd.add("-css");
cmd.add(css.toString());
}
cmd.add("-d");
cmd.add(input.toString());
cmd.add("-o");
cmd.add(output.toString());
if (pdfProfile != null && !pdfProfile.isEmpty()) {
cmd.add("-p");
cmd.add("@" + pdfProfile);
}
return cmd;
}
private List<String> buildWeasyprintCommand(String exe,
Path input, Path output) {
ArrayList<String> cmd = new ArrayList<String>();
cmd.add(exe);
cmd.add(input.toString());
cmd.add(output.toString());
for (File css : effectiveStylesheets()) {
cmd.add("--stylesheet");
cmd.add(css.toString());
}
return cmd;
}
private List<String> buildXepCommand(String exe,
Path input, Path output) {
ArrayList<String> cmd = new ArrayList<String>();
cmd.add(exe);
if (classpath != null && !classpath.isEmpty()) {
cmd.add("-classpath");
cmd.add(classpath);
}
if (configFile != null) {
cmd.add("-Dcom.renderx.xep.CONFIG=" + configFile);
}
cmd.add("com.renderx.xep.XSLDriver");
cmd.add("-fo");
cmd.add(input.toString());
cmd.add("-pdf");
cmd.add(output.toString());
return cmd;
}
private List<String> buildFopCommand(String exe,
Path input, Path output) {
ArrayList<String> cmd = new ArrayList<String>();
cmd.add(exe);
if (classpath != null && !classpath.isEmpty()) {
cmd.add("-classpath");
cmd.add(classpath);
}
cmd.add("org.apache.fop.cli.Main");
cmd.add("-r");
if (configFile != null) {
cmd.add("-c");
cmd.add(configFile.toString());
}
cmd.add("-fo");
cmd.add(input.toString());
cmd.add("-pdf");
cmd.add(output.toString());
return cmd;
}
/**
* The ordered stylesheets to apply for CSS renderers.
*
* @return {@link #stylesheets} when non-empty, otherwise the single
* {@link #stylesheet} (or an empty list when neither is set)
*/
private List<File> effectiveStylesheets() {
if (stylesheets != null && !stylesheets.isEmpty()) {
return stylesheets;
}
return (stylesheet != null) ? List.of(stylesheet) : List.of();
}
// ── Process invocation ───────────────────────────────────────────
/**
* Invoke the renderer as an external process.
*
* @param command the full command line
* @return process exit code
* @throws IOException if the process cannot be started
* @throws InterruptedException if the process is interrupted
*/
int invokeRenderer(List<String> command)
throws IOException, InterruptedException {
getLog().debug("render-pdf: " + String.join(" ", command));
ProcessBuilder builder = new ProcessBuilder(command);
builder.redirectErrorStream(true);
if (logFile != null) {
Files.createDirectories(logFile.toPath().getParent());
builder.redirectOutput(
ProcessBuilder.Redirect.appendTo(logFile));
} else {
builder.redirectOutput(ProcessBuilder.Redirect.DISCARD);
}
Process process = builder.start();
return process.waitFor();
}
// ── Document discovery ───────────────────────────────────────────
/**
* Discover input files to render.
*
* <p>If {@code documents} is set, only those named files are returned.
* Otherwise, all files in {@code inputDir} matching the renderer's
* input extension are returned.
*
* @param type the renderer type (determines input extension)
* @return list of input file paths
* @throws MojoException if inputDir is not a directory
*/
List<Path> discoverInputFiles(RendererType type)
throws MojoException {
if (!inputDir.isDirectory()) {
return List.of();
}
String ext = type.inputExtension;
ArrayList<Path> result = new ArrayList<Path>();
if (documents != null && !documents.isEmpty()) {
// Explicit document list
for (String doc : documents) {
Path file = inputDir.toPath().resolve(doc + ext);
if (Files.isRegularFile(file)) {
result.add(file);
} else {
getLog().warn("render-pdf [" + renderer
+ "]: document not found — " + file.getFileName());
}
}
} else {
// Auto-discover all input files
try (DirectoryStream<Path> stream =
Files.newDirectoryStream(inputDir.toPath(),
"*" + ext)) {
for (Path file : stream) {
if (Files.isRegularFile(file)) {
result.add(file);
}
}
} catch (IOException e) {
throw new MojoException(
"Failed to scan input directory: " + inputDir, e);
}
}
if (bodyClassFilter != null && !bodyClassFilter.isEmpty()
&& type.isCssBased()) {
result.removeIf(file -> {
if (htmlBodyHasClass(file, bodyClassFilter)) {
return false;
}
getLog().info("render-pdf [" + renderer + "]: skipping "
+ file.getFileName() + " — <body> class is not '"
+ bodyClassFilter + "'");
return true;
});
}
return result;
}
// ── Helpers ──────────────────────────────────────────────────────
static RendererType resolveRenderer(String name)
throws MojoException {
return switch (name.toLowerCase()) {
case "prince" -> RendererType.PRINCE;
case "ah", "antennahouse" -> RendererType.AH;
case "weasyprint" -> RendererType.WEASYPRINT;
case "xep" -> RendererType.XEP;
case "fop" -> RendererType.FOP;
default -> throw new MojoException(
"Unknown renderer: " + name
+ ". Supported: prince, ah, weasyprint, xep, fop");
};
}
private String resolveExecutable(RendererType type) {
return (executable != null && !executable.isEmpty())
? executable
: type.defaultExecutable;
}
private void validateExecutable(String exe, RendererType type)
throws MojoException {
// For java-based renderers (xep, fop), validate classpath instead
if (type == RendererType.XEP || type == RendererType.FOP) {
if (classpath == null || classpath.isEmpty()) {
throw new MojoException(
"Renderer " + renderer
+ " requires <classpath> to be set");
}
return;
}
// For external executables, check if on PATH
try {
ProcessBuilder check = new ProcessBuilder("which", exe);
check.redirectErrorStream(true);
check.redirectOutput(ProcessBuilder.Redirect.DISCARD);
int result = check.start().waitFor();
if (result != 0) {
throw new MojoException(
"Renderer executable not found: " + exe
+ ". Install it or set <executable>.");
}
} catch (IOException | InterruptedException e) {
getLog().warn("Could not validate executable '" + exe
+ "': " + e.getMessage());
}
}
static String stripExtension(String filename) {
int dot = filename.lastIndexOf('.');
return (dot > 0) ? filename.substring(0, dot) : filename;
}
/** Matches the {@code class} attribute of the HTML {@code <body>} element. */
private static final Pattern BODY_CLASS = Pattern.compile(
"<body\\b[^>]*\\bclass\\s*=\\s*[\"']([^\"']*)[\"']",
Pattern.CASE_INSENSITIVE);
/**
* Test whether an HTML file's {@code <body>} element carries a given
* CSS class token.
*
* @param html the HTML file to inspect
* @param requiredClass the class token to look for (e.g. {@code book})
* @return {@code true} if the {@code <body>} class list contains the
* token; {@code false} if it does not, or the file cannot be read
*/
private boolean htmlBodyHasClass(Path html, String requiredClass) {
try {
Matcher matcher = BODY_CLASS.matcher(Files.readString(html));
if (matcher.find()) {
for (String token : matcher.group(1).trim().split("\\s+")) {
if (token.equals(requiredClass)) {
return true;
}
}
}
return false;
} catch (IOException e) {
getLog().warn("render-pdf [" + renderer + "]: could not read "
+ html.getFileName() + " for body-class filter: "
+ e.getMessage());
return false;
}
}
}