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/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). 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 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/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/document/layout/LayoutCompiler.java b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java index 5004204a..8b92cdd3 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,40 @@ 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; + + /** + * 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; /** @@ -122,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()) { @@ -325,7 +362,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) { @@ -365,12 +402,16 @@ 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()) { 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, @@ -379,6 +420,7 @@ private void compileHorizontalRow(PreparedNode prepared, cursorX, rowInnerY, slotWidth, + FixedSlotKind.ROW_SLOT, slotCtx); cursorX += slotWidth + layoutSpec.spacing(); continue; @@ -499,7 +541,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 +784,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) { @@ -876,6 +918,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, @@ -884,6 +929,7 @@ private void placeStackLayer(DocumentNode child, alignedSlotX, alignedSlotTopY, childOuterWidth, + FixedSlotKind.STACK_LAYER_SLOT, layerCtx); } @@ -974,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(); @@ -1051,12 +1099,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 @@ -1068,6 +1125,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 @@ -1094,14 +1152,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(); @@ -1213,6 +1278,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, @@ -1221,6 +1289,7 @@ private double compileNodeInFixedSlot(PreparedNode prepared, childRegionX, childTopY, childRegionWidth, + kind, ctx); childTopY -= consumed; if (i < children.size() - 1) { @@ -1445,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+)."); } } 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..22c9fbb1 --- /dev/null +++ b/src/main/java/com/demcha/compose/engine/render/pdf/GlyphFallbackLogger.java @@ -0,0 +1,115 @@ +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)); + } + } + + /** + * 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. + */ + 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 6eb3957f..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 @@ -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,32 +118,53 @@ public double getTextWidth(TextStyle style, String text) { } } - - private String prepareForRender(String text) { - return textSanitizer(text); - } - - /** Keeps spaces, only replaces characters the font can't encode. */ - private 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 sb.append('?'); // or " " if you prefer - }); - return sb.toString(); + /** + * 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); } - private boolean canEncode(PDFont font, String ch) { - try { - font.encode(ch); - return true; - } catch (Exception e) { - return false; - } + /** + * 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) { + 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; 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); + } +} 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() 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/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()); + } +} 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-"); + } +} 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-"); + } +} 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,