From f220f77134e0a2865c7b7dff8a20b9fdf894574c Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 19 May 2026 14:46:02 +0100 Subject: [PATCH 01/12] feat(engine): expose sanitizeForRender entry point on PdfFont Promote PdfFont.sanitizeByFont(PDFont, String) to public and add a companion PdfFont.sanitizeForRender(TextStyle, String) entry point. sanitizeForRender resolves the active font from a TextStyle, applies the standard control-character cleanup, then substitutes any code point the font cannot encode with '?'. This is the single seam render handlers will route through in R1.b/R1.c so wrap geometry stays in lockstep with the bytes drawn on the page. getTextWidth now measures against sanitizeForRender so width and render see the same string. Existing ASCII-only callers are unaffected. One snapshot (document/nested_list_three_levels) widened because ListBuilder default markers for deep nesting (U+25E6 white bullet, U+25AA black small square) are outside Helvetica's WinAnsi coverage. Pre-R1.a measurement silently returned 0 for those markers and the renderer would have crashed when actually drawing them; the new width matches the substituted glyphs that R1.b will draw. Follow-up tracked: ListBuilder defaults should be ASCII-safe or font-aware so end users do not see '?' as a bullet. Adds PdfFontSanitizerTest covering: unknown glyph substitution, ASCII pass-through, single/collapsed spaces, empty/null input, width consistency between bullet and substitute inputs, and the direct sanitizeByFont escape hatch. Refs R1.a; precedes R1.b (paragraph render handler wiring) and R1.c (watermark/header/footer/table wiring). --- .../compose/engine/render/pdf/PdfFont.java | 71 ++++++++++-- .../render/pdf/PdfFontSanitizerTest.java | 103 ++++++++++++++++++ .../document/nested_list_three_levels.json | 8 +- 3 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 src/test/java/com/demcha/compose/engine/render/pdf/PdfFontSanitizerTest.java diff --git a/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java b/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java index 6eb3957f..5956104f 100644 --- a/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java +++ b/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java @@ -84,8 +84,18 @@ public double getTextHeight(TextStyle style) { } /** - * @param style - * @return + * Measures the rendered width of {@code text} in {@code style}'s font. + * + *

Width is measured against the same string the PDF render path will + * actually emit — that is, after {@link #sanitizeForRender(TextStyle, String)} + * replaces any glyph the selected font cannot encode. Keeping width + * measurement and render in lockstep prevents wrap geometry from + * drifting when input contains characters outside the font's coverage + * (arrows, dots, emoji, custom unicode).

+ * + * @param style style selecting the concrete font variant + * @param text raw text from the document model + * @return rendered width in points */ @Override public double getTextWidth(TextStyle style, String text) { @@ -94,9 +104,11 @@ public double getTextWidth(TextStyle style, String text) { double size = style.size(); try { - // ✅ IMPORTANT: preserve whitespace runs exactly + // Preserve all-whitespace runs exactly (used by wrapping for + // leading/trailing space accounting); sanitize the rest so + // width matches the bytes actually shown by the renderer. boolean whitespaceOnly = text.chars().allMatch(Character::isWhitespace); - String measured = whitespaceOnly ? text : textSanitizer(text); // sanitizer must not strip trailing spaces ideally + String measured = whitespaceOnly ? text : sanitizeForRender(style, text); double width = fontType(style.decoration()).getStringWidth(measured) / 1000d * size; return width; @@ -106,13 +118,52 @@ public double getTextWidth(TextStyle style, String text) { } } - - private String prepareForRender(String text) { - return textSanitizer(text); + /** + * Sanitises {@code text} for safe rendering with the font selected by + * {@code style}. Applies the standard control-character cleanup that + * {@code getTextWidth} has always done, then substitutes any code point + * the resolved font cannot encode with {@code '?'}. + * + *

This is the single entry point shared by the PDF render path + * (paragraphs, tables, watermarks, header/footer) and the width + * measurement path. Calling it everywhere keeps wrap geometry + * consistent with the bytes that are actually drawn on the page, + * and prevents {@link PDFont#encode(String)} from throwing on + * characters outside the font's coverage (arrows ↦ U+2192, bullets + * ↦ U+25CF, emoji, custom unicode).

+ * + * @param style style selecting the concrete font variant + * @param text raw text from the document model; {@code null} or empty + * is returned unchanged + * @return text safe to pass to + * {@code PDPageContentStream.showText(...)} + */ + public String sanitizeForRender(TextStyle style, String text) { + if (text == null || text.isEmpty()) { + return text == null ? "" : text; + } + String cleaned = textSanitizer(text); + PDFont font = fontType(style.decoration()); + return sanitizeByFont(font, cleaned); } - /** Keeps spaces, only replaces characters the font can't encode. */ - private String sanitizeByFont(PDFont font, String s) { + /** + * Replaces every code point in {@code s} that {@code font} cannot + * encode with {@code '?'}. Newlines are dropped (the renderer handles + * line breaks at a higher layer); spaces are preserved. + * + *

Public so render handlers in sibling packages can sanitise text + * against a specific {@link PDFont} when the active text style is + * already known. Most callers should prefer + * {@link #sanitizeForRender(TextStyle, String)} which resolves the + * font from a {@link TextStyle} and applies the standard control + * cleanup first.

+ * + * @param font font to validate glyph coverage against + * @param s text to sanitise + * @return text containing only code points the font can encode + */ + public String sanitizeByFont(PDFont font, String s) { StringBuilder sb = new StringBuilder(s.length()); s.codePoints().forEach(cp -> { // keep spaces/newlines logic correct for wrapping @@ -120,7 +171,7 @@ private String sanitizeByFont(PDFont font, String s) { String ch = new String(Character.toChars(cp)); if (canEncode(font, ch)) sb.append(ch); - else sb.append('?'); // or " " if you prefer + else sb.append('?'); }); return sb.toString(); } diff --git a/src/test/java/com/demcha/compose/engine/render/pdf/PdfFontSanitizerTest.java b/src/test/java/com/demcha/compose/engine/render/pdf/PdfFontSanitizerTest.java new file mode 100644 index 00000000..09a9f655 --- /dev/null +++ b/src/test/java/com/demcha/compose/engine/render/pdf/PdfFontSanitizerTest.java @@ -0,0 +1,103 @@ +package com.demcha.compose.engine.render.pdf; + +import com.demcha.compose.engine.components.content.text.TextStyle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies the glyph sanitizer that protects the PDF render path from + * crashes when the active font cannot encode a code point (arrows ↦ + * U+2192, bullets ↦ U+25CF, emoji, custom unicode). + * + *

The renderer's contract is: the bytes width measurement sees and + * the bytes {@code showText(...)} sees must match exactly. These tests + * pin both the substitution policy ({@code ?} for unknown glyphs) and + * the safe pass-throughs (ASCII, single spaces) so a future engine + * change cannot silently break wrap geometry or rendering.

+ * + * @author Artem Demchyshyn + */ +class PdfFontSanitizerTest { + + private PdfFont helvetica; + + @BeforeEach + void setUp() { + helvetica = new PdfFont( + new PDType1Font(Standard14Fonts.FontName.HELVETICA), + new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD), + new PDType1Font(Standard14Fonts.FontName.HELVETICA_OBLIQUE), + new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD_OBLIQUE)); + } + + @Test + void sanitizeForRender_replacesUnknownGlyphs() { + String input = "Star ● Arrow → Done"; + String output = helvetica.sanitizeForRender(TextStyle.DEFAULT_STYLE, input); + + assertThat(output).isEqualTo("Star ? Arrow ? Done"); + } + + @Test + void sanitizeForRender_preservesAscii() { + String input = "Hello, World! 1234 abc XYZ"; + String output = helvetica.sanitizeForRender(TextStyle.DEFAULT_STYLE, input); + + assertThat(output).isEqualTo(input); + } + + @Test + void sanitizeForRender_preservesSingleSpaces() { + String input = "one two three"; + String output = helvetica.sanitizeForRender(TextStyle.DEFAULT_STYLE, input); + + assertThat(output).isEqualTo("one two three"); + } + + @Test + void sanitizeForRender_collapsesConsecutiveSpaces() { + // Pins existing textSanitizer behaviour: multiple spaces collapse + // to a single space. If this contract ever changes, wrap geometry + // assumptions across the engine must be revisited. + String input = "spaced out"; + String output = helvetica.sanitizeForRender(TextStyle.DEFAULT_STYLE, input); + + assertThat(output).isEqualTo("spaced out"); + } + + @Test + void sanitizeForRender_handlesEmpty() { + assertThat(helvetica.sanitizeForRender(TextStyle.DEFAULT_STYLE, "")).isEmpty(); + } + + @Test + void sanitizeForRender_handlesNull() { + assertThat(helvetica.sanitizeForRender(TextStyle.DEFAULT_STYLE, null)).isEmpty(); + } + + @Test + void getTextWidth_returnsConsistentValueWithSanitizedString() { + // Width must be measured against the sanitized form so wrap + // geometry matches what the renderer actually draws. + double widthOfBulletInput = helvetica.getTextWidth(TextStyle.DEFAULT_STYLE, "Hello ●"); + double widthOfQuestionInput = helvetica.getTextWidth(TextStyle.DEFAULT_STYLE, "Hello ?"); + + assertThat(widthOfBulletInput).isEqualTo(widthOfQuestionInput); + } + + @Test + void sanitizeByFont_directlyReplacesUnsupportedGlyphsOnly() { + // Public escape hatch for render handlers that already have a + // resolved PDFont in hand (e.g. PdfParagraphFragmentRenderHandler + // after setFont). Should NOT do general control-char cleanup — + // that is sanitizeForRender's job. + var font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + String output = helvetica.sanitizeByFont(font, "ok ● then"); + + assertThat(output).isEqualTo("ok ? then"); + } +} diff --git a/src/test/resources/layout-snapshots/document/nested_list_three_levels.json b/src/test/resources/layout-snapshots/document/nested_list_three_levels.json index a61bae1d..bcece595 100644 --- a/src/test/resources/layout-snapshots/document/nested_list_three_levels.json +++ b/src/test/resources/layout-snapshots/document/nested_list_three_levels.json @@ -25,11 +25,11 @@ "computedY" : 269.15, "placementX" : 12.0, "placementY" : 269.15, - "placementWidth" : 32.914, + "placementWidth" : 42.812, "placementHeight" : 38.85, "startPage" : 0, "endPage" : 0, - "contentWidth" : 32.914, + "contentWidth" : 42.812, "contentHeight" : 38.85, "margin" : { "top" : 0.0, @@ -55,11 +55,11 @@ "computedY" : 269.15, "placementX" : 12.0, "placementY" : 269.15, - "placementWidth" : 32.914, + "placementWidth" : 42.812, "placementHeight" : 38.85, "startPage" : 0, "endPage" : 0, - "contentWidth" : 32.914, + "contentWidth" : 42.812, "contentHeight" : 38.85, "margin" : { "top" : 0.0, From c3484d4746b7b2965ddcaa9bb778b9198aace1c9 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 19 May 2026 14:50:57 +0100 Subject: [PATCH 02/12] feat(engine): wire glyph sanitizer into paragraph render handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PdfParagraphFragmentRenderHandler now sanitises text via PdfFont.sanitizeForRender(textStyle, text) — the same entry point introduced in R1.a for width measurement — before passing it to PDPageContentStream.showText(). Any code point Helvetica's WinAnsiEncoding cannot encode (arrows U+2192, bullets U+25CF, emoji, custom unicode) is substituted with '?', preventing PDFBox from throwing IllegalArgumentException during render. Width measurement and render now see the same string for the same text, so wrap geometry stays consistent with the painted output. GlyphFallbackLogger (new) emits a single WARN per unique (font, codePoint) pair on first substitution. Subsequent substitutions of the same glyph are suppressed to avoid log flooding on documents that use one unsupported character many times. Logger category: com.demcha.compose.engine.render.pdf.glyph-fallback. Set to DEBUG in logback-test.xml to inspect every substitution. The dead private sanitize() helper and unused TextControlSanitizer import are removed — control-character cleanup is already part of sanitizeForRender. Refs R1.b; precedes R1.c (watermark/header/footer/table wiring) and R1.d (UnicodeFallbackDemoTest visual regression). --- .../PdfParagraphFragmentRenderHandler.java | 14 ++-- .../render/pdf/GlyphFallbackLogger.java | 71 +++++++++++++++++++ .../compose/engine/render/pdf/PdfFont.java | 8 ++- 3 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/demcha/compose/engine/render/pdf/GlyphFallbackLogger.java diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java index 780805c3..e79e91ee 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java @@ -11,7 +11,6 @@ import com.demcha.compose.document.node.InlineImageAlignment; import com.demcha.compose.font.FontLibrary; import com.demcha.compose.engine.render.pdf.PdfFont; -import com.demcha.compose.engine.text.TextControlSanitizer; import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; @@ -89,7 +88,14 @@ private void renderLine(PDPageContentStream stream, try { for (ParagraphSpan span : spans) { if (span instanceof ParagraphTextSpan textSpan) { - String text = sanitize(textSpan.text()); + PdfFont font = fonts.getFont(textSpan.textStyle().fontName(), PdfFont.class).orElseThrow(); + // Font-aware sanitization keeps width measurement + // (PdfFont.getTextWidth) and the bytes emitted here + // in lockstep. PdfFont.sanitizeForRender substitutes + // any code point the resolved font cannot encode + // with '?', preventing PDFBox from throwing on + // arrows / bullets / emoji / unsupported unicode. + String text = font.sanitizeForRender(textSpan.textStyle(), textSpan.text()); if (text.isEmpty()) { cursorX += textSpan.width(); continue; @@ -99,7 +105,6 @@ private void renderLine(PDPageContentStream stream, stream.newLineAtOffset((float) cursorX, (float) baselineY); inTextBlock = true; } - PdfFont font = fonts.getFont(textSpan.textStyle().fontName(), PdfFont.class).orElseThrow(); stream.setFont(font.fontType(textSpan.textStyle().decoration()), (float) textSpan.textStyle().size()); stream.setNonStrokingColor(textSpan.textStyle().color()); stream.showText(text); @@ -150,7 +155,4 @@ private static double resolveImageBottom(ParagraphImageSpan imageSpan, return base + imageSpan.baselineOffset(); } - private String sanitize(String text) { - return TextControlSanitizer.remove(text); - } } diff --git a/src/main/java/com/demcha/compose/engine/render/pdf/GlyphFallbackLogger.java b/src/main/java/com/demcha/compose/engine/render/pdf/GlyphFallbackLogger.java new file mode 100644 index 00000000..2d3d290f --- /dev/null +++ b/src/main/java/com/demcha/compose/engine/render/pdf/GlyphFallbackLogger.java @@ -0,0 +1,71 @@ +package com.demcha.compose.engine.render.pdf; + +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Emits a single WARN per unique {@code (font, codePoint)} pair the PDF + * glyph sanitizer had to substitute. Used by + * {@link PdfFont#sanitizeByFont(PDFont, String)} so document authors see + * exactly which characters were swapped for {@code '?'} during rendering + * without flooding the log when the same character appears many times. + * + *

Deduplication is JVM-scoped via a {@link ConcurrentHashMap}-backed + * set. The cache is intentionally never invalidated — a substitution is + * a property of the loaded font, and within one process the same + * {@code (font, codePoint)} pair will always be missing, so a single + * lifetime warning is the right cadence.

+ * + *

Logger category: {@code com.demcha.compose.engine.render.pdf.glyph-fallback}. + * Set to DEBUG in {@code logback-test.xml} to inspect every substitution + * (not just first-of-kind) during a test run.

+ * + * @author Artem Demchyshyn + */ +public final class GlyphFallbackLogger { + + private static final Logger LOG = LoggerFactory.getLogger( + "com.demcha.compose.engine.render.pdf.glyph-fallback"); + + /** + * Set of {@code (fontName, codePoint)} pairs already warned about. + * Packed into a single {@code long}: upper 32 bits hold the font name + * hash, lower 32 bits hold the code point. Collisions on font name + * hash are possible but harmless — at worst one substitution is + * silently coalesced with an unrelated font. + */ + private static final Set SEEN = ConcurrentHashMap.newKeySet(); + + private GlyphFallbackLogger() { + } + + /** + * Records a glyph substitution. Emits one WARN if the + * {@code (font, codePoint)} pair has not been seen before, no-op + * otherwise. + * + * @param font the font that could not encode the code point; + * {@code null} treated as {@code ""} + * @param codePoint the Unicode code point that was substituted + */ + public static void report(PDFont font, int codePoint) { + String fontName = font != null ? font.getName() : ""; + long key = ((long) fontName.hashCode() << 32) | (codePoint & 0xFFFFFFFFL); + if (SEEN.add(key)) { + LOG.warn("glyph.missing font={} codePoint=U+{} replaced='?'", + fontName, String.format("%04X", codePoint)); + } + } + + /** + * Visible for tests. Clears the deduplication cache so a fresh test + * can assert on the warn sequence without process restart. + */ + static void resetForTesting() { + SEEN.clear(); + } +} diff --git a/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java b/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java index 5956104f..5192509d 100644 --- a/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java +++ b/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java @@ -170,8 +170,12 @@ public String sanitizeByFont(PDFont font, String s) { if (cp == '\n' || cp == '\r') return; String ch = new String(Character.toChars(cp)); - if (canEncode(font, ch)) sb.append(ch); - else sb.append('?'); + if (canEncode(font, ch)) { + sb.append(ch); + } else { + GlyphFallbackLogger.report(font, cp); + sb.append('?'); + } }); return sb.toString(); } From b74c30f0fdef5e620e53d07766cc4681a12245ab Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 19 May 2026 14:57:43 +0100 Subject: [PATCH 03/12] feat(engine): wire glyph sanitizer into watermark/header/footer/table render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes the remaining six PDF text render seams through the same glyph substitution policy R1.b applied to the paragraph handler. Any code point Helvetica's WinAnsiEncoding cannot encode is replaced with '?' instead of crashing PDFBox showText: - PdfWatermarkRenderer: one sanitise call before render covers both the single-placement and tiled branches. - PdfHeaderFooterRenderer: left / center / right zones each sanitise; the now-uniform empty check folds the null guard into isEmpty() on the sanitised result. - PdfTextRenderHandler: route through font.sanitizeForRender so width and render agree on the same string. - PdfTableRowRenderHandler: per-line sanitise inside the setFont/setColor envelope so a single bad glyph in one cell does not crash the whole table render. - PdfTableRowFragmentRenderHandler: mirror of the row-render handler on the fragment path. - PdfBlockTextRenderHandler: setFont now returns the active PDFont so the body loop can sanitise against it; the IllegalArgumentException catch is removed because showText can no longer throw on unsupported glyphs. To keep both raw-PDFont helpers (watermark, header/footer) and PdfFont-wrapper handlers on the exact same substitution policy, GlyphFallbackLogger.sanitize(PDFont, String) was promoted to a public static helper. PdfFont.sanitizeByFont now delegates to it, so the PdfFontSanitizerTest contract from R1.a still holds end-to-end. Verified: full mvnw verify zelyony (828 tests). No snapshots shifted beyond the deliberate ListBuilder marker baseline already updated in R1.a. Refs R1.c; closes the wiring half of R1 — only R1.d (UnicodeFallbackDemoTest end-to-end visual regression) remains. --- .../PdfTableRowFragmentRenderHandler.java | 5 ++- .../render/pdf/GlyphFallbackLogger.java | 44 +++++++++++++++++++ .../compose/engine/render/pdf/PdfFont.java | 24 +--------- .../handlers/PdfBlockTextRenderHandler.java | 23 +++++----- .../handlers/PdfTableRowRenderHandler.java | 5 ++- .../pdf/handlers/PdfTextRenderHandler.java | 7 ++- .../pdf/helpers/PdfHeaderFooterRenderer.java | 24 ++++++---- .../pdf/helpers/PdfWatermarkRenderer.java | 7 ++- 8 files changed, 93 insertions(+), 46 deletions(-) diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfTableRowFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfTableRowFragmentRenderHandler.java index 7ef961ad..e25fbd8b 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfTableRowFragmentRenderHandler.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfTableRowFragmentRenderHandler.java @@ -181,9 +181,12 @@ private void renderCellText(PDPageContentStream stream, if (line.text().isEmpty()) { continue; } + // Sanitise per-line so a single unsupported glyph in a + // cell does not crash the whole table render. + String safeText = font.sanitizeForRender(cell.style().textStyle(), line.text()); stream.beginText(); stream.newLineAtOffset((float) line.x(), (float) line.baselineY()); - stream.showText(line.text()); + stream.showText(safeText); stream.endText(); } } finally { diff --git a/src/main/java/com/demcha/compose/engine/render/pdf/GlyphFallbackLogger.java b/src/main/java/com/demcha/compose/engine/render/pdf/GlyphFallbackLogger.java index 2d3d290f..22c9fbb1 100644 --- a/src/main/java/com/demcha/compose/engine/render/pdf/GlyphFallbackLogger.java +++ b/src/main/java/com/demcha/compose/engine/render/pdf/GlyphFallbackLogger.java @@ -61,6 +61,50 @@ public static void report(PDFont font, int codePoint) { } } + /** + * Sanitises {@code text} against {@code font}, substituting every + * code point the font cannot encode with {@code '?'} and reporting + * each substitution through {@link #report(PDFont, int)}. Newlines + * are dropped (the renderer handles line breaks at a higher layer); + * spaces are preserved. + * + *

Static convenience for render helpers that hold a raw + * {@link PDFont} (watermarks, header/footer chrome) and have no + * {@code PdfFont} wrapper handy. {@link PdfFont#sanitizeByFont(PDFont, String)} + * delegates here so both paths use the exact same substitution + * policy and produce identical bytes.

+ * + * @param font font to validate glyph coverage against + * @param text raw text to sanitise; {@code null} returns empty + * @return text containing only code points the font can encode + */ + public static String sanitize(PDFont font, String text) { + if (text == null || text.isEmpty()) { + return text == null ? "" : text; + } + StringBuilder sb = new StringBuilder(text.length()); + text.codePoints().forEach(cp -> { + if (cp == '\n' || cp == '\r') return; + String ch = new String(Character.toChars(cp)); + if (canEncode(font, ch)) { + sb.append(ch); + } else { + report(font, cp); + sb.append('?'); + } + }); + return sb.toString(); + } + + private static boolean canEncode(PDFont font, String ch) { + try { + font.encode(ch); + return true; + } catch (Exception e) { + return false; + } + } + /** * Visible for tests. Clears the deduplication cache so a fresh test * can assert on the warn sequence without process restart. diff --git a/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java b/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java index 5192509d..f42c6eac 100644 --- a/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java +++ b/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java @@ -164,29 +164,7 @@ public String sanitizeForRender(TextStyle style, String text) { * @return text containing only code points the font can encode */ public String sanitizeByFont(PDFont font, String s) { - StringBuilder sb = new StringBuilder(s.length()); - s.codePoints().forEach(cp -> { - // keep spaces/newlines logic correct for wrapping - if (cp == '\n' || cp == '\r') return; - - String ch = new String(Character.toChars(cp)); - if (canEncode(font, ch)) { - sb.append(ch); - } else { - GlyphFallbackLogger.report(font, cp); - sb.append('?'); - } - }); - return sb.toString(); - } - - private boolean canEncode(PDFont font, String ch) { - try { - font.encode(ch); - return true; - } catch (Exception e) { - return false; - } + return GlyphFallbackLogger.sanitize(font, s); } public double getTextWidthNoSanitize(TextStyle style, String text) { double size = style.size(); diff --git a/src/main/java/com/demcha/compose/engine/render/pdf/handlers/PdfBlockTextRenderHandler.java b/src/main/java/com/demcha/compose/engine/render/pdf/handlers/PdfBlockTextRenderHandler.java index e82b0070..1ac4e946 100644 --- a/src/main/java/com/demcha/compose/engine/render/pdf/handlers/PdfBlockTextRenderHandler.java +++ b/src/main/java/com/demcha/compose/engine/render/pdf/handlers/PdfBlockTextRenderHandler.java @@ -6,6 +6,7 @@ import com.demcha.compose.engine.components.layout.coordinator.Placement; import com.demcha.compose.engine.components.renderable.BlockText; import com.demcha.compose.engine.core.EntityManager; +import com.demcha.compose.engine.render.pdf.GlyphFallbackLogger; import com.demcha.compose.engine.render.pdf.PdfFont; import com.demcha.compose.engine.render.pdf.PdfRenderingSystemECS; import com.demcha.compose.engine.render.guides.GuidesRenderer; @@ -91,14 +92,14 @@ private boolean renderBlock(Entity entity, contentStream.setTextMatrix(new Matrix(1, 0, 0, 1, (float) line.x(), (float) line.y())); for (TextDataBody body : line.bodies()) { - setFont(entityManager, body, contentStream); - try { - String text = BlockText.sanitizeText(body.text()); - if (!text.isEmpty()) { - contentStream.showText(text); - } - } catch (IllegalArgumentException ex) { - throw new IllegalCharsetNameException("Exception in rendering char " + body.text() + "\n " + line.bodies() + "\n" + ex); + PDFont activeFont = setFont(entityManager, body, contentStream); + // BlockText.sanitizeText handles control-character cleanup; + // GlyphFallbackLogger.sanitize then substitutes any code + // point the resolved font cannot encode so showText cannot + // throw on unsupported glyphs. + String text = GlyphFallbackLogger.sanitize(activeFont, BlockText.sanitizeText(body.text())); + if (!text.isEmpty()) { + contentStream.showText(text); } } } @@ -119,10 +120,12 @@ private boolean renderBlock(Entity entity, return true; } - private void setFont(EntityManager entityManager, TextDataBody body, PDPageContentStream contentStream) throws IOException { + private PDFont setFont(EntityManager entityManager, TextDataBody body, PDPageContentStream contentStream) throws IOException { var style = body.textStyle(); PdfFont pdfFont = entityManager.getFonts().getFont(style.fontName(), PdfFont.class).orElseThrow(); - contentStream.setFont(pdfFont.fontType(style.decoration()), (float) style.size()); + PDFont activeFont = pdfFont.fontType(style.decoration()); + contentStream.setFont(activeFont, (float) style.size()); + return activeFont; } /** diff --git a/src/main/java/com/demcha/compose/engine/render/pdf/handlers/PdfTableRowRenderHandler.java b/src/main/java/com/demcha/compose/engine/render/pdf/handlers/PdfTableRowRenderHandler.java index 5ffc8574..d659a9fa 100644 --- a/src/main/java/com/demcha/compose/engine/render/pdf/handlers/PdfTableRowRenderHandler.java +++ b/src/main/java/com/demcha/compose/engine/render/pdf/handlers/PdfTableRowRenderHandler.java @@ -205,9 +205,12 @@ private void renderCellText(PDPageContentStream stream, if (line.text().isEmpty()) { continue; } + // Sanitise per-line so any unsupported glyph in a single + // cell does not crash the entire table render. + String safeText = font.sanitizeForRender(style.textStyle(), line.text()); stream.beginText(); stream.newLineAtOffset((float) line.x(), (float) line.baselineY()); - stream.showText(line.text()); + stream.showText(safeText); stream.endText(); } } finally { diff --git a/src/main/java/com/demcha/compose/engine/render/pdf/handlers/PdfTextRenderHandler.java b/src/main/java/com/demcha/compose/engine/render/pdf/handlers/PdfTextRenderHandler.java index 844a9b38..88ddabb7 100644 --- a/src/main/java/com/demcha/compose/engine/render/pdf/handlers/PdfTextRenderHandler.java +++ b/src/main/java/com/demcha/compose/engine/render/pdf/handlers/PdfTextRenderHandler.java @@ -75,13 +75,18 @@ public boolean render(EntityManager manager, + (float) padding.bottom() + (float) metrics.baselineOffsetFromBottom(); + // Font-aware sanitisation routes through the same seam as the + // paragraph render handler (PdfFont.sanitizeForRender) so width + // measurement and render stay in lockstep on unsupported glyphs. + String safeText = ((PdfFont) font).sanitizeForRender(data.style(), data.textValue().value()); + contentStream.saveGraphicsState(); try { contentStream.setFont(pdfFont, (float) data.style().size()); contentStream.setNonStrokingColor(data.style().color()); contentStream.beginText(); contentStream.newLineAtOffset(baselineX, baselineY); - contentStream.showText(data.textValue().value()); + contentStream.showText(safeText); contentStream.endText(); } finally { contentStream.restoreGraphicsState(); diff --git a/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfHeaderFooterRenderer.java b/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfHeaderFooterRenderer.java index 3f42fe9a..40aeef8d 100644 --- a/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfHeaderFooterRenderer.java +++ b/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfHeaderFooterRenderer.java @@ -2,6 +2,7 @@ import com.demcha.compose.engine.components.content.header_footer.HeaderFooterConfig; import com.demcha.compose.engine.components.content.header_footer.HeaderFooterZone; +import com.demcha.compose.engine.render.pdf.GlyphFallbackLogger; import lombok.extern.slf4j.Slf4j; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; @@ -83,9 +84,14 @@ private static void renderZone(PDPageContentStream cs, cs.setNonStrokingColor(config.getTextColor()); - // Left text - String leftText = HeaderFooterConfig.resolvePlaceholders(config.getLeftText(), currentPage, totalPages); - if (leftText != null && !leftText.isEmpty()) { + // Header/footer text frequently includes page-number glyphs, + // copyright marks, or user branding outside Helvetica's WinAnsi + // coverage. Sanitise each zone through GlyphFallbackLogger so + // unsupported code points become '?' instead of crashing the + // whole document render. + String leftText = GlyphFallbackLogger.sanitize(font, + HeaderFooterConfig.resolvePlaceholders(config.getLeftText(), currentPage, totalPages)); + if (!leftText.isEmpty()) { cs.beginText(); cs.setFont(font, fontSize); cs.newLineAtOffset(marginLeft, baseY); @@ -93,9 +99,9 @@ private static void renderZone(PDPageContentStream cs, cs.endText(); } - // Center text - String centerText = HeaderFooterConfig.resolvePlaceholders(config.getCenterText(), currentPage, totalPages); - if (centerText != null && !centerText.isEmpty()) { + String centerText = GlyphFallbackLogger.sanitize(font, + HeaderFooterConfig.resolvePlaceholders(config.getCenterText(), currentPage, totalPages)); + if (!centerText.isEmpty()) { float textWidth = font.getStringWidth(centerText) / 1000f * fontSize; float centerX = marginLeft + (usableWidth - textWidth) / 2f; cs.beginText(); @@ -105,9 +111,9 @@ private static void renderZone(PDPageContentStream cs, cs.endText(); } - // Right text - String rightText = HeaderFooterConfig.resolvePlaceholders(config.getRightText(), currentPage, totalPages); - if (rightText != null && !rightText.isEmpty()) { + String rightText = GlyphFallbackLogger.sanitize(font, + HeaderFooterConfig.resolvePlaceholders(config.getRightText(), currentPage, totalPages)); + if (!rightText.isEmpty()) { float textWidth = font.getStringWidth(rightText) / 1000f * fontSize; float rightX = pageWidth - marginRight - textWidth; cs.beginText(); diff --git a/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfWatermarkRenderer.java b/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfWatermarkRenderer.java index d157f4e5..a3143456 100644 --- a/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfWatermarkRenderer.java +++ b/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfWatermarkRenderer.java @@ -2,6 +2,7 @@ import com.demcha.compose.engine.components.content.watermark.WatermarkConfig; import com.demcha.compose.engine.components.content.watermark.WatermarkPosition; +import com.demcha.compose.engine.render.pdf.GlyphFallbackLogger; import lombok.extern.slf4j.Slf4j; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; @@ -73,7 +74,11 @@ private static void renderTextWatermark(PDPageContentStream cs, PDRectangle mediaBox) throws IOException { PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD); float fontSize = config.getFontSize(); - String text = config.getText(); + // Sanitise once: watermark text may carry user copyright / brand + // symbols outside WinAnsi coverage. R1.b paragraph path is wired + // separately; watermarks render via raw PDFont so we delegate + // straight to GlyphFallbackLogger.sanitize for the same policy. + String text = GlyphFallbackLogger.sanitize(font, config.getText()); float textWidth = font.getStringWidth(text) / 1000f * fontSize; float textHeight = fontSize; From 1e1759e02e0abac8b6e5321fc28158e44e3753e3 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 19 May 2026 15:04:08 +0100 Subject: [PATCH 04/12] test(engine): visual regression for unicode fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end proof that R1.a-R1.c protect every PDF text render seam from glyphs Helvetica's WinAnsi encoding cannot cover. Four independent test methods exercise the four wired paths so a future regression in any one is localised to a single failure: - paragraphWithUnsupportedGlyphsRendersWithoutCrash — arrows, bullets, white circles, small squares, emoji, copyright in a paragraph body - tableWithUnsupportedGlyphsRendersWithoutCrash — header + body cells containing bullets and emoji - watermarkWithUnsupportedGlyphRendersWithoutCrash — diagonal DRAFT watermark text with arrow, copyright, bullet - headerFooterWithUnsupportedGlyphRendersWithoutCrash — left / right header zones and centre footer with arrow, bullet, white circle, copyright, emoji Pre-R1 every assertion would never run because PDFBox would throw IllegalArgumentException ('arrowright is not available in the font Helvetica, encoding: WinAnsiEncoding') deep inside showText. After R1.b/c the unsupported code points become '?' and the document completes; output PDFs land in target/visual-tests/glyph-fallback/ for manual review. assertValidPdf checks the %PDF- magic header and a > 500 byte body — small enough to allow single-paragraph PDFBox output, large enough to catch a truncated/empty render. Refs R1.d; closes R1. v1.6.2 R2 (capacity tolerance), R3 (LayerStack row relax), R4 (error messages) follow. --- .../visual/UnicodeFallbackDemoTest.java | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 src/test/java/com/demcha/testing/visual/UnicodeFallbackDemoTest.java diff --git a/src/test/java/com/demcha/testing/visual/UnicodeFallbackDemoTest.java b/src/test/java/com/demcha/testing/visual/UnicodeFallbackDemoTest.java new file mode 100644 index 00000000..e5491cb7 --- /dev/null +++ b/src/test/java/com/demcha/testing/visual/UnicodeFallbackDemoTest.java @@ -0,0 +1,187 @@ +package com.demcha.testing.visual; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.output.DocumentHeaderFooter; +import com.demcha.compose.document.output.DocumentHeaderFooterZone; +import com.demcha.compose.document.output.DocumentWatermark; +import com.demcha.compose.document.output.DocumentWatermarkLayer; +import com.demcha.compose.document.output.DocumentWatermarkPosition; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.document.theme.BusinessTheme; +import com.demcha.compose.font.FontName; +import com.demcha.testing.VisualTestOutputs; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end regression for R1 glyph sanitizer. Renders documents that + * intentionally contain code points Helvetica's WinAnsi encoding cannot + * cover — arrows ({@code U+2192}), bullets ({@code U+25CF}), emoji + * ({@code U+1F389}), white circles ({@code U+25E6}), copyright marks — + * through every PDF text render seam wired by R1.b and R1.c and + * asserts no {@link IllegalArgumentException} escapes from PDFBox. + * + *

Pre-R1 every test in this class would crash with + * {@code "U+2192 ('arrowright') is not available in the font Helvetica, + * encoding: WinAnsiEncoding"} or an equivalent message. After R1 the + * substituted glyphs render as {@code '?'} and the document completes + * successfully — verified here by writing real PDFs to + * {@code target/visual-tests/glyph-fallback/} and asserting on file + * size and the {@code %PDF-} header.

+ * + * @author Artem Demchyshyn + */ +class UnicodeFallbackDemoTest { + + private static final BusinessTheme THEME = BusinessTheme.modern(); + private static final DocumentColor INK = DocumentColor.rgb(34, 38, 50); + private static final DocumentColor MUTED = DocumentColor.rgb(112, 116, 128); + + @Test + void paragraphWithUnsupportedGlyphsRendersWithoutCrash() throws Exception { + Path output = VisualTestOutputs.preparePdf("paragraph", "glyph-fallback"); + + try (DocumentSession document = GraphCompose.document() + .pageSize(595, 842) + .pageBackground(THEME.pageBackground()) + .margin(DocumentInsets.of(36)) + .create()) { + + document.pageFlow(page -> page + .addSection("Unicode", section -> section + .addParagraph(p -> p + .text("R1 sanitiser: arrows → bullets ● white-circles ◦ " + + "small-squares ▪ middle-dots · emoji 🎉 © ™ — none crash.") + .textStyle(body())))); + + Files.write(output, document.toPdfBytes()); + } + + assertValidPdf(output); + } + + @Test + void tableWithUnsupportedGlyphsRendersWithoutCrash() throws Exception { + Path output = VisualTestOutputs.preparePdf("table", "glyph-fallback"); + + try (DocumentSession document = GraphCompose.document() + .pageSize(595, 842) + .pageBackground(THEME.pageBackground()) + .margin(DocumentInsets.of(36)) + .create()) { + + document.pageFlow(page -> page + .addTable(table -> table + .columns( + DocumentTableColumn.fixed(120), + DocumentTableColumn.fixed(120), + DocumentTableColumn.fixed(120)) + .headerRow("Symbol →", "Code", "Notes ●") + .row("● bullet", "U+25CF", "Sanitised → ?") + .row("🎉 party", "U+1F389", "Emoji → ?"))); + + Files.write(output, document.toPdfBytes()); + } + + assertValidPdf(output); + } + + @Test + void watermarkWithUnsupportedGlyphRendersWithoutCrash() throws Exception { + Path output = VisualTestOutputs.preparePdf("watermark", "glyph-fallback"); + + try (DocumentSession document = GraphCompose.document() + .pageSize(595, 842) + .pageBackground(THEME.pageBackground()) + .margin(DocumentInsets.of(36)) + .create()) { + + document.watermark(DocumentWatermark.builder() + .text("DRAFT → © 2026 ●") + .fontSize(72f) + .rotation(45f) + .color(DocumentColor.rgb(180, 60, 60)) + .opacity(0.15f) + .layer(DocumentWatermarkLayer.BEHIND_CONTENT) + .position(DocumentWatermarkPosition.CENTER) + .build()); + + document.pageFlow(page -> page + .addParagraph(p -> p + .text("Page body underneath a watermark with unsupported glyphs.") + .textStyle(body()))); + + Files.write(output, document.toPdfBytes()); + } + + assertValidPdf(output); + } + + @Test + void headerFooterWithUnsupportedGlyphRendersWithoutCrash() throws Exception { + Path output = VisualTestOutputs.preparePdf("header-footer", "glyph-fallback"); + + try (DocumentSession document = GraphCompose.document() + .pageSize(595, 842) + .pageBackground(THEME.pageBackground()) + .margin(48, 34, 48, 34) + .create()) { + + document.header(DocumentHeaderFooter.builder() + .zone(DocumentHeaderFooterZone.HEADER) + .leftText("GraphCompose ● Demo") + .rightText("→ Page {page}") + .fontSize(9f) + .textColor(MUTED) + .showSeparator(true) + .build()); + + document.footer(DocumentHeaderFooter.builder() + .zone(DocumentHeaderFooterZone.FOOTER) + .centerText("© 2026 ◦ Confidential 🎉") + .fontSize(9f) + .textColor(MUTED) + .showSeparator(true) + .build()); + + document.pageFlow(page -> page + .addParagraph(p -> p + .text("Body content. Header and footer above/below contain unsupported glyphs.") + .textStyle(body()))); + + Files.write(output, document.toPdfBytes()); + } + + assertValidPdf(output); + } + + private static DocumentTextStyle body() { + return DocumentTextStyle.builder() + .fontName(FontName.HELVETICA) + .size(11) + .color(INK) + .build(); + } + + private static void assertValidPdf(Path output) throws Exception { + byte[] bytes = Files.readAllBytes(output); + // 500 bytes catches a truncated / empty render without + // over-specifying real PDF body size (PDFBox can emit a valid + // single-paragraph PDF in under 1 KB). + assertThat(bytes) + .describedAs("PDF should be a non-empty, reasonably-sized file") + .hasSizeGreaterThan(500); + assertThat(new String(bytes, 0, 5, StandardCharsets.US_ASCII)) + .describedAs("PDF must start with the %PDF- magic header") + .isEqualTo("%PDF-"); + } +} From b3567e1f50fdbf2ead81d77ae424a0de553124c2 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 19 May 2026 15:09:52 +0100 Subject: [PATCH 05/12] fix(engine): allow 0.5pt rounding tolerance in page capacity check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocumentPageSize.A4 resolves to the exact PDF point value 841.88977 pt. Pre-R2 the page-capacity guard compared against this with EPS=1e-6 tolerance, which meant a node authored with the rounded human input 842.0 pt failed with "requires outer height 842.0 but page capacity is 841.88977" — blocking legitimate full-page background surfaces. Introduces a separate CAPACITY_TOLERANCE = 0.5 pt constant used only where outer height is compared against the full inner-canvas height (row composite, stack composite, atomic leaf). EPS stays 1e-6 for floating-point noise inside split decisions and remaining-height checks — bigger tolerance there would mask real overflow inside the splittable path. 0.5 pt ≈ 0.18 mm — visually indistinguishable, and the test matrix proves the boundary: 156.4 pt on a 156 pt inner area now renders (was throwing); 158 pt on the same area still throws as a real overflow. PaginationEdgeCaseTest gains two boundary cases pinning the new contract — atomicNodeHalfPointOverCapacityShouldFitWithinTolerance and atomicNodeClearlyOverCapacityShouldStillThrow — without weakening the existing oversizedAtomicImageShouldThrowDomainSpecificError which still triggers on the much larger 240 pt vs 156 pt mismatch. Refs R2.a; precedes R2.b (full-A4 surface visual regression). --- .../document/layout/LayoutCompiler.java | 21 ++++++-- .../document/api/PaginationEdgeCaseTest.java | 53 +++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java index 5004204a..3db13460 100644 --- a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java +++ b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java @@ -26,6 +26,21 @@ public final class LayoutCompiler { private static final Logger PAGINATION_LOG = LoggerFactory.getLogger("com.demcha.compose.engine.pagination"); private static final double EPS = 1e-6; + /** + * Tolerance applied when comparing a node's required outer height + * against the full page capacity. EPS (1e-6) is floating-point noise + * tolerance; CAPACITY_TOLERANCE absorbs the discrepancy between + * rounded human input (e.g. {@code 842.0} for an A4 height) and the + * exact PDF point value ({@code DocumentPageSize.A4.height() == + * 841.88977}). 0.5 pt ≈ 0.18 mm — visually indistinguishable, while + * a > 1 pt overflow still throws {@link AtomicNodeTooLargeException}. + * + *

Use EPS for floating-point noise inside split / remaining-height + * decisions; reserve CAPACITY_TOLERANCE for the "does this fit on a + * full page at all?" check.

+ */ + private static final double CAPACITY_TOLERANCE = 0.5; + private final NodeRegistry registry; /** @@ -325,7 +340,7 @@ private void compileHorizontalRow(PreparedNode prepared, DocumentNode node = prepared.node(); double rowOuterHeight = naturalMeasure.height() + margin.vertical(); double fullPageHeight = state.canvas.innerHeight(); - if (rowOuterHeight > fullPageHeight + EPS) { + if (rowOuterHeight > fullPageHeight + CAPACITY_TOLERANCE) { throw atomicTooLarge(path, rowOuterHeight, fullPageHeight); } if (rowOuterHeight > state.remainingHeight() + EPS && state.usedHeight > EPS) { @@ -499,7 +514,7 @@ private void compileStackedLayer(PreparedNode prepared, DocumentNode node = prepared.node(); double stackOuterHeight = naturalMeasure.height() + margin.vertical(); double fullPageHeight = state.canvas.innerHeight(); - if (stackOuterHeight > fullPageHeight + EPS) { + if (stackOuterHeight > fullPageHeight + CAPACITY_TOLERANCE) { throw atomicTooLarge(path, stackOuterHeight, fullPageHeight); } if (stackOuterHeight > state.remainingHeight() + EPS && state.usedHeight > EPS) { @@ -742,7 +757,7 @@ private void compileAtomicLeaf(PreparedNode prepared, double outerHeight = naturalMeasure.height() + margin.vertical(); double fullPageHeight = state.canvas.innerHeight(); - if (outerHeight > fullPageHeight + EPS) { + if (outerHeight > fullPageHeight + CAPACITY_TOLERANCE) { throw atomicTooLarge(path, outerHeight, fullPageHeight); } if (outerHeight > state.remainingHeight() + EPS && state.usedHeight > EPS) { diff --git a/src/test/java/com/demcha/compose/document/api/PaginationEdgeCaseTest.java b/src/test/java/com/demcha/compose/document/api/PaginationEdgeCaseTest.java index 247fea0e..edb0a1ea 100644 --- a/src/test/java/com/demcha/compose/document/api/PaginationEdgeCaseTest.java +++ b/src/test/java/com/demcha/compose/document/api/PaginationEdgeCaseTest.java @@ -297,6 +297,59 @@ void oversizedAtomicImageShouldThrowDomainSpecificError() throws Exception { } } + @Test + void atomicNodeHalfPointOverCapacityShouldFitWithinTolerance() throws Exception { + // Inner area = 180 - 2*12 = 156 pt. CAPACITY_TOLERANCE is 0.5 + // pt, so an image authored at 156.4 pt is within tolerance and + // must render rather than throw. Mirrors the real-world case + // where a user supplies a height rounded from the exact PDF + // point value of a page size (e.g. 842.0 vs the true + // 841.88977 for A4). + try (DocumentSession session = GraphCompose.document() + .pageSize(180, 180) + .margin(DocumentInsets.of(12)) + .create()) { + + session.add(new ImageNode( + "BarelyOverImage", + DocumentImageData.fromBytes(onePixelPng()), + 96.0, + 156.4, + DocumentInsets.zero(), + DocumentInsets.zero())); + + // Should not throw — fits within the 0.5 pt human-input + // rounding tolerance. + LayoutGraph graph = session.layoutGraph(); + assertThat(graph.totalPages()).isEqualTo(1); + } + } + + @Test + void atomicNodeClearlyOverCapacityShouldStillThrow() throws Exception { + // Inner area = 156 pt; image at 158 pt overflows by 2 pt which + // is well above CAPACITY_TOLERANCE (0.5 pt). The atomic-too- + // large exception must still fire so real overflows are caught. + try (DocumentSession session = GraphCompose.document() + .pageSize(180, 180) + .margin(DocumentInsets.of(12)) + .create()) { + + session.add(new ImageNode( + "GenuinelyTooTallImage", + DocumentImageData.fromBytes(onePixelPng()), + 96.0, + 158.0, + DocumentInsets.zero(), + DocumentInsets.zero())); + + assertThatThrownBy(session::layoutGraph) + .isInstanceOf(AtomicNodeTooLargeException.class) + .hasMessageContaining("GenuinelyTooTallImage") + .hasMessageContaining("requires outer height"); + } + } + @Test void tableRowTooTallForAnEmptyPageShouldThrowDomainSpecificError() throws Exception { try (DocumentSession session = GraphCompose.document() From ba924a0cd98bb51d19068999d71c3cff7e15f8ba Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 19 May 2026 15:11:08 +0100 Subject: [PATCH 06/12] test(engine): visual regression for full-A4 surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a single ShapeNode sized 595 x 842 pt on a DocumentSession configured with DocumentPageSize.A4 (exact PDF point value 841.88977) and zero margin. The 0.11 pt difference between the rounded user input (842) and the true inner-canvas height (841.88977) pre-R2 hit the EPS=1e-6 capacity check and threw AtomicNodeTooLargeException. After R2.a the new CAPACITY_TOLERANCE absorbs the rounding and a valid PDF lands in target/visual-tests/page-capacity/PageCapacityToleranceDemo.pdf. Asserts the rendered file has the %PDF- magic header and a > 500 byte body — small enough to allow a single-shape PDFBox output, large enough to catch a truncated render. Refs R2.b; closes R2. R3 (LayerStack row relax) and R4 (error message clarity pass) follow. --- .../visual/PageCapacityToleranceDemoTest.java | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/test/java/com/demcha/testing/visual/PageCapacityToleranceDemoTest.java diff --git a/src/test/java/com/demcha/testing/visual/PageCapacityToleranceDemoTest.java b/src/test/java/com/demcha/testing/visual/PageCapacityToleranceDemoTest.java new file mode 100644 index 00000000..851c481b --- /dev/null +++ b/src/test/java/com/demcha/testing/visual/PageCapacityToleranceDemoTest.java @@ -0,0 +1,74 @@ +package com.demcha.testing.visual; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.node.ShapeNode; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.testing.VisualTestOutputs; +import org.junit.jupiter.api.Test; + +import java.awt.Color; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end regression for R2 capacity tolerance. + * + *

{@link DocumentPageSize#A4} resolves to {@code 595.27563 × 841.88977} + * points — the exact PDF point value for ISO A4. Pre-R2 a node authored + * at the rounded human input {@code 842.0} pt failed the page-capacity + * check with {@code "requires outer height 842.0 but page capacity is + * 841.88977"}. The 0.11 pt mismatch (~0.04 mm — invisible) blocked + * legitimate full-page background surfaces.

+ * + *

After R2 the page-capacity check tolerates up to 0.5 pt of + * rounding overhead, so the rendered PDF lands on disk and this test + * passes. Output goes to + * {@code target/visual-tests/page-capacity/PageCapacityToleranceDemo.pdf} + * for manual review.

+ * + * @author Artem Demchyshyn + */ +class PageCapacityToleranceDemoTest { + + @Test + void fullA4HeightShapeWithRoundedHumanInputRendersWithoutCrash() throws Exception { + Path output = VisualTestOutputs.preparePdf("PageCapacityToleranceDemo", "page-capacity"); + + // No margin: inner height == DocumentPageSize.A4.height() == + // 841.88977. A shape sized at the human-friendly 842.0 + // overshoots by 0.11 pt and pre-R2 threw + // AtomicNodeTooLargeException. The new CAPACITY_TOLERANCE + // absorbs the rounding and lets the render complete. + try (DocumentSession document = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(DocumentInsets.zero()) + .create()) { + + document.add(new ShapeNode( + "FullPageBackground", + 595.0, + 842.0, + new Color(34, 70, 96), + DocumentStroke.of(DocumentColor.BLACK, 0), + DocumentInsets.zero(), + DocumentInsets.zero())); + + Files.write(output, document.toPdfBytes()); + } + + byte[] bytes = Files.readAllBytes(output); + assertThat(bytes) + .describedAs("Full-page background PDF should render to a real file") + .hasSizeGreaterThan(500); + assertThat(new String(bytes, 0, 5, StandardCharsets.US_ASCII)) + .describedAs("PDF must start with the %PDF- magic header") + .isEqualTo("%PDF-"); + } +} From 2e50c21985c4bcc983afedf02d5053f6598354fb Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 19 May 2026 15:15:06 +0100 Subject: [PATCH 07/12] refactor(engine): introduce FixedSlotKind enum threaded through compileNodeInFixedSlot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure plumbing change — no behavior is altered. Introduces a private FixedSlotKind enum and threads it through compileNodeInFixedSlot: - ROW_SLOT (default for row-composite children) — column of a horizontal row band where a nested row would create real composition conflict. - STACK_LAYER_SLOT — child of a LayerStackNode layer rectangle, where a 'horizontal' arrangement is just a column-row inside an already-fixed layer area, not a competing band. Three call sites updated to pass the correct kind: - row composite child (compileRowComposite line ~408): ROW_SLOT - placeStackLayer (line ~913): STACK_LAYER_SLOT - recursive VERTICAL children (compileNodeInFixedSlot line ~1267): propagate the caller's kind so a STACK descendant (column → row → ...) keeps the relaxed policy. Validator at line ~1102 still rejects HORIZONTAL uniformly — the behaviour relax happens in R3.b. This commit alone verifies green (no test sees a row inside a layer yet). Refs R3.a; precedes R3.b (validator relax) and R3.c (visual + snapshot). --- .../document/layout/LayoutCompiler.java | 51 +++++++++++++++++-- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java index 3db13460..a975f9cd 100644 --- a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java +++ b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java @@ -41,6 +41,25 @@ public final class LayoutCompiler { */ private static final double CAPACITY_TOLERANCE = 0.5; + /** + * Identifies the kind of fixed slot a child is being compiled into, + * so the validator can distinguish "child of a horizontal row band" + * (where a nested {@code Row} would create real composition conflict) + * from "child of a {@link com.demcha.compose.document.node.LayerStackNode} + * layer" (where a nested {@code Row} is a normal column-row inside an + * already-fixed layer rectangle). + * + *

{@link #compileNodeInFixedSlot} takes the kind as a parameter + * and propagates it down recursive calls so the validator can + * relax just for STACK layer parents.

+ */ + private enum FixedSlotKind { + /** Child sits inside a horizontal row band (column of a row). */ + ROW_SLOT, + /** Child sits inside a {@link LayerStackNode} layer rectangle. */ + STACK_LAYER_SLOT + } + private final NodeRegistry registry; /** @@ -386,6 +405,9 @@ private void compileHorizontalRow(PreparedNode prepared, if (childPrepared.isComposite()) { PlacementContext slotCtx = new FixedSlotPlacementContext( state.pageIndex, state.canvas, prepareContext, fragmentContext, nodes, fragments); + // Column of a horizontal row band — keep the strict + // ROW_SLOT validator so a nested horizontal row is + // rejected (would compete with the parent row band). compileNodeInFixedSlot( childPrepared, path, @@ -394,6 +416,7 @@ private void compileHorizontalRow(PreparedNode prepared, cursorX, rowInnerY, slotWidth, + FixedSlotKind.ROW_SLOT, slotCtx); cursorX += slotWidth + layoutSpec.spacing(); continue; @@ -891,6 +914,9 @@ private void placeStackLayer(DocumentNode child, ctx.nodes(), ctx.fragments()); + // Child sits inside a LayerStack layer rectangle — the validator + // can relax for STACK_LAYER_SLOT because there is no competing + // horizontal row band, only the layer's own fixed area. compileNodeInFixedSlot( childPrepared, parentPath, @@ -899,6 +925,7 @@ private void placeStackLayer(DocumentNode child, alignedSlotX, alignedSlotTopY, childOuterWidth, + FixedSlotKind.STACK_LAYER_SLOT, layerCtx); } @@ -1066,12 +1093,21 @@ private void compileSplittableLeaf(PreparedNode prepared, } /** - * Compiles a composite or leaf node inside a fixed horizontal row slot. + * Compiles a composite or leaf node inside a fixed slot. + * + *

The slot is constrained to a single page: composite children are + * laid out with a local top-down y-cursor. No global pagination state + * is mutated; the parent's outer height check guarantees the column + * fits.

* - *

The slot is constrained to a single page: composite row children are - * laid out as columns inside the row's atomic band, with a local - * top-down y-cursor. No global pagination state is mutated; the row's - * outer height check guarantees the column fits.

+ *

{@code kind} identifies whether the slot is a horizontal row + * band ({@link FixedSlotKind#ROW_SLOT}) or a + * {@link com.demcha.compose.document.node.LayerStackNode} layer + * rectangle ({@link FixedSlotKind#STACK_LAYER_SLOT}). The validator + * uses it to relax the "no nested horizontal row" rule for stack + * layers, where a {@code Row} is a normal column-row inside an + * already-fixed layer rectangle rather than a competing horizontal + * band.

* * @return outer height (measured height + vertical margin) consumed by the * node, used by the caller's local y-cursor @@ -1083,6 +1119,7 @@ private double compileNodeInFixedSlot(PreparedNode prepared, double slotX, double slotTopY, double slotWidth, + FixedSlotKind kind, PlacementContext ctx) { // Alias locals so the body keeps the same names it had before the // PlacementContext refactor; the context is the only authoritative @@ -1228,6 +1265,9 @@ private double compileNodeInFixedSlot(PreparedNode prepared, DocumentNode child = children.get(i); PreparedNode childPrepared = prepareForRegionWidth(prepareContext, child, childRegionWidth); + // Propagate the parent's slot kind so a STACK layer + // descendant (column → row → ...) keeps the relaxed + // validation policy all the way down. double consumed = compileNodeInFixedSlot( childPrepared, path, @@ -1236,6 +1276,7 @@ private double compileNodeInFixedSlot(PreparedNode prepared, childRegionX, childTopY, childRegionWidth, + kind, ctx); childTopY -= consumed; if (i < children.size() - 1) { From 3427fb3e7b6f80fcbb6402be62bfb633d8dde09f Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 19 May 2026 15:20:37 +0100 Subject: [PATCH 08/12] feat(engine): allow Row inside LayerStack content layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relaxes the no-nested-horizontal-row rule at LayoutCompiler line ~1154 so it fires only when the immediate parent slot is a ROW_SLOT. STACK_LAYER_SLOT parents (LayerStack layers and any section descendants thereof) accept horizontal composites because the surrounding rectangle is already fixed by the layer's alignment — a row there is a normal column-row inside the layer area, not a competing band. Real-world case unlocked: page-level LayerStack with a full-width dark band as one layer and a sidebar+main row as the content layer — exactly what the Noir corporate CV flow was trying to do (see audit feedback item 1). Error message rewritten to point at the new escape hatch and the historical workaround: "Row '...' cannot contain a nested horizontal row. Wrap the inner row in a LayerStack layer (allowed since v1.6.2), or stack horizontal content as sections inside a vertical column." LayerStackRowCompositionTest (new) pins both ends of the contract: - rowSitsDirectlyInsideLayerStackContentLayer - layerStackAtRootWithBackgroundLayerAndContentRowRenders (Noir CV shape: dark band + sidebar+main row) - rowDeepInsideLayerStackThroughVerticalSectionsRenders (verifies STACK_LAYER_SLOT propagates through nested vertical composites) - rowInsideRowStillThrowsAfterRelaxation (negative guard — the relaxation must not leak into the ROW_SLOT path) Refs R3.b; precedes R3.c (visual demo + snapshot baselines). --- .../document/layout/LayoutCompiler.java | 21 +- .../api/LayerStackRowCompositionTest.java | 186 ++++++++++++++++++ 2 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 src/test/java/com/demcha/compose/document/api/LayerStackRowCompositionTest.java diff --git a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java index a975f9cd..0bc00325 100644 --- a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java +++ b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java @@ -1146,14 +1146,21 @@ private double compileNodeInFixedSlot(PreparedNode prepared, if (prepared.isComposite()) { CompositeLayoutSpec layoutSpec = prepared.requireCompositeLayout(); - // Horizontal rows and splittable tables remain forbidden inside a - // row slot — they would compete with the parent row's horizontal - // band or break atomic pagination. STACK overlays (e.g. - // LayerStackNode) are allowed because they are atomic and place - // their layers at the same point with explicit alignment. - if (layoutSpec.axis() == CompositeLayoutSpec.Axis.HORIZONTAL) { + // Horizontal rows remain forbidden inside a ROW_SLOT (column + // of a row band) because they would compete with the parent + // row's horizontal band. Inside a STACK_LAYER_SLOT, however, + // the surrounding rectangle is already fixed by the layer's + // alignment — a "horizontal row" there is just a normal + // column-row inside the layer area, not a competing band. + // STACK composites (e.g. LayerStackNode) are always allowed + // because they are atomic and anchor their children inside + // the existing slot. + if (layoutSpec.axis() == CompositeLayoutSpec.Axis.HORIZONTAL + && kind == FixedSlotKind.ROW_SLOT) { throw new IllegalStateException("Row '" + path - + "' cannot contain a nested horizontal row; use a section column instead."); + + "' cannot contain a nested horizontal row. " + + "Wrap the inner row in a LayerStack layer (allowed since v1.6.2), " + + "or stack horizontal content as sections inside a vertical column."); } int decorationInsertIndex = fragments.size(); diff --git a/src/test/java/com/demcha/compose/document/api/LayerStackRowCompositionTest.java b/src/test/java/com/demcha/compose/document/api/LayerStackRowCompositionTest.java new file mode 100644 index 00000000..e8c8b5d7 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/api/LayerStackRowCompositionTest.java @@ -0,0 +1,186 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.dsl.LayerStackBuilder; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.LayerAlign; +import com.demcha.compose.document.node.LayerStackNode; +import com.demcha.compose.document.node.RowNode; +import com.demcha.compose.document.node.SectionNode; +import com.demcha.compose.document.node.SpacerNode; +import com.demcha.compose.document.style.DocumentInsets; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Pins the v1.6.2 R3 relaxation: a {@code Row} may sit directly inside + * a {@link LayerStackNode} content layer (and at any depth below it + * through vertical sections), while a {@code Row} nested inside another + * {@code Row} band remains rejected by the validator. + * + *

Before R3 every test in this class would throw + * {@code IllegalStateException("Row '...' cannot contain a nested + * horizontal row")} because the compiler validator could not tell a + * row-band parent apart from a layer rectangle parent. The R3.a + * refactor introduced {@code FixedSlotKind} and R3.b taught the + * validator to relax only when {@code STACK_LAYER_SLOT}.

+ * + * @author Artem Demchyshyn + */ +class LayerStackRowCompositionTest { + + @Test + void rowSitsDirectlyInsideLayerStackContentLayer() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(400, 300) + .margin(DocumentInsets.of(20)) + .create()) { + + RowNode contentRow = rowOf( + sectionOf("Sidebar", new SpacerNode("SidebarFiller", 120.0, 80.0, + DocumentInsets.zero(), DocumentInsets.zero())), + sectionOf("Main", new SpacerNode("MainFiller", 200.0, 80.0, + DocumentInsets.zero(), DocumentInsets.zero()))); + + LayerStackNode stack = new LayerStackBuilder() + .name("PageWithRowLayer") + .layer(contentRow, LayerAlign.TOP_LEFT) + .build(); + + session.add(stack); + + LayoutGraph graph = session.layoutGraph(); + assertThat(graph.totalPages()).isEqualTo(1); + assertThat(graph.nodes()) + .extracting("semanticName") + .contains("PageWithRowLayer", "Sidebar", "Main"); + } + } + + @Test + void layerStackAtRootWithBackgroundLayerAndContentRowRenders() throws Exception { + // Mirrors the CV-style use case from the Noir corporate feedback: + // a dark full-width band as one layer, a sidebar + main row as + // the content layer. + try (DocumentSession session = GraphCompose.document() + .pageSize(595, 500) + .margin(DocumentInsets.of(0)) + .create()) { + + SpacerNode darkBand = new SpacerNode("DarkBand", 595.0, 80.0, + DocumentInsets.zero(), DocumentInsets.zero()); + + RowNode contentRow = rowOf( + sectionOf("Sidebar", new SpacerNode("SidebarBody", 180.0, 400.0, + DocumentInsets.zero(), DocumentInsets.zero())), + sectionOf("Main", new SpacerNode("MainBody", 415.0, 400.0, + DocumentInsets.zero(), DocumentInsets.zero()))); + + LayerStackNode stack = new LayerStackBuilder() + .name("NoirCv") + .layer(darkBand, LayerAlign.TOP_LEFT) + .layer(contentRow, LayerAlign.TOP_LEFT) + .build(); + + session.add(stack); + + LayoutGraph graph = session.layoutGraph(); + assertThat(graph.totalPages()).isEqualTo(1); + assertThat(graph.nodes()) + .extracting("semanticName") + .contains("NoirCv", "DarkBand", "Sidebar", "Main"); + } + } + + @Test + void rowDeepInsideLayerStackThroughVerticalSectionsRenders() throws Exception { + // STACK_LAYER_SLOT must propagate through nested VERTICAL + // composites — section -> section -> row should still relax. + try (DocumentSession session = GraphCompose.document() + .pageSize(400, 300) + .margin(DocumentInsets.of(20)) + .create()) { + + RowNode innerRow = rowOf( + sectionOf("ColA", new SpacerNode("ColAFiller", 100.0, 60.0, + DocumentInsets.zero(), DocumentInsets.zero())), + sectionOf("ColB", new SpacerNode("ColBFiller", 100.0, 60.0, + DocumentInsets.zero(), DocumentInsets.zero()))); + + SectionNode outerSection = sectionOf("Outer", + sectionOf("Inner", innerRow)); + + LayerStackNode stack = new LayerStackBuilder() + .name("StackWithDeepRow") + .layer(outerSection, LayerAlign.TOP_LEFT) + .build(); + + session.add(stack); + + LayoutGraph graph = session.layoutGraph(); + assertThat(graph.totalPages()).isEqualTo(1); + assertThat(graph.nodes()) + .extracting("semanticName") + .contains("Outer", "Inner", "ColA", "ColB"); + } + } + + @Test + void rowInsideRowStillThrowsAfterRelaxation() throws Exception { + // Negative guard: the relaxation must NOT leak into the + // ROW_SLOT path. A row whose direct child is another row is + // still real composition conflict and must throw. + try (DocumentSession session = GraphCompose.document() + .pageSize(400, 300) + .margin(DocumentInsets.of(20)) + .create()) { + + RowNode innerRow = rowOf( + new SpacerNode("Inner1", 50.0, 30.0, + DocumentInsets.zero(), DocumentInsets.zero()), + new SpacerNode("Inner2", 50.0, 30.0, + DocumentInsets.zero(), DocumentInsets.zero())); + + RowNode outerRow = rowOf( + innerRow, + new SpacerNode("Sibling", 50.0, 30.0, + DocumentInsets.zero(), DocumentInsets.zero())); + + session.add(outerRow); + + assertThatThrownBy(session::layoutGraph) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cannot contain a nested horizontal row") + .hasMessageContaining("LayerStack layer"); + } + } + + private static SectionNode sectionOf(String name, DocumentNode child) { + return new SectionNode( + name, + List.of(child), + 0.0, + DocumentInsets.zero(), + DocumentInsets.zero(), + null, + null); + } + + private static RowNode rowOf(DocumentNode... children) { + return new RowNode( + "Row", + List.of(children), + List.of(), + 0.0, + DocumentInsets.zero(), + DocumentInsets.zero(), + null, + null, + null); + } +} From 9874905f951167110b91e5b4f3704f673ce73ff7 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 19 May 2026 15:24:36 +0100 Subject: [PATCH 09/12] test(engine): visual regression for row-in-layer-stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end PDF render of the Noir-corporate-CV-style shape from the v1.6.2 audit feedback: A4 page with a full-width dark hero band as one LayerStack layer and a sidebar + main row as the second content layer. Pre-R3 this exact composition crashed with "Row '...' cannot contain a nested horizontal row" because the validator could not tell a row-band parent from a layer rectangle parent. After R3.a (FixedSlotKind plumbing) + R3.b (validator relax) the document renders cleanly to target/visual-tests/layer-stack/LayerStackRowDemo.pdf for manual review. Sidebar is intentionally sized well under the page capacity (no fillRemainingHeight() primitive yet — see v1.7.0 B6 in the execution plan) so this regression isolates R3 from the fill-to-page-bottom feature. Refs R3.c; closes R3. R4 (error message clarity pass for remaining exceptions) follows. --- .../testing/visual/LayerStackRowDemoTest.java | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 src/test/java/com/demcha/testing/visual/LayerStackRowDemoTest.java diff --git a/src/test/java/com/demcha/testing/visual/LayerStackRowDemoTest.java b/src/test/java/com/demcha/testing/visual/LayerStackRowDemoTest.java new file mode 100644 index 00000000..084aa93a --- /dev/null +++ b/src/test/java/com/demcha/testing/visual/LayerStackRowDemoTest.java @@ -0,0 +1,154 @@ +package com.demcha.testing.visual; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.LayerStackBuilder; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.LayerAlign; +import com.demcha.compose.document.node.LayerStackNode; +import com.demcha.compose.document.node.ParagraphNode; +import com.demcha.compose.document.node.RowNode; +import com.demcha.compose.document.node.SectionNode; +import com.demcha.compose.document.node.ShapeNode; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.theme.BusinessTheme; +import com.demcha.compose.font.FontName; +import com.demcha.testing.VisualTestOutputs; +import org.junit.jupiter.api.Test; + +import java.awt.Color; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end visual regression for R3: a {@code Row} inside a + * {@code LayerStack} content layer renders successfully to PDF. + * + *

Reproduces the Noir corporate CV use case from the v1.6.2 audit + * feedback: a full-width dark band sits as one layer, the document + * body (sidebar column + main column) sits as a Row in the second + * content layer. Pre-R3 this threw + * {@code IllegalStateException("Row '...' cannot contain a nested + * horizontal row")}; the relaxation in R3.b lets it render.

+ * + *

Output PDF lands in + * {@code target/visual-tests/layer-stack/LayerStackRowDemo.pdf} for + * manual review.

+ * + * @author Artem Demchyshyn + */ +class LayerStackRowDemoTest { + + private static final BusinessTheme THEME = BusinessTheme.modern(); + private static final DocumentColor DARK = DocumentColor.rgb(28, 32, 44); + private static final DocumentColor SIDEBAR_BG = DocumentColor.rgb(244, 240, 232); + + @Test + void layerStackWithRowContentLayerRendersFullCvShape() throws Exception { + Path output = VisualTestOutputs.preparePdf("LayerStackRowDemo", "layer-stack"); + + try (DocumentSession document = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(DocumentInsets.of(0)) + .create()) { + + // Layer 0: full-width dark hero band across the top of the + // page. Pure background paint; no inner content needed. + ShapeNode darkBand = new ShapeNode( + "DarkHeroBand", + 595.0, + 160.0, + new Color(DARK.color().getRed(), DARK.color().getGreen(), DARK.color().getBlue()), + DocumentStroke.of(DocumentColor.BLACK, 0), + DocumentInsets.zero(), + DocumentInsets.zero()); + + // Layer 1: sidebar + main row as the page content layer. + // Pre-R3 this row would have been rejected because the + // layer slot was indistinguishable from a row band slot. + RowNode contentRow = new RowNode( + "ContentRow", + List.of( + sidebarSection(), + mainSection()), + List.of(), + 0.0, + DocumentInsets.zero(), + DocumentInsets.zero(), + null, + null, + null); + + LayerStackNode page = new LayerStackBuilder() + .name("NoirCorporateCv") + .layer(darkBand, LayerAlign.TOP_LEFT) + .layer(contentRow, LayerAlign.TOP_LEFT) + .build(); + + document.add(page); + + Files.write(output, document.toPdfBytes()); + } + + byte[] bytes = Files.readAllBytes(output); + assertThat(bytes) + .describedAs("CV-style PDF should render to a real file") + .hasSizeGreaterThan(500); + assertThat(new String(bytes, 0, 5, StandardCharsets.US_ASCII)) + .describedAs("PDF must start with the %PDF- magic header") + .isEqualTo("%PDF-"); + } + + private static SectionNode sidebarSection() { + // Tinted sidebar column kept under page capacity (full A4 inner + // height is 841.88977 pt; this section stays well under it). + return new SectionNode( + "Sidebar", + List.of( + paragraph("CONTACT", THEME.text().h3()), + paragraph("hello@example.com", THEME.text().body()), + paragraph("+44 20 5555 1000", THEME.text().body())), + 6.0, + DocumentInsets.of(20), + DocumentInsets.zero(), + SIDEBAR_BG, + null); + } + + private static SectionNode mainSection() { + return new SectionNode( + "Main", + List.of( + paragraph("Jordan Rivera", THEME.text().h1()), + paragraph("Principal Engineer", THEME.text().h3()), + paragraph( + "Builds engine internals and document layouts that other " + + "engineers can extend without reading the source.", + THEME.text().body())), + 10.0, + DocumentInsets.of(30), + DocumentInsets.zero(), + null, + null); + } + + private static ParagraphNode paragraph(String text, DocumentTextStyle style) { + return new ParagraphNode( + "p", + text, + style, + TextAlign.LEFT, + 1.0, + DocumentInsets.zero(), + DocumentInsets.zero()); + } +} From b514dcbba07f73dfb6e4e080253aa756f3fe56be Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 19 May 2026 15:27:13 +0100 Subject: [PATCH 10/12] chore(engine): exception messages now suggest concrete actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the five remaining engine-thrown exception messages to include an action verb so developers see what to do next, not just which rule fired. Existing substring assertions in PaginationEdgeCaseTest and LayerStackRowCompositionTest stay intact — every message still contains its original anchor phrase plus the new action sentence. Changes: - "Node '...' has no horizontal layout space." -> + "Reduce padding or margin on the parent, or increase the page width." - "Node '...' measured width X exceeds available width Y." -> + "Reduce the node width, shorten inline content, or wrap content in a smaller container." - "Row '...' child '...' measured height X exceeds row inner height." -> + "Reduce the child height, shorten its content, or increase the row height." - "Split did not make progress for node '...'." -> + "The node's NodeDefinition.split() returned the original input as the tail — check the definition for an infinite split loop and ensure each split advances." - "Node '...' requires outer height X but page capacity is Y." (AtomicNodeTooLargeException) -> + "Reduce the node height, split content into multiple atomic blocks, or increase the page size. Differences under 0.5 pt are tolerated as rounding noise (v1.6.2+)." Refs R4; closes the v1.6.2 robustness pass (R1 + R2 + R3 + R4). --- .../document/layout/LayoutCompiler.java | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java index 0bc00325..8b92cdd3 100644 --- a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java +++ b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java @@ -156,13 +156,16 @@ private void compileNode(PreparedNode prepared, } if (availableWidth <= EPS) { - throw new IllegalStateException("Node '" + path + "' has no horizontal layout space."); + throw new IllegalStateException("Node '" + path + + "' has no horizontal layout space. " + + "Reduce padding or margin on the parent, or increase the page width."); } MeasureResult naturalMeasure = prepared.measureResult(); if (naturalMeasure.width() > availableWidth + EPS) { throw new IllegalStateException("Node '" + path + "' measured width " + naturalMeasure.width() - + " exceeds available width " + availableWidth + "."); + + " exceeds available width " + availableWidth + ". " + + "Reduce the node width, shorten inline content, or wrap content in a smaller container."); } if (prepared.isComposite()) { @@ -399,7 +402,8 @@ private void compileHorizontalRow(PreparedNode prepared, (NodeDefinition) registry.definitionFor(child); if (childMeasure.height() > naturalMeasure.height() - padding.vertical() + EPS) { throw new IllegalStateException("Row '" + path + "' child '" + child.nodeKind() - + "' measured height " + childMeasure.height() + " exceeds row inner height."); + + "' measured height " + childMeasure.height() + " exceeds row inner height. " + + "Reduce the child height, shorten its content, or increase the row height."); } if (childPrepared.isComposite()) { @@ -1016,7 +1020,9 @@ private void compileSplittableLeaf(PreparedNode prepared, throw atomicTooLarge(path, pieceOuterHeight, fullPageOuterHeight); } if (tail != null && tail.equals(current)) { - throw new IllegalStateException("Split did not make progress for node '" + path + "'."); + throw new IllegalStateException("Split did not make progress for node '" + path + + "'. The node's NodeDefinition.split() returned the original input as the tail — " + + "check the definition for an infinite split loop and ensure each split advances."); } DocumentNode headNode = head.node(); @@ -1508,7 +1514,11 @@ private String semanticName(DocumentNode node) { private AtomicNodeTooLargeException atomicTooLarge(String path, double outerHeight, double pageHeight) { return new AtomicNodeTooLargeException( - "Node '" + path + "' requires outer height " + outerHeight + " but page capacity is " + pageHeight + "."); + "Node '" + path + "' requires outer height " + outerHeight + + " but page capacity is " + pageHeight + ". " + + "Reduce the node height, split content into multiple atomic blocks, " + + "or increase the page size. Differences under 0.5 pt are tolerated as " + + "rounding noise (v1.6.2+)."); } } From 613910b39d1015b681c82ce0a80b55fe74383695 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 20 May 2026 09:28:53 +0100 Subject: [PATCH 11/12] chore(meta): update maintainer email to demchishynartem@gmail.com The previous demchyshyn.artem@gmail.com address does not exist (typo in the spelling: 'shyshyn' vs 'shishyn'); routing reports there would silently bounce. Switches both the pom.xml developer entry and the CODE_OF_CONDUCT enforcement contact to the maintainer's real inbox so JitPack-published artifact metadata, IDE 'view developers' lookups, and community CoC reports all resolve. Files: - pom.xml: entry - CODE_OF_CONDUCT.md: Enforcement section maintainer contact --- CODE_OF_CONDUCT.md | 2 +- pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 2850189c..1452ac50 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -56,7 +56,7 @@ when an individual is officially representing the community in public spaces. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behaviour may be -reported privately to the maintainer at **demchyshyn.artem@gmail.com**. +reported privately to the maintainer at **demchishynartem@gmail.com**. All complaints will be reviewed and investigated promptly and fairly. The maintainer is obligated to respect the privacy and security of the reporter of any incident. diff --git a/pom.xml b/pom.xml index 2621f858..cc54e11e 100644 --- a/pom.xml +++ b/pom.xml @@ -24,7 +24,7 @@ DemchaAV Artem Demchyshyn - demchyshyn.artem@gmail.com + demchishynartem@gmail.com https://github.com/DemchaAV Lead Developer From e389f6a67b5220fd6b3d6222d47a2b8793b45d4b Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 20 May 2026 09:30:09 +0100 Subject: [PATCH 12/12] docs(readme): link experimental graphcompose-ai-flow companion project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Companion projects section (between Documentation and License) pointing to https://github.com/DemchaAV/graphcompose-ai-flow — an experimental sister repo exploring AI-assisted document authoring on top of the GraphCompose semantic model. Independent codebase with a separate lifecycle: nothing in this repo depends on it. Linked here so readers interested in agentic document composition find the experiment instead of stumbling on it later, and so contributors who want to influence its API direction know where to look. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index cafee5da..8909868b 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,10 @@ document.pageFlow().addCanvas(523, 360, canvas -> canvas - Recipes: [shape-as-container](./docs/recipes/shape-as-container.md) · [transforms](./docs/recipes/transforms.md) · [tables](./docs/recipes/tables.md) · [themes](./docs/recipes/themes.md) · [streaming](./docs/recipes/streaming.md) · [extending](./docs/recipes/extending.md) - [Migration v1.5 → v1.6](./docs/migration-v1-5-to-v1-6.md) · [Release process](./docs/release-process.md) · [Contributing](./CONTRIBUTING.md) +## Companion projects + +- [**graphcompose-ai-flow**](https://github.com/DemchaAV/graphcompose-ai-flow) — experimental sister project exploring an AI-assisted authoring flow on top of GraphCompose. Independent codebase, separate lifecycle — nothing in this repo depends on it. Track it if you are interested in agentic document composition driven by the same semantic node model. + ## License MIT — see [`LICENSE`](./LICENSE).