From 0570c4c4d9e7bf4786a02ab770ef75f116ac3719 Mon Sep 17 00:00:00 2001
From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Date: Tue, 14 Jul 2026 13:30:33 +0100
Subject: [PATCH] Create-PDF engine: render from a structured document (#7018)
---
app/allowed-licenses.json | 12 ++
app/proprietary/build.gradle | 14 ++
.../api/CreatePdfAgentController.java | 55 ++++--
.../model/api/ai/create/AiDocument.java | 35 ++++
.../service/AiDocumentHtmlRenderer.java | 135 +++++++++++++
.../templates/ai/create}/document.html.jinja2 | 40 ++--
.../policy/engine/PolicyExecutorTest.java | 4 +-
.../service/AiDocumentHtmlRendererTest.java | 139 ++++++++++++++
build.gradle | 9 +
engine/pyproject.toml | 1 -
.../src/stirling/agents/pdf_create/agent.py | 41 ++--
engine/src/stirling/contracts/pdf_create.py | 14 +-
.../src/stirling/models/agent_tool_models.py | 2 +-
engine/tests/agents/test_pdf_create.py | 177 +++---------------
engine/uv.lock | 2 -
15 files changed, 453 insertions(+), 227 deletions(-)
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/create/AiDocument.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java
rename {engine/src/stirling/agents/pdf_create/templates => app/proprietary/src/main/resources/templates/ai/create}/document.html.jinja2 (87%)
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/AiDocumentHtmlRendererTest.java
diff --git a/app/allowed-licenses.json b/app/allowed-licenses.json
index 9f1ff96359..88ed8ba4d5 100644
--- a/app/allowed-licenses.json
+++ b/app/allowed-licenses.json
@@ -208,6 +208,18 @@
"moduleName": ".*",
"moduleLicense": "The W3C License"
},
+ {
+ "moduleName": "com.google.re2j:re2j",
+ "moduleLicense": "Go License"
+ },
+ {
+ "moduleName": "com.hubspot:algebra",
+ "moduleLicense": null
+ },
+ {
+ "moduleName": "com.hubspot.immutables:immutables-exceptions",
+ "moduleLicense": null
+ },
{
"moduleName": ".*",
"moduleLicense": "UnRar License"
diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle
index fb21ed0d0c..1e51b820ae 100644
--- a/app/proprietary/build.gradle
+++ b/app/proprietary/build.gradle
@@ -66,6 +66,20 @@ dependencies {
implementation "com.google.code.gson:gson:${gsonVersion}"
+ // jinjava/jjwt transitively request older Jackson 2 versions; declare the current
+ // version directly so it is selected consistently (root build.gradle pins are the fallback).
+ runtimeOnly "com.fasterxml.jackson.core:jackson-core:${jackson2Version}"
+ runtimeOnly "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}"
+
+ implementation("com.hubspot.jinjava:jinjava:${jinjavaVersion}") {
+ // Compile-time-only annotation artifacts (class-retention annotations, not needed at
+ // runtime) whose declared licences (LGPL / none) fail the licence compatibility check.
+ exclude group: 'com.google.code.findbugs', module: 'annotations'
+ exclude group: 'org.derive4j', module: 'derive4j-annotation'
+ exclude group: 'com.hubspot.immutables', module: 'hubspot-style'
+ exclude group: 'com.hubspot.immutables', module: 'immutable-collection-encodings'
+ }
+
api 'io.micrometer:micrometer-registry-prometheus'
api "io.jsonwebtoken:jjwt-api:${jwtVersion}"
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java
index 15b7982dec..5e2298937a 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java
@@ -8,12 +8,14 @@ import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.core.io.Resource;
+import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Hidden;
@@ -24,18 +26,24 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
+import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
+import stirling.software.proprietary.model.api.ai.create.AiDocument;
+import stirling.software.proprietary.service.AiDocumentHtmlRenderer;
+
+import tools.jackson.core.JacksonException;
+import tools.jackson.databind.ObjectMapper;
/**
- * Dispatchable tool that converts an AI-generated HTML string to a PDF via WeasyPrint.
+ * Dispatchable tool that converts an AI-generated document model to a PDF via WeasyPrint.
*
*
Called by {@link stirling.software.proprietary.service.AiWorkflowService} when the engine
- * emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The HTML comes from a trusted Jinja
- * template so sanitization is intentionally skipped.
+ * emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The engine supplies the document as
+ * structured fields; the HTML is built here from a fixed template.
*/
@Slf4j
@Hidden
@@ -48,6 +56,9 @@ public class CreatePdfAgentController {
private final TempFileManager tempFileManager;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig;
+ private final ApplicationProperties applicationProperties;
+ private final ObjectMapper objectMapper;
+ private final AiDocumentHtmlRenderer htmlRenderer;
/**
* Returns true only when WeasyPrint is definitively unavailable — either the binary could not
@@ -74,32 +85,42 @@ public class CreatePdfAgentController {
value = "/create-pdf-from-html-agent",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
- summary = "Convert AI-generated HTML to a PDF",
+ summary = "Convert an AI-generated document to a PDF",
description =
- "Accepts an HTML document as a plain-text parameter and returns a PDF."
- + " This endpoint is dispatched by the AI workflow orchestrator as a"
- + " plan step; it is not intended for direct client use.")
- public ResponseEntity createPdfFromHtml(
- @RequestParam("htmlContent") String htmlContent,
- @RequestParam("filename") String filename)
+ "Accepts a structured document as a JSON parameter and returns a PDF. This"
+ + " endpoint is dispatched by the AI workflow orchestrator as a plan"
+ + " step; it is not intended for direct client use.")
+ public ResponseEntity createPdf(
+ @RequestParam("document") String document, @RequestParam("filename") String filename)
throws Exception {
+ if (!applicationProperties.getAiEngine().isEnabled()) {
+ throw new ResponseStatusException(HttpStatus.NOT_FOUND);
+ }
+
+ AiDocument model;
+ try {
+ model = objectMapper.readValue(document, AiDocument.class);
+ } catch (JacksonException e) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
+ }
+
+ String html = htmlRenderer.render(model);
+
log.info(
- "[create-pdf-agent] converting HTML to PDF via WeasyPrint — html_bytes={}",
- htmlContent.length());
+ "[create-pdf-agent] converting document to PDF via WeasyPrint — html_bytes={}",
+ html.length());
try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html");
TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) {
- Files.writeString(htmlFile.getPath(), htmlContent, StandardCharsets.UTF_8);
+ Files.writeString(htmlFile.getPath(), html, StandardCharsets.UTF_8);
List command = new ArrayList<>();
command.add(runtimePathConfig.getWeasyPrintPath());
command.add("-e");
command.add("utf-8");
command.add("-v");
- // SSRF: the HTML is self-contained and the engine validates style colours, so no
- // external url() reaches WeasyPrint. For full isolation, run it network-isolated.
command.add(htmlFile.getAbsolutePath());
command.add(pdfFile.getAbsolutePath());
@@ -126,8 +147,8 @@ public class CreatePdfAgentController {
// avoids materialising the whole document as a byte[] twice (read-all + re-serialise),
// which matters for large generated documents.
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
- try (PDDocument document = pdfDocumentFactory.load(pdfFile.getPath())) {
- document.save(tempOut.getPath().toFile());
+ try (PDDocument pdDocument = pdfDocumentFactory.load(pdfFile.getPath())) {
+ pdDocument.save(tempOut.getPath().toFile());
} catch (Exception e) {
tempOut.close();
throw e;
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/create/AiDocument.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/create/AiDocument.java
new file mode 100644
index 0000000000..6fd95728c2
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/create/AiDocument.java
@@ -0,0 +1,35 @@
+package stirling.software.proprietary.model.api.ai.create;
+
+import java.util.List;
+
+import lombok.Data;
+
+@Data
+public class AiDocument {
+
+ private String title;
+ private String subtitle;
+ private String referenceNumber;
+ private Style style;
+ private List sections;
+
+ @Data
+ public static class Style {
+ private String primaryColor;
+ private String backgroundColor;
+ private String bodyTextColor;
+ }
+
+ @Data
+ public static class Section {
+ private String type;
+ private String heading;
+ private String body;
+ private List> pairs;
+ private List columns;
+ private List> rows;
+ private List totalRow;
+ private List items;
+ private List signatories;
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java
new file mode 100644
index 0000000000..26eff3113c
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java
@@ -0,0 +1,135 @@
+package stirling.software.proprietary.service;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.stereotype.Component;
+
+import com.hubspot.jinjava.Jinjava;
+import com.hubspot.jinjava.JinjavaConfig;
+
+import stirling.software.proprietary.model.api.ai.create.AiDocument;
+
+/** Renders an {@link AiDocument} to HTML using a Jinja template loaded from the classpath. */
+@Component
+public class AiDocumentHtmlRenderer {
+
+ private static final String TEMPLATE_PATH = "templates/ai/create/document.html.jinja2";
+
+ private static final Pattern SAFE_COLOR = Pattern.compile("^#[0-9a-fA-F]{6}$");
+
+ private final Jinjava jinjava;
+ private final String template;
+
+ public AiDocumentHtmlRenderer() {
+ JinjavaConfig config =
+ JinjavaConfig.newBuilder().withNestedInterpretationEnabled(false).build();
+ this.jinjava = new Jinjava(config);
+ this.template = loadTemplate();
+ }
+
+ public String render(AiDocument doc) {
+ return jinjava.render(template, buildContext(doc));
+ }
+
+ private static Map buildContext(AiDocument doc) {
+ Map context = new LinkedHashMap<>();
+ context.put("title", doc.getTitle());
+ context.put("subtitle", doc.getSubtitle());
+ context.put("reference_number", doc.getReferenceNumber());
+
+ AiDocument.Style style = doc.getStyle();
+ if (style != null) {
+ context.put("style_primary", safeColor(style.getPrimaryColor()));
+ context.put("style_background", safeColor(style.getBackgroundColor()));
+ context.put("style_body", safeColor(style.getBodyTextColor()));
+ }
+
+ List