From a1d7a80c84d92137708a6b611f1cf931457a3bbf Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 11 Jun 2026 14:54:46 +0100
Subject: [PATCH] feat(pdf): debug node-label overlay - PdfDebugOptions with
guides + semantic path badges
PdfDebugOptions (guides / nodeLabels / LabelText NAME|FULL_PATH) configures the canonical PDF backend through GraphCompose.document().debug(...), DocumentSession.debug(...), or PdfFixedLayoutBackend.builder().debug(...). PdfNodeLabelRenderer prints each node's stable semantic path once per owner and page as a corner badge straddling the top-right edge of the owner bounds (base-14 Helvetica, non-WinAnsi degrades to '?'). guideLines(boolean) became sugar over the options; disabled debug output stays byte-identical (same-length assertion). Ships DebugOverlayExample + gallery/README registration + committed preview, 13 new tests across unit/integration/visual. Full gate: 1233 tests, javadoc, BUILD SUCCESS.
---
CHANGELOG.md | 15 ++
assets/readme/examples/debug-overlay.pdf | Bin 0 -> 1789 bytes
examples/README.md | 28 ++++
.../demcha/examples/GenerateAllExamples.java | 2 +
.../features/debug/DebugOverlayExample.java | 81 ++++++++++
.../examples/support/ShowcaseMetadata.java | 2 +
.../java/com/demcha/compose/GraphCompose.java | 23 +++
.../document/api/DocumentChromeOptions.java | 14 +-
.../compose/document/api/DocumentSession.java | 37 ++++-
.../fixed/pdf/PdfFixedLayoutBackend.java | 72 ++++++---
.../fixed/pdf/PdfNodeLabelRenderer.java | 121 ++++++++++++++
.../fixed/pdf/options/PdfDebugOptions.java | 152 ++++++++++++++++++
.../fixed/pdf/PdfDebugNodeLabelsTest.java | 121 ++++++++++++++
.../pdf/options/PdfDebugOptionsTest.java | 72 +++++++++
.../visual/DebugNodeLabelsDemoTest.java | 65 ++++++++
15 files changed, 770 insertions(+), 35 deletions(-)
create mode 100644 assets/readme/examples/debug-overlay.pdf
create mode 100644 examples/src/main/java/com/demcha/examples/features/debug/DebugOverlayExample.java
create mode 100644 src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfNodeLabelRenderer.java
create mode 100644 src/main/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfDebugOptions.java
create mode 100644 src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfDebugNodeLabelsTest.java
create mode 100644 src/test/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfDebugOptionsTest.java
create mode 100644 src/test/java/com/demcha/testing/visual/DebugNodeLabelsDemoTest.java
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 0000000000000000000000000000000000000000..9872ee1c1b31a88c194b5f37fdfc3ea9a538cbaa
GIT binary patch
literal 1789
zcmbuAdo+}J7{|%AHxfh9MQ=$dmv>%sF(V_>TuN;0&_pH801To*0ee5-eU
z=q|2{d6}X~jqO+O6kQxTZ+vQr{R%XswcQz9Ied$4SNYg^Y$KuIVdcE^m4@d~?A0~4
zTFdqX#FE*&j~`-&4M$j%J4udYF%}J+3)g-A+iA&bj-2y0v~2R2?O5(wPE+IW2u&(D
zC#&rYEuTJPdm*}xe<3<@QNE;QSa3b2De10XO?>aOmLgf#bMgDAB^xSOk=s559|&$B
za=VDVI`Rhbud=f~{tl5P?hBiOd{?>n6iG|M-$%9Jd-lpDykA-eGf1a66m|+_1IPGMXfjFi%o=fdOe}mhqW^ZZo9HqW|~>v!ZA1JI!zQC~l6Wa=kDq5hdXj
za`FxqT)U~g{isRj^!mGQ`I|nb7201jV>WUVpmweG2WtZ_y)yM^=o?H~?Ga~G9`{U{
z+7Vbc5VfnVKyBrxPW(H;seM4^i~h_<{#xGq7n~83N`{ky#(v=StzNhd+}dX|p8N+&
zChm9CHhTRssQS+yp`Y?6+mlst&rQB|F;`EfT9y7CZ)#?~yKZSf!WlpoQp*u5l^y9C
zEz4qMrm>$|mPVZ*CK4XBwU2EI^&kAGqhj*zrP3Kv7g05Mko#e-V^F2u3ojMW{mSiE
zjXJvmw~HtpQqdC&bI!A?0#d%l8Ys%;!9gbayREx
zU5imWU%5gw?X0OUy&9eoT5jF
z?Yl9ukdva>9bQ$QW|YQXlIE!Ry*_-z?3SL^;Fg?67Veq}!bX(PO}Cj$PBmiP9<~l;
z@UMSVuQzb13ylAJzA-T@V^=h9G_16&`aCOKf{h|AO`9`kG;g$HT(*(i?pnNPTj7HW
ze4J+@Dap)p>JXRSE}+1dpgvQYiDPPYr`L}
z{p_PZONf8J`xjOazh#9zn(zfX9_;;Iq96d;3eLdhd-#}X3PVI72J^$_2Zh4K<$6t(f8KGwR?5?ixs7N0kKVMMpN*(ubmF>SRiKJ*T*HWitDRegdIhp3
z_pMv(?r-7l=#1Ljl`ej!d)_GU07nJsJ6tieNqFt^vsDLaTxL4>lk*g3`MHc4Y1$v^1%VvVWM9|C^a5O-LbHLc1VSQcmSqK%jQo!DUXx4B`6
z0K^aoMBpsG835!;1$R2($aE?mhROCg3>u3iQm6z9!2w6X;fXZ7J&lHQAYiFz8ktI`
z6RA`jod(m$bo@q~J=TGShVeMEGx-0X@cXh(9|p1)fV={u5DPx#
literal 0
HcmV?d00001
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 extends PdfFragmentRenderHandler>> handlers) {
- this(handlers, false, null, null, null, List.of());
+ this(handlers, PdfDebugOptions.none(), null, null, null, List.of());
}
private PdfFixedLayoutBackend(Collection extends PdfFragmentRenderHandler>> handlers,
- boolean guideLines,
+ PdfDebugOptions debug,
PdfMetadataOptions metadataOptions,
PdfWatermarkOptions watermarkOptions,
PdfProtectionOptions protectionOptions,
@@ -71,7 +71,7 @@ private PdfFixedLayoutBackend(Collection extends PdfFragmentRenderHandler>>
}
}
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();
+ }
+ }
+}