diff --git a/CHANGELOG.md b/CHANGELOG.md index 64784acb1..81cb6794a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,21 @@ Entries land here as they merge. Consumers who relied on the helper can copy the former ~100-line class into their own codebase or load configs directly with Jackson (`new ObjectMapper(new YAMLFactory()).readValue(...)`). +- **PDF debug node labels** (`@since 1.8.0`). The debug overlay grew a second + layer: `PdfDebugOptions` (guides + node labels + label-text mode) configures + the canonical PDF backend via `GraphCompose.document(...).debug(...)`, + `DocumentSession.debug(...)`, or `PdfFixedLayoutBackend.builder().debug(...)`. + With `nodeLabels()` enabled, every rendered node prints its stable semantic + path — the same path `layoutSnapshot()` reports — once per node and page at + the node's top-left corner (5pt Helvetica on a pale halo), so a misplaced + block on the sheet reads straight back to the builder call that authored it. + `LabelText.NAME` (default) prints the compact own segment + (`PriceSummaryTitle[0]`); `FULL_PATH` prints the whole ancestry. The overlay + uses the base-14 Helvetica font (non-WinAnsi name characters degrade to + `?`), draws strictly on top of content, and never touches measurement or + pagination. `guideLines(boolean)` everywhere became sugar over the new + options — node-label settings survive the toggle — and disabled debug + output stays byte-identical. ### Bug fixes diff --git a/assets/readme/examples/debug-overlay.pdf b/assets/readme/examples/debug-overlay.pdf new file mode 100644 index 000000000..9872ee1c1 Binary files /dev/null and b/assets/readme/examples/debug-overlay.pdf differ diff --git a/examples/README.md b/examples/README.md index a1c08f539..25ecf3628 100644 --- a/examples/README.md +++ b/examples/README.md @@ -95,6 +95,7 @@ are with the canonical DSL, then jump to its detailed section below. | [HTTP streaming](#http-streaming) | `writePdf(OutputStream)` for Servlet / S3 / GCS — caller's stream is not closed | [PDF](../assets/readme/examples/invoice-http-stream.pdf) · [Source](src/main/java/com/demcha/examples/features/streaming/HttpStreamingExample.java) | | [Word export (DOCX)](#word-export-docx) | `DocxSemanticBackend` — the same session renders a fixed-layout PDF and an editable Word file; paragraphs / lists / tables / images map 1:1, charts fall back to their data table | [PDF](../assets/readme/examples/word-export-companion.pdf) · [DOCX](../assets/readme/examples/word-export-companion.docx) · [Source](src/main/java/com/demcha/examples/features/docx/WordExportExample.java) | | [Layout snapshot regression](#layout-snapshot-regression) | Deterministic `layoutSnapshot()` workflow with baseline + drift report — production regression-testing pattern | [PDF](../assets/readme/examples/invoice-snapshot-regression.pdf) · [Source](src/main/java/com/demcha/examples/features/snapshots/LayoutSnapshotRegressionExample.java) | +| [Debug overlay](#debug-overlay) | `PdfDebugOptions` — guide lines + semantic node-path labels on the sheet; trace any misplaced block back to the builder call that authored it | [PDF](../assets/readme/examples/debug-overlay.pdf) · [Source](src/main/java/com/demcha/examples/features/debug/DebugOverlayExample.java) | | [Business report cover](#business-report-cover) | Single-page Q1 investor brief — hero image, KPI cards, bar chart, metrics table | [PDF](../assets/readme/examples/business-report.pdf) · [Source](src/main/java/com/demcha/examples/flagships/BusinessReportExample.java) | | [Master showcase](#master-showcase) | Kitchen-sink "Q2 sample report" combining the canonical surface end-to-end | [PDF](../assets/readme/examples/master-showcase.pdf) · [Source](src/main/java/com/demcha/examples/flagships/MasterShowcaseExample.java) | | Feature catalog | Browsable reference PDF: every shipped capability as a block — outline-clickable heading, the exact API call, the rendered result right under it | [PDF](../assets/readme/examples/feature-catalog.pdf) · [Source](src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java) | @@ -642,6 +643,33 @@ document.buildPdf(); [📄 View PDF](../assets/readme/examples/invoice-snapshot-regression.pdf) · [📜 Full source](src/main/java/com/demcha/examples/features/snapshots/LayoutSnapshotRegressionExample.java) +### Debug overlay + +One switch turns the rendered sheet into a self-describing layout map: +fragment boxes, dashed margin / padding guides, and a small purple label +with each node's stable semantic path — the same path +`layoutSnapshot()` reports. Spot a misplaced block on paper, read its +label, then search that name in your builder code. + +```java +try (DocumentSession document = GraphCompose.document(outputFile) + .debug(PdfDebugOptions.guidesAndNodeLabels()) + .create()) { + document.pageFlow(page -> page + .module("InvoiceHeader", m -> m.paragraph("ACME Corp — Invoice 2026-104"))); + document.buildPdf(); +} +``` + +Labels default to the compact own segment (`InvoiceHeaderTitle[0]`); +`PdfDebugOptions.LabelText.FULL_PATH` prints the whole ancestor chain +instead. Debug overlays draw strictly on top of content and never +affect measurement or pagination — disabling them returns the exact +production bytes. + +[📄 View PDF](../assets/readme/examples/debug-overlay.pdf) · +[📜 Full source](src/main/java/com/demcha/examples/features/debug/DebugOverlayExample.java) + --- ## Operational documents diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index 099e78bf5..645189d91 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -3,6 +3,7 @@ import com.demcha.examples.features.barcodes.BarcodeShowcaseExample; import com.demcha.examples.features.charts.ChartShowcaseExample; import com.demcha.examples.features.canvas.CanvasLayerExample; +import com.demcha.examples.features.debug.DebugOverlayExample; import com.demcha.examples.features.docx.WordExportExample; import com.demcha.examples.features.chrome.PdfChromeExample; import com.demcha.examples.features.lists.NestedListExample; @@ -151,6 +152,7 @@ public static void main(String[] args) throws Exception { // Pipelines + tooling System.out.println("Generated: " + HttpStreamingExample.generate()); System.out.println("Generated: " + LayoutSnapshotRegressionExample.generate()); + System.out.println("Generated: " + DebugOverlayExample.generate()); // === Flagships === System.out.println("Generated: " + ModuleFirstFileExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/features/debug/DebugOverlayExample.java b/examples/src/main/java/com/demcha/examples/features/debug/DebugOverlayExample.java new file mode 100644 index 000000000..18eb6d469 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/debug/DebugOverlayExample.java @@ -0,0 +1,81 @@ +package com.demcha.examples.features.debug; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.backend.fixed.pdf.options.PdfDebugOptions; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +/** + * Runnable showcase for the PDF debug overlay (v1.8): guide lines plus + * semantic node labels. + * + *

One switch turns the rendered sheet into a self-describing layout + * map:

+ * + *
{@code
+ * GraphCompose.document(out)
+ *         .debug(PdfDebugOptions.guidesAndNodeLabels())
+ *         .create()
+ * }
+ * + *

Every rendered node then prints its stable semantic path — the same + * path {@code DocumentSession.layoutSnapshot()} reports — once per node + * and page at the node's top-left corner, next to the familiar fragment + * boxes and dashed margin/padding guides. Spot a misplaced block on + * paper, read its label, and grep that name straight in your builder + * code. {@code PdfDebugOptions.LabelText.FULL_PATH} switches the labels + * from the compact own segment to the whole ancestor chain.

+ * + *

Debug overlays draw strictly on top of regular content and never + * affect measurement or pagination — disabling them returns the exact + * production bytes.

+ * + * @author Artem Demchyshyn + */ +public final class DebugOverlayExample { + + private DebugOverlayExample() { + } + + /** + * Renders a small annotated sheet with guides and node labels enabled. + * + * @return path to the generated PDF + * @throws Exception if rendering or file IO fails + */ + public static Path generate() throws Exception { + Path pdfFile = ExampleOutputPaths.prepare("features/debug", "debug-overlay.pdf"); + + try (DocumentSession document = GraphCompose.document(pdfFile) + .pageSize(DocumentPageSize.A4) + .margin(DocumentInsets.of(28)) + .debug(PdfDebugOptions.guidesAndNodeLabels()) + .create()) { + document.pageFlow(page -> page + .module("HowToReadThisSheet", module -> module + .paragraph("Every block carries its debug overlay: gray fragment boxes, " + + "dashed margin (blue) and padding (orange) guides, and a small " + + "purple label with the owning node's semantic path.") + .paragraph("Labels print the same stable path that layoutSnapshot() reports — " + + "spot a misplaced block on paper, then search the label text " + + "in your builder code.")) + .module("InvoiceHeader", module -> module + .paragraph("ACME Corp — Invoice 2026-104") + .paragraph("Issued 2026-06-11, due in 14 days")) + .module("PriceSummary", module -> module + .paragraph("Subtotal 1,180.00 · VAT 236.00 · Total 1,416.00"))); + + document.buildPdf(); + } + + return pdfFile; + } + + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } +} diff --git a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java index 8243582e3..3f23df311 100644 --- a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java +++ b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java @@ -104,6 +104,7 @@ record Entry(String title, String description, List tags, String codeUrl feature("streaming", "invoice-http-stream", "HTTP Streaming", "Stream PDF directly to a Servlet response with no buffering.", "streaming", "http"); feature("snapshots", "invoice-snapshot-regression", "Layout Snapshots", "How LayoutSnapshotAssertions captures the resolved layout graph for regression testing.", "snapshots", "testing"); feature("docx", "word-export-companion", "Word Export (DOCX)", "DocxSemanticBackend — the same document as a fixed-layout PDF and an editable Word file; charts fall back to their data table.", "docx", "word", "export"); + feature("debug", "debug-overlay", "Debug Overlay", "PdfDebugOptions — guide lines plus semantic node-path labels on the rendered sheet; trace any misplaced block back to the builder call that authored it.", "debug", "labels", "v1.8"); // ===== Flagships ===== flagship("master-showcase", "Master Showcase", "Kitchen-sink demo combining every primitive into a single document — the full GraphCompose surface.", "showcase"); @@ -141,6 +142,7 @@ static String groupLabel(String category, String group) { case "features/chrome" -> "PDF Chrome (header / footer / watermark)"; case "features/streaming" -> "Streaming & I/O"; case "features/snapshots" -> "Snapshot Testing"; + case "features/debug" -> "Debug & Diagnostics"; case "flagships/default" -> "Flagship Demos"; default -> capitalize(group); }; diff --git a/src/main/java/com/demcha/compose/GraphCompose.java b/src/main/java/com/demcha/compose/GraphCompose.java index f20ba3ca7..d1481c050 100644 --- a/src/main/java/com/demcha/compose/GraphCompose.java +++ b/src/main/java/com/demcha/compose/GraphCompose.java @@ -6,6 +6,7 @@ import com.demcha.compose.font.DefaultFonts; import com.demcha.compose.document.api.DocumentPageSize; import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.backend.fixed.pdf.options.PdfDebugOptions; import com.demcha.compose.document.style.DocumentInsets; import java.nio.file.Path; @@ -140,6 +141,7 @@ public static final class DocumentBuilder { private DocumentInsets margin = DocumentInsets.zero(); private boolean markdown = true; private boolean guideLines; + private PdfDebugOptions debug; private com.demcha.compose.document.style.DocumentColor pageBackground; private java.util.List pageBackgrounds; private final List customFontFamilies = new ArrayList<>(); @@ -226,6 +228,24 @@ public DocumentBuilder guideLines(boolean enabled) { return this; } + /** + * Configures PDF debug overlays (guide lines and semantic node labels) + * for the session's convenience PDF output. + * + *

Combines with {@link #guideLines(boolean)}: when both switches are + * used, the guide overlay is enabled if either of them requests it. + * Like guide lines, debug overlays draw on top of regular content and + * never alter semantic layout geometry or layout snapshots.

+ * + * @param options debug overlay options, or {@code null} for none + * @return this builder + * @since 1.8.0 + */ + public DocumentBuilder debug(PdfDebugOptions options) { + this.debug = options; + return this; + } + /** * Configures a document-wide page background fill applied behind every * fragment on every page. @@ -391,6 +411,9 @@ public DocumentSession create() { List.copyOf(customFontFamilies), markdown, guideLines); + if (debug != null) { + session.debug(debug.withGuides(debug.showGuides() || guideLines)); + } if (pageBackgrounds != null) { // Explicit pageBackgrounds() call wins over a prior // pageBackground(color). Empty list = clear; see builder Javadoc. diff --git a/src/main/java/com/demcha/compose/document/api/DocumentChromeOptions.java b/src/main/java/com/demcha/compose/document/api/DocumentChromeOptions.java index 49f618a2e..24b00198b 100644 --- a/src/main/java/com/demcha/compose/document/api/DocumentChromeOptions.java +++ b/src/main/java/com/demcha/compose/document/api/DocumentChromeOptions.java @@ -94,20 +94,20 @@ DocumentOutputOptions snapshot() { /** * Builds a configured {@link PdfFixedLayoutBackend} for the session's - * convenience PDF methods. When {@code guideLines} is {@code false} and - * no chrome is attached, returns the bare default backend so callers do + * convenience PDF methods. When no debug overlay is enabled and no + * chrome is attached, returns the bare default backend so callers do * not pay for empty option arrays. * - * @param guideLines whether the convenience PDF backend should draw - * guide-line overlays + * @param debug debug overlay options for the convenience PDF backend; + * never {@code null} * @return ready-to-use PDF backend */ - PdfFixedLayoutBackend toConveniencePdfBackend(boolean guideLines) { - if (!guideLines && isEmpty()) { + PdfFixedLayoutBackend toConveniencePdfBackend(PdfDebugOptions debug) { + if (!debug.enabled() && isEmpty()) { return new PdfFixedLayoutBackend(); } PdfFixedLayoutBackend.Builder builder = PdfFixedLayoutBackend.builder() - .guideLines(guideLines) + .debug(debug) .metadata(PdfOutputOptionsTranslator.toPdf(metadata)) .watermark(PdfOutputOptionsTranslator.toPdf(watermark)) .protect(PdfOutputOptionsTranslator.toPdf(protection)); diff --git a/src/main/java/com/demcha/compose/document/api/DocumentSession.java b/src/main/java/com/demcha/compose/document/api/DocumentSession.java index 548099e85..ee2d2daed 100644 --- a/src/main/java/com/demcha/compose/document/api/DocumentSession.java +++ b/src/main/java/com/demcha/compose/document/api/DocumentSession.java @@ -4,6 +4,7 @@ import com.demcha.compose.document.backend.fixed.FixedLayoutBackend; import com.demcha.compose.document.backend.fixed.pdf.PdfFixedLayoutBackend; import com.demcha.compose.document.backend.fixed.pdf.PdfMeasurementResources; +import com.demcha.compose.document.backend.fixed.pdf.options.PdfDebugOptions; import com.demcha.compose.document.backend.fixed.pdf.options.PdfHeaderFooterOptions; import com.demcha.compose.document.backend.fixed.pdf.options.PdfMetadataOptions; import com.demcha.compose.document.backend.fixed.pdf.options.PdfProtectionOptions; @@ -73,7 +74,7 @@ public final class DocumentSession implements AutoCloseable { private DocumentInsets margin; private LayoutCanvas canvas; private boolean markdown; - private boolean guideLines; + private PdfDebugOptions debug = PdfDebugOptions.none(); private List pageBackgrounds = List.of(); private PdfMeasurementResources measurementResources; private boolean closed; @@ -100,7 +101,7 @@ public DocumentSession(Path defaultOutputFile, this.margin = margin == null ? DocumentInsets.zero() : margin; this.canvas = LayoutCanvas.from(pageSize.width(), pageSize.height(), toEngineMargin(this.margin)); this.markdown = markdown; - this.guideLines = guideLines; + this.debug = PdfDebugOptions.none().withGuides(guideLines); this.registry = BuiltInNodeDefinitions.registerDefaults(new InvalidatingNodeRegistry()); this.compiler = new LayoutCompiler(registry); this.customFontFamilies.addAll(List.copyOf(customFontFamilies)); @@ -308,12 +309,40 @@ public DocumentSession markdown(boolean enabled) { * and {@link #toPdfBytes()}. It does not change the semantic layout graph, * so existing layout cache entries remain valid.

* + *

Shorthand for toggling only the guide overlay on the current + * {@link #debug(PdfDebugOptions) debug} configuration; node-label + * settings are preserved.

+ * * @param enabled {@code true} to draw debug guide-line overlays * @return this session */ public DocumentSession guideLines(boolean enabled) { ensureOpen(); - this.guideLines = enabled; + this.debug = this.debug.withGuides(enabled); + return this; + } + + /** + * Configures PDF debug overlays (guide lines and semantic node labels) + * for convenience PDF output. + * + *

This option affects {@link #buildPdf()}, {@link #writePdf(OutputStream)}, + * and {@link #toPdfBytes()}. Debug overlays draw on top of regular content + * and never participate in measurement or pagination, so the semantic + * layout graph and existing layout cache entries remain valid.

+ * + *

Node labels print each node's stable semantic path — the same path + * reported by {@link #layoutSnapshot()} — so a misplaced block on the + * sheet can be traced straight back to the builder call that authored + * it.

+ * + * @param options debug overlay options; {@code null} disables all overlays + * @return this session + * @since 1.8.0 + */ + public DocumentSession debug(PdfDebugOptions options) { + ensureOpen(); + this.debug = options == null ? PdfDebugOptions.none() : options; return this; } @@ -1008,7 +1037,7 @@ public DocumentOutputOptions outputOptions() { @Override public PdfFixedLayoutBackend conveniencePdfBackend() { - return chromeOptions.toConveniencePdfBackend(guideLines); + return chromeOptions.toConveniencePdfBackend(debug); } } } diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java index f5dfdecca..36049cc69 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java @@ -40,7 +40,7 @@ public final class PdfFixedLayoutBackend implements FixedLayoutBackend { private static final Logger RENDER_LOG = LoggerFactory.getLogger("com.demcha.compose.engine.render"); private final Map, PdfFragmentRenderHandler> handlers; - private final boolean guideLines; + private final PdfDebugOptions debug; private final PdfMetadataOptions metadataOptions; private final PdfWatermarkOptions watermarkOptions; private final PdfProtectionOptions protectionOptions; @@ -50,15 +50,15 @@ public final class PdfFixedLayoutBackend implements FixedLayoutBackend { * Creates a backend with the built-in paragraph, shape, image, and table handlers. */ public PdfFixedLayoutBackend() { - this(defaultHandlers(), false, null, null, null, List.of()); + this(defaultHandlers(), PdfDebugOptions.none(), null, null, null, List.of()); } PdfFixedLayoutBackend(Collection> handlers) { - this(handlers, false, null, null, null, List.of()); + this(handlers, PdfDebugOptions.none(), null, null, null, List.of()); } private PdfFixedLayoutBackend(Collection> handlers, - boolean guideLines, + PdfDebugOptions debug, PdfMetadataOptions metadataOptions, PdfWatermarkOptions watermarkOptions, PdfProtectionOptions protectionOptions, @@ -71,7 +71,7 @@ private PdfFixedLayoutBackend(Collection> } } this.handlers = Map.copyOf(registry); - this.guideLines = guideLines; + this.debug = debug == null ? PdfDebugOptions.none() : debug; this.metadataOptions = metadataOptions; this.watermarkOptions = watermarkOptions; this.protectionOptions = protectionOptions; @@ -192,12 +192,12 @@ public byte[] render(LayoutGraph graph, FixedLayoutRenderContext context) throws long startNanos = System.nanoTime(); RENDER_LOG.debug( - "render.pdf.fixed.start pages={} fragments={} outputConfigured={} streamConfigured={} guideLines={}", + "render.pdf.fixed.start pages={} fragments={} outputConfigured={} streamConfigured={} debug={}", graph.totalPages(), graph.fragments().size(), context.outputFile() != null, context.outputStream() != null, - guideLines); + debug); try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { int pageCount = renderToOutput(graph, context, output); byte[] bytes = output.toByteArray(); @@ -240,11 +240,11 @@ public void write(LayoutGraph graph, FixedLayoutRenderContext context) throws Ex long startNanos = System.nanoTime(); RENDER_LOG.debug( - "render.pdf.fixed.stream.start pages={} fragments={} outputConfigured={} guideLines={}", + "render.pdf.fixed.stream.start pages={} fragments={} outputConfigured={} debug={}", graph.totalPages(), graph.fragments().size(), context.outputFile() != null, - guideLines); + debug); try { int pageCount; if (context.outputFile() == null) { @@ -280,18 +280,19 @@ private int renderToOutput(LayoutGraph graph, FixedLayoutRenderContext context, try (PdfRenderSession session = new PdfRenderSession(document, pages)) { PdfRenderEnvironment environment = new PdfRenderEnvironment(document, fonts, session); - Map> ownerBounds = guideLines + Map> ownerBounds = debug.enabled() ? PdfGuideLinesRenderer.computeOwnerBounds(graph.fragments()) : Map.of(); + Set labelKeys = debug.showNodeLabels() ? new HashSet<>() : Set.of(); PdfFragmentRenderHandler tableRowHandler = handlers.get(TableRowFragmentPayload.class); for (int index = 0; index < graph.fragments().size(); index++) { PlacedFragment fragment = graph.fragments().get(index); if (fragment.payload() instanceof TableRowFragmentPayload && tableRowHandler instanceof PdfTableRowFragmentRenderHandler tableHandler) { - index = renderTableRowGroup(graph.fragments(), index, tableHandler, environment, guideLines, ownerBounds); + index = renderTableRowGroup(graph.fragments(), index, tableHandler, environment, ownerBounds, labelKeys); continue; } - renderFragment(fragment, environment, guideLines, ownerBounds); + renderFragment(fragment, environment, ownerBounds, labelKeys); } PdfBookmarkOutlineWriter.apply(document, environment.bookmarkRecords()); } @@ -313,8 +314,8 @@ private int renderTableRowGroup(List fragments, int startIndex, PdfTableRowFragmentRenderHandler handler, PdfRenderEnvironment environment, - boolean guideLines, - Map> ownerBounds) throws Exception { + Map> ownerBounds, + Set labelKeys) throws Exception { String tablePath = fragments.get(startIndex).path(); int endExclusive = startIndex; while (endExclusive < fragments.size() @@ -335,7 +336,7 @@ private int renderTableRowGroup(List fragments, TableRowFragmentPayload payload = (TableRowFragmentPayload) fragment.payload(); handler.renderBordersAndText(fragment, payload, environment); - finishRenderedFragment(fragment, payload, environment, guideLines, ownerBounds); + finishRenderedFragment(fragment, payload, environment, ownerBounds, labelKeys); } return endExclusive - 1; @@ -355,19 +356,19 @@ private List createPages(PDDocument document, LayoutGraph graph) { private void renderFragment(PlacedFragment fragment, PdfRenderEnvironment environment, - boolean guideLines, - Map> ownerBounds) throws Exception { + Map> ownerBounds, + Set labelKeys) throws Exception { Object payload = fragment.payload(); PdfFragmentRenderHandler handler = handlerFor(payload); handler.render(fragment, payload, environment); - finishRenderedFragment(fragment, payload, environment, guideLines, ownerBounds); + finishRenderedFragment(fragment, payload, environment, ownerBounds, labelKeys); } private void finishRenderedFragment(PlacedFragment fragment, Object payload, PdfRenderEnvironment environment, - boolean guideLines, - Map> ownerBounds) throws Exception { + Map> ownerBounds, + Set labelKeys) throws Exception { if (payload instanceof ParagraphFragmentPayload paragraphPayload) { addParagraphLinks(fragment, paragraphPayload, environment); } @@ -386,9 +387,12 @@ private void finishRenderedFragment(PlacedFragment fragment, environment.registerBookmark(fragment, semanticPayload.bookmarkOptions()); } } - if (guideLines) { + if (debug.showGuides()) { PdfGuideLinesRenderer.draw(fragment, payload, environment, ownerBounds); } + if (debug.showNodeLabels()) { + PdfNodeLabelRenderer.draw(fragment, environment, ownerBounds, labelKeys, debug.labelText()); + } } private void addParagraphLinks(PlacedFragment fragment, @@ -483,7 +487,7 @@ private PdfFragmentRenderHandler handlerFor(Object payload) { public static final class Builder { private final List headerFooterOptions = new ArrayList<>(); private final List> additionalHandlers = new ArrayList<>(); - private boolean guideLines; + private PdfDebugOptions debug = PdfDebugOptions.none(); private PdfMetadataOptions metadataOptions; private PdfWatermarkOptions watermarkOptions; private PdfProtectionOptions protectionOptions; @@ -528,11 +532,31 @@ public Builder addHandler(PdfFragmentRenderHandler handler) { /** * Enables or disables guide-line overlays in rendered PDFs. * + *

Convenience switch equivalent to toggling + * {@link PdfDebugOptions#withGuides(boolean)} on the current debug + * configuration; node-label settings made via {@link #debug(PdfDebugOptions)} + * are preserved.

+ * * @param enabled {@code true} to draw guide lines * @return this builder */ public Builder guideLines(boolean enabled) { - this.guideLines = enabled; + this.debug = this.debug.withGuides(enabled); + return this; + } + + /** + * Configures debug overlays (guide lines and semantic node labels). + * + *

Replaces the whole debug configuration; {@code null} resets to + * {@link PdfDebugOptions#none()}.

+ * + * @param options debug overlay options, or {@code null} to disable all + * @return this builder + * @since 1.8.0 + */ + public Builder debug(PdfDebugOptions options) { + this.debug = options == null ? PdfDebugOptions.none() : options; return this; } @@ -607,7 +631,7 @@ public Builder footer(PdfHeaderFooterOptions options) { public PdfFixedLayoutBackend build() { return new PdfFixedLayoutBackend( mergeHandlers(defaultHandlers(), additionalHandlers), - guideLines, + debug, metadataOptions, watermarkOptions, protectionOptions, diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfNodeLabelRenderer.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfNodeLabelRenderer.java new file mode 100644 index 000000000..dc6bd0f9e --- /dev/null +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfNodeLabelRenderer.java @@ -0,0 +1,121 @@ +package com.demcha.compose.document.backend.fixed.pdf; + +import com.demcha.compose.document.backend.fixed.pdf.options.PdfDebugOptions; +import com.demcha.compose.document.layout.PlacedFragment; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; + +import java.awt.*; +import java.io.IOException; +import java.util.Map; +import java.util.Set; + +/** + * Internal node-label overlay for the canonical semantic PDF backend. + * + *

Prints the stable semantic path of a fragment's owning node once per + * owner and page, anchored at the top-left corner of the owner's union + * bounds (the same bounds the guide renderer uses for margin/padding + * rectangles). Uses the built-in Helvetica base-14 font so the overlay + * never touches the session's font system.

+ */ +final class PdfNodeLabelRenderer { + private static final PDType1Font FONT = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + private static final float FONT_SIZE = 5f; + private static final float PADDING = 1f; + private static final Color HALO_COLOR = new Color(255, 250, 205); + private static final Color TEXT_COLOR = new Color(150, 20, 150); + + private PdfNodeLabelRenderer() { + } + + /** + * Draws the semantic label for the fragment's owner if it has not been + * drawn on this page yet. + * + * @param fragment fragment whose owner may need a label + * @param environment per-render PDF environment + * @param ownerBoundsByPath path → page → union bounds map shared with the + * guide renderer + * @param drawnKeys mutable set of {@code path#page} keys already + * labelled during this render pass + * @param labelText which text variant to print + * @throws IOException if writing to the page content stream fails + */ + static void draw(PlacedFragment fragment, + PdfRenderEnvironment environment, + Map> ownerBoundsByPath, + Set drawnKeys, + PdfDebugOptions.LabelText labelText) throws IOException { + String path = fragment.path(); + if (path == null || !drawnKeys.add(path + '#' + fragment.pageIndex())) { + return; + } + + PdfGuideLinesRenderer.Bounds bounds = lookupOwnerBounds(fragment, ownerBoundsByPath); + String text = sanitize(labelText == PdfDebugOptions.LabelText.NAME + ? path.substring(path.lastIndexOf('/') + 1) + : path); + if (text.isEmpty()) { + return; + } + + float textWidth = FONT.getStringWidth(text) / 1000f * FONT_SIZE; + float boxHeight = FONT_SIZE + 2 * PADDING; + // Corner-badge placement: anchored at the top-RIGHT of the owner + // bounds (content usually starts flush left) and straddling the top + // edge, so half the badge sits in the inter-block gap above and only + // a half-strip of the first line can be covered. Clamped back onto + // the page when the owner touches the page top or is narrower than + // the label. + float boxWidth = textWidth + 2 * PADDING; + float boxX = (float) Math.max(bounds.x(), bounds.x() + bounds.width() - boxWidth); + double pageTop = environment.document().getPage(fragment.pageIndex()).getMediaBox().getUpperRightY(); + float boxTop = (float) Math.min(pageTop, bounds.y() + bounds.height() + boxHeight / 2.0); + + PDPageContentStream stream = environment.pageSurface(fragment.pageIndex()); + stream.saveGraphicsState(); + try { + stream.setNonStrokingColor(HALO_COLOR); + stream.addRect(boxX, boxTop - boxHeight, boxWidth, boxHeight); + stream.fill(); + + stream.setNonStrokingColor(TEXT_COLOR); + stream.beginText(); + stream.setFont(FONT, FONT_SIZE); + // Baseline sits one padding plus the approximate ascent below the + // box top; Helvetica's ascent is ~80% of the point size. + stream.newLineAtOffset(boxX + PADDING, boxTop - PADDING - FONT_SIZE * 0.8f); + stream.showText(text); + stream.endText(); + } finally { + stream.restoreGraphicsState(); + } + } + + private static PdfGuideLinesRenderer.Bounds lookupOwnerBounds( + PlacedFragment fragment, + Map> ownerBoundsByPath) { + Map byPage = + ownerBoundsByPath == null ? null : ownerBoundsByPath.get(fragment.path()); + PdfGuideLinesRenderer.Bounds bounds = byPage == null ? null : byPage.get(fragment.pageIndex()); + return bounds == null ? PdfGuideLinesRenderer.Bounds.from(fragment) : bounds; + } + + /** + * Replaces every character outside printable ASCII with {@code ?} so the + * base-14 Helvetica encoder never rejects a label. Semantic names accept + * any Unicode letter (the DSL normalizer keeps letters and digits), while + * WinAnsi covers only a Latin subset — a Cyrillic or CJK node name must + * degrade gracefully instead of failing the debug render. + */ + private static String sanitize(String text) { + StringBuilder safe = new StringBuilder(text.length()); + for (int index = 0; index < text.length(); index++) { + char current = text.charAt(index); + safe.append(current >= 0x20 && current <= 0x7E ? current : '?'); + } + return safe.toString(); + } +} diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfDebugOptions.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfDebugOptions.java new file mode 100644 index 000000000..64f5f4612 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfDebugOptions.java @@ -0,0 +1,152 @@ +package com.demcha.compose.document.backend.fixed.pdf.options; + +import java.util.Objects; + +/** + * Debug-overlay configuration for the canonical PDF backend. + * + *

Debug overlays are development aids drawn on top of the regular page + * content. They never participate in measurement or pagination, so enabling + * them does not change the layout graph — and leaving them disabled (the + * default) keeps rendered documents byte-identical to previous releases.

+ * + *

Two overlays are available:

+ *
    + *
  • Guides — fragment boxes plus dashed margin/padding rectangles, + * the overlay previously toggled by the {@code guideLines(boolean)} + * convenience switch.
  • + *
  • Node labels — the stable semantic path of the owning node + * printed once per node and page at the top-left corner of the node's + * bounds. Labels make a misplaced block traceable back to the exact + * builder call that authored it: name nodes via the DSL (for example + * {@code pageFlow().name("InvoiceSheet")}; module titles auto-name + * their blocks) and the same name appears on the sheet and in + * {@code DocumentSession.layoutSnapshot()}.
  • + *
+ * + *

Typical usage through the session convenience API:

+ *
{@code
+ * try (DocumentSession document = GraphCompose.document(out)
+ *         .debug(PdfDebugOptions.guidesAndNodeLabels())
+ *         .create()) {
+ *     // author content ...
+ *     document.buildPdf();
+ * }
+ * }
+ * + * @param showGuides whether the guide-line overlay (fragment boxes, + * margin and padding rectangles) is drawn + * @param showNodeLabels whether semantic node labels are drawn + * @param labelText which text the node-label overlay prints; never + * {@code null} + * @author Artem Demchyshyn + * @since 1.8.0 + */ +public record PdfDebugOptions(boolean showGuides, boolean showNodeLabels, LabelText labelText) { + + private static final PdfDebugOptions NONE = new PdfDebugOptions(false, false, LabelText.NAME); + + /** + * Validates record invariants. + */ + public PdfDebugOptions { + Objects.requireNonNull(labelText, "labelText"); + } + + /** + * Returns the default configuration with every overlay disabled. + * + * @return options with all debug overlays off + */ + public static PdfDebugOptions none() { + return NONE; + } + + /** + * Returns options with only the guide-line overlay enabled — the + * equivalent of the {@code guideLines(true)} convenience switch. + * + * @return options drawing fragment boxes and margin/padding guides + */ + public static PdfDebugOptions guides() { + return new PdfDebugOptions(true, false, LabelText.NAME); + } + + /** + * Returns options with only the node-label overlay enabled. + * + * @return options drawing semantic node labels + */ + public static PdfDebugOptions nodeLabels() { + return new PdfDebugOptions(false, true, LabelText.NAME); + } + + /** + * Returns options with both the guide-line and node-label overlays + * enabled. + * + * @return options drawing guides and semantic node labels + */ + public static PdfDebugOptions guidesAndNodeLabels() { + return new PdfDebugOptions(true, true, LabelText.NAME); + } + + /** + * Returns a copy with the guide-line overlay toggled. + * + * @param enabled {@code true} to draw guide lines + * @return new options instance with the requested guide state + */ + public PdfDebugOptions withGuides(boolean enabled) { + return enabled == showGuides ? this : new PdfDebugOptions(enabled, showNodeLabels, labelText); + } + + /** + * Returns a copy with the node-label overlay toggled. + * + * @param enabled {@code true} to draw semantic node labels + * @return new options instance with the requested label state + */ + public PdfDebugOptions withNodeLabels(boolean enabled) { + return enabled == showNodeLabels ? this : new PdfDebugOptions(showGuides, enabled, labelText); + } + + /** + * Returns a copy printing the requested label text. + * + * @param text label text mode; must not be {@code null} + * @return new options instance with the requested label text mode + */ + public PdfDebugOptions withLabelText(LabelText text) { + Objects.requireNonNull(text, "text"); + return text == labelText ? this : new PdfDebugOptions(showGuides, showNodeLabels, text); + } + + /** + * Indicates whether any debug overlay is enabled. + * + * @return {@code true} when at least one overlay draws + */ + public boolean enabled() { + return showGuides || showNodeLabels; + } + + /** + * Text printed by the node-label overlay. + * + * @since 1.8.0 + */ + public enum LabelText { + /** + * Only the node's own path segment, for example {@code InvoiceHeader[0]}. + * Compact; the default. + */ + NAME, + /** + * The full ancestor chain, for example + * {@code Root[0]/InvoiceHeader[0]/Paragraph[2]}. Verbose but + * unambiguous on documents with repeated component names. + */ + FULL_PATH + } +} diff --git a/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfDebugNodeLabelsTest.java b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfDebugNodeLabelsTest.java new file mode 100644 index 000000000..4ebec7a6a --- /dev/null +++ b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfDebugNodeLabelsTest.java @@ -0,0 +1,121 @@ +package com.demcha.compose.document.backend.fixed.pdf; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.backend.fixed.pdf.options.PdfDebugOptions; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration coverage for the node-label debug overlay: labels print the + * owning node's semantic path, respect the configured text mode, stay off by + * default, and degrade non-WinAnsi names to placeholders instead of failing + * the render. + */ +class PdfDebugNodeLabelsTest { + + @Test + void nodeLabelsPrintLeafSegmentByDefault() throws Exception { + String text = extractText(render(PdfDebugOptions.nodeLabels(), "PriceSummary")); + + // The module title is an auto-named paragraph at child index 0; the + // body paragraph follows at index 1. + assertThat(text).contains("PriceSummaryTitle[0]"); + assertThat(text).contains("ParagraphNode[1]"); + // NAME mode prints only the owner's own segment, never the ancestry. + assertThat(text).doesNotContain("PriceSummary[0]/"); + } + + @Test + void fullPathLabelsIncludeAncestry() throws Exception { + String text = extractText(render( + PdfDebugOptions.nodeLabels().withLabelText(PdfDebugOptions.LabelText.FULL_PATH), + "PriceSummary")); + + assertThat(text).contains("PriceSummary[0]"); + assertThat(text).contains("/ParagraphNode[1]"); + } + + @Test + void labelsStayOffByDefault() throws Exception { + String text = extractText(render(null, "PriceSummary")); + + assertThat(text).doesNotContain("ParagraphNode[1]"); + assertThat(text).doesNotContain("PriceSummaryTitle[0]"); + } + + @Test + void guideOverlayAloneDrawsNoLabels() throws Exception { + String text = extractText(render(PdfDebugOptions.guides(), "PriceSummary")); + + assertThat(text).doesNotContain("ParagraphNode[1]"); + assertThat(text).doesNotContain("PriceSummaryTitle[0]"); + } + + @Test + void guideLinesToggleAfterDebugKeepsLabelSettings() throws Exception { + try (DocumentSession document = GraphCompose.document() + .pageSize(340, 260) + .margin(DocumentInsets.of(18)) + .create()) { + document.debug(PdfDebugOptions.nodeLabels()); + document.guideLines(true); + document.pageFlow(page -> page.module("PriceSummary", + module -> module.paragraph("Body copy"))); + + String text = extractText(document.toPdfBytes()); + assertThat(text).contains("ParagraphNode[1]"); + } + } + + @Test + void nonWinAnsiNamesDegradeToPlaceholders() throws Exception { + String text = extractText(render( + PdfDebugOptions.nodeLabels().withLabelText(PdfDebugOptions.LabelText.FULL_PATH), + "Шапка")); + + // The five Cyrillic letters survive name normalization but exceed the + // base-14 Helvetica WinAnsi range, so the label degrades to '?' + // placeholders instead of throwing inside the debug render. + assertThat(text).contains("?????[0]"); + assertThat(text).doesNotContain("Шапка[0]"); + } + + @Test + void disabledDebugOptionsMatchTheDefaultRender() throws Exception { + byte[] plain = render(null, "PriceSummary"); + byte[] explicitNone = render(PdfDebugOptions.none(), "PriceSummary"); + + assertThat(new String(plain, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-"); + assertThat(new String(explicitNone, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-"); + // PDFBox stamps a fresh /ID on every save, so byte-for-byte equality + // is impossible; identical length proves the overlay emitted nothing. + assertThat(explicitNone).hasSameSizeAs(plain); + } + + private static byte[] render(PdfDebugOptions debug, String moduleName) throws Exception { + try (DocumentSession document = GraphCompose.document() + .pageSize(340, 260) + .margin(DocumentInsets.of(18)) + .debug(debug) + .create()) { + document.pageFlow(page -> page.module(moduleName, + module -> module.paragraph("Body copy for the overlay test"))); + return document.toPdfBytes(); + } + } + + private static String extractText(byte[] pdf) throws IOException { + try (PDDocument document = Loader.loadPDF(pdf)) { + return new PDFTextStripper().getText(document); + } + } +} diff --git a/src/test/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfDebugOptionsTest.java b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfDebugOptionsTest.java new file mode 100644 index 000000000..e3213a9fe --- /dev/null +++ b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfDebugOptionsTest.java @@ -0,0 +1,72 @@ +package com.demcha.compose.document.backend.fixed.pdf.options; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +/** + * Unit coverage for the {@link PdfDebugOptions} value semantics: factory + * presets, wither transitions, and the enabled() aggregate. + */ +class PdfDebugOptionsTest { + + @Test + void noneDisablesEveryOverlay() { + PdfDebugOptions options = PdfDebugOptions.none(); + + assertThat(options.showGuides()).isFalse(); + assertThat(options.showNodeLabels()).isFalse(); + assertThat(options.labelText()).isEqualTo(PdfDebugOptions.LabelText.NAME); + assertThat(options.enabled()).isFalse(); + } + + @Test + void factoriesEnableTheRequestedOverlays() { + assertThat(PdfDebugOptions.guides().showGuides()).isTrue(); + assertThat(PdfDebugOptions.guides().showNodeLabels()).isFalse(); + assertThat(PdfDebugOptions.guides().enabled()).isTrue(); + + assertThat(PdfDebugOptions.nodeLabels().showGuides()).isFalse(); + assertThat(PdfDebugOptions.nodeLabels().showNodeLabels()).isTrue(); + assertThat(PdfDebugOptions.nodeLabels().enabled()).isTrue(); + + PdfDebugOptions both = PdfDebugOptions.guidesAndNodeLabels(); + assertThat(both.showGuides()).isTrue(); + assertThat(both.showNodeLabels()).isTrue(); + assertThat(both.enabled()).isTrue(); + } + + @Test + void withersToggleSingleAspectsAndPreserveTheRest() { + PdfDebugOptions labelsWithGuides = PdfDebugOptions.nodeLabels().withGuides(true); + assertThat(labelsWithGuides.showGuides()).isTrue(); + assertThat(labelsWithGuides.showNodeLabels()).isTrue(); + + PdfDebugOptions guidesOnlyAgain = labelsWithGuides.withNodeLabels(false); + assertThat(guidesOnlyAgain.showGuides()).isTrue(); + assertThat(guidesOnlyAgain.showNodeLabels()).isFalse(); + + PdfDebugOptions fullPath = PdfDebugOptions.nodeLabels() + .withLabelText(PdfDebugOptions.LabelText.FULL_PATH); + assertThat(fullPath.labelText()).isEqualTo(PdfDebugOptions.LabelText.FULL_PATH); + assertThat(fullPath.showNodeLabels()).isTrue(); + } + + @Test + void noOpWithersReturnTheSameInstance() { + PdfDebugOptions options = PdfDebugOptions.guides(); + + assertThat(options.withGuides(true)).isSameAs(options); + assertThat(options.withNodeLabels(false)).isSameAs(options); + assertThat(options.withLabelText(PdfDebugOptions.LabelText.NAME)).isSameAs(options); + } + + @Test + void nullLabelTextIsRejected() { + assertThatNullPointerException() + .isThrownBy(() -> new PdfDebugOptions(false, false, null)); + assertThatNullPointerException() + .isThrownBy(() -> PdfDebugOptions.none().withLabelText(null)); + } +} diff --git a/src/test/java/com/demcha/testing/visual/DebugNodeLabelsDemoTest.java b/src/test/java/com/demcha/testing/visual/DebugNodeLabelsDemoTest.java new file mode 100644 index 000000000..7e26e7d6b --- /dev/null +++ b/src/test/java/com/demcha/testing/visual/DebugNodeLabelsDemoTest.java @@ -0,0 +1,65 @@ +package com.demcha.testing.visual; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.backend.fixed.pdf.options.PdfDebugOptions; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.testing.visual.ImageDiff; +import com.demcha.compose.testing.visual.PdfVisualRegression; +import org.junit.jupiter.api.Test; + +import java.awt.image.BufferedImage; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Renders the same two-module sheet with and without the debug overlay + * (guide lines + semantic node labels) and writes a human-review PDF. + * Asserts the overlay visibly paints on top of the plain render. + */ +class DebugNodeLabelsDemoTest { + + private static final PdfVisualRegression VISUAL = PdfVisualRegression.standard(); + + @Test + void debugOverlayPaintsGuidesAndNodeLabels() throws Exception { + byte[] plain = sheet(PdfDebugOptions.none()); + byte[] debug = sheet(PdfDebugOptions.guidesAndNodeLabels()); + + assertThat(debug).isNotEmpty(); + assertThat(new String(debug, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-"); + + BufferedImage plainPage = VISUAL.renderPages(plain).get(0); + BufferedImage debugPage = VISUAL.renderPages(debug).get(0); + ImageDiff.Result diff = ImageDiff.compare(plainPage, debugPage, 8); + assertThat(diff.mismatchedPixelCount()) + .as("the debug overlay must visibly draw guides and labels (%s)", diff.summary()) + .isGreaterThan(500L); + + Path out = Path.of("target/visual-tests/debug-overlay/debug_node_labels.pdf"); + Files.createDirectories(out.getParent()); + Files.write(out, debug); + javax.imageio.ImageIO.write(debugPage, "png", + out.resolveSibling("debug_node_labels.png").toFile()); + } + + private static byte[] sheet(PdfDebugOptions options) throws Exception { + try (DocumentSession document = GraphCompose.document() + .pageSize(360, 320) + .margin(DocumentInsets.of(22)) + .debug(options) + .create()) { + document.pageFlow(page -> page + .module("InvoiceHeader", module -> module + .paragraph("ACME Corp — Invoice 2026-104") + .paragraph("Issued 2026-06-11, due in 14 days")) + .module("PriceSummary", module -> module + .paragraph("Subtotal 1,180.00") + .paragraph("Total 1,416.00"))); + return document.toPdfBytes(); + } + } +}