From 85b3675b4c384c97e493e5750fc1b7a8bc10f157 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 12 Jun 2026 00:49:01 +0100 Subject: [PATCH 1/2] fix(pdf): debug overlay hardening - neutral DocumentDebugOptions, top-most label post-pass Review follow-ups on the v1.8 debug overlay: (1) PdfDebugOptions moves to the backend-neutral document.output as DocumentDebugOptions, following the established neutral-output-options pattern so future fixed-layout backends reuse it; (2) node labels paint as one deterministic post-pass over the owner-bounds map after all content - badges can no longer be overdrawn by children or higher layers, the per-fragment dedup set and its key churn are gone, and the badge font is constructed per render pass (the watermark/header-footer pattern) eliminating the shared static PDType1Font race; (3) label text degrades through the shared GlyphFallbackLogger so WinAnsi accents like e-acute survive; (4) badge X is clamped to the page box; (5) GraphCompose.DocumentBuilder drops its boolean+options field pair for one DocumentDebugOptions field - guideLines()/debug() now follow last-write-wins on all three surfaces and debug(none()) reliably disables everything; (6) corner-badge prose corrected in Javadoc/example/CHANGELOG. 17 tests across the overlay suite incl. new e-acute survival, builder-semantics, and split-owner-per-page cases. --- CHANGELOG.md | 37 +++-- examples/README.md | 6 +- .../features/debug/DebugOverlayExample.java | 13 +- .../examples/support/ShowcaseMetadata.java | 2 +- .../java/com/demcha/compose/GraphCompose.java | 33 +++-- .../document/api/DocumentChromeOptions.java | 2 +- .../compose/document/api/DocumentSession.java | 11 +- .../fixed/pdf/PdfFixedLayoutBackend.java | 49 +++---- .../fixed/pdf/PdfNodeLabelRenderer.java | 138 +++++++++--------- .../DocumentDebugOptions.java} | 59 ++++---- .../fixed/pdf/PdfDebugNodeLabelsTest.java | 90 +++++++++++- .../pdf/options/PdfDebugOptionsTest.java | 72 --------- .../output/DocumentDebugOptionsTest.java | 72 +++++++++ .../visual/DebugNodeLabelsDemoTest.java | 8 +- 14 files changed, 343 insertions(+), 249 deletions(-) rename src/main/java/com/demcha/compose/document/{backend/fixed/pdf/options/PdfDebugOptions.java => output/DocumentDebugOptions.java} (61%) delete mode 100644 src/test/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfDebugOptionsTest.java create mode 100644 src/test/java/com/demcha/compose/document/output/DocumentDebugOptionsTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 81cb6794..8548cf38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,21 +104,28 @@ 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. +- **Debug node labels** (`@since 1.8.0`). The debug overlay grew a second + layer: backend-neutral `DocumentDebugOptions` (guides + node labels + + label-text mode, in `document.output` next to the other neutral output + options) configures fixed-layout rendering 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, as a small corner + badge straddling the top edge of the node's bounds (right-aligned 5pt + Helvetica on a pale halo), so a misplaced block on the sheet reads straight + back to the builder call that authored it. Labels paint as a single + deterministic post-pass after all content, so badges always sit on top — + a container's children or a higher layer can never overdraw the label that + annotates them. `LabelText.NAME` (default) prints the compact own segment + (`PriceSummaryTitle[0]`); `FULL_PATH` prints the whole ancestry. Label text + degrades through the shared WinAnsi fallback (accents like `é` survive, + anything outside WinAnsi becomes `?` with a `glyph.missing` log). The + overlay draws strictly on top of content and never touches measurement or + pagination. `guideLines(boolean)` everywhere became sugar over the options + object with uniform last-write-wins semantics on all three surfaces — + node-label settings survive the toggle, `debug(none())` reliably disables + everything — and disabled debug output stays byte-identical. ### Bug fixes diff --git a/examples/README.md b/examples/README.md index 25ecf362..f7b7893f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -95,7 +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) | +| [Debug overlay](#debug-overlay) | `DocumentDebugOptions` — 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) | @@ -653,7 +653,7 @@ label, then search that name in your builder code. ```java try (DocumentSession document = GraphCompose.document(outputFile) - .debug(PdfDebugOptions.guidesAndNodeLabels()) + .debug(DocumentDebugOptions.guidesAndNodeLabels()) .create()) { document.pageFlow(page -> page .module("InvoiceHeader", m -> m.paragraph("ACME Corp — Invoice 2026-104"))); @@ -662,7 +662,7 @@ try (DocumentSession document = GraphCompose.document(outputFile) ``` Labels default to the compact own segment (`InvoiceHeaderTitle[0]`); -`PdfDebugOptions.LabelText.FULL_PATH` prints the whole ancestor chain +`DocumentDebugOptions.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. 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 index 18eb6d46..e057a03f 100644 --- a/examples/src/main/java/com/demcha/examples/features/debug/DebugOverlayExample.java +++ b/examples/src/main/java/com/demcha/examples/features/debug/DebugOverlayExample.java @@ -3,7 +3,7 @@ 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.output.DocumentDebugOptions; import com.demcha.compose.document.style.DocumentInsets; import com.demcha.examples.support.ExampleOutputPaths; @@ -18,16 +18,17 @@ * *
{@code
  * GraphCompose.document(out)
- *         .debug(PdfDebugOptions.guidesAndNodeLabels())
+ *         .debug(DocumentDebugOptions.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 + * and page as a corner badge straddling the top edge of the node's bounds + * (right-aligned), 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 + * code. {@code DocumentDebugOptions.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 @@ -53,7 +54,7 @@ public static Path generate() throws Exception { try (DocumentSession document = GraphCompose.document(pdfFile) .pageSize(DocumentPageSize.A4) .margin(DocumentInsets.of(28)) - .debug(PdfDebugOptions.guidesAndNodeLabels()) + .debug(DocumentDebugOptions.guidesAndNodeLabels()) .create()) { document.pageFlow(page -> page .module("HowToReadThisSheet", module -> module 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 3f23df31..45f2c60c 100644 --- a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java +++ b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java @@ -104,7 +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"); + feature("debug", "debug-overlay", "Debug Overlay", "DocumentDebugOptions — 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"); diff --git a/src/main/java/com/demcha/compose/GraphCompose.java b/src/main/java/com/demcha/compose/GraphCompose.java index d1481c05..a1b5409c 100644 --- a/src/main/java/com/demcha/compose/GraphCompose.java +++ b/src/main/java/com/demcha/compose/GraphCompose.java @@ -6,7 +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.output.DocumentDebugOptions; import com.demcha.compose.document.style.DocumentInsets; import java.nio.file.Path; @@ -140,8 +140,7 @@ public static final class DocumentBuilder { private DocumentPageSize pageSize = DocumentPageSize.A4; private DocumentInsets margin = DocumentInsets.zero(); private boolean markdown = true; - private boolean guideLines; - private PdfDebugOptions debug; + private DocumentDebugOptions debug = DocumentDebugOptions.none(); private com.demcha.compose.document.style.DocumentColor pageBackground; private java.util.List pageBackgrounds; private final List customFontFamilies = new ArrayList<>(); @@ -220,29 +219,35 @@ public DocumentBuilder markdown(boolean enabled) { * {@link DocumentSession#toPdfBytes()}. It does not alter semantic layout * geometry or layout snapshots.

* + *

Shorthand for toggling only the guide overlay on the current + * {@link #debug(DocumentDebugOptions) debug} configuration — node-label + * settings are preserved, and the call order with {@code debug(...)} + * follows last-write-wins, exactly like the equivalent switches on + * {@code DocumentSession} and the PDF backend builder.

+ * * @param enabled {@code true} to draw debug guide-line overlays * @return this builder */ public DocumentBuilder guideLines(boolean enabled) { - this.guideLines = enabled; + this.debug = this.debug.withGuides(enabled); return this; } /** - * Configures PDF debug overlays (guide lines and semantic node labels) + * Configures 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.

+ *

Replaces the whole debug configuration; {@code null} resets to + * {@link DocumentDebugOptions#none()}. 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; + public DocumentBuilder debug(DocumentDebugOptions options) { + this.debug = options == null ? DocumentDebugOptions.none() : options; return this; } @@ -410,10 +415,8 @@ public DocumentSession create() { margin, List.copyOf(customFontFamilies), markdown, - guideLines); - if (debug != null) { - session.debug(debug.withGuides(debug.showGuides() || guideLines)); - } + debug.showGuides()); + session.debug(debug); 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 24b00198..af483d66 100644 --- a/src/main/java/com/demcha/compose/document/api/DocumentChromeOptions.java +++ b/src/main/java/com/demcha/compose/document/api/DocumentChromeOptions.java @@ -102,7 +102,7 @@ DocumentOutputOptions snapshot() { * never {@code null} * @return ready-to-use PDF backend */ - PdfFixedLayoutBackend toConveniencePdfBackend(PdfDebugOptions debug) { + PdfFixedLayoutBackend toConveniencePdfBackend(DocumentDebugOptions debug) { if (!debug.enabled() && isEmpty()) { return new PdfFixedLayoutBackend(); } 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 ee2d2dae..d166274e 100644 --- a/src/main/java/com/demcha/compose/document/api/DocumentSession.java +++ b/src/main/java/com/demcha/compose/document/api/DocumentSession.java @@ -4,7 +4,6 @@ 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; @@ -74,7 +73,7 @@ public final class DocumentSession implements AutoCloseable { private DocumentInsets margin; private LayoutCanvas canvas; private boolean markdown; - private PdfDebugOptions debug = PdfDebugOptions.none(); + private DocumentDebugOptions debug = DocumentDebugOptions.none(); private List pageBackgrounds = List.of(); private PdfMeasurementResources measurementResources; private boolean closed; @@ -101,7 +100,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.debug = PdfDebugOptions.none().withGuides(guideLines); + this.debug = DocumentDebugOptions.none().withGuides(guideLines); this.registry = BuiltInNodeDefinitions.registerDefaults(new InvalidatingNodeRegistry()); this.compiler = new LayoutCompiler(registry); this.customFontFamilies.addAll(List.copyOf(customFontFamilies)); @@ -310,7 +309,7 @@ public DocumentSession markdown(boolean enabled) { * so existing layout cache entries remain valid.

* *

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

* * @param enabled {@code true} to draw debug guide-line overlays @@ -340,9 +339,9 @@ public DocumentSession guideLines(boolean enabled) { * @return this session * @since 1.8.0 */ - public DocumentSession debug(PdfDebugOptions options) { + public DocumentSession debug(DocumentDebugOptions options) { ensureOpen(); - this.debug = options == null ? PdfDebugOptions.none() : options; + this.debug = options == null ? DocumentDebugOptions.none() : options; return this; } 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 36049cc6..6d2d7925 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 @@ -8,6 +8,7 @@ import com.demcha.compose.document.layout.LayoutGraph; import com.demcha.compose.document.layout.PlacedFragment; import com.demcha.compose.document.layout.payloads.*; +import com.demcha.compose.document.output.DocumentDebugOptions; import com.demcha.compose.font.FontLibrary; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; @@ -40,7 +41,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 PdfDebugOptions debug; + private final DocumentDebugOptions debug; private final PdfMetadataOptions metadataOptions; private final PdfWatermarkOptions watermarkOptions; private final PdfProtectionOptions protectionOptions; @@ -50,15 +51,15 @@ public final class PdfFixedLayoutBackend implements FixedLayoutBackend { * Creates a backend with the built-in paragraph, shape, image, and table handlers. */ public PdfFixedLayoutBackend() { - this(defaultHandlers(), PdfDebugOptions.none(), null, null, null, List.of()); + this(defaultHandlers(), DocumentDebugOptions.none(), null, null, null, List.of()); } PdfFixedLayoutBackend(Collection> handlers) { - this(handlers, PdfDebugOptions.none(), null, null, null, List.of()); + this(handlers, DocumentDebugOptions.none(), null, null, null, List.of()); } private PdfFixedLayoutBackend(Collection> handlers, - PdfDebugOptions debug, + DocumentDebugOptions debug, PdfMetadataOptions metadataOptions, PdfWatermarkOptions watermarkOptions, PdfProtectionOptions protectionOptions, @@ -71,7 +72,7 @@ private PdfFixedLayoutBackend(Collection> } } this.handlers = Map.copyOf(registry); - this.debug = debug == null ? PdfDebugOptions.none() : debug; + this.debug = debug == null ? DocumentDebugOptions.none() : debug; this.metadataOptions = metadataOptions; this.watermarkOptions = watermarkOptions; this.protectionOptions = protectionOptions; @@ -283,16 +284,20 @@ private int renderToOutput(LayoutGraph graph, FixedLayoutRenderContext context, 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, ownerBounds, labelKeys); + index = renderTableRowGroup(graph.fragments(), index, tableHandler, environment, ownerBounds); continue; } - renderFragment(fragment, environment, ownerBounds, labelKeys); + renderFragment(fragment, environment, ownerBounds); + } + // Node labels paint as one post-pass so badges always land on + // top of the content they annotate, in deterministic order. + if (debug.showNodeLabels()) { + PdfNodeLabelRenderer.drawAll(ownerBounds, environment, debug.labelText()); } PdfBookmarkOutlineWriter.apply(document, environment.bookmarkRecords()); } @@ -314,8 +319,7 @@ private int renderTableRowGroup(List fragments, int startIndex, PdfTableRowFragmentRenderHandler handler, PdfRenderEnvironment environment, - Map> ownerBounds, - Set labelKeys) throws Exception { + Map> ownerBounds) throws Exception { String tablePath = fragments.get(startIndex).path(); int endExclusive = startIndex; while (endExclusive < fragments.size() @@ -336,7 +340,7 @@ private int renderTableRowGroup(List fragments, TableRowFragmentPayload payload = (TableRowFragmentPayload) fragment.payload(); handler.renderBordersAndText(fragment, payload, environment); - finishRenderedFragment(fragment, payload, environment, ownerBounds, labelKeys); + finishRenderedFragment(fragment, payload, environment, ownerBounds); } return endExclusive - 1; @@ -356,19 +360,17 @@ private List createPages(PDDocument document, LayoutGraph graph) { private void renderFragment(PlacedFragment fragment, PdfRenderEnvironment environment, - Map> ownerBounds, - Set labelKeys) throws Exception { + Map> ownerBounds) throws Exception { Object payload = fragment.payload(); PdfFragmentRenderHandler handler = handlerFor(payload); handler.render(fragment, payload, environment); - finishRenderedFragment(fragment, payload, environment, ownerBounds, labelKeys); + finishRenderedFragment(fragment, payload, environment, ownerBounds); } private void finishRenderedFragment(PlacedFragment fragment, Object payload, PdfRenderEnvironment environment, - Map> ownerBounds, - Set labelKeys) throws Exception { + Map> ownerBounds) throws Exception { if (payload instanceof ParagraphFragmentPayload paragraphPayload) { addParagraphLinks(fragment, paragraphPayload, environment); } @@ -390,9 +392,6 @@ private void finishRenderedFragment(PlacedFragment fragment, 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, @@ -487,7 +486,7 @@ private PdfFragmentRenderHandler handlerFor(Object payload) { public static final class Builder { private final List headerFooterOptions = new ArrayList<>(); private final List> additionalHandlers = new ArrayList<>(); - private PdfDebugOptions debug = PdfDebugOptions.none(); + private DocumentDebugOptions debug = DocumentDebugOptions.none(); private PdfMetadataOptions metadataOptions; private PdfWatermarkOptions watermarkOptions; private PdfProtectionOptions protectionOptions; @@ -533,8 +532,8 @@ 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)} + * {@link DocumentDebugOptions#withGuides(boolean)} on the current debug + * configuration; node-label settings made via {@link #debug(DocumentDebugOptions)} * are preserved.

* * @param enabled {@code true} to draw guide lines @@ -549,14 +548,14 @@ public Builder guideLines(boolean enabled) { * Configures debug overlays (guide lines and semantic node labels). * *

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

+ * {@link DocumentDebugOptions#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; + public Builder debug(DocumentDebugOptions options) { + this.debug = options == null ? DocumentDebugOptions.none() : options; return this; } 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 index dc6bd0f9..cb6acf87 100644 --- 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 @@ -1,27 +1,37 @@ 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 com.demcha.compose.document.output.DocumentDebugOptions; +import com.demcha.compose.engine.render.pdf.GlyphFallbackLogger; import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; 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; +import java.util.TreeMap; /** * 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.

+ *

Runs as a single post-pass after all content fragments have rendered, + * so badges always paint on top of the page — a container's children or a + * higher layer can never overdraw the label that annotates them. For every + * owner path and page it prints one corner badge anchored at the top-right + * of the owner's union bounds (the same bounds the guide renderer outlines), + * straddling the top edge so at most an ascender half-strip of the first + * content line is covered.

+ * + *

The badge font is the built-in Helvetica base-14 face, constructed per + * render pass (PDFBox font objects carry lazily-populated encode caches that + * must not be shared across concurrent renders — the same reason the + * watermark and header/footer renderers construct theirs per call). Text + * degrades through {@link GlyphFallbackLogger#sanitize}, so WinAnsi-encodable + * characters such as {@code é} survive and anything else becomes {@code ?} + * with a one-time {@code glyph.missing} log.

*/ 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); @@ -31,50 +41,71 @@ private PdfNodeLabelRenderer() { } /** - * Draws the semantic label for the fragment's owner if it has not been - * drawn on this page yet. + * Draws one semantic label per owner path and page on top of the fully + * rendered content. * - * @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 + * @param ownerBoundsByPath path → page → union bounds map shared with the + * guide renderer; one badge is drawn per entry + * @param environment per-render PDF environment + * @param labelText which text variant to print + * @throws IOException if writing to a 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())) { + static void drawAll(Map> ownerBoundsByPath, + PdfRenderEnvironment environment, + DocumentDebugOptions.LabelText labelText) throws IOException { + if (ownerBoundsByPath.isEmpty()) { return; } + // Regroup page-first and sort both levels so the emitted content + // stream is deterministic regardless of the hash order of the + // incoming maps (snapshot and byte-stability invariant). + TreeMap> byPage = new TreeMap<>(); + for (Map.Entry> byPath : ownerBoundsByPath.entrySet()) { + for (Map.Entry pageBounds : byPath.getValue().entrySet()) { + byPage.computeIfAbsent(pageBounds.getKey(), key -> new TreeMap<>()) + .put(byPath.getKey(), pageBounds.getValue()); + } + } + + PDType1Font font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + for (Map.Entry> page : byPage.entrySet()) { + int pageIndex = page.getKey(); + PDRectangle mediaBox = environment.document().getPage(pageIndex).getMediaBox(); + PDPageContentStream stream = environment.pageSurface(pageIndex); + for (Map.Entry label : page.getValue().entrySet()) { + draw(stream, font, mediaBox, label.getKey(), label.getValue(), labelText); + } + } + } - PdfGuideLinesRenderer.Bounds bounds = lookupOwnerBounds(fragment, ownerBoundsByPath); - String text = sanitize(labelText == PdfDebugOptions.LabelText.NAME - ? path.substring(path.lastIndexOf('/') + 1) - : path); + private static void draw(PDPageContentStream stream, + PDType1Font font, + PDRectangle mediaBox, + String path, + PdfGuideLinesRenderer.Bounds bounds, + DocumentDebugOptions.LabelText labelText) throws IOException { + String text = GlyphFallbackLogger.sanitize(font, + labelText == DocumentDebugOptions.LabelText.NAME + ? path.substring(path.lastIndexOf('/') + 1) + : path); if (text.isEmpty()) { return; } - float textWidth = FONT.getStringWidth(text) / 1000f * FONT_SIZE; + 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; + // Corner badge: anchored at the top-RIGHT of the owner bounds + // (content usually starts flush left) and straddling the top edge so + // only an ascender half-strip of the first line can be covered. + // Clamped onto the page when the owner is narrower than the label or + // touches a page edge. 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); + boxX = Math.min(boxX, mediaBox.getUpperRightX() - boxWidth); + boxX = Math.max(boxX, mediaBox.getLowerLeftX()); + float boxTop = (float) Math.min(mediaBox.getUpperRightY(), + bounds.y() + bounds.height() + boxHeight / 2.0); - PDPageContentStream stream = environment.pageSurface(fragment.pageIndex()); stream.saveGraphicsState(); try { stream.setNonStrokingColor(HALO_COLOR); @@ -83,7 +114,7 @@ static void draw(PlacedFragment fragment, stream.setNonStrokingColor(TEXT_COLOR); stream.beginText(); - stream.setFont(FONT, FONT_SIZE); + 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); @@ -93,29 +124,4 @@ static void draw(PlacedFragment fragment, 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/output/DocumentDebugOptions.java similarity index 61% rename from src/main/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfDebugOptions.java rename to src/main/java/com/demcha/compose/document/output/DocumentDebugOptions.java index 64f5f461..465ec1b2 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfDebugOptions.java +++ b/src/main/java/com/demcha/compose/document/output/DocumentDebugOptions.java @@ -1,33 +1,38 @@ -package com.demcha.compose.document.backend.fixed.pdf.options; +package com.demcha.compose.document.output; import java.util.Objects; /** - * Debug-overlay configuration for the canonical PDF backend. + * Debug-overlay configuration for fixed-layout document rendering. * *

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.

+ * default) keeps rendered documents byte-identical to previous releases. + * Like the other backend-neutral output options in this package + * ({@link DocumentWatermark}, {@link DocumentMetadata}, …), the carrier is + * format-agnostic; the canonical PDF backend implements both overlays today, + * and future fixed-layout backends can honour the same options.

* *

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()}.
  • + *
  • Node labels — the stable semantic path of the owning node, + * printed once per node and page as a small corner badge straddling + * the top edge of the node's bounds (right-aligned, so it rarely + * covers the node's own text). 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())
+ *         .debug(DocumentDebugOptions.guidesAndNodeLabels())
  *         .create()) {
  *     // author content ...
  *     document.buildPdf();
@@ -42,14 +47,14 @@
  * @author Artem Demchyshyn
  * @since 1.8.0
  */
-public record PdfDebugOptions(boolean showGuides, boolean showNodeLabels, LabelText labelText) {
+public record DocumentDebugOptions(boolean showGuides, boolean showNodeLabels, LabelText labelText) {
 
-    private static final PdfDebugOptions NONE = new PdfDebugOptions(false, false, LabelText.NAME);
+    private static final DocumentDebugOptions NONE = new DocumentDebugOptions(false, false, LabelText.NAME);
 
     /**
      * Validates record invariants.
      */
-    public PdfDebugOptions {
+    public DocumentDebugOptions {
         Objects.requireNonNull(labelText, "labelText");
     }
 
@@ -58,7 +63,7 @@ public record PdfDebugOptions(boolean showGuides, boolean showNodeLabels, LabelT
      *
      * @return options with all debug overlays off
      */
-    public static PdfDebugOptions none() {
+    public static DocumentDebugOptions none() {
         return NONE;
     }
 
@@ -68,8 +73,8 @@ public static PdfDebugOptions none() {
      *
      * @return options drawing fragment boxes and margin/padding guides
      */
-    public static PdfDebugOptions guides() {
-        return new PdfDebugOptions(true, false, LabelText.NAME);
+    public static DocumentDebugOptions guides() {
+        return new DocumentDebugOptions(true, false, LabelText.NAME);
     }
 
     /**
@@ -77,8 +82,8 @@ public static PdfDebugOptions guides() {
      *
      * @return options drawing semantic node labels
      */
-    public static PdfDebugOptions nodeLabels() {
-        return new PdfDebugOptions(false, true, LabelText.NAME);
+    public static DocumentDebugOptions nodeLabels() {
+        return new DocumentDebugOptions(false, true, LabelText.NAME);
     }
 
     /**
@@ -87,8 +92,8 @@ public static PdfDebugOptions nodeLabels() {
      *
      * @return options drawing guides and semantic node labels
      */
-    public static PdfDebugOptions guidesAndNodeLabels() {
-        return new PdfDebugOptions(true, true, LabelText.NAME);
+    public static DocumentDebugOptions guidesAndNodeLabels() {
+        return new DocumentDebugOptions(true, true, LabelText.NAME);
     }
 
     /**
@@ -97,8 +102,8 @@ public static PdfDebugOptions guidesAndNodeLabels() {
      * @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);
+    public DocumentDebugOptions withGuides(boolean enabled) {
+        return enabled == showGuides ? this : new DocumentDebugOptions(enabled, showNodeLabels, labelText);
     }
 
     /**
@@ -107,8 +112,8 @@ public PdfDebugOptions withGuides(boolean enabled) {
      * @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);
+    public DocumentDebugOptions withNodeLabels(boolean enabled) {
+        return enabled == showNodeLabels ? this : new DocumentDebugOptions(showGuides, enabled, labelText);
     }
 
     /**
@@ -117,9 +122,9 @@ public PdfDebugOptions withNodeLabels(boolean enabled) {
      * @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) {
+    public DocumentDebugOptions withLabelText(LabelText text) {
         Objects.requireNonNull(text, "text");
-        return text == labelText ? this : new PdfDebugOptions(showGuides, showNodeLabels, text);
+        return text == labelText ? this : new DocumentDebugOptions(showGuides, showNodeLabels, text);
     }
 
     /**
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
index 4ebec7a6..fcd246e3 100644
--- 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
@@ -2,7 +2,7 @@
 
 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.output.DocumentDebugOptions;
 import com.demcha.compose.document.style.DocumentInsets;
 import org.apache.pdfbox.Loader;
 import org.apache.pdfbox.pdmodel.PDDocument;
@@ -24,7 +24,7 @@ class PdfDebugNodeLabelsTest {
 
     @Test
     void nodeLabelsPrintLeafSegmentByDefault() throws Exception {
-        String text = extractText(render(PdfDebugOptions.nodeLabels(), "PriceSummary"));
+        String text = extractText(render(DocumentDebugOptions.nodeLabels(), "PriceSummary"));
 
         // The module title is an auto-named paragraph at child index 0; the
         // body paragraph follows at index 1.
@@ -37,7 +37,7 @@ void nodeLabelsPrintLeafSegmentByDefault() throws Exception {
     @Test
     void fullPathLabelsIncludeAncestry() throws Exception {
         String text = extractText(render(
-                PdfDebugOptions.nodeLabels().withLabelText(PdfDebugOptions.LabelText.FULL_PATH),
+                DocumentDebugOptions.nodeLabels().withLabelText(DocumentDebugOptions.LabelText.FULL_PATH),
                 "PriceSummary"));
 
         assertThat(text).contains("PriceSummary[0]");
@@ -54,7 +54,7 @@ void labelsStayOffByDefault() throws Exception {
 
     @Test
     void guideOverlayAloneDrawsNoLabels() throws Exception {
-        String text = extractText(render(PdfDebugOptions.guides(), "PriceSummary"));
+        String text = extractText(render(DocumentDebugOptions.guides(), "PriceSummary"));
 
         assertThat(text).doesNotContain("ParagraphNode[1]");
         assertThat(text).doesNotContain("PriceSummaryTitle[0]");
@@ -66,7 +66,7 @@ void guideLinesToggleAfterDebugKeepsLabelSettings() throws Exception {
                 .pageSize(340, 260)
                 .margin(DocumentInsets.of(18))
                 .create()) {
-            document.debug(PdfDebugOptions.nodeLabels());
+            document.debug(DocumentDebugOptions.nodeLabels());
             document.guideLines(true);
             document.pageFlow(page -> page.module("PriceSummary",
                     module -> module.paragraph("Body copy")));
@@ -76,10 +76,84 @@ void guideLinesToggleAfterDebugKeepsLabelSettings() throws Exception {
         }
     }
 
+    @Test
+    void winAnsiEncodableAccentsSurviveInLabels() throws Exception {
+        String text = extractText(render(DocumentDebugOptions.nodeLabels(), "Résumé"));
+
+        // é is WinAnsi-encodable, so the shared GlyphFallbackLogger
+        // degradation keeps it intact instead of mangling it to '?'.
+        assertThat(text).contains("RésuméTitle[0]");
+        assertThat(text).doesNotContain("R?sum?Title[0]");
+    }
+
+    @Test
+    void builderDebugAfterGuideLinesReplacesTheWholeConfig() throws Exception {
+        // Last-write-wins on the GraphCompose builder, same as the session:
+        // debug(none()) after guideLines(true) disables everything, so the
+        // bytes match a render that never enabled debug at all.
+        byte[] disabled;
+        try (DocumentSession document = GraphCompose.document()
+                .pageSize(340, 260)
+                .margin(DocumentInsets.of(18))
+                .guideLines(true)
+                .debug(DocumentDebugOptions.none())
+                .create()) {
+            document.pageFlow(page -> page.module("PriceSummary",
+                    module -> module.paragraph("Body copy for the overlay test")));
+            disabled = document.toPdfBytes();
+        }
+        byte[] plain = render(null, "PriceSummary");
+
+        assertThat(disabled).hasSameSizeAs(plain);
+    }
+
+    @Test
+    void builderGuideLinesAfterDebugPreservesLabelSettings() throws Exception {
+        try (DocumentSession document = GraphCompose.document()
+                .pageSize(340, 260)
+                .margin(DocumentInsets.of(18))
+                .debug(DocumentDebugOptions.nodeLabels())
+                .guideLines(true)
+                .create()) {
+            document.pageFlow(page -> page.module("PriceSummary",
+                    module -> module.paragraph("Body copy")));
+
+            String text = extractText(document.toPdfBytes());
+            assertThat(text).contains("ParagraphNode[1]");
+        }
+    }
+
+    @Test
+    void splitOwnersAreLabelledOnEveryPageTheyTouch() throws Exception {
+        try (DocumentSession document = GraphCompose.document()
+                .pageSize(220, 120)
+                .margin(DocumentInsets.of(16))
+                .debug(DocumentDebugOptions.nodeLabels())
+                .create()) {
+            document.pageFlow(page -> page.module("LongStory",
+                    module -> module.paragraph("flow ".repeat(160))));
+
+            byte[] pdf = document.toPdfBytes();
+            try (PDDocument loaded = Loader.loadPDF(pdf)) {
+                assertThat(loaded.getNumberOfPages()).isGreaterThan(1);
+                PDFTextStripper firstPage = new PDFTextStripper();
+                firstPage.setStartPage(1);
+                firstPage.setEndPage(1);
+                PDFTextStripper secondPage = new PDFTextStripper();
+                secondPage.setStartPage(2);
+                secondPage.setEndPage(2);
+                // The split paragraph's owner gets one badge on each page it
+                // touches, not just on the page of its first fragment.
+                assertThat(firstPage.getText(loaded)).contains("ParagraphNode[1]");
+                assertThat(secondPage.getText(loaded)).contains("ParagraphNode[1]");
+            }
+        }
+    }
+
     @Test
     void nonWinAnsiNamesDegradeToPlaceholders() throws Exception {
         String text = extractText(render(
-                PdfDebugOptions.nodeLabels().withLabelText(PdfDebugOptions.LabelText.FULL_PATH),
+                DocumentDebugOptions.nodeLabels().withLabelText(DocumentDebugOptions.LabelText.FULL_PATH),
                 "Шапка"));
 
         // The five Cyrillic letters survive name normalization but exceed the
@@ -92,7 +166,7 @@ void nonWinAnsiNamesDegradeToPlaceholders() throws Exception {
     @Test
     void disabledDebugOptionsMatchTheDefaultRender() throws Exception {
         byte[] plain = render(null, "PriceSummary");
-        byte[] explicitNone = render(PdfDebugOptions.none(), "PriceSummary");
+        byte[] explicitNone = render(DocumentDebugOptions.none(), "PriceSummary");
 
         assertThat(new String(plain, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-");
         assertThat(new String(explicitNone, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-");
@@ -101,7 +175,7 @@ void disabledDebugOptionsMatchTheDefaultRender() throws Exception {
         assertThat(explicitNone).hasSameSizeAs(plain);
     }
 
-    private static byte[] render(PdfDebugOptions debug, String moduleName) throws Exception {
+    private static byte[] render(DocumentDebugOptions debug, String moduleName) throws Exception {
         try (DocumentSession document = GraphCompose.document()
                 .pageSize(340, 260)
                 .margin(DocumentInsets.of(18))
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
deleted file mode 100644
index e3213a9f..00000000
--- a/src/test/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfDebugOptionsTest.java
+++ /dev/null
@@ -1,72 +0,0 @@
-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/compose/document/output/DocumentDebugOptionsTest.java b/src/test/java/com/demcha/compose/document/output/DocumentDebugOptionsTest.java
new file mode 100644
index 00000000..271a4e78
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/output/DocumentDebugOptionsTest.java
@@ -0,0 +1,72 @@
+package com.demcha.compose.document.output;
+
+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 DocumentDebugOptions} value semantics: factory
+ * presets, wither transitions, and the enabled() aggregate.
+ */
+class DocumentDebugOptionsTest {
+
+    @Test
+    void noneDisablesEveryOverlay() {
+        DocumentDebugOptions options = DocumentDebugOptions.none();
+
+        assertThat(options.showGuides()).isFalse();
+        assertThat(options.showNodeLabels()).isFalse();
+        assertThat(options.labelText()).isEqualTo(DocumentDebugOptions.LabelText.NAME);
+        assertThat(options.enabled()).isFalse();
+    }
+
+    @Test
+    void factoriesEnableTheRequestedOverlays() {
+        assertThat(DocumentDebugOptions.guides().showGuides()).isTrue();
+        assertThat(DocumentDebugOptions.guides().showNodeLabels()).isFalse();
+        assertThat(DocumentDebugOptions.guides().enabled()).isTrue();
+
+        assertThat(DocumentDebugOptions.nodeLabels().showGuides()).isFalse();
+        assertThat(DocumentDebugOptions.nodeLabels().showNodeLabels()).isTrue();
+        assertThat(DocumentDebugOptions.nodeLabels().enabled()).isTrue();
+
+        DocumentDebugOptions both = DocumentDebugOptions.guidesAndNodeLabels();
+        assertThat(both.showGuides()).isTrue();
+        assertThat(both.showNodeLabels()).isTrue();
+        assertThat(both.enabled()).isTrue();
+    }
+
+    @Test
+    void withersToggleSingleAspectsAndPreserveTheRest() {
+        DocumentDebugOptions labelsWithGuides = DocumentDebugOptions.nodeLabels().withGuides(true);
+        assertThat(labelsWithGuides.showGuides()).isTrue();
+        assertThat(labelsWithGuides.showNodeLabels()).isTrue();
+
+        DocumentDebugOptions guidesOnlyAgain = labelsWithGuides.withNodeLabels(false);
+        assertThat(guidesOnlyAgain.showGuides()).isTrue();
+        assertThat(guidesOnlyAgain.showNodeLabels()).isFalse();
+
+        DocumentDebugOptions fullPath = DocumentDebugOptions.nodeLabels()
+                .withLabelText(DocumentDebugOptions.LabelText.FULL_PATH);
+        assertThat(fullPath.labelText()).isEqualTo(DocumentDebugOptions.LabelText.FULL_PATH);
+        assertThat(fullPath.showNodeLabels()).isTrue();
+    }
+
+    @Test
+    void noOpWithersReturnTheSameInstance() {
+        DocumentDebugOptions options = DocumentDebugOptions.guides();
+
+        assertThat(options.withGuides(true)).isSameAs(options);
+        assertThat(options.withNodeLabels(false)).isSameAs(options);
+        assertThat(options.withLabelText(DocumentDebugOptions.LabelText.NAME)).isSameAs(options);
+    }
+
+    @Test
+    void nullLabelTextIsRejected() {
+        assertThatNullPointerException()
+                .isThrownBy(() -> new DocumentDebugOptions(false, false, null));
+        assertThatNullPointerException()
+                .isThrownBy(() -> DocumentDebugOptions.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
index 7e26e7d6..c1af0a25 100644
--- a/src/test/java/com/demcha/testing/visual/DebugNodeLabelsDemoTest.java
+++ b/src/test/java/com/demcha/testing/visual/DebugNodeLabelsDemoTest.java
@@ -2,7 +2,7 @@
 
 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.output.DocumentDebugOptions;
 import com.demcha.compose.document.style.DocumentInsets;
 import com.demcha.compose.testing.visual.ImageDiff;
 import com.demcha.compose.testing.visual.PdfVisualRegression;
@@ -26,8 +26,8 @@ class DebugNodeLabelsDemoTest {
 
     @Test
     void debugOverlayPaintsGuidesAndNodeLabels() throws Exception {
-        byte[] plain = sheet(PdfDebugOptions.none());
-        byte[] debug = sheet(PdfDebugOptions.guidesAndNodeLabels());
+        byte[] plain = sheet(DocumentDebugOptions.none());
+        byte[] debug = sheet(DocumentDebugOptions.guidesAndNodeLabels());
 
         assertThat(debug).isNotEmpty();
         assertThat(new String(debug, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-");
@@ -46,7 +46,7 @@ void debugOverlayPaintsGuidesAndNodeLabels() throws Exception {
                 out.resolveSibling("debug_node_labels.png").toFile());
     }
 
-    private static byte[] sheet(PdfDebugOptions options) throws Exception {
+    private static byte[] sheet(DocumentDebugOptions options) throws Exception {
         try (DocumentSession document = GraphCompose.document()
                 .pageSize(360, 320)
                 .margin(DocumentInsets.of(22))

From d1db433527c24b450ce1abafeb5278bfefaf9a32 Mon Sep 17 00:00:00 2001
From: DemchaAV 
Date: Fri, 12 Jun 2026 00:50:20 +0100
Subject: [PATCH 2/2] docs(examples): regenerate debug-overlay preview after
 label post-pass

---
 assets/readme/examples/debug-overlay.pdf | Bin 1789 -> 1787 bytes
 1 file changed, 0 insertions(+), 0 deletions(-)

diff --git a/assets/readme/examples/debug-overlay.pdf b/assets/readme/examples/debug-overlay.pdf
index 9872ee1c1b31a88c194b5f37fdfc3ea9a538cbaa..70ed58f383a7963866b146c158d4b17233b5c4cf 100644
GIT binary patch
delta 1170
zcmey%`ye{*}6-HgBa{mr*}=`&r)
z&mXEhK2xD@YP;XBZRUdGw(RGcxXwK)IJf6W=%$1foi~>pz8hT1sK2-QW@Lyv|Hhja
zb3)JmUs!q1BsFK{oh>0pZk9Qk-dp$S?)ohmpJeZv{(NK?COzw_ebVoFU3+4WHNE?s
z78v%W)F3+d@%8d4;YZIGRC}Fk+*xcDD7B{k^CFGatZ!bctrt6)yivECg~R{U%Dv~q
zwWIQ9f7QCAHUFrn?X=mdxzi@TIZ*b7k-M%B`Bu&-ZPf#hRk<<#_J2H%u?f
z!;IcFe@+oezZRkAQTfcD)xf}I%Re1PyH^WE!t6p8%u|{_Bj1Vm`)zDuNm5W*4@lEI9gI)1-sn9$0^({4MJghg##M
zLHgc_GaPpvsW0AsPZ3B~SAN)e@bbxTa>9k>oU`JLTUo5Rnk@f2)vay5dUN;7#ewG{
zzSO-c|MFen7N@04V8zj=97`LScDcD-_;%>Ux&o0?&wiXOj^}-U?-5gK>fD4~w~LJ;
z(yn;k@K+XY;J9&~@p{QbO|OHj#+O?Qp@DGtz^A_?EGow32z$pCfMIz+0;AfpW};L
z%v!&%PjY5_7wFS}$4)3nK-#PJDV69nVPs-8d$mj)w(&k8k#s88JJl*+9}vjVgxhLjL9)<
bMeGI)48{^nx|6T6DR7u_sj9mAyKw;kv7#kD

delta 1228
zcmey(`aqZ5I`E~n~y3K@j{@!ve`?#=f#?|>rac9C(tz(bg*qgX{|K^W6zqNnQx?59!
zaMPoWA2Vig=Iv2GJ1>6uVxLXzd-M6HYK6g?&YN3~*S_jyacQ{Ec)eud
zlbIruq>U5}^jX906qH`?NYXoa$L^Dp#rmrUxo0Q|P8Uv6JF3enn~>4qm=xDh9ai?b
zLV2G}U;X?b@dwk(Ii9bW()T`jbMoiXy+7J@Kh-bOOg?>TU8?Jx{ON0sT`%4Dd#dj2
zn&Wx1YbwQ8_nm#6zp?go*Phs;O?RK3-L&S$^X{Kj()RlkD?|SjWEY&YOu1osmwofe
z?nP^+MZ}v|ZuJp6og1m*5V2{_*7BcKXU%S>ZSKpNd;ak(+bMUuN>`tF)vk3jtW>Z*
zGgA8fr&ZeG=j*p^mJ%(}$>uZyQq+Hx_nT+PCTx*Jt
zC3hb=a(M>xS=oj)5)J>(%2d^NTDDkRxp?W5dujaRzw9r@$+>!s^byh}sxizVF`
zvD&(DtMHb^UT>FqEZx5}yYoIDU4zrTKR
zMK|9sMwcQ6tDuht4?TIAA9CE=_Es#pm*r;pp?#BuCg({{wmPuxMEP6E9Xza$y_Q~6
z_F-u*Iwf4>&2h%jaiUP-&ez(dL3tbhGM?afIG)+`l~2U7Y*tK7&WEyHdk@b{EblX^
zvXz<4#PnhLhs)pAI5}SO+#(*j;mUrq#();v`Yy?UUvF%=zU_Z{!0JfB7s(%c1J6aA
zIcCCd_}}e?kEimMJ>gBqGHOm77X8t(QKR==s-vTbTzzHU>O|W$7R?zE>g}I7SWov_
zE!B9Uy4~WWkk*{O%_|Qr7rcF$v4d^F{O)7rcQpS;@N%W*r4*MGr6%TrSouj=T$Tz3
z3i(N!>sX=~>rEZqT+9p&9gR$l4NOcdon0)PEzC`wP0cJ_&5T@KP0cM#TntRdw}u;{pIsQ7={i