mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Create-PDF engine: render from a structured document (#7018)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
+38
-17
@@ -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.
|
||||
*
|
||||
* <p>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<Resource> 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<Resource> 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<String> 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;
|
||||
|
||||
+35
@@ -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<Section> 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<List<String>> pairs;
|
||||
private List<String> columns;
|
||||
private List<List<String>> rows;
|
||||
private List<String> totalRow;
|
||||
private List<String> items;
|
||||
private List<String> signatories;
|
||||
}
|
||||
}
|
||||
+135
@@ -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<String, Object> buildContext(AiDocument doc) {
|
||||
Map<String, Object> 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<Map<String, Object>> sections = new ArrayList<>();
|
||||
if (doc.getSections() != null) {
|
||||
for (AiDocument.Section section : doc.getSections()) {
|
||||
if (section != null && section.getType() != null) {
|
||||
sections.add(buildSection(section));
|
||||
}
|
||||
}
|
||||
}
|
||||
context.put("sections", sections);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static Map<String, Object> buildSection(AiDocument.Section section) {
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("type", section.getType());
|
||||
node.put("heading", section.getHeading());
|
||||
switch (section.getType()) {
|
||||
case "text" -> node.put("paragraphs", paragraphs(section.getBody()));
|
||||
case "key_value" -> node.put("pairs", pairs(section.getPairs()));
|
||||
case "line_items" -> {
|
||||
node.put("columns", orEmpty(section.getColumns()));
|
||||
node.put("rows", orEmptyRows(section.getRows()));
|
||||
node.put("total_row", emptyToNull(section.getTotalRow()));
|
||||
}
|
||||
case "bullet_list" -> node.put("items", orEmpty(section.getItems()));
|
||||
case "signature" -> node.put("signatories", orEmpty(section.getSignatories()));
|
||||
default -> {}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private static List<String> paragraphs(String body) {
|
||||
String text = body == null ? "" : body;
|
||||
List<String> out = new ArrayList<>();
|
||||
for (String paragraph : text.split("\n\n")) {
|
||||
out.add(paragraph.replace("\n", " "));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static List<Map<String, String>> pairs(List<List<String>> pairs) {
|
||||
List<Map<String, String>> out = new ArrayList<>();
|
||||
if (pairs != null) {
|
||||
for (List<String> pair : pairs) {
|
||||
Map<String, String> node = new LinkedHashMap<>();
|
||||
node.put("label", pair.isEmpty() ? "" : pair.get(0));
|
||||
node.put("value", pair.size() < 2 ? "" : pair.get(1));
|
||||
out.add(node);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static List<String> orEmpty(List<String> values) {
|
||||
return values == null ? List.of() : values;
|
||||
}
|
||||
|
||||
private static List<List<String>> orEmptyRows(List<List<String>> rows) {
|
||||
return rows == null ? List.of() : rows;
|
||||
}
|
||||
|
||||
private static List<String> emptyToNull(List<String> values) {
|
||||
return values == null || values.isEmpty() ? null : values;
|
||||
}
|
||||
|
||||
private static String safeColor(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return SAFE_COLOR.matcher(trimmed).matches() ? trimmed : null;
|
||||
}
|
||||
|
||||
private static String loadTemplate() {
|
||||
try {
|
||||
return new ClassPathResource(TEMPLATE_PATH).getContentAsString(StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-19
@@ -1,3 +1,4 @@
|
||||
{%- autoescape true -%}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -175,18 +176,18 @@
|
||||
color: var(--color-label);
|
||||
}
|
||||
</style>
|
||||
{%- if doc.style %}
|
||||
{%- if style_primary or style_background or style_body %}
|
||||
<style>
|
||||
:root {
|
||||
{%- if doc.style.primary_color %}
|
||||
--color-primary: {{ doc.style.primary_color }};
|
||||
{%- if style_primary %}
|
||||
--color-primary: {{ style_primary }};
|
||||
{%- endif %}
|
||||
{%- if doc.style.background_color %}
|
||||
--color-bg: {{ doc.style.background_color }};
|
||||
{%- if style_background %}
|
||||
--color-bg: {{ style_background }};
|
||||
{%- endif %}
|
||||
{%- if doc.style.body_text_color %}
|
||||
--color-body: {{ doc.style.body_text_color }};
|
||||
--color-label: {{ doc.style.body_text_color }};
|
||||
{%- if style_body %}
|
||||
--color-body: {{ style_body }};
|
||||
--color-label: {{ style_body }};
|
||||
{%- endif %}
|
||||
}
|
||||
</style>
|
||||
@@ -195,16 +196,16 @@
|
||||
<body>
|
||||
|
||||
<div class="doc-header">
|
||||
<div class="doc-title">{{ doc.title }}</div>
|
||||
{%- if doc.subtitle %}
|
||||
<div class="doc-subtitle">{{ doc.subtitle }}</div>
|
||||
<div class="doc-title">{{ title }}</div>
|
||||
{%- if subtitle %}
|
||||
<div class="doc-subtitle">{{ subtitle }}</div>
|
||||
{%- endif %}
|
||||
{%- if doc.reference_number %}
|
||||
<div class="doc-reference">{{ doc.reference_number }}</div>
|
||||
{%- if reference_number %}
|
||||
<div class="doc-reference">{{ reference_number }}</div>
|
||||
{%- endif %}
|
||||
</div>
|
||||
|
||||
{%- for section in doc.sections %}
|
||||
{%- for section in sections %}
|
||||
|
||||
{%- if section.type == "text" %}
|
||||
<section>
|
||||
@@ -212,8 +213,8 @@
|
||||
<h2>{{ section.heading }}</h2>
|
||||
{%- endif %}
|
||||
<div class="text-body">
|
||||
{%- for para in section.body.split('\n\n') %}
|
||||
<p>{{ para | replace('\n', ' ') }}</p>
|
||||
{%- for para in section.paragraphs %}
|
||||
<p>{{ para }}</p>
|
||||
{%- endfor %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -225,10 +226,10 @@
|
||||
{%- endif %}
|
||||
<table class="kv-table">
|
||||
<tbody>
|
||||
{%- for label, value in section.pairs %}
|
||||
{%- for pair in section.pairs %}
|
||||
<tr>
|
||||
<td class="kv-label">{{ label }}</td>
|
||||
<td class="kv-value">{{ value }}</td>
|
||||
<td class="kv-label">{{ pair.label }}</td>
|
||||
<td class="kv-value">{{ pair.value }}</td>
|
||||
</tr>
|
||||
{%- endfor %}
|
||||
</tbody>
|
||||
@@ -299,3 +300,4 @@
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{%- endautoescape %}
|
||||
+2
-2
@@ -166,8 +166,8 @@ class PolicyExecutorTest {
|
||||
new PipelineStep(
|
||||
createPdf,
|
||||
Map.of(
|
||||
"htmlContent",
|
||||
"<p>hi</p>",
|
||||
"document",
|
||||
"{\"title\":\"PO\",\"sections\":[]}",
|
||||
"filename",
|
||||
"purchase-order.pdf"))),
|
||||
PolicyInputs.of(List.of()),
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.proprietary.model.api.ai.create.AiDocument;
|
||||
|
||||
class AiDocumentHtmlRendererTest {
|
||||
|
||||
private final AiDocumentHtmlRenderer renderer = new AiDocumentHtmlRenderer();
|
||||
|
||||
private static AiDocument.Section section(String type) {
|
||||
AiDocument.Section s = new AiDocument.Section();
|
||||
s.setType(type);
|
||||
return s;
|
||||
}
|
||||
|
||||
private static AiDocument document(String title, List<AiDocument.Section> sections) {
|
||||
AiDocument doc = new AiDocument();
|
||||
doc.setTitle(title);
|
||||
doc.setSections(sections);
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersAllSectionTypes() {
|
||||
AiDocument.Section text = section("text");
|
||||
text.setBody("Some prose text.");
|
||||
AiDocument.Section kv = section("key_value");
|
||||
kv.setPairs(List.of(List.of("Key", "Value")));
|
||||
AiDocument.Section items = section("line_items");
|
||||
items.setColumns(List.of("A", "B"));
|
||||
items.setRows(List.of(List.of("1", "2")));
|
||||
AiDocument.Section bullets = section("bullet_list");
|
||||
bullets.setItems(List.of("item one"));
|
||||
AiDocument.Section sign = section("signature");
|
||||
sign.setSignatories(List.of("Alice"));
|
||||
|
||||
String html = renderer.render(document("All", List.of(text, kv, items, bullets, sign)));
|
||||
|
||||
assertTrue(html.contains("<!DOCTYPE html>"));
|
||||
assertTrue(html.contains("Some prose text."));
|
||||
assertTrue(html.contains("Key") && html.contains("Value"));
|
||||
assertTrue(html.contains("<th>"));
|
||||
assertTrue(html.contains("item one"));
|
||||
assertTrue(html.contains("Alice"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersMarkupCharactersAsText() {
|
||||
AiDocument.Section text = section("text");
|
||||
text.setBody("a <b>x</b> & y");
|
||||
|
||||
String html = renderer.render(document("Doc", List.of(text)));
|
||||
|
||||
assertFalse(html.contains("<b>"));
|
||||
assertTrue(html.contains("<b>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void totalRowRenderedWhenPresent() {
|
||||
AiDocument.Section items = section("line_items");
|
||||
items.setColumns(List.of("Item", "Total"));
|
||||
items.setRows(List.of(List.of("Widget", "$10")));
|
||||
items.setTotalRow(List.of("Total", "$10"));
|
||||
|
||||
assertTrue(
|
||||
renderer.render(document("Table", List.of(items)))
|
||||
.contains("<tr class=\"total-row\">"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void totalRowAbsentWhenNotProvided() {
|
||||
AiDocument.Section items = section("line_items");
|
||||
items.setColumns(List.of("Item"));
|
||||
items.setRows(List.of(List.of("Widget")));
|
||||
|
||||
assertFalse(
|
||||
renderer.render(document("Table", List.of(items)))
|
||||
.contains("<tr class=\"total-row\">"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersSubtitleAndReference() {
|
||||
AiDocument doc = document("My Doc", List.of());
|
||||
doc.setSubtitle("Subtitle Here");
|
||||
doc.setReferenceNumber("REF-42");
|
||||
|
||||
String html = renderer.render(doc);
|
||||
|
||||
assertTrue(html.contains("Subtitle Here"));
|
||||
assertTrue(html.contains("REF-42"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesHexColourOverride() {
|
||||
AiDocument doc = document("Styled", List.of());
|
||||
AiDocument.Style style = new AiDocument.Style();
|
||||
style.setPrimaryColor("#ff00ff");
|
||||
style.setBackgroundColor("#111111");
|
||||
doc.setStyle(style);
|
||||
|
||||
String html = renderer.render(doc);
|
||||
|
||||
assertTrue(html.contains("--color-primary: #ff00ff"));
|
||||
assertTrue(html.contains("--color-bg: #111111"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresColourWithDisallowedCharacters() {
|
||||
AiDocument doc = document("Styled", List.of());
|
||||
AiDocument.Style style = new AiDocument.Style();
|
||||
style.setPrimaryColor("rgb(255, 0, 0)");
|
||||
doc.setStyle(style);
|
||||
|
||||
String html = renderer.render(doc);
|
||||
|
||||
assertFalse(html.contains("rgb("));
|
||||
assertTrue(html.contains("<!DOCTYPE html>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresNonHexColour() {
|
||||
AiDocument doc = document("Styled", List.of());
|
||||
AiDocument.Style style = new AiDocument.Style();
|
||||
style.setPrimaryColor("magenta");
|
||||
style.setBackgroundColor("#fff");
|
||||
doc.setStyle(style);
|
||||
|
||||
String html = renderer.render(doc);
|
||||
|
||||
assertFalse(html.contains("--color-primary: magenta"));
|
||||
assertFalse(html.contains("--color-bg: #fff;"));
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ ext {
|
||||
okhttpBomVersion = "5.3.2"
|
||||
gsonVersion = "2.14.0"
|
||||
guavaVersion = "33.6.0-jre"
|
||||
jinjavaVersion = "2.8.3"
|
||||
jackson2Version = "2.21.2"
|
||||
bucket4jVersion = "8.19.0"
|
||||
archunitVersion = "1.4.2"
|
||||
batikVersion = "1.19"
|
||||
@@ -222,6 +224,13 @@ subprojects {
|
||||
resolutionStrategy.force "org.apache.commons:commons-lang3:${commonsLang3}"
|
||||
// CVE-2024-47554: commons-io DoS prevention
|
||||
resolutionStrategy.force "commons-io:commons-io:${commonsIoVersion}"
|
||||
// Jackson 2 is transitive-only here (jinjava, opensaml, jjwt request older versions);
|
||||
// pin the family to a current release and keep modules aligned.
|
||||
resolutionStrategy.force "com.fasterxml.jackson.core:jackson-core:${jackson2Version}"
|
||||
resolutionStrategy.force "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}"
|
||||
resolutionStrategy.force "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${jackson2Version}"
|
||||
resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:${jackson2Version}"
|
||||
resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jackson2Version}"
|
||||
// Keep BouncyCastle modules aligned to avoid runtime linkage errors
|
||||
resolutionStrategy.force "org.bouncycastle:bcprov-jdk18on:${bouncycastleVersion}"
|
||||
resolutionStrategy.force "org.bouncycastle:bcpkix-jdk18on:${bouncycastleVersion}"
|
||||
|
||||
@@ -5,7 +5,6 @@ description = "AI Document Engine"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi>=0.116.0",
|
||||
"jinja2>=3.1.0",
|
||||
"pgvector>=0.3.6",
|
||||
"psycopg[binary,pool]>=3.2",
|
||||
"pydantic>=2.0.0",
|
||||
|
||||
@@ -10,7 +10,7 @@ Flow:
|
||||
4. SectionWriterAgents (smart_model) run in parallel via asyncio.gather.
|
||||
Each returns a WrittenSections with fully populated DocumentSection objects.
|
||||
5. The assembler collects sections in plan order → GeneratedDocument.
|
||||
6. Jinja renders the document to HTML. The LLM never writes HTML.
|
||||
6. The assembled document is emitted as structured fields. The LLM never writes HTML.
|
||||
|
||||
The planner is split into two calls (meta then sections) so each LLM output schema
|
||||
stays small enough for grammar compilation on all model tiers including Haiku.
|
||||
@@ -22,9 +22,7 @@ import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.output import NativeOutput
|
||||
|
||||
@@ -51,8 +49,6 @@ from stirling.services import AppRuntime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
|
||||
# ── Token budget ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Conservative per-section token estimates mapped from planner-assigned depth.
|
||||
@@ -166,10 +162,13 @@ Analyse the user's request and produce a DocumentMeta with:
|
||||
document, if the user provides one. Leave empty if the user provides no such context.
|
||||
|
||||
- style_primary_color: accent and heading colour. Set ONLY when the user explicitly names a
|
||||
colour or colour scheme (e.g. "make it red", "use navy blue"). Use CSS named colours
|
||||
(e.g. "magenta", "navy", "crimson") or hex values. Leave null if no colour is stated.
|
||||
- style_background_color: page background colour. Set only if explicitly requested.
|
||||
- style_body_text_color: body text colour. Set only if explicitly requested.
|
||||
colour or colour scheme (e.g. "make it red", "use navy blue"). Express it as a 6-digit hex
|
||||
code in #RRGGBB format (map any named colour to its hex value yourself, e.g. "navy" →
|
||||
"#000080"). No other format is accepted. Leave null if no colour is stated.
|
||||
- style_background_color: page background colour, same #RRGGBB format. Set only if explicitly
|
||||
requested.
|
||||
- style_body_text_color: body text colour, same #RRGGBB format. Set only if explicitly
|
||||
requested.
|
||||
|
||||
- cannot_do_reason: set this ONLY when the request is not asking to create a document at all
|
||||
(e.g. a question, a greeting, an edit request to an existing document). Never set it
|
||||
@@ -299,15 +298,6 @@ def _build_writer_prompt(plan: DocumentPlan, chunk: _Chunk) -> str:
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_jinja_env() -> Environment:
|
||||
return Environment(
|
||||
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
|
||||
autoescape=True,
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
|
||||
def _safe_filename(title: str) -> str:
|
||||
slug = re.sub(r"[^\w\s-]", "", title.lower())
|
||||
slug = re.sub(r"[\s_-]+", "-", slug).strip("-")
|
||||
@@ -320,7 +310,6 @@ def _safe_filename(title: str) -> str:
|
||||
class PdfCreateAgent:
|
||||
def __init__(self, runtime: AppRuntime) -> None:
|
||||
self.runtime = runtime
|
||||
self._jinja_env = _build_jinja_env()
|
||||
|
||||
self._meta_planner: Agent[None, DocumentMeta] = Agent(
|
||||
model=runtime.smart_model,
|
||||
@@ -401,14 +390,12 @@ class PdfCreateAgent:
|
||||
sections=all_sections,
|
||||
)
|
||||
|
||||
# ── Phase 6: render ────────────────────────────────────────────────────
|
||||
logger.info("[pdf-create] phase 6/6: rendering HTML")
|
||||
html = self._render(doc)
|
||||
# ── Phase 6: emit ──────────────────────────────────────────────────────
|
||||
filename = _safe_filename(plan.title)
|
||||
logger.info(
|
||||
"[pdf-create] done — filename=%r html_bytes=%d",
|
||||
"[pdf-create] done — filename=%r sections=%d",
|
||||
filename,
|
||||
len(html),
|
||||
len(all_sections),
|
||||
)
|
||||
|
||||
return EditPlanResponse(
|
||||
@@ -417,7 +404,7 @@ class PdfCreateAgent:
|
||||
ToolOperationStep(
|
||||
tool=AgentToolId.CREATE_PDF_FROM_HTML_AGENT,
|
||||
parameters=CreatePdfFromHtmlAgentParams(
|
||||
html_content=html,
|
||||
document=doc.model_dump_json(),
|
||||
filename=filename,
|
||||
),
|
||||
)
|
||||
@@ -437,7 +424,3 @@ class PdfCreateAgent:
|
||||
len(result.output.sections),
|
||||
)
|
||||
return result.output
|
||||
|
||||
def _render(self, doc: GeneratedDocument) -> str:
|
||||
template = self._jinja_env.get_template("document.html.jinja2")
|
||||
return template.render(doc=doc)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Contracts for the PDF Create Agent.
|
||||
|
||||
The agent accepts a natural-language prompt and returns a single
|
||||
CREATE_PDF_FROM_HTML_AGENT plan step carrying the rendered HTML.
|
||||
CREATE_PDF_FROM_HTML_AGENT plan step carrying the assembled document.
|
||||
|
||||
Pipeline:
|
||||
1. PlannerAgent (smart_model) → DocumentPlan: structured skeleton, no body text.
|
||||
2. Python chunks the plan by token budget.
|
||||
3. SectionWriterAgents (smart_model, parallel) → WrittenSections per chunk.
|
||||
4. Assembler collects sections in plan order → GeneratedDocument.
|
||||
5. Jinja renders GeneratedDocument → HTML. The LLM never writes HTML.
|
||||
5. The document is emitted as structured fields. The LLM never writes HTML.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -81,14 +81,12 @@ type DocumentSection = Annotated[
|
||||
]
|
||||
|
||||
|
||||
# Named colour or hex only — anything else is dropped so a colour can't inject CSS into the
|
||||
# <style> block (which would let WeasyPrint fetch an attacker-controlled url() → SSRF).
|
||||
_SAFE_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$|^[a-zA-Z]{1,30}$")
|
||||
# Colours must be a 6-digit hex code (#RRGGBB); anything else is dropped to None.
|
||||
_SAFE_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||
|
||||
|
||||
class DocumentStyle(ApiModel):
|
||||
"""Document colours, inferred by the meta planner and rendered into the engine's Jinja
|
||||
template (never sent to Java). Unsafe colours are dropped to ``None``."""
|
||||
"""Document colours, inferred by the meta planner. Non-hex values are dropped to ``None``."""
|
||||
|
||||
primary_color: str | None = Field(default=None)
|
||||
background_color: str | None = Field(default=None)
|
||||
@@ -103,7 +101,7 @@ class DocumentStyle(ApiModel):
|
||||
|
||||
|
||||
class GeneratedDocument(ApiModel):
|
||||
"""The full document model passed to Jinja for HTML rendering."""
|
||||
"""The full document model emitted for rendering."""
|
||||
|
||||
title: str
|
||||
subtitle: str | None = None
|
||||
|
||||
@@ -29,7 +29,7 @@ class PdfCommentAgentParams(ApiModel):
|
||||
|
||||
|
||||
class CreatePdfFromHtmlAgentParams(ApiModel):
|
||||
html_content: str
|
||||
document: str
|
||||
filename: str = Field(pattern=r"^.+\.pdf$")
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Coverage:
|
||||
1. Section model validation (each section type round-trips correctly)
|
||||
2. Jinja rendering (_render produces valid HTML for each section type)
|
||||
2. orchestrate() emits the assembled document as structured JSON
|
||||
3. _safe_filename produces clean slugs
|
||||
4. _make_chunks groups sections correctly by token budget
|
||||
5. orchestrate() produces the correct EditPlanResponse via planner + writer mocks
|
||||
@@ -12,6 +12,8 @@ Coverage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from conftest import build_app_settings
|
||||
from pydantic_ai.models.test import TestModel
|
||||
@@ -64,37 +66,6 @@ def agent(runtime: AppRuntime) -> PdfCreateAgent:
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _invoice_doc() -> GeneratedDocument:
|
||||
return GeneratedDocument(
|
||||
title="Invoice",
|
||||
subtitle="Acme Corp",
|
||||
reference_number="Invoice #INV-001",
|
||||
sections=[
|
||||
KeyValueSection(
|
||||
heading="Details",
|
||||
pairs=[("Date", "2026-05-06"), ("Due", "2026-06-06"), ("Currency", "USD")],
|
||||
),
|
||||
LineItemsSection(
|
||||
heading="Line Items",
|
||||
columns=["Description", "Qty", "Unit Price", "Total"],
|
||||
rows=[
|
||||
["Consulting services", "10", "$500.00", "$5,000.00"],
|
||||
["Expenses", "1", "$200.00", "$200.00"],
|
||||
],
|
||||
total_row=["Total", "", "", "$5,200.00"],
|
||||
),
|
||||
TextSection(
|
||||
heading="Payment Terms",
|
||||
body="Payment is due within 30 days.\n\nPlease reference the invoice number.",
|
||||
),
|
||||
SignatureSection(
|
||||
heading="Authorised By",
|
||||
signatories=["Jane Smith, CEO", "Bob Jones, CFO"],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _simple_meta() -> DocumentMeta:
|
||||
return DocumentMeta(
|
||||
title="Invoice",
|
||||
@@ -199,82 +170,6 @@ def test_generated_document_optional_fields() -> None:
|
||||
assert doc.reference_number is None
|
||||
|
||||
|
||||
# ── Jinja rendering ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_render_produces_html(agent: PdfCreateAgent) -> None:
|
||||
doc = _invoice_doc()
|
||||
html = agent._render(doc)
|
||||
assert "<!DOCTYPE html>" in html
|
||||
assert "Invoice" in html
|
||||
assert "INV-001" in html
|
||||
|
||||
|
||||
def test_render_includes_all_section_types(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="All Sections",
|
||||
sections=[
|
||||
TextSection(body="Some prose text."),
|
||||
KeyValueSection(pairs=[("Key", "Value")]),
|
||||
LineItemsSection(columns=["A", "B"], rows=[["1", "2"]]),
|
||||
BulletListSection(items=["item one"]),
|
||||
SignatureSection(signatories=["Alice"]),
|
||||
],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "Some prose text." in html
|
||||
assert "Key" in html and "Value" in html
|
||||
assert "<th>" in html
|
||||
assert "item one" in html
|
||||
assert "Alice" in html
|
||||
|
||||
|
||||
def test_render_escapes_html_in_content(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="XSS Test",
|
||||
sections=[TextSection(body="<script>alert('xss')</script>")],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "<script>" not in html
|
||||
assert "<script>" in html
|
||||
|
||||
|
||||
def test_render_total_row_present(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="Table",
|
||||
sections=[
|
||||
LineItemsSection(
|
||||
columns=["Item", "Total"],
|
||||
rows=[["Widget", "$10"]],
|
||||
total_row=["Total", "$10"],
|
||||
)
|
||||
],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "total-row" in html
|
||||
|
||||
|
||||
def test_render_no_total_row_skips_tfoot(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="Table",
|
||||
sections=[LineItemsSection(columns=["Item"], rows=[["Widget"]])],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "<tfoot>" not in html
|
||||
|
||||
|
||||
def test_render_subtitle_and_reference(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="My Doc",
|
||||
subtitle="Subtitle Here",
|
||||
reference_number="REF-42",
|
||||
sections=[TextSection(body="Content.")],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "Subtitle Here" in html
|
||||
assert "REF-42" in html
|
||||
|
||||
|
||||
# ── _safe_filename ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -396,8 +291,9 @@ async def test_orchestrate_returns_plan_step(agent: PdfCreateAgent) -> None:
|
||||
assert step.tool == AgentToolId.CREATE_PDF_FROM_HTML_AGENT
|
||||
assert isinstance(step.parameters, CreatePdfFromHtmlAgentParams)
|
||||
assert step.parameters.filename.endswith(".pdf")
|
||||
assert "<!DOCTYPE html>" in step.parameters.html_content
|
||||
assert "Invoice" in step.parameters.html_content
|
||||
parsed = json.loads(step.parameters.document)
|
||||
assert parsed["title"] == "Invoice"
|
||||
assert parsed["sections"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -468,9 +364,9 @@ async def test_orchestrate_assembles_multiple_chunks(agent: PdfCreateAgent) -> N
|
||||
result = await agent.orchestrate(_orchestrator_request("Create a multi-chunk doc"))
|
||||
|
||||
assert isinstance(result, EditPlanResponse)
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "Introduction text." in html
|
||||
assert "Details" in html
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert "Introduction text." in document
|
||||
assert "Details" in document
|
||||
|
||||
|
||||
# ── Style inference ───────────────────────────────────────────────────────────────────────────────
|
||||
@@ -482,7 +378,7 @@ async def test_orchestrate_applies_planner_inferred_style(agent: PdfCreateAgent)
|
||||
meta = DocumentMeta(
|
||||
title="Styled Doc",
|
||||
tone_brief="Professional.",
|
||||
style_primary_color="magenta",
|
||||
style_primary_color="#ff00ff",
|
||||
)
|
||||
sections = _simple_sections()
|
||||
written = _written_sections()
|
||||
@@ -499,50 +395,35 @@ async def test_orchestrate_applies_planner_inferred_style(agent: PdfCreateAgent)
|
||||
result = await agent.orchestrate(_orchestrator_request("Make an invoice, magenta styling"))
|
||||
|
||||
assert isinstance(result, EditPlanResponse)
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "magenta" in html
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert json.loads(document)["style"]["primaryColor"] == "#ff00ff"
|
||||
|
||||
|
||||
def test_render_applies_style(agent: PdfCreateAgent) -> None:
|
||||
"""DocumentStyle fields are injected as CSS custom properties in the rendered HTML."""
|
||||
doc = GeneratedDocument(
|
||||
title="Styled",
|
||||
sections=[TextSection(body="Content.")],
|
||||
style=DocumentStyle(primary_color="magenta", background_color="#111111"),
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "--color-primary: magenta" in html
|
||||
assert "--color-bg: #111111" in html
|
||||
|
||||
|
||||
def test_document_style_drops_unsafe_colors() -> None:
|
||||
"""Unsafe colours (not named/hex) are dropped, closing the <style> url() injection."""
|
||||
safe = DocumentStyle(primary_color="navy", background_color="#1e3a5f", body_text_color="#fff")
|
||||
def test_document_style_keeps_only_six_digit_hex() -> None:
|
||||
"""Only #RRGGBB hex is kept; named colours and other formats drop to None."""
|
||||
safe = DocumentStyle(primary_color="#1e3a5f", background_color="#ffffff", body_text_color="#1A1A1A")
|
||||
assert (safe.primary_color, safe.background_color, safe.body_text_color) == (
|
||||
"navy",
|
||||
"#1e3a5f",
|
||||
"#fff",
|
||||
"#ffffff",
|
||||
"#1A1A1A",
|
||||
)
|
||||
|
||||
unsafe = DocumentStyle(
|
||||
primary_color="red; background: url(http://evil.test/steal)",
|
||||
background_color="expression(alert(1))",
|
||||
body_text_color="navy; }",
|
||||
)
|
||||
assert unsafe.primary_color is None
|
||||
assert unsafe.background_color is None
|
||||
assert unsafe.body_text_color is None
|
||||
assert DocumentStyle(primary_color="navy").primary_color is None
|
||||
assert DocumentStyle(primary_color="#fff").primary_color is None
|
||||
assert DocumentStyle(primary_color="#1e3a5f00").primary_color is None
|
||||
assert DocumentStyle(primary_color="rgb(255, 0, 0)").primary_color is None
|
||||
assert DocumentStyle(background_color="teal darken-2").background_color is None
|
||||
# A trailing newline must not slip a value through (fullmatch, not $-before-newline).
|
||||
assert DocumentStyle(primary_color="navy\n").primary_color is None
|
||||
assert DocumentStyle(primary_color="#1e3a5f\n").primary_color is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orchestrate_drops_unsafe_planner_color(agent: PdfCreateAgent) -> None:
|
||||
"""An unsafe colour inferred by the meta planner never reaches the rendered HTML."""
|
||||
async def test_orchestrate_drops_non_hex_planner_colour(agent: PdfCreateAgent) -> None:
|
||||
"""A non-hex colour inferred by the meta planner is dropped before the document is emitted."""
|
||||
meta = DocumentMeta(
|
||||
title="Doc",
|
||||
tone_brief="Professional.",
|
||||
style_primary_color="blue; background: url(http://evil.test/)",
|
||||
style_primary_color="rgb(0, 0, 255)",
|
||||
)
|
||||
sections = _simple_sections()
|
||||
written = _written_sections()
|
||||
@@ -559,6 +440,6 @@ async def test_orchestrate_drops_unsafe_planner_color(agent: PdfCreateAgent) ->
|
||||
result = await agent.orchestrate(_orchestrator_request("make it blue"))
|
||||
|
||||
assert isinstance(result, EditPlanResponse)
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "evil.test" not in html
|
||||
assert "url(" not in html
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert "rgb(" not in document
|
||||
assert json.loads(document)["style"]["primaryColor"] is None
|
||||
|
||||
Generated
-2
@@ -604,7 +604,6 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "pgvector" },
|
||||
{ name = "posthog" },
|
||||
@@ -631,7 +630,6 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.116.0" },
|
||||
{ name = "jinja2", specifier = ">=3.1.0" },
|
||||
{ name = "opentelemetry-sdk", specifier = ">=1.39.0" },
|
||||
{ name = "pgvector", specifier = ">=0.3.6" },
|
||||
{ name = "posthog", specifier = ">=3.0.0" },
|
||||
|
||||
Reference in New Issue
Block a user