From 850a7c878221f84b340d7bb8a63f4572d51e8da3 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 6 Oct 2026 11:52:52 +0100 Subject: [PATCH 1/3] fix(docx): name a clip where it cuts what is painted, and a turned container's transform A Word file has no clip a container can set round its layers. The report named a shape container's clip on one path only, and there of every container, cut or not; it named no layer stack's clip, nor the clip of a container written as a badge, a title and its dates, over the flow or as drawing alone. A turned container whose outline draws nothing lost its transform in silence. DocxClipInk measures, from the layout's fragments as the file draws them, whether a clip cuts any ink: strokes with their caps, joins and side borders as the PDF paints them (DocxInkOutline), pictures cropped to the ellipse they fill, text from its glyphs' outlines. reportClipCut names a clip that cuts something on every write path, and one composed in a table's cells on the table. Nothing written changes. --- CHANGELOG.md | 29 ++ .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 17 +- .../backend/semantic/docx/DocxClipInk.java | 351 ++++++++++++++++++ .../backend/semantic/docx/DocxInkOutline.java | 301 +++++++++++++++ .../semantic/docx/DocxLayoutMetrics.java | 73 +++- .../semantic/docx/DocxSemanticBackend.java | 111 +++++- .../semantic/docx/DocxClipInkTest.java | 318 ++++++++++++++++ .../semantic/docx/DocxClipReportTest.java | 291 +++++++++++++++ .../docx/DocxNodeFieldLedgerTest.java | 6 +- 10 files changed, 1476 insertions(+), 23 deletions(-) create mode 100644 render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java create mode 100644 render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInkOutline.java create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInkTest.java create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipReportTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 9618a05ff..4b01d2bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,35 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export's report names a clip where it cuts something, and a turned container's + transform where no outline names it.** A Word file has no clip a container can set round its + layers: a sidebar's ornament set past its side, a square tile's corners in a disc, a label + run past its chip are written whole. The report named a shape container's clip on one path + only, and there of every one, `OVERFLOW_VISIBLE` included; a table's `cell drawing` note said + of every drawing its cells hold that a clip on it is not carried. It named no layer stack's + `clipToBounds`, and no clip of a container written as a badge, a title and its dates, over + the flow or as drawing alone. Now: + - on every path, a `clipped shape container` or `clipped layer stack` note names a clip that + cuts what the node's layers paint, and a `clipped cell content` note on a table names one + composed in its cells; + - what is painted is measured from the layout's fragments as the file draws them, upright + (`DocxClipInk`): a fill to its outline; a stroke half its width either side of its line, + with its caps, its joins and a box's side borders each drawn as the PDF draws them; a + picture cropped to the ellipse it fills as cropped; a line of text from its letters' tops + to their feet, read from their glyphs' outlines, or over its whole line where those are not + known; + - a clip that cuts nothing is not named, and the `cell drawing` note no longer speaks of + clips; + - the note every shape container's layers were written with is now `shape container`: "its + layers are written inline, one after another in source order", with no claim of a clip; + - a shape container turned by a transform whose outline draws nothing — unpainted, or an + outline no shape shows — names it: what it holds stands upright at its size. + + Measured across the DOCX fidelity corpus, the report names one clip: `LumaStudioInvoice`'s + sidebar ornament. None of this changes what is written: the 62 documents of the corpus export + to the same bytes. In `DocxNodeFieldLedgerTest` a layer stack's `clipToBounds` and a shape + container's `clipPolicy` and `transform` move from a gap to `REPORTED`; 12 node-field gaps + remain. - **A DOCX export's report names what a container written as its contents leaves of its own layout.** A canvas's caption set at its middle came out at its top with everything under it risen to meet it, a band bled to the page's edges stopped at its box, a column fixed diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 7ce0d410e..41603ed47 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -81,7 +81,7 @@ Payload records live in `core` under | Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ `DocxSemanticBackend.writeImage` (the box comes from `NodeDefinitionSupport.resolveImageDimensions`, the same rule layout applies to `width` / `height` / `scale` and the content-width clamp; CONTAIN is embedded at its fitted size, COVER via the picture source crop as in PPTX, and the picture type is read from the bytes; its left margin and padding are not written, and a picture drawn beside its text is fitted to its box with its padding in it — each named in the report) | | Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (vector: the ZXing bit matrix filled as merged rectangles) | ✅ `PptxBarcodeFragmentRenderHandler` (native freeforms: the same ZXing bit matrix as merged rectangles) | ⚠️ `DocxSemanticBackend.writeBarcode` (a PNG picture of the same ZXing bit matrix through `BarcodeMatrices`, one pixel a cell, in the symbol's two colours with their alpha and at the node's size, its data as the picture's description; it scans, but its data is part of the picture rather than editable, reported `APPROXIMATED`, which also names a link, a transform or its left margin and padding as not carried; an `anchor` is a bookmark on its paragraph; in a page zone it is skipped) | | Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders` — the engine's default 1pt black rule where the table states none, not Word's thinner grid — its padding to `w:tcMar`, less above and below the room Word makes for the horizontal rules (half of a rule between two rows, the lower row's, to each; the rules above and below the table whole to their row); a row's cells at the row's smallest top and bottom margins, since both editors give every cell the row's largest, the rest of each cell's padding as space above its first paragraph and below its last, down to the largest margin a cell opening with a table or in a vertical merge keeps; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, less its own margins and padding, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; the paragraph Word requires after a nested table is hidden where it ends its cell holding nothing and no space; a fill's opacity is dropped since `w:shd` is opaque; Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way; the paragraph Word requires after a document's closing table is an ordinary one where the last page has room for two lines below it, so a reader can type below the table, and otherwise a point tall with its mark hidden, so it opens no blank page, reported `APPROXIMATED` since text typed at the end then goes into the table's last cell) | -| Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning; a picture that fills a container clipped to an ellipse takes the ellipse as its geometry, which both editors crop it to; a badge's glyph — a smaller picture in a painted container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn by `DocxDrawings` as a floating picture over the outline, where the layout places it, reported `APPROXIMATED` — inside a filled panel the badge and its glyph are drawn in front of the shading; an icon picture beside its text in an unpainted container or a layer stack is drawn the same way; a filled or outlined rectangle or rounded rectangle holding text, composed in a table cell, which has no place in the layout to be drawn at, is written as a panel — a one-cell table in its fill and outline, its corners squared and reported, its row held at least the outline's height less the borders both editors draw outside it where its padding does not hold its top border, and a one-line label the shape centres top to bottom on a line taller than the room Word leaves its content cut alike on both sides to that room, no closer to its letters than three quarters of a point, and seated where the page sets it; the rest of what a composed cell draws (an icon, a tile, a disc) is the table's own drawing and is drawn by `drawCellDrawing` where the layout puts it, anchored as a rectangle is | +| Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning; the report names a clip that cuts what its layers paint, measured from the layout's fragments by `DocxClipInk` — a clip that cuts nothing (an icon inside its box, a disc's initials, a photo filling its circle) is not named; a picture that fills a container clipped to an ellipse takes the ellipse as its geometry, which both editors crop it to; a badge's glyph — a smaller picture in a painted container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn by `DocxDrawings` as a floating picture over the outline, where the layout places it, reported `APPROXIMATED` — inside a filled panel the badge and its glyph are drawn in front of the shading; an icon picture beside its text in an unpainted container or a layer stack is drawn the same way; a filled or outlined rectangle or rounded rectangle holding text, composed in a table cell, which has no place in the layout to be drawn at, is written as a panel — a one-cell table in its fill and outline, its corners squared and reported, its row held at least the outline's height less the borders both editors draw outside it where its padding does not hold its top border, and a one-line label the shape centres top to bottom on a line taller than the room Word leaves its content cut alike on both sides to that room, no closer to its letters than three quarters of a point, and seated where the page sets it; the rest of what a composed cell draws (an icon, a tile, a disc) is the table's own drawing and is drawn by `drawCellDrawing` where the layout puts it, anchored as a rectangle is | | Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ `DocxDrawings` — the rail is read from the resolved layout's pass fragments and drawn per page as a `line` shape, and the markers as the shapes they are, anchored as a rectangle is: beside an entry's text they move with it when the text above is edited | | Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning | | Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ✅ `DocxSemanticBackend` — an anchor becomes a `w:bookmarkStart` / `w:bookmarkEnd` pair wrapping the paragraph's text, named as Word requires (letters, digits and underscores, starting with a letter, 40 characters); two anchors that clean to one name stay two bookmarks | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 3fd532a75..98a711b21 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -715,7 +715,15 @@ tint it was flattened to. That is recorded with the rest. - **Shape containers → inline layers.** DOCX has no portable equivalent of a graphics-state path clip, so the container's layers are written inline, in source order, without clipping — again with one warning per - export. The outline is drawn as a shape where the page draws it — a + export. The report names the clip — a shape container's, and a layer + stack's that clips to its bounds — wherever it cuts what the layers paint, + measured from the layout's fragments as the file draws them, upright, and + text from the outlines of its letters: a square tile's corners in a disc, a + label run past its chip, an ornament set past a sidebar's side — and one + composed in a table's cell, which has no place of its own, on its table. + One that cuts nothing — an icon inside its box, a disc's initials, a photo + filling its circle, a label whose line stands past its chip while its + letters stay inside — is not named. The outline is drawn as a shape where the page draws it — a star, a diamond or a path as custom geometry — and a picture that fills a container clipped to an ellipse takes the ellipse's shape: a portrait is round inside its ring. A picture the layout placed is written the @@ -903,7 +911,9 @@ margin where that is wider, and draws both borders outside the row's height. Mea border's width shorter there. The body's shapes stand above the page backgrounds, which LibreOffice stacks together with them. Each of these limits is named in the report: -- A transform is not carried: a rotated or scaled shape is drawn upright at its size. +- A transform is not carried: a rotated or scaled shape is drawn upright at its size, and so + is what a turned shape container holds — named with its outline, or on its own where the + outline draws nothing. - A drawing carries no link, no outline entry and no bookmark: a link to a drawn shape's anchor points at none, and a page reference to it is a fixed number. A gradient fill, unequal corners (drawn at the largest radius) and a line's dash pattern (drawn solid) are @@ -920,7 +930,8 @@ shapes stand above the page backgrounds, which LibreOffice stacks together with Polygons and paths — a star, a chevron, an SVG icon's layers, a portrait drawn as paths — are drawn the same way, as custom geometry through the same points and curves, their fill and stroke colours carried; the dash pattern, the caps and joins and the clip round an -SVG icon are not. A shape filled only with a gradient paint shows nothing the export +SVG icon are not — a clip that cuts the icon's art, parked outside its box, is named in the +report. A shape filled only with a gradient paint shows nothing the export carries and is **skipped**, and the report names each one. In the flow a drawn or skipped shape still takes its room: its placed height and margins are owed as space above what follows, and so are diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java new file mode 100644 index 000000000..f785b5afd --- /dev/null +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java @@ -0,0 +1,351 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.payloads.AnchorMarkerPayload; +import com.demcha.compose.document.layout.payloads.BookmarkMarkerPayload; +import com.demcha.compose.document.layout.payloads.EllipseFragmentPayload; +import com.demcha.compose.document.layout.payloads.ImageFragmentPayload; +import com.demcha.compose.document.layout.payloads.LayoutAnchorPayload; +import com.demcha.compose.document.layout.payloads.LineFragmentPayload; +import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; +import com.demcha.compose.document.layout.payloads.ParagraphLine; +import com.demcha.compose.document.layout.payloads.ParagraphLineGeometry; +import com.demcha.compose.document.layout.payloads.PathFragmentPayload; +import com.demcha.compose.document.layout.payloads.PolygonFragmentPayload; +import com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload; +import com.demcha.compose.document.layout.payloads.ShapeClipEndPayload; +import com.demcha.compose.document.layout.payloads.ShapeFragmentPayload; +import com.demcha.compose.document.layout.payloads.SideBorders; +import com.demcha.compose.document.layout.payloads.TransformBeginPayload; +import com.demcha.compose.document.layout.payloads.TransformEndPayload; +import com.demcha.compose.document.style.ClipPolicy; +import com.demcha.compose.document.style.DocumentCornerRadius; +import com.demcha.compose.document.style.DocumentLineCap; +import com.demcha.compose.document.style.DocumentLineJoin; +import com.demcha.compose.document.style.ShapeOutline; +import com.demcha.compose.engine.components.content.shape.Stroke; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; + +/** + * Whether a clip the page sets cuts any of what is painted inside it. + * + *

A Word file has no clip a container can set round its layers, so what a layer stack or a + * shape container clips on the page is written whole. That loses something only where the clip + * cuts something: an icon's art parked outside its box, the corners of a square tile in a disc, + * a label longer than its chip. Most clips cut nothing — an icon drawn inside its box, a disc's + * initials, a chip's label whose line stands past the chip while its letters stay inside.

+ * + *

What is painted is measured from the layout's fragments as the Word file draws them, + * upright, a transform not being carried: a fill to its outline, a fill of a gradient alone not + * at all, as the file draws none; a stroke as the PDF paints it ({@link DocxInkOutline}), a box's + * side borders each a line of its own, ended flat at its corners; a picture to its box, or to the + * ellipse the file crops it to; and a line of text across the width it was set at and from its + * letters' tops to their feet, read from the outlines of its glyphs (see {@link DocxInk}), or + * over its whole line where those are not known. Ink within half a point of the clip is not + * counted as cut.

+ * + *

{@code PptxClipSafety} asks the stricter question of the same fragments — whether a clip + * provably cuts nothing, so that a slide may keep its shapes — and so takes any stroked path, and + * any text near an edge, as cut. A note in the report must name only what is lost, and this + * measures what is.

+ */ +final class DocxClipInk { + + /** How far ink may stand past a clip before the clip is taken to cut it. */ + static final double TOLERANCE = 0.5; + + private DocxClipInk() { + } + + /** + * How far a line's letters reach above and below the baseline the page sets it on. + */ + @FunctionalInterface + interface LetterReach { + /** + * The reach of a line's letters. + * + * @param paragraph the paragraph the line is set in + * @param line the line + * @return {@code {above, below}} its baseline in points, or {@code null} when not known + */ + double[] of(ParagraphFragmentPayload paragraph, ParagraphLine line); + } + + /** + * Whether a clip cuts any of what is painted inside it. + * + * @param clip the fragment opening the clip, whose box the clip's outline is + * set in + * @param painted what the page paints between the clip's opening and its close + * @param croppedToAnEllipse the pictures the Word file crops to the ellipse they fill + * @param letters how far a line's letters reach + * @return whether anything painted stands past the clip + */ + static boolean cuts(PlacedFragment clip, List painted, + Predicate croppedToAnEllipse, LetterReach letters) { + Region region = regionOf((ShapeClipBeginPayload) clip.payload(), clip); + for (PlacedFragment fragment : painted) { + for (double[] point : inkOf(fragment, croppedToAnEllipse.test(fragment), letters)) { + if (!region.holds(point[0], point[1])) { + return true; + } + } + } + return false; + } + + /** The area a clip keeps, measured on the page with y up. */ + @FunctionalInterface + private interface Region { + boolean holds(double x, double y); + } + + private static Region regionOf(ShapeClipBeginPayload clip, PlacedFragment box) { + double left = box.x(); + double bottom = box.y(); + double right = left + box.width(); + double top = bottom + box.height(); + Region bounds = (x, y) -> x >= left - TOLERANCE && x <= right + TOLERANCE + && y >= bottom - TOLERANCE && y <= top + TOLERANCE; + ShapeOutline outline = clip.outline(); + if (clip.policy() != ClipPolicy.CLIP_PATH || outline instanceof ShapeOutline.Rectangle) { + return bounds; + } + if (outline instanceof ShapeOutline.Ellipse) { + double a = box.width() / 2 + TOLERANCE; + double b = box.height() / 2 + TOLERANCE; + double cx = left + box.width() / 2; + double cy = bottom + box.height() / 2; + return (x, y) -> square((x - cx) / a) + square((y - cy) / b) <= 1; + } + if (outline instanceof ShapeOutline.RoundedRectangle rounded) { + double r = rounded.cornerRadius(); + return roundedRegion(bounds, left, bottom, right, top, r, r, r, r); + } + if (outline instanceof ShapeOutline.RoundedRectanglePerCorner rounded) { + DocumentCornerRadius corners = rounded.corners(); + return roundedRegion(bounds, left, bottom, right, top, + corners.topLeft(), corners.topRight(), corners.bottomRight(), corners.bottomLeft()); + } + List> rings = new ArrayList<>(); + if (outline instanceof ShapeOutline.Polygon polygon) { + rings.add(DocxInkOutline.ring(polygon.points(), box)); + } else { + for (DocxInkOutline.Run run : DocxInkOutline.flatten(((ShapeOutline.Path) outline).segments(), box)) { + rings.add(run.points()); + } + } + // Past the outline's own box a point is outside it, without walking its edges. + double[] extent = {Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY}; + for (List ring : rings) { + for (double[] point : ring) { + extent[0] = Math.min(extent[0], point[0]); + extent[1] = Math.min(extent[1], point[1]); + extent[2] = Math.max(extent[2], point[0]); + extent[3] = Math.max(extent[3], point[1]); + } + } + return (x, y) -> x >= extent[0] - TOLERANCE && x <= extent[2] + TOLERANCE + && y >= extent[1] - TOLERANCE && y <= extent[3] + TOLERANCE + && (winding(rings, x, y) != 0 || nearAnEdge(rings, x, y)); + } + + /** A box whose corners are rounded, each radius clamped to half the smaller side. */ + private static Region roundedRegion(Region bounds, double left, double bottom, double right, double top, + double topLeft, double topRight, double bottomRight, double bottomLeft) { + double half = Math.min(right - left, top - bottom) / 2; + double[][] corners = { + {left, top, -1, 1, Math.min(topLeft, half)}, + {right, top, 1, 1, Math.min(topRight, half)}, + {right, bottom, 1, -1, Math.min(bottomRight, half)}, + {left, bottom, -1, -1, Math.min(bottomLeft, half)}}; + return (x, y) -> { + if (!bounds.holds(x, y)) { + return false; + } + for (double[] corner : corners) { + double r = corner[4]; + double cx = corner[0] - corner[2] * r; + double cy = corner[1] - corner[3] * r; + // Past the corner's centre on both axes, the point must be inside its arc. + if (r > 0 && (x - cx) * corner[2] > 0 && (y - cy) * corner[3] > 0 + && Math.hypot(x - cx, y - cy) > r + TOLERANCE) { + return false; + } + } + return true; + }; + } + + /** Points on the outer edge of what a fragment paints, or none for a marker. */ + private static List inkOf(PlacedFragment fragment, boolean croppedToAnEllipse, LetterReach letters) { + Object payload = fragment.payload(); + List ink = new ArrayList<>(); + if (payload instanceof ShapeClipBeginPayload || payload instanceof ShapeClipEndPayload + || payload instanceof TransformBeginPayload || payload instanceof TransformEndPayload + || payload instanceof AnchorMarkerPayload || payload instanceof BookmarkMarkerPayload + || payload instanceof LayoutAnchorPayload) { + return ink; + } + if (payload instanceof ShapeFragmentPayload shape) { + DocumentCornerRadius radius = shape.cornerRadius() == null ? DocumentCornerRadius.ZERO : shape.cornerRadius(); + if (shape.fillColor() != null) { + DocxInkOutline.roundedBox(ink, fragment, 0, radius); + } + SideBorders sides = shape.sideBorders(); + if (sides != null && sides.hasAny()) { + // Each side is a line of its own, ended flat at the box's corners, in place of + // the stroke round it. + double left = fragment.x(); + double bottom = fragment.y(); + double right = left + fragment.width(); + double top = bottom + fragment.height(); + side(ink, sides.top(), left, top, right, top); + side(ink, sides.right(), right, top, right, bottom); + side(ink, sides.bottom(), left, bottom, right, bottom); + side(ink, sides.left(), left, top, left, bottom); + } else if (halfOf(shape.stroke()) > 0) { + DocxInkOutline.roundedBox(ink, fragment, halfOf(shape.stroke()), radius); + } + } else if (payload instanceof EllipseFragmentPayload ellipse) { + double reach = halfOf(ellipse.stroke()); + if (ellipse.fillColor() != null || reach > 0) { + DocxInkOutline.ellipse(ink, fragment, reach); + } + } else if (payload instanceof LineFragmentPayload line) { + double half = halfOf(line.stroke()); + if (half > 0) { + DocxInkOutline.stroke(ink, List.of( + new double[]{fragment.x() + line.startX(), fragment.y() + line.startY()}, + new double[]{fragment.x() + line.endX(), fragment.y() + line.endY()}), + false, half, line.lineCap(), DocumentLineJoin.MITER); + } + } else if (payload instanceof PathFragmentPayload path) { + double half = halfOf(path.stroke()); + for (DocxInkOutline.Run run : DocxInkOutline.flatten(path.segments(), fragment)) { + if (path.fillColor() != null) { + DocxInkOutline.filled(ink, run.points()); + } + if (half > 0) { + DocxInkOutline.stroke(ink, run.points(), run.closed(), half, path.lineCap(), path.lineJoin()); + } + } + } else if (payload instanceof PolygonFragmentPayload polygon) { + double half = halfOf(polygon.stroke()); + if (!polygon.points().isEmpty()) { + List ring = DocxInkOutline.ring(polygon.points(), fragment); + if (polygon.fillColor() != null) { + DocxInkOutline.filled(ink, ring); + } + if (half > 0) { + DocxInkOutline.stroke(ink, ring, true, half, DocumentLineCap.BUTT, DocumentLineJoin.MITER); + } + } + } else if (payload instanceof ParagraphFragmentPayload paragraph) { + text(ink, fragment, paragraph, letters); + } else if (payload instanceof ImageFragmentPayload && croppedToAnEllipse) { + DocxInkOutline.ellipse(ink, fragment, 0); + } else { + // A picture, a barcode, a table's row, and anything else: its box. + DocxInkOutline.roundedBox(ink, fragment, 0, DocumentCornerRadius.ZERO); + } + return ink; + } + + /** + * Each line of a paragraph, across the width it was set at and from its letters' tops to + * their feet on the baseline the page sets it on — over its whole line where its letters' + * reach is not known, or is said to run past the line the layout measured for them: an + * outline read in other units than the layout's, as a face the PDF stands another in for + * gives, is not to be trusted. + */ + private static void text(List ink, PlacedFragment fragment, ParagraphFragmentPayload paragraph, + LetterReach letters) { + boolean padded = paragraph.padding() != null; + double innerX = fragment.x() + (padded ? paragraph.padding().left() : 0); + double innerWidth = Math.max(0, + fragment.width() - (padded ? paragraph.padding().left() + paragraph.padding().right() : 0)); + double lineTop = ParagraphLineGeometry.contentTop(fragment.y(), fragment.height(), + padded ? paragraph.padding().top() : 0); + for (ParagraphLine line : paragraph.lines()) { + if (line.width() > 0) { + double start = ParagraphLineGeometry.lineStartX(paragraph.align(), innerX, innerWidth, line.width()); + double lineBottom = lineTop - line.lineHeight(); + double baseline = ParagraphLineGeometry.baselineY(lineTop, line.lineHeight(), + line.baselineOffsetFromBottom()); + double[] reach = letters.of(paragraph, line); + if (reach == null || baseline + reach[0] > lineTop + TOLERANCE + || baseline - reach[1] < lineBottom - TOLERANCE) { + DocxInkOutline.box(ink, start, lineBottom, start + line.width(), lineTop); + } else if (reach[0] + reach[1] > 0) { + DocxInkOutline.box(ink, start, baseline - reach[1], start + line.width(), baseline + reach[0]); + } + } + lineTop = ParagraphLineGeometry.nextLineTop(lineTop, line.lineHeight(), paragraph.lineGap()); + } + } + + /** One side of a box, drawn as a line of its own. */ + private static void side(List ink, Stroke stroke, double x1, double y1, double x2, double y2) { + double half = halfOf(stroke); + if (half > 0) { + DocxInkOutline.stroke(ink, List.of(new double[]{x1, y1}, new double[]{x2, y2}), false, half, + DocumentLineCap.BUTT, DocumentLineJoin.MITER); + } + } + + /** The non-zero winding number of the rings round a point. */ + private static int winding(List> rings, double x, double y) { + int winding = 0; + for (List ring : rings) { + for (int index = 0; index < ring.size(); index++) { + double[] from = ring.get(index); + double[] to = ring.get((index + 1) % ring.size()); + if (from[1] <= y) { + if (to[1] > y && cross(from, to, x, y) > 0) { + winding++; + } + } else if (to[1] <= y && cross(from, to, x, y) < 0) { + winding--; + } + } + } + return winding; + } + + /** Which side of the line from one point to another a point stands, by sign. */ + private static double cross(double[] from, double[] to, double x, double y) { + return (to[0] - from[0]) * (y - from[1]) - (x - from[0]) * (to[1] - from[1]); + } + + /** Whether a point is within the tolerance of an edge of the rings. */ + private static boolean nearAnEdge(List> rings, double x, double y) { + for (List ring : rings) { + for (int index = 0; index < ring.size(); index++) { + double[] from = ring.get(index); + double[] to = ring.get((index + 1) % ring.size()); + double dx = to[0] - from[0]; + double dy = to[1] - from[1]; + double length = dx * dx + dy * dy; + double t = length == 0 ? 0 : Math.max(0, Math.min(1, ((x - from[0]) * dx + (y - from[1]) * dy) / length)); + if (Math.hypot(x - from[0] - t * dx, y - from[1] - t * dy) <= TOLERANCE) { + return true; + } + } + } + return false; + } + + private static double halfOf(Stroke stroke) { + return stroke == null ? 0 : Math.max(0, stroke.width()) / 2; + } + + private static double square(double value) { + return value * value; + } +} diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInkOutline.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInkOutline.java new file mode 100644 index 000000000..9429bb5c3 --- /dev/null +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInkOutline.java @@ -0,0 +1,301 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.style.DocumentCornerRadius; +import com.demcha.compose.document.style.DocumentLineCap; +import com.demcha.compose.document.style.DocumentLineJoin; +import com.demcha.compose.document.style.DocumentPathSegment; +import com.demcha.compose.document.style.ShapePoint; + +import java.util.ArrayList; +import java.util.List; + +/** + * Where a drawing's paint runs: points along the outer edge of a fill or a stroke, as the PDF + * paints it, measured on the page with y up (see {@link DocxClipInk}). + * + *

A fill runs to its outline. A stroke runs half its width either side of each of its lines; + * round a round join, out to the point of a mitred one where the PDF's miter limit keeps it, and + * no further than its lines' edges at a bevel; at an open run's ends, nothing past a butt cap, + * half its width past a square one and round a round one. Points along an edge stand no further + * apart than {@link #STEP}, and a curve is taken at {@link #CURVE_STEPS} points along it.

+ */ +final class DocxInkOutline { + + /** The PDF's default miter limit, which the page paints every join with. */ + private static final double MITER_LIMIT = 10; + + /** The longest step between two points measured along an edge. */ + private static final double STEP = 2; + + /** The points a cubic curve is measured at. */ + private static final int CURVE_STEPS = 16; + + /** The points an ellipse or a round end is measured at. */ + private static final int ROUND_STEPS = 32; + + /** The cosine of the least turn a round join is measured round: ten degrees. */ + private static final double MIN_TURN_COSINE = Math.cos(Math.toRadians(10)); + + private DocxInkOutline() { + } + + /** + * A sub-path, as the points it runs through. + * + * @param points the points, a curve's taken along its length + * @param closed whether a close ends it + */ + record Run(List points, boolean closed) { + } + + /** The edge of a filled run: the points along it, its closing edge with them. */ + static void filled(List ink, List points) { + for (int index = 0; index < points.size(); index++) { + edge(ink, points.get(index), points.get((index + 1) % points.size())); + } + } + + /** + * The edge of a stroke along a run of points. A run of one point is a dot, which a round or + * square cap draws. + * + * @param half the stroke's half width + */ + static void stroke(List ink, List points, boolean closed, double half, + DocumentLineCap cap, DocumentLineJoin join) { + List run = new ArrayList<>(); + for (double[] point : points) { + if (run.isEmpty() || !same(run.get(run.size() - 1), point)) { + run.add(point); + } + } + if (closed && run.size() > 1 && same(run.get(0), run.get(run.size() - 1))) { + run.remove(run.size() - 1); + } + if (run.isEmpty()) { + return; + } + if (run.size() == 1) { + if (!closed) { + end(ink, run.get(0), new double[]{1, 0}, half, cap); + end(ink, run.get(0), new double[]{-1, 0}, half, cap); + } + return; + } + int count = run.size(); + int lines = closed ? count : count - 1; + for (int index = 0; index < lines; index++) { + double[] from = run.get(index); + double[] to = run.get((index + 1) % count); + double[] normal = normal(from, to); + List along = new ArrayList<>(); + edge(along, from, to); + along.add(to); + for (double[] point : along) { + ink.add(new double[]{point[0] + normal[0] * half, point[1] + normal[1] * half}); + ink.add(new double[]{point[0] - normal[0] * half, point[1] - normal[1] * half}); + } + } + for (int index = closed ? 0 : 1; index < (closed ? count : count - 1); index++) { + double[] before = run.get((index - 1 + count) % count); + double[] corner = run.get(index); + double[] after = run.get((index + 1) % count); + if (join == DocumentLineJoin.ROUND) { + // Where a curve's points turn by a few degrees, the two lines' edges already + // meet its arc within a few hundredths of the stroke's half width. + if (turns(before, corner, after)) { + round(ink, corner, half); + } + } else if (join == DocumentLineJoin.MITER) { + miterTip(ink, before, corner, after, half); + } + } + if (!closed) { + end(ink, run.get(0), direction(run.get(1), run.get(0)), half, cap); + end(ink, run.get(count - 1), direction(run.get(count - 2), run.get(count - 1)), half, cap); + } + } + + /** + * A box grown by {@code reach}: a rounded corner round its arc, its radius grown the same, and + * a square one to its point, as a mitred stroke draws it. + */ + static void roundedBox(List ink, PlacedFragment fragment, double reach, DocumentCornerRadius radius) { + double left = fragment.x(); + double bottom = fragment.y(); + double right = left + fragment.width(); + double top = bottom + fragment.height(); + double half = Math.min(fragment.width(), fragment.height()) / 2; + double[][] corners = { + {right, top, 1, 1, Math.min(radius.topRight(), half)}, + {left, top, -1, 1, Math.min(radius.topLeft(), half)}, + {left, bottom, -1, -1, Math.min(radius.bottomLeft(), half)}, + {right, bottom, 1, -1, Math.min(radius.bottomRight(), half)}}; + List ring = new ArrayList<>(); + for (double[] corner : corners) { + double r = Math.max(0, corner[4]); + if (r == 0) { + ring.add(new double[]{corner[0] + corner[2] * reach, corner[1] + corner[3] * reach}); + continue; + } + double cx = corner[0] - corner[2] * r; + double cy = corner[1] - corner[3] * r; + for (int step = 0; step <= ROUND_STEPS / 4; step++) { + double angle = Math.PI / 2 * step / (ROUND_STEPS / 4); + ring.add(new double[]{cx + corner[2] * (r + reach) * Math.cos(angle), + cy + corner[3] * (r + reach) * Math.sin(angle)}); + } + } + filled(ink, ring); + } + + static void box(List ink, double left, double bottom, double right, double top) { + filled(ink, List.of(new double[]{left, bottom}, new double[]{right, bottom}, + new double[]{right, top}, new double[]{left, top})); + } + + /** The ellipse a box holds, its axes grown by {@code reach}. */ + static void ellipse(List ink, PlacedFragment fragment, double reach) { + double a = fragment.width() / 2 + reach; + double b = fragment.height() / 2 + reach; + double cx = fragment.x() + fragment.width() / 2; + double cy = fragment.y() + fragment.height() / 2; + int steps = ROUND_STEPS * 2; + for (int step = 0; step < steps; step++) { + double angle = 2 * Math.PI * step / steps; + ink.add(new double[]{cx + a * Math.cos(angle), cy + b * Math.sin(angle)}); + } + } + + /** A polygon's normalized points, set in a box. */ + static List ring(List points, PlacedFragment box) { + List ring = new ArrayList<>(points.size()); + for (ShapePoint point : points) { + ring.add(new double[]{box.x() + point.x() * box.width(), box.y() + point.y() * box.height()}); + } + return ring; + } + + /** A path's normalized segments, set in a box, each sub-path as the points it runs through. */ + static List flatten(List segments, PlacedFragment box) { + List runs = new ArrayList<>(); + List current = null; + double[] start = null; + for (DocumentPathSegment segment : segments) { + if (segment instanceof DocumentPathSegment.MoveTo move) { + // A move alone draws nothing; a sub-path needs a segment. + if (current != null && current.size() > 1) { + runs.add(new Run(current, false)); + } + start = at(box, move.x(), move.y()); + current = new ArrayList<>(); + current.add(start); + } else if (current == null) { + // A path opens with a move; anything before one draws nothing. + continue; + } else if (segment instanceof DocumentPathSegment.LineTo line) { + current.add(at(box, line.x(), line.y())); + } else if (segment instanceof DocumentPathSegment.CubicTo cubic) { + double[] from = current.get(current.size() - 1); + double[] first = at(box, cubic.control1X(), cubic.control1Y()); + double[] second = at(box, cubic.control2X(), cubic.control2Y()); + double[] to = at(box, cubic.x(), cubic.y()); + for (int step = 1; step <= CURVE_STEPS; step++) { + double t = (double) step / CURVE_STEPS; + double u = 1 - t; + current.add(new double[]{ + u * u * u * from[0] + 3 * u * u * t * first[0] + 3 * u * t * t * second[0] + t * t * t * to[0], + u * u * u * from[1] + 3 * u * u * t * first[1] + 3 * u * t * t * second[1] + t * t * t * to[1]}); + } + } else if (segment instanceof DocumentPathSegment.Close) { + current.add(start); + runs.add(new Run(current, true)); + // What follows a close without a move starts again where the sub-path did. + current = new ArrayList<>(); + current.add(start); + } + } + if (current != null && current.size() > 1) { + runs.add(new Run(current, false)); + } + return runs; + } + + /** Whether a run turns at a point by more than ten degrees. */ + private static boolean turns(double[] before, double[] corner, double[] after) { + double[] in = direction(before, corner); + double[] out = direction(corner, after); + return in[0] * out[0] + in[1] * out[1] < MIN_TURN_COSINE; + } + + /** The end of an open stroke, running out along {@code outward} from its last point. */ + private static void end(List ink, double[] point, double[] outward, double half, DocumentLineCap cap) { + if (cap == DocumentLineCap.ROUND) { + round(ink, point, half); + } else if (cap == DocumentLineCap.SQUARE) { + double x = point[0] + outward[0] * half; + double y = point[1] + outward[1] * half; + ink.add(new double[]{x - outward[1] * half, y + outward[0] * half}); + ink.add(new double[]{x + outward[1] * half, y - outward[0] * half}); + } + } + + /** The point a mitred join runs to, where the miter limit keeps it rather than bevelling it. */ + private static void miterTip(List ink, double[] before, double[] corner, double[] after, double half) { + double[] in = direction(before, corner); + double[] out = direction(corner, after); + // The tip lies along the difference of the two directions, past the outer side of the turn. + double tipX = in[0] - out[0]; + double tipY = in[1] - out[1]; + double tipLength = Math.hypot(tipX, tipY); + if (tipLength < 1e-9) { + return; + } + // The miter runs 1 / sin(θ/2) half-widths past the corner, θ the angle between the lines; + // sin(θ/2) is half the length of the sum of the two directions. + double ratio = 2 / Math.hypot(in[0] + out[0], in[1] + out[1]); + if (!(ratio <= MITER_LIMIT)) { + return; + } + ink.add(new double[]{corner[0] + tipX / tipLength * half * ratio, corner[1] + tipY / tipLength * half * ratio}); + } + + /** Points round {@code centre} at {@code radius}. */ + private static void round(List ink, double[] centre, double radius) { + for (int step = 0; step < ROUND_STEPS; step++) { + double angle = 2 * Math.PI * step / ROUND_STEPS; + ink.add(new double[]{centre[0] + radius * Math.cos(angle), centre[1] + radius * Math.sin(angle)}); + } + } + + /** The points from {@code from} towards {@code to}, no further apart than {@link #STEP}. */ + private static void edge(List into, double[] from, double[] to) { + int steps = Math.max(1, (int) Math.ceil(Math.hypot(to[0] - from[0], to[1] - from[1]) / STEP)); + for (int step = 0; step < steps; step++) { + double t = (double) step / steps; + into.add(new double[]{from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t}); + } + } + + /** The unit direction from one point to another, or none where they meet. */ + private static double[] direction(double[] from, double[] to) { + double dx = to[0] - from[0]; + double dy = to[1] - from[1]; + double length = Math.hypot(dx, dy); + return length == 0 ? new double[]{0, 0} : new double[]{dx / length, dy / length}; + } + + private static double[] normal(double[] from, double[] to) { + double[] along = direction(from, to); + return new double[]{-along[1], along[0]}; + } + + private static double[] at(PlacedFragment box, double x, double y) { + return new double[]{box.x() + x * box.width(), box.y() + y * box.height()}; + } + + private static boolean same(double[] first, double[] second) { + return Math.abs(first[0] - second[0]) < 1e-9 && Math.abs(first[1] - second[1]) < 1e-9; + } +} diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java index eeabc8374..e0e3648fc 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java @@ -7,6 +7,8 @@ import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; import com.demcha.compose.document.layout.payloads.ParagraphLine; import com.demcha.compose.document.layout.payloads.ParagraphLineGeometry; +import com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload; +import com.demcha.compose.document.layout.payloads.ShapeClipEndPayload; import com.demcha.compose.document.layout.payloads.TableRowFragmentPayload; import com.demcha.compose.document.node.DocumentNode; import com.demcha.compose.document.node.InlineRun; @@ -49,11 +51,13 @@ final class DocxLayoutMetrics { /** What an export with no compiled layout uses: every question answers "unknown". */ static final DocxLayoutMetrics EMPTY = - new DocxLayoutMetrics(new IdentityHashMap<>(), Map.of(), Map.of(), 0); + new DocxLayoutMetrics(new IdentityHashMap<>(), Map.of(), Map.of(), List.of(), 0); private final Map paths; private final Map> fragments; private final Map placed; + // Every fragment, in the order the page paints them. + private final List painted; private final int pageCount; // A table's measured cells by name, filled on first use — see cellLineHeightsOf. private final Map> cellLineHeights = new IdentityHashMap<>(); @@ -66,14 +70,18 @@ final class DocxLayoutMetrics { private Map composedText; // The text and pictures of each page, indexed on first use — see textOnPage. private Map> textByPage; + // Each page's fragments in paint order, indexed on first use — see clipsOf. + private Map> paintedByPage; private DocxLayoutMetrics(Map paths, Map> fragments, Map placed, + List painted, int pageCount) { this.paths = paths; this.fragments = fragments; this.placed = placed; + this.painted = painted; this.pageCount = pageCount; } @@ -95,7 +103,7 @@ static DocxLayoutMetrics of(DocumentGraph graph, LayoutGraph layout) { if (layout == null) { // No measurements, but the paths still name the nodes — which is what a // diagnostic note needs to say where in the document it came from. - return new DocxLayoutMetrics(paths, Map.of(), Map.of(), 0); + return new DocxLayoutMetrics(paths, Map.of(), Map.of(), List.of(), 0); } Map> fragments = new HashMap<>(); for (PlacedFragment fragment : layout.fragments()) { @@ -105,7 +113,7 @@ static DocxLayoutMetrics of(DocumentGraph graph, LayoutGraph layout) { for (PlacedNode node : layout.nodes()) { placed.putIfAbsent(node.path(), node); } - return new DocxLayoutMetrics(paths, fragments, placed, layout.totalPages()); + return new DocxLayoutMetrics(paths, fragments, placed, layout.fragments(), layout.totalPages()); } /** @@ -1142,6 +1150,65 @@ List ownFragments(DocumentNode node) { return fragmentsOf(node); } + /** + * What a node clips, on each page it opens a clip: the fragment opening the clip, and what + * the page paints after it until the clip closes — the node's layers, and anything they + * clip in turn. + * + * @param node a node of the graph + * @return its clips, empty when it opens none or there is no layout + */ + List clipsOf(DocumentNode node) { + List clips = new ArrayList<>(); + for (PlacedFragment opening : fragmentsOf(node)) { + if (!(opening.payload() instanceof ShapeClipBeginPayload begin)) { + continue; + } + List page = paintedOn(opening.pageIndex()); + int from = indexOf(page, opening); + if (from < 0) { + continue; + } + int to = from + 1; + // Up to the close of this clip; a clip the page never closes runs to the page's end. + while (to < page.size() && !(page.get(to).payload() instanceof ShapeClipEndPayload end + && end.ownerPath().equals(begin.ownerPath()))) { + to++; + } + clips.add(new Clip(opening, page.subList(from + 1, to))); + } + return clips; + } + + /** + * A clip a node opens on a page. + * + * @param opening the fragment opening it, whose box the clip's outline is set in + * @param painted what the page paints inside it, in paint order + */ + record Clip(PlacedFragment opening, List painted) { + } + + private List paintedOn(int page) { + if (paintedByPage == null) { + paintedByPage = new HashMap<>(); + for (PlacedFragment fragment : painted) { + paintedByPage.computeIfAbsent(fragment.pageIndex(), key -> new ArrayList<>()).add(fragment); + } + } + return paintedByPage.getOrDefault(page, List.of()); + } + + /** The position of a fragment in a page's list, by identity: two fragments may be equal. */ + private static int indexOf(List page, PlacedFragment fragment) { + for (int index = 0; index < page.size(); index++) { + if (page.get(index) == fragment) { + return index; + } + } + return -1; + } + /** * The drawing the layout paints in a pass of its own rather than for a node of the tree: a * timeline's rail. The other passes are written as what they are — page backgrounds as page diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index 194722be8..27242971f 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -2033,6 +2033,7 @@ private static void keepOnOnePage(List written, boolean withNext) } private void writeNodeContent(XWPFDocument document, DocumentNode node) throws Exception { + reportClipCut(node); ParagraphNode initials = node instanceof ShapeContainerNode badge ? textBadgeParagraph(badge) : null; if (initials != null) { writeTextBadge(document, (ShapeContainerNode) node, initials); @@ -2082,6 +2083,84 @@ && paintOf(candidate).isEmpty(), } } + /** + * Names the clip a node opens where it cuts what the node's layers paint + * ({@link DocxClipInk}): the Word file has no clip a container can set round its layers, so + * what the page cuts away is written whole, however the node is written. The page opens one + * for a layer stack that clips to its bounds and a shape container whose policy clips; one + * composed in a table's cell has no place of its own, and its clip is among the table's + * fragments, named on the table. A clip that cuts nothing — an icon drawn inside its box, a + * disc's initials, a photo filling its circle — loses nothing and is not named. A picture + * filling the ellipse that clips it is cropped to that ellipse in the file (see + * {@link #fillsItsEllipse}), and is measured as cropped. + */ + private void reportClipCut(DocumentNode node) { + List clips = layout.clipsOf(node); + if (clips.isEmpty()) { + return; + } + java.util.Set cropped = new java.util.HashSet<>(); + collectCroppedPictures(node, clipContainer, cropped); + for (DocxLayoutMetrics.Clip clip : clips) { + if (DocxClipInk.cuts(clip.opening(), clip.painted(), + fragment -> fragment.payload() instanceof com.demcha.compose.document.layout.payloads.ImageFragmentPayload + && cropped.contains(fragment.path()), + this::letterReach)) { + boolean toItsOutline = ((com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload) + clip.opening().payload()).policy() == com.demcha.compose.document.style.ClipPolicy.CLIP_PATH; + if (node instanceof ShapeContainerNode || node instanceof com.demcha.compose.document.node.LayerStackNode) { + report.add(DocxExportReport.Severity.APPROXIMATED, + node instanceof ShapeContainerNode ? "clipped shape container" : "clipped layer stack", + layout.pathOf(node), "its clip is not in the file, so what its layers paint past its " + + (toItsOutline ? "outline" : "box") + " is written whole"); + } else { + report.add(DocxExportReport.Severity.APPROXIMATED, "clipped cell content", layout.pathOf(node), + "a clip composed in its cells is not in the file, so what is painted past it is " + + "written whole"); + } + return; + } + } + } + + /** + * How far a laid-out line's letters reach above and below the baseline the page sets it on, + * read from their glyphs' outlines (see {@link DocxInk}) and moved as the page seats the + * line; {@code null} where the fonts the layout measured with do not say — measuring here + * must not fail the export. + */ + private double[] letterReach(com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload paragraph, + com.demcha.compose.document.layout.payloads.ParagraphLine line) { + try { + double[] reach = DocxInk.of(line, measuredFonts()); + if (reach == null) { + return null; + } + double seated = paragraph.verticalAlign() == TextVerticalAlign.DEFAULT ? 0 + : com.demcha.compose.document.backend.fixed.pdf.handlers.ParagraphSeating + .shift(line, measuredFonts(), paragraph.verticalAlign()); + return new double[]{reach[0] + seated, reach[1] - seated}; + } catch (RuntimeException unknownFace) { + return null; + } + } + + /** + * The paths of the pictures under a node that fill the ellipse clipping them, each clipped + * by the nearest shape container round it that clips to its outline. + */ + private void collectCroppedPictures(DocumentNode node, ShapeContainerNode clip, java.util.Set into) { + ShapeContainerNode nearest = node instanceof ShapeContainerNode container + && container.clipPolicy() == com.demcha.compose.document.style.ClipPolicy.CLIP_PATH + ? container : clip; + for (DocumentNode child : node.children()) { + if (child instanceof ImageNode image && fillsItsEllipse(image, nearest)) { + into.add(layout.pathOf(image)); + } + collectCroppedPictures(child, nearest, into); + } + } + /** * Whether a node in the flow is laid over it: an overlay the page gives no room, its margins * taking back its whole height, and holding text that a text box can set where the page does. @@ -2754,12 +2833,15 @@ private boolean drawOwnFragments(DocumentNode node) { /** * Draws the outline a shape container paints round its layers, or reports it lost when no - * shape shows it — a path, a polygon, a star. + * shape shows it. Where no outline is drawn, it names the transform that turns the + * container's layers, which only a drawn outline's note otherwise names. */ private void drawOutlineOf(DocumentNode node) { - if (!drawOwnFragments(node) && !drawnByItsTable(node) - && node instanceof ShapeContainerNode container - && (container.fillColor() != null || container.stroke() != null && container.stroke().width() > 0)) { + if (drawOwnFragments(node) || drawnByItsTable(node) || !(node instanceof ShapeContainerNode container)) { + // A drawn outline's note names its transform; a table's note, its cells'. + return; + } + if (container.fillColor() != null || container.stroke() != null && container.stroke().width() > 0) { String outline = container.outline().getClass().getSimpleName().toLowerCase(java.util.Locale.ROOT); report.add(DocxExportReport.Severity.DROPPED, "shape container outline", layout.pathOf(node), composedInACell(node) @@ -2768,6 +2850,11 @@ private void drawOutlineOf(DocumentNode node) { : "a " + outline + " outline has no shape this export draws, so it is not in the " + "document"); } + // With no outline drawn, nothing else names the transform its layers are turned by. + if (container.transform() != null && !container.transform().isIdentity()) { + report.add(DocxExportReport.Severity.APPROXIMATED, node.nodeKind(), layout.pathOf(node), + "its transform is not carried, so what it holds stands upright at its size"); + } } /** Whether a path is painted or stroked in a way custom geometry does not say. */ @@ -6112,14 +6199,11 @@ private void writeShapeContainer(XWPFDocument document, ShapeContainerNode node, writePanel(document, node, new ContainerPaint(node.fillColor(), bordersOf(null, node.stroke()))); return; } - // POI/DOCX has no portable equivalent of a graphics-state path clip. - // The fallback rule (recorded in docs/canonical-legacy-parity.md) is - // to render the container's layers inline, in source order, without - // clipping; a picture clipped to an ellipse takes the ellipse's shape. - report.add(DocxExportReport.Severity.APPROXIMATED, "clipped shape container", - layout.pathOf(node), - "DOCX has no graphics-state clip, so the layers are written inline, in source " - + "order, without being clipped to the outline"); + // The container's layers are written inline, in source order; a clip that cuts them is + // named where every container is written (reportClipCut), and a picture clipped to an + // ellipse takes the ellipse's shape. + report.add(DocxExportReport.Severity.APPROXIMATED, "shape container", layout.pathOf(node), + "its layers are written inline, one after another in source order"); // The outline itself is drawing — a badge's circle, a ring round a portrait — and is // drawn where the page draws it, behind the layers now held in to where it sets them. drawOutlineOf(node); @@ -8933,7 +9017,8 @@ private CellDrawing drawCellDrawing(TableNode table) { report.add(DocxExportReport.Severity.APPROXIMATED, "cell drawing", layout.pathOf(table), "what its cells draw is drawn as shapes where the layout puts it — anchored in the " + "cell a drawing is all of, and otherwise " + ANCHORED_BESIDE_ITS_TEXT - + "; a clip, a transform, a gradient or a dash on it is not carried"); + + "; a transform, a gradient or a dash on it is not carried"); + // A clip its cells set is named where it cuts something (reportClipCut). } return new CellDrawing(drew, skipped, pending); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInkTest.java new file mode 100644 index 000000000..1edce466e --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInkTest.java @@ -0,0 +1,318 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.payloads.EllipseFragmentPayload; +import com.demcha.compose.document.layout.payloads.ImageFragmentPayload; +import com.demcha.compose.document.layout.payloads.LineFragmentPayload; +import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; +import com.demcha.compose.document.layout.payloads.ParagraphLine; +import com.demcha.compose.document.layout.payloads.PathFragmentPayload; +import com.demcha.compose.document.layout.payloads.PolygonFragmentPayload; +import com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload; +import com.demcha.compose.document.layout.payloads.ShapeClipEndPayload; +import com.demcha.compose.document.layout.payloads.ShapeFragmentPayload; +import com.demcha.compose.document.layout.payloads.SideBorders; +import com.demcha.compose.document.layout.payloads.TransformBeginPayload; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.ClipPolicy; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentCornerRadius; +import com.demcha.compose.document.style.DocumentPaint; +import com.demcha.compose.document.style.DocumentLineCap; +import com.demcha.compose.document.style.DocumentLineJoin; +import com.demcha.compose.document.style.DocumentPathSegment; +import com.demcha.compose.document.style.DocumentTransform; +import com.demcha.compose.document.style.ShapeOutline; +import com.demcha.compose.document.style.ShapePoint; +import com.demcha.compose.engine.components.content.shape.Stroke; +import com.demcha.compose.engine.components.content.text.TextDecoration; +import com.demcha.compose.engine.components.content.text.TextStyle; +import com.demcha.compose.engine.components.style.Padding; +import com.demcha.compose.font.FontName; +import org.junit.jupiter.api.Test; + +import java.awt.Color; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Whether a clip cuts what is painted inside it, measured from the layout's fragments as the + * Word file draws them: a clip that cuts nothing loses nothing in a file that has no clip a + * container can set round its layers. + */ +class DocxClipInkTest { + + private static final double X = 100; + private static final double Y = 50; + private static final double W = 80; + private static final double H = 60; + + @Test + void aBoxInsideItsClipIsNotCutAndOnePastItIs() { + PlacedFragment clip = clip(new ShapeOutline.Rectangle(W, H), ClipPolicy.CLIP_BOUNDS); + + assertThat(cuts(clip, at(10, 10, 40, 30, shape(0)))).isFalse(); + assertThat(cuts(clip, at(0, 0, W, H, shape(0)))).as("exactly its box").isFalse(); + assertThat(cuts(clip, at(50, 10, 40, 30, shape(0)))).as("10pt past its right side").isTrue(); + assertThat(cuts(clip, at(W - 39.7, 10, 40, 30, shape(0)))).as("a hair past it").isFalse(); + } + + @Test + void aStrokeReachesHalfItsWidthPastItsBox() { + PlacedFragment clip = clip(new ShapeOutline.Rectangle(W, H), ClipPolicy.CLIP_BOUNDS); + + assertThat(cuts(clip, at(0, 0, W, H, shape(0.8)))).as("0.4pt past").isFalse(); + assertThat(cuts(clip, at(0, 0, W, H, shape(4)))).as("2pt past").isTrue(); + assertThat(cuts(clip, at(2, 2, W - 4, H - 4, shape(4)))).isFalse(); + } + + @Test + void anEllipseCutsTheCornersOfASquareButNotTheCircleInsideIt() { + PlacedFragment clip = clip(new ShapeOutline.Ellipse(W, H), ClipPolicy.CLIP_PATH); + + assertThat(cuts(clip, at(0, 0, W, H, shape(0)))).as("the box's corners").isTrue(); + assertThat(cuts(clip, at(0, 0, W, H, ellipse(0)))).as("the same ellipse").isFalse(); + assertThat(cuts(clip, at(W / 2 - 10, H / 2 - 10, 20, 20, shape(0)))).as("a box at its middle").isFalse(); + assertThat(cuts(clip, at(0, 0, W, H, ellipse(4)))).as("its ring stroked 2pt past it").isTrue(); + } + + @Test + void aPictureCroppedToTheEllipseItFillsIsNotCut() { + PlacedFragment clip = clip(new ShapeOutline.Ellipse(W, H), ClipPolicy.CLIP_PATH); + PlacedFragment picture = at(0, 0, W, H, new ImageFragmentPayload(null, null, null, null)); + + assertThat(DocxClipInk.cuts(clip, List.of(picture), fragment -> false, UNKNOWN)) + .as("square, as written").isTrue(); + assertThat(DocxClipInk.cuts(clip, List.of(picture), fragment -> fragment == picture, UNKNOWN)) + .as("cropped to the ellipse").isFalse(); + } + + @Test + void aRoundedClipCutsOnlyPastItsArcs() { + PlacedFragment clip = clip(new ShapeOutline.RoundedRectangle(W, H, 20), ClipPolicy.CLIP_PATH); + + assertThat(cuts(clip, at(0, 0, W, H, shape(0)))).as("a square fill's corners").isTrue(); + assertThat(cuts(clip, at(0, 0, W, H, roundedShape(DocumentCornerRadius.of(20))))).isFalse(); + assertThat(cuts(clip, at(20, 0, W - 40, H, shape(0)))).as("a band between the arcs").isFalse(); + assertThat(cuts(clip, at(0, 20, W, H - 40, shape(0)))).as("a band across them").isFalse(); + } + + @Test + void eachCornerKeepsItsOwnRadius() { + PlacedFragment clip = clip(new ShapeOutline.RoundedRectanglePerCorner(W, H, + DocumentCornerRadius.of(20, 0, 0, 0)), ClipPolicy.CLIP_PATH); + + assertThat(cuts(clip, at(W - 10, H - 10, 10, 10, shape(0)))).as("the square top right").isFalse(); + assertThat(cuts(clip, at(0, H - 10, 10, 10, shape(0)))).as("the rounded top left").isTrue(); + } + + @Test + void aPathIsMeasuredWhereItRunsNotByItsBox() { + PlacedFragment clip = clip(new ShapeOutline.Rectangle(W, H), ClipPolicy.CLIP_BOUNDS); + List inside = List.of(new DocumentPathSegment.MoveTo(0.2, 0.2), + new DocumentPathSegment.LineTo(0.8, 0.2), new DocumentPathSegment.LineTo(0.5, 0.8), + new DocumentPathSegment.Close()); + List parkedOutside = List.of(new DocumentPathSegment.MoveTo(1.2, 0.2), + new DocumentPathSegment.LineTo(1.8, 0.2), new DocumentPathSegment.LineTo(1.5, 0.8), + new DocumentPathSegment.Close()); + + assertThat(cuts(clip, at(0, 0, W, H, path(inside, 2, DocumentLineJoin.ROUND)))) + .as("an icon's stroked art inside its box").isFalse(); + assertThat(cuts(clip, at(0, 0, W, H, path(parkedOutside, 0, DocumentLineJoin.MITER)))) + .as("art parked outside the box").isTrue(); + } + + @Test + void aCurveIsMeasuredAlongItsLengthNotByItsControlPoints() { + PlacedFragment clip = clip(new ShapeOutline.Rectangle(W, H), ClipPolicy.CLIP_BOUNDS); + // Control points well above the box; the curve itself peaks at three quarters of their height. + List arch = List.of(new DocumentPathSegment.MoveTo(0.1, 0.1), + new DocumentPathSegment.CubicTo(0.1, 1.2, 0.9, 1.2, 0.9, 0.1)); + + assertThat(cuts(clip, at(0, 0, W, H, path(arch, 0, DocumentLineJoin.ROUND)))).isFalse(); + } + + @Test + void aMitredCornerRunsPastItsStrokeWhereARoundOneDoesNot() { + PlacedFragment clip = clip(new ShapeOutline.Rectangle(W, H), ClipPolicy.CLIP_BOUNDS); + // A spike whose tip stands 2pt below the top: a 2pt stroke's round join reaches 1pt above + // it, its miter more than three. + List spike = List.of(new DocumentPathSegment.MoveTo(0.3, 0.1), + new DocumentPathSegment.LineTo(0.5, (H - 2) / H), new DocumentPathSegment.LineTo(0.7, 0.1)); + + assertThat(cuts(clip, at(0, 0, W, H, path(spike, 2, DocumentLineJoin.ROUND)))).isFalse(); + assertThat(cuts(clip, at(0, 0, W, H, path(spike, 2, DocumentLineJoin.MITER)))).isTrue(); + } + + @Test + void aStrokedPolygonRunsToItsMitredPoint() { + // A spike whose point stands 2pt below the top: its lines' edges stay a point under it, + // the mitred point of its 2pt stroke more than three past it. + PlacedFragment clip = clip(new ShapeOutline.Rectangle(W, H), ClipPolicy.CLIP_BOUNDS); + List spike = List.of(new ShapePoint(0.3, 0.1), new ShapePoint(0.5, (H - 2) / H), + new ShapePoint(0.7, 0.1)); + + assertThat(cuts(clip, at(0, 0, W, H, new PolygonFragmentPayload(spike, Color.ORANGE, null, null, null)))) + .as("filled").isFalse(); + assertThat(cuts(clip, at(0, 0, W, H, + new PolygonFragmentPayload(spike, Color.ORANGE, new Stroke(Color.BLACK, 2), null, null)))) + .as("stroked").isTrue(); + } + + @Test + void aBoxsSideBordersAreEachALineEndedFlat() { + // A band flush with the clip's top, ruled 2pt along its foot only: nothing passes the clip. + PlacedFragment clip = clip(new ShapeOutline.Rectangle(W, H), ClipPolicy.CLIP_BOUNDS); + ShapeFragmentPayload ruled = new ShapeFragmentPayload(null, null, DocumentCornerRadius.ZERO, null, null, + new SideBorders(null, null, new Stroke(Color.BLACK, 2), null), null); + ShapeFragmentPayload framed = new ShapeFragmentPayload(null, null, DocumentCornerRadius.ZERO, null, null, + new SideBorders(new Stroke(Color.BLACK, 2), null, null, null), null); + + assertThat(cuts(clip, at(0, H - 20, W, 20, ruled))).isFalse(); + assertThat(cuts(clip, at(0, H - 20, W, 20, framed))).as("its top rule half past the clip's top").isTrue(); + } + + @Test + void aLineEndsAsItsCapsEndIt() { + // A 4pt rule across the whole box: a butt cap ends at its points, a round or a square one + // runs 2pt past them. + PlacedFragment clip = clip(new ShapeOutline.Rectangle(W, H), ClipPolicy.CLIP_BOUNDS); + + assertThat(cuts(clip, at(0, 30, W, 0, line(4, DocumentLineCap.BUTT, W)))).isFalse(); + assertThat(cuts(clip, at(0, 30, W, 0, line(4, DocumentLineCap.ROUND, W)))).isTrue(); + assertThat(cuts(clip, at(0, 30, W, 0, line(4, DocumentLineCap.SQUARE, W)))).isTrue(); + assertThat(cuts(clip, at(10, 30, 60, 0, line(4, DocumentLineCap.ROUND, 60)))).as("well inside").isFalse(); + assertThat(cuts(clip, at(0, H - 1, W, 0, line(4, DocumentLineCap.BUTT, W)))).as("its side past the top") + .isTrue(); + } + + @Test + void aStrokedBoxIsSquareAtItsCorners() { + // A box's stroke is mitred: its corner runs out to a point, past where a round one ends. + PlacedFragment clip = clip(new ShapeOutline.RoundedRectangle(W, H, 10), ClipPolicy.CLIP_PATH); + + assertThat(cuts(clip, at(4.2, 4.2, W - 8.4, H - 8.4, shape(4)))).isTrue(); + assertThat(cuts(clip, at(4.2, 4.2, W - 8.4, H - 8.4, + new ShapeFragmentPayload(Color.ORANGE, new Stroke(Color.BLACK, 4), DocumentCornerRadius.of(6), + null, null, null, null)))).as("rounded, its stroke round its arcs").isFalse(); + } + + @Test + void aGradientAloneIsNotDrawnAndCutsNothing() { + PlacedFragment clip = clip(new ShapeOutline.Rectangle(W, H), ClipPolicy.CLIP_BOUNDS); + + assertThat(cuts(clip, at(-20, -20, 200, 200, new ShapeFragmentPayload(null, null, DocumentCornerRadius.ZERO, + null, null, null, DocumentPaint.linear(DocumentColor.BLACK, DocumentColor.WHITE))))).isFalse(); + } + + @Test + void aPathOutlineCutsWhatCrossesItsSlantedSide() { + // A triangle: what stands under its point is kept, what reaches its slanted side is cut. + PlacedFragment clip = clip(new ShapeOutline.Path(W, H, List.of(new DocumentPathSegment.MoveTo(0, 0), + new DocumentPathSegment.LineTo(1, 0), new DocumentPathSegment.LineTo(0.5, 1), + new DocumentPathSegment.Close())), ClipPolicy.CLIP_PATH); + + assertThat(cuts(clip, at(W / 2 - 5, 5, 10, 10, shape(0)))).isFalse(); + assertThat(cuts(clip, at(0, 0, W, H / 2, shape(0)))).isTrue(); + } + + @Test + void markersPaintNothingAndATransformIsNotApplied() { + PlacedFragment clip = clip(new ShapeOutline.Rectangle(W, H), ClipPolicy.CLIP_BOUNDS); + + // The Word file draws what a transform turns upright, at its box: that is what is measured. + assertThat(cuts(clip, + at(0, 0, W, H, new TransformBeginPayload(DocumentTransform.rotate(45), "root/card/turned")), + at(10, 10, 20, 20, shape(0)), + at(0, 0, 0, 0, new ShapeClipEndPayload("root/card/inner")))).isFalse(); + assertThat(cuts(clip, at(-20, -20, 200, 200, new ShapeClipBeginPayload( + new ShapeOutline.Rectangle(200, 200), ClipPolicy.CLIP_BOUNDS, "root/card/inner")))).isFalse(); + } + + @Test + void aLineOfTextIsMeasuredFromItsLettersTopsToTheirFeet() { + // A label on a 12pt line, its baseline 2pt up it, centred in a chip 10pt tall: its line + // stands a point past the chip at the top and the foot, its letters inside it. + PlacedFragment clip = clip(new ShapeOutline.RoundedRectangle(W, 10, 5), ClipPolicy.CLIP_PATH); + PlacedFragment label = at(0, -1, W, 12, paragraph(40, 12, Padding.zero())); + DocxClipInk.LetterReach digits = (paragraph, line) -> new double[]{7, 0}; + DocxClipInk.LetterReach tall = (paragraph, line) -> new double[]{10, 0}; + + assertThat(DocxClipInk.cuts(clip, List.of(label), fragment -> false, digits)).isFalse(); + assertThat(DocxClipInk.cuts(clip, List.of(label), fragment -> false, tall)).as("letters past the top") + .isTrue(); + assertThat(DocxClipInk.cuts(clip, List.of(label), fragment -> false, UNKNOWN)) + .as("its whole line, where its letters' reach is not known").isTrue(); + assertThat(DocxClipInk.cuts(clip, List.of(at(0, 0.5, W, 9, paragraph(40, 9, Padding.zero()))), + fragment -> false, (paragraph, line) -> new double[]{20, 0})) + .as("its whole line, inside the chip, where its letters are said to run past it").isFalse(); + assertThat(DocxClipInk.cuts(clip, List.of(at(0, -1, W, 12, paragraph(W + 10, 12, Padding.zero()))), + fragment -> false, digits)).as("a label set wider than its chip").isTrue(); + assertThat(DocxClipInk.cuts(clip, List.of(at(0, -1, W, 12, paragraph(40, 12, null))), + fragment -> false, digits)).as("a paragraph with no padding given").isFalse(); + } + + @Test + void aPolygonWithNoPointsPaintsNothing() { + PlacedFragment clip = clip(new ShapeOutline.Rectangle(W, H), ClipPolicy.CLIP_BOUNDS); + + assertThat(cuts(clip, at(-20, -20, 200, 200, + new PolygonFragmentPayload(List.of(), Color.ORANGE, new Stroke(Color.BLACK, 2), null, null)))).isFalse(); + } + + @Test + void anUnpaintedShapePaintsNothing() { + PlacedFragment clip = clip(new ShapeOutline.Rectangle(W, H), ClipPolicy.CLIP_BOUNDS); + + assertThat(cuts(clip, at(-20, -20, 200, 200, + new ShapeFragmentPayload(null, null, DocumentCornerRadius.ZERO, null, null, null, null)))).isFalse(); + assertThat(cuts(clip, at(-20, -20, 200, 200, new EllipseFragmentPayload(null, null, null, null)))).isFalse(); + } + + /** Letters whose reach the fonts do not say, measured over their whole line. */ + private static final DocxClipInk.LetterReach UNKNOWN = (paragraph, line) -> null; + + private static boolean cuts(PlacedFragment clip, PlacedFragment... painted) { + return DocxClipInk.cuts(clip, List.of(painted), fragment -> false, UNKNOWN); + } + + private static PlacedFragment clip(ShapeOutline outline, ClipPolicy policy) { + return new PlacedFragment("root/card", 1, 0, X, Y, outline.width(), outline.height(), null, null, + new ShapeClipBeginPayload(outline, policy, "root/card")); + } + + private static PlacedFragment at(double x, double y, double width, double height, Object payload) { + return new PlacedFragment("root/card/layer", 0, 0, X + x, Y + y, width, height, null, null, payload); + } + + private static ShapeFragmentPayload shape(double strokeWidth) { + return new ShapeFragmentPayload(Color.ORANGE, strokeWidth > 0 ? new Stroke(Color.BLACK, strokeWidth) : null, + DocumentCornerRadius.ZERO, null, null, null, null); + } + + private static ShapeFragmentPayload roundedShape(DocumentCornerRadius radius) { + return new ShapeFragmentPayload(Color.ORANGE, null, radius, null, null, null, null); + } + + private static EllipseFragmentPayload ellipse(double strokeWidth) { + return new EllipseFragmentPayload(Color.BLUE, strokeWidth > 0 ? new Stroke(Color.BLACK, strokeWidth) : null, + null, null); + } + + private static PathFragmentPayload path(List segments, double strokeWidth, DocumentLineJoin join) { + return new PathFragmentPayload(segments, strokeWidth > 0 ? null : Color.ORANGE, null, + strokeWidth > 0 ? new Stroke(Color.BLACK, strokeWidth) : null, null, null, null, null, + DocumentLineCap.BUTT, join); + } + + private static ParagraphFragmentPayload paragraph(double lineWidth, double lineHeight, Padding padding) { + ParagraphLine line = new ParagraphLine("Label", lineWidth, lineHeight, lineHeight, 8, 2, List.of(), List.of()); + return new ParagraphFragmentPayload(new TextStyle(FontName.HELVETICA, 10, TextDecoration.DEFAULT, Color.BLACK), + TextAlign.CENTER, padding, lineHeight, 0, 0, List.of(line), null, null, null); + } + + private static LineFragmentPayload line(double width, DocumentLineCap cap, double length) { + return new LineFragmentPayload(new Stroke(Color.BLACK, width), 0, 0, length, 0, null, null, null, cap); + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipReportTest.java new file mode 100644 index 000000000..1f01153d5 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipReportTest.java @@ -0,0 +1,291 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.ImageBuilder; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.ShapeBuilder; +import com.demcha.compose.document.dsl.ShapeContainerBuilder; +import com.demcha.compose.document.image.DocumentImageData; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.LayerAlign; +import com.demcha.compose.document.style.ClipPolicy; +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.style.DocumentTransform; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.font.FontName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The clip of a layer stack or a shape container, and the transform of a container whose outline + * draws nothing, are in the report wherever the export loses them. + * + *

A Word file has no clip a container can set round its layers: what the page cut away was + * written whole, and the report named a container's clip on one path only — and there of every + * container, cut or not. A clip that cuts nothing loses nothing, and is not named.

+ */ +class DocxClipReportTest { + + private static final DocumentColor INK = DocumentColor.rgb(26, 86, 148); + private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249); + private static final String PAST_ITS_BOX = + "its clip is not in the file, so what its layers paint past its box is written whole"; + private static final String PAST_ITS_OUTLINE = + "its clip is not in the file, so what its layers paint past its outline is written whole"; + + @Test + void aLayerStackNamesTheClipThatCutsItsLayers() throws Exception { + // A dot set past the stack's right side: the page cuts it at the box, the file does not. + DocxExportReport report = reportOf(page -> page + .addParagraph("Above") + .addLayerStack(stack -> stack.name("Stack").clipToBounds() + .layer(box(100, 40), LayerAlign.TOP_LEFT, 0) + .position(box(30, 30), 90, 5, LayerAlign.TOP_LEFT, 1)) + .addParagraph("Below")); + + assertThat(detailOf(report, "clipped layer stack")).isEqualTo(PAST_ITS_BOX); + } + + @Test + void aClipThatCutsNothingIsNotNamed() throws Exception { + DocxExportReport inside = reportOf(page -> page + .addParagraph("Above") + .addLayerStack(stack -> stack.name("Stack").clipToBounds() + .layer(box(100, 40), LayerAlign.TOP_LEFT, 0) + .position(box(30, 30), 60, 5, LayerAlign.TOP_LEFT, 1)) + .addParagraph("Below")); + DocxExportReport notClipping = reportOf(page -> page + .addParagraph("Above") + .addLayerStack(stack -> stack.name("Stack") + .layer(box(100, 40), LayerAlign.TOP_LEFT, 0) + .position(box(30, 30), 90, 5, LayerAlign.TOP_LEFT, 1)) + .addParagraph("Below")); + DocxExportReport overflowVisible = reportOf(page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Disc").circle(40).fillColor(INK) + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .center(box(40, 40)).build()) + .addParagraph("Below")); + + // A rota's shift chip, set as CobaltRota sets it: its label's line two points taller than + // the chip, its digits inside it. + DocxExportReport chip = reportOf(page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Chip").roundedRect(90, 8, 4).fillColor(SURFACE) + .clipPolicy(ClipPolicy.CLIP_PATH) + .center(new ParagraphBuilder().text("08:00-16:00") + .textStyle(DocumentTextStyle.builder().fontName(FontName.CARLITO).size(8.2).build()) + .build()) + .build()) + .addParagraph("Below")); + + assertThat(inside.bySubject()).doesNotContainKeys("clipped layer stack", "clipped shape container"); + assertThat(chip.bySubject()).doesNotContainKeys("clipped layer stack", "clipped shape container"); + assertThat(notClipping.bySubject()).doesNotContainKeys("clipped layer stack", "clipped shape container"); + assertThat(overflowVisible.bySubject()).doesNotContainKeys("clipped layer stack", "clipped shape container"); + } + + @Test + void aDrawingClippedToItsCircleNamesTheCornersItShows() throws Exception { + // A square filling a disc: the page rounds its corners off, the file draws them. + DocxExportReport report = reportOf(page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Disc").circle(40).fillColor(SURFACE) + .clipPolicy(ClipPolicy.CLIP_PATH) + .center(box(40, 40)).build()) + .addParagraph("Below")); + DocxExportReport toItsBox = reportOf(page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Disc").circle(40).fillColor(SURFACE) + .clipPolicy(ClipPolicy.CLIP_BOUNDS) + .position(box(40, 40), 10, 0, LayerAlign.TOP_LEFT).build()) + .addParagraph("Below")); + + assertThat(detailOf(report, "clipped shape container")).isEqualTo(PAST_ITS_OUTLINE); + assertThat(detailOf(toItsBox, "clipped shape container")).isEqualTo(PAST_ITS_BOX); + } + + @Test + void aPhotoFillingItsCircleIsCroppedToIt() throws Exception { + // Written with the ellipse as its geometry, the picture shows only what the page shows. + Consumer avatar = page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Avatar").circle(60).fillColor(INK) + .clipPolicy(ClipPolicy.CLIP_PATH) + .center(new ImageBuilder().name("Photo").source(DocumentImageData.fromBytes(png())) + .size(60, 60).build()) + .build()) + .addParagraph("Below"); + DocxExportReport report = reportOf(avatar); + + assertThat(bodyOf(avatar)).as("the picture written with the ellipse as its geometry") + .containsPattern(""); + assertThat(report.bySubject()).doesNotContainKey("clipped shape container"); + assertThat(detailOf(report, "shape container")).as("its layers, with no word of a clip") + .isEqualTo("its layers are written inline, one after another in source order"); + } + + @Test + void aBadgeNamesTheInitialsItsCircleCuts() throws Exception { + // Held in the badge's shape, a letter taller than its disc runs past it; the page cuts it. + Consumer badge = page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Badge").circle(20).fillColor(INK) + .center(new ParagraphBuilder().text("W") + .textStyle(DocumentTextStyle.DEFAULT.withSize(30)).build()) + .build()) + .addParagraph("Below"); + DocxExportReport report = reportOf(badge); + DocxExportReport fitting = reportOf(page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Badge").circle(40).fillColor(INK) + .center(new ParagraphBuilder().text("JR").build()) + .build()) + .addParagraph("Below")); + + assertThat(bodyOf(badge)).as("written as a badge holding its letters") + .containsPattern(".*>W<"); + assertThat(detailOf(report, "clipped shape container")).isEqualTo(PAST_ITS_OUTLINE); + assertThat(fitting.bySubject()).doesNotContainKey("clipped shape container"); + } + + @Test + void aTitleAndItsDatesInAPillNameTheClipThatCutsTheTitle() throws Exception { + // Written as one line with a tab, set from the pill's left: its first letters, set past it, + // show. + Consumer pill = page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Pill").roundedRect(240, 20, 10).fillColor(SURFACE) + .clipPolicy(ClipPolicy.CLIP_PATH) + .position(new ParagraphBuilder().name("Title").text("Senior engineer").build(), + -20, 0, LayerAlign.CENTER_LEFT) + .position(new ParagraphBuilder().name("Dates").text("2020 - 2024").build(), + -8, 0, LayerAlign.CENTER_RIGHT) + .build()) + .addParagraph("Below"); + DocxExportReport report = reportOf(pill); + + assertThat(bodyOf(pill)).as("written as one line, the dates after a tab") + .containsPattern(">Senior engineer<.*.*>2020 - 2024<"); + assertThat(detailOf(report, "clipped shape container")).isEqualTo(PAST_ITS_OUTLINE); + } + + @Test + void aContainerLaidOverTheFlowNamesTheClipThatCutsItsOrnament() throws Exception { + // A sidebar the page gives no room, its ornament set past its side: LumaStudioInvoice's. + double stub = 120; + DocumentNode sidebar = new ShapeContainerBuilder().name("Sidebar") + .rectangle(100, stub).clipPolicy(ClipPolicy.CLIP_BOUNDS) + .margin(new DocumentInsets(-40, 0, 40 - stub, -40)) + .position(new ShapeBuilder().name("BrandBlock").size(100, stub).fillColor(INK).build(), + 0, 0, LayerAlign.TOP_LEFT, 0) + .position(new ShapeBuilder().name("Ornament").size(80, 80).fillColor(SURFACE).build(), + 60, 20, LayerAlign.TOP_LEFT, 1) + .position(new ParagraphBuilder().name("Monogram").text("L").build(), 10, 10, LayerAlign.TOP_LEFT, 2) + .build(); + DocxExportReport report = reportOf(page -> page.add(sidebar).addParagraph("Masthead")); + + assertThat(detailOf(report, "clipped shape container")).isEqualTo(PAST_ITS_BOX); + assertThat(report.bySubject().get("ParagraphNode")).extracting(DocxExportReport.Note::detail) + .as("written over the flow").anyMatch(detail -> detail.startsWith("laid over the flow")); + } + + @Test + void aClipComposedInATableCellIsNamedOnTheTable() throws Exception { + // Composed in a cell, a chip has no place of its own: its clip is among its table's + // fragments. A label wrapping below the chip runs past it. + DocxExportReport report = reportOf(page -> page.addTable(table -> table.name("Rota") + .columns(DocumentTableColumn.fixed(200)) + .rowCells(DocumentTableCell.node(chip("A long label run past its chip"))))); + DocxExportReport fitting = reportOf(page -> page.addTable(table -> table.name("Rota") + .columns(DocumentTableColumn.fixed(200)) + .rowCells(DocumentTableCell.node(chip("Early"))))); + + assertThat(detailOf(report, "clipped cell content")) + .isEqualTo("a clip composed in its cells is not in the file, so what is painted past it is written whole"); + assertThat(report.bySubject().get("clipped cell content").get(0).path()).endsWith("Rota[0]"); + assertThat(fitting.bySubject()).doesNotContainKey("clipped cell content"); + } + + @Test + void aTurnedContainerWhoseOutlineDrawsNothingNamesItsTransform() throws Exception { + DocxExportReport unpainted = reportOf(page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Turned").rectangle(120, 30) + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE).transform(DocumentTransform.rotate(30)) + .center(new ParagraphBuilder().text("Turned").build()).build()) + .addParagraph("Below")); + DocxExportReport painted = reportOf(page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Turned").rectangle(120, 30).fillColor(SURFACE) + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE).transform(DocumentTransform.rotate(30)) + .center(new ParagraphBuilder().text("Turned").build()).build()) + .addParagraph("Below")); + + assertThat(detailOf(unpainted, "ShapeContainerNode")) + .isEqualTo("its transform is not carried, so what it holds stands upright at its size"); + // Its drawn outline's note names the transform; nothing more is said of it. + assertThat(detailOf(painted, "ShapeContainerNode")) + .startsWith("drawn as a shape").contains("its transform is not carried, so it is drawn upright"); + } + + private static DocumentNode chip(String label) { + return new ShapeContainerBuilder().name("Chip").roundedRect(60, 10, 5).fillColor(SURFACE) + .clipPolicy(ClipPolicy.CLIP_PATH) + .center(new ParagraphBuilder().text(label).textStyle(DocumentTextStyle.DEFAULT.withSize(7)).build()) + .build(); + } + + private static DocumentNode box(double width, double height) { + return new ShapeBuilder().size(width, height).fillColor(INK).build(); + } + + private static String detailOf(DocxExportReport report, String subject) { + List notes = report.bySubject().get(subject); + assertThat(notes).as("the note on the %s", subject).isNotNull().hasSize(1); + return notes.get(0).detail(); + } + + private static DocxExportReport reportOf(Consumer content) throws Exception { + AtomicReference captured = new AtomicReference<>(); + try (DocumentSession session = GraphCompose.document() + .pageSize(400, 600) + .margin(DocumentInsets.of(40)) + .create()) { + session.pageFlow(content::accept); + session.export(new DocxSemanticBackend(captured::set)); + } + assertThat(captured.get()).as("the sink is called once the bytes exist").isNotNull(); + return captured.get(); + } + + private static String bodyOf(Consumer content) throws Exception { + try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(40)).create()) { + session.pageFlow(content::accept); + try (org.apache.poi.xwpf.usermodel.XWPFDocument document = new org.apache.poi.xwpf.usermodel.XWPFDocument( + new java.io.ByteArrayInputStream(session.export(new DocxSemanticBackend())))) { + return document.getDocument().getBody().xmlText(); + } + } + } + + private static byte[] png() { + try (java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) { + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(20, 20, + java.awt.image.BufferedImage.TYPE_INT_RGB), "png", out); + return out.toByteArray(); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java index fd0b2f88c..ee7e0238a 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java @@ -129,7 +129,7 @@ private record Entry(Fate fate, String note) { "margin:REPORTED:its left side; its right moves nothing in a paragraph set from the left", "transform:REPORTED", "anchor:WRITTEN"); node(LayerStackNode.class, "name:INERT", "layers:WRITTEN", "padding:WRITTEN", "margin:WRITTEN", - "clipToBounds:GAP:the clip"); + "clipToBounds:REPORTED:where it cuts what its layers paint"); node(LineNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "startX:WRITTEN", "startY:WRITTEN", "endX:WRITTEN", "endY:WRITTEN", "stroke:WRITTEN", "linkTarget:REPORTED", @@ -182,9 +182,9 @@ private record Entry(Fate fate, String note) { "bookmarkOptions:REPORTED", "keepWithNext:WRITTEN", "flowWidth:REPORTED:of an unpainted one, a panel in a table cell and a layer stack's column"); node(ShapeContainerNode.class, "name:INERT", "outline:WRITTEN", "layers:WRITTEN", - "clipPolicy:GAP:the clip of a container written as a badge, a line pair or over the flow", + "clipPolicy:REPORTED:where it cuts what its layers paint; composed in a table cell, on its table", "fillColor:WRITTEN", "stroke:WRITTEN", "padding:WRITTEN", "margin:WRITTEN", - "transform:GAP:the transform of a container whose outline paints nothing"); + "transform:REPORTED"); node(ShapeNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "fillColor:WRITTEN", "stroke:WRITTEN", "cornerRadius:REPORTED:unequal corners, drawn at the largest radius", "linkTarget:REPORTED", "bookmarkOptions:REPORTED", "padding:WRITTEN", From b09e9ac19ed29841aba96e722c26c11a90637d95 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 6 Oct 2026 13:10:29 +0100 Subject: [PATCH 2/3] fix(docx): measure a clip's cut with the JDK stroker, where the page draws a picture, and inside nested clips A stroke is the area java.awt.BasicStroke makes of it, as DocxShapePictures already strokes an inline shape, in place of geometry of its own. A contained picture is measured where it is drawn in its box; what a clip inside another cuts away is named on that clip only; a polygon or path clip indexes its edges by height. A standard face the PDF does not embed is measured by its line, its outlines being read through a stand-in in other units; any other face's letters count where they reach, past their line too. With no layout behind the export, every node that clips is named, its cut not measured. Nothing written changes. --- CHANGELOG.md | 20 +- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 18 +- .../backend/semantic/docx/DocxClipInk.java | 349 +++++++++++------- .../backend/semantic/docx/DocxInk.java | 24 ++ .../backend/semantic/docx/DocxInkOutline.java | 338 ++++++----------- .../semantic/docx/DocxSemanticBackend.java | 51 ++- .../semantic/docx/DocxClipInkTest.java | 42 ++- .../semantic/docx/DocxClipReportTest.java | 78 +++- .../backend/semantic/docx/DocxExports.java | 14 + .../docx/DocxNodeFieldLedgerTest.java | 2 +- 11 files changed, 547 insertions(+), 391 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b01d2bd8..6f1dc888e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,14 +19,20 @@ follow semantic versioning; release dates are ISO 8601. - on every path, a `clipped shape container` or `clipped layer stack` note names a clip that cuts what the node's layers paint, and a `clipped cell content` note on a table names one composed in its cells; - - what is painted is measured from the layout's fragments as the file draws them, upright - (`DocxClipInk`): a fill to its outline; a stroke half its width either side of its line, - with its caps, its joins and a box's side borders each drawn as the PDF draws them; a - picture cropped to the ellipse it fills as cropped; a line of text from its letters' tops - to their feet, read from their glyphs' outlines, or over its whole line where those are not - known; + - what is painted is measured from the layout's fragments as the page paints it, upright as + the file writes it (`DocxClipInk`): + - a fill to its outline, a gradient alone being none the file draws; + - a stroke as `java.awt.BasicStroke` makes it, with its cap, its join and the PDF's miter + limit, and a box's side borders each a line of its own; + - a picture where it is drawn, fitted in its box where it is contained, and cropped to the + ellipse it fills where the file crops it; + - a line of text from its letters' tops to their feet, read from their glyphs' outlines, or + over its whole line where those are not known — always for a standard face the PDF does + not embed, whose outlines are read through a stand-in font in its own units; + - what a clip inside another cuts away is named on that clip, not on the one round it; - a clip that cuts nothing is not named, and the `cell drawing` note no longer speaks of - clips; + clips; with no layout behind the export, every node that clips is named, its cut not + measured; - the note every shape container's layers were written with is now `shape container`: "its layers are written inline, one after another in source order", with no claim of a clip; - a shape container turned by a transform whose outline draws nothing — unpainted, or an diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 41603ed47..24c58db66 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -83,7 +83,7 @@ Payload records live in `core` under | Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders` — the engine's default 1pt black rule where the table states none, not Word's thinner grid — its padding to `w:tcMar`, less above and below the room Word makes for the horizontal rules (half of a rule between two rows, the lower row's, to each; the rules above and below the table whole to their row); a row's cells at the row's smallest top and bottom margins, since both editors give every cell the row's largest, the rest of each cell's padding as space above its first paragraph and below its last, down to the largest margin a cell opening with a table or in a vertical merge keeps; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, less its own margins and padding, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; the paragraph Word requires after a nested table is hidden where it ends its cell holding nothing and no space; a fill's opacity is dropped since `w:shd` is opaque; Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way; the paragraph Word requires after a document's closing table is an ordinary one where the last page has room for two lines below it, so a reader can type below the table, and otherwise a point tall with its mark hidden, so it opens no blank page, reported `APPROXIMATED` since text typed at the end then goes into the table's last cell) | | Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning; the report names a clip that cuts what its layers paint, measured from the layout's fragments by `DocxClipInk` — a clip that cuts nothing (an icon inside its box, a disc's initials, a photo filling its circle) is not named; a picture that fills a container clipped to an ellipse takes the ellipse as its geometry, which both editors crop it to; a badge's glyph — a smaller picture in a painted container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn by `DocxDrawings` as a floating picture over the outline, where the layout places it, reported `APPROXIMATED` — inside a filled panel the badge and its glyph are drawn in front of the shading; an icon picture beside its text in an unpainted container or a layer stack is drawn the same way; a filled or outlined rectangle or rounded rectangle holding text, composed in a table cell, which has no place in the layout to be drawn at, is written as a panel — a one-cell table in its fill and outline, its corners squared and reported, its row held at least the outline's height less the borders both editors draw outside it where its padding does not hold its top border, and a one-line label the shape centres top to bottom on a line taller than the room Word leaves its content cut alike on both sides to that room, no closer to its letters than three quarters of a point, and seated where the page sets it; the rest of what a composed cell draws (an icon, a tile, a disc) is the table's own drawing and is drawn by `drawCellDrawing` where the layout puts it, anchored as a rectangle is | | Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ `DocxDrawings` — the rail is read from the resolved layout's pass fragments and drawn per page as a `line` shape, and the markers as the shapes they are, anchored as a rectangle is: beside an entry's text they move with it when the text above is edited | -| Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning | +| Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning; a turned shape container is written upright, and the report names its transform — in its drawn outline's note, or on its own where the outline draws nothing | | Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ✅ `DocxSemanticBackend` — an anchor becomes a `w:bookmarkStart` / `w:bookmarkEnd` pair wrapping the paragraph's text, named as Word requires (letters, digits and underscores, starting with a letter, 40 characters); two anchors that clean to one name stay two bookmarks | | Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` + `PptxNavigationWriter` (PPTX has no outline tree — the first bookmark on a page names its slide, further bookmarks on the same page are dropped with a debug note) | ✅ `DocxSemanticBackend` — the stated outline level becomes Word's own `HeadingN` style, so the Navigation Pane, the outline view and a generated table of contents all see the document's structure. The style carries the outline level and no formatting, so the paragraph keeps the look its author gave it; only the levels the document uses are defined, and one past Word's nine is clamped. The role is never inferred from type size | | Alpha / opacity | ✅ `PdfAlphaSupport` (`PDExtendedGraphicsState` on every surface — shape fills/strokes, text runs, lines, side borders, table paint) | ✅ native `` via POI on every surface — fills, strokes, text runs, table paint | ❌ | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 98a711b21..0caf321b6 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -716,14 +716,16 @@ tint it was flattened to. That is recorded with the rest. a graphics-state path clip, so the container's layers are written inline, in source order, without clipping — again with one warning per export. The report names the clip — a shape container's, and a layer - stack's that clips to its bounds — wherever it cuts what the layers paint, - measured from the layout's fragments as the file draws them, upright, and - text from the outlines of its letters: a square tile's corners in a disc, a - label run past its chip, an ornament set past a sidebar's side — and one - composed in a table's cell, which has no place of its own, on its table. - One that cuts nothing — an icon inside its box, a disc's initials, a photo - filling its circle, a label whose line stands past its chip while its - letters stay inside — is not named. The outline is drawn as a shape where the page draws it — a + stack's that clips to its bounds — wherever it cuts what the layers paint: + a square tile's corners in a disc, a label run past its chip, an ornament + set past a sidebar's side. One composed in a table's cell, which has no + place of its own, is named on its table. What is painted is measured from + the layout's fragments as the page paints it, upright as the file writes + it, and text from the outlines of its letters. A clip that cuts nothing is + not named: an icon inside its box, a disc's initials, a photo filling its + circle, a label whose line stands past its chip while its letters stay + inside. Exported with no layout behind it, every node that clips is named. + The outline is drawn as a shape where the page draws it — a star, a diamond or a path as custom geometry — and a picture that fills a container clipped to an ellipse takes the ellipse's shape: a portrait is round inside its ring. A picture the layout placed is written the diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java index f785b5afd..6beaca2a1 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java @@ -1,5 +1,6 @@ package com.demcha.compose.document.backend.semantic.docx; +import com.demcha.compose.document.image.DocumentImageFitMode; import com.demcha.compose.document.layout.PlacedFragment; import com.demcha.compose.document.layout.payloads.AnchorMarkerPayload; import com.demcha.compose.document.layout.payloads.BookmarkMarkerPayload; @@ -23,9 +24,13 @@ import com.demcha.compose.document.style.DocumentLineCap; import com.demcha.compose.document.style.DocumentLineJoin; import com.demcha.compose.document.style.ShapeOutline; +import com.demcha.compose.engine.components.content.ImageData; import com.demcha.compose.engine.components.content.shape.Stroke; +import java.awt.Shape; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Deque; import java.util.List; import java.util.function.Predicate; @@ -38,14 +43,17 @@ * a label longer than its chip. Most clips cut nothing — an icon drawn inside its box, a disc's * initials, a chip's label whose line stands past the chip while its letters stay inside.

* - *

What is painted is measured from the layout's fragments as the Word file draws them, - * upright, a transform not being carried: a fill to its outline, a fill of a gradient alone not - * at all, as the file draws none; a stroke as the PDF paints it ({@link DocxInkOutline}), a box's - * side borders each a line of its own, ended flat at its corners; a picture to its box, or to the - * ellipse the file crops it to; and a line of text across the width it was set at and from its - * letters' tops to their feet, read from the outlines of its glyphs (see {@link DocxInk}), or - * over its whole line where those are not known. Ink within half a point of the clip is not - * counted as cut.

+ *

What is painted is measured from the layout's fragments as the page paints it + * ({@link DocxInkOutline}), upright, as the file writes it, a transform not being carried: a + * fill to its outline, and a fill of a gradient alone not at all, the file drawing none; a + * stroke with its cap and its join; a box's side borders each a line of its own, ended flat at + * its corners; a picture to the box it is drawn in — fitted inside its own where it is + * contained — or to the ellipse the file crops it to; and a line of text across the width it was + * set at and from its letters' tops to their feet, read from the outlines of its glyphs (see + * {@link DocxInk}), or over its whole line where those are not known. What a clip inside it cuts + * away is that clip's loss, not this one's. Ink within half a point of the clip is not counted as + * cut. A highlight's chip behind its run, a table row's border and a fill over a hole in a clip + * are not measured.

* *

{@code PptxClipSafety} asks the stricter question of the same fragments — whether a clip * provably cuts nothing, so that a slide may keep its shapes — and so takes any stroked path, and @@ -55,7 +63,10 @@ final class DocxClipInk { /** How far ink may stand past a clip before the clip is taken to cut it. */ - static final double TOLERANCE = 0.5; + private static final double TOLERANCE = 0.5; + + /** The most bands a clip's outline is indexed in, by height. */ + private static final int MAX_BANDS = 256; private DocxClipInk() { } @@ -88,9 +99,22 @@ interface LetterReach { static boolean cuts(PlacedFragment clip, List painted, Predicate croppedToAnEllipse, LetterReach letters) { Region region = regionOf((ShapeClipBeginPayload) clip.payload(), clip); + // The clips opened inside this one, innermost first: what they cut away the page never + // paints, so only what stands inside all of them counts here. + Deque inner = new ArrayDeque<>(); for (PlacedFragment fragment : painted) { + if (fragment.payload() instanceof ShapeClipBeginPayload begin) { + inner.push(new Inner(begin.ownerPath(), regionOf(begin, fragment))); + continue; + } + if (fragment.payload() instanceof ShapeClipEndPayload end) { + if (!inner.isEmpty() && inner.peek().ownerPath().equals(end.ownerPath())) { + inner.pop(); + } + continue; + } for (double[] point : inkOf(fragment, croppedToAnEllipse.test(fragment), letters)) { - if (!region.holds(point[0], point[1])) { + if (keptByEvery(inner, point) && !region.holds(point[0], point[1])) { return true; } } @@ -104,6 +128,19 @@ private interface Region { boolean holds(double x, double y); } + /** A clip opened inside the one measured. */ + private record Inner(String ownerPath, Region region) { + } + + private static boolean keptByEvery(Deque inner, double[] point) { + for (Inner clip : inner) { + if (!clip.region().holds(point[0], point[1])) { + return false; + } + } + return true; + } + private static Region regionOf(ShapeClipBeginPayload clip, PlacedFragment box) { double left = box.x(); double bottom = box.y(); @@ -131,28 +168,10 @@ private static Region regionOf(ShapeClipBeginPayload clip, PlacedFragment box) { return roundedRegion(bounds, left, bottom, right, top, corners.topLeft(), corners.topRight(), corners.bottomRight(), corners.bottomLeft()); } - List> rings = new ArrayList<>(); - if (outline instanceof ShapeOutline.Polygon polygon) { - rings.add(DocxInkOutline.ring(polygon.points(), box)); - } else { - for (DocxInkOutline.Run run : DocxInkOutline.flatten(((ShapeOutline.Path) outline).segments(), box)) { - rings.add(run.points()); - } - } - // Past the outline's own box a point is outside it, without walking its edges. - double[] extent = {Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY, - Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY}; - for (List ring : rings) { - for (double[] point : ring) { - extent[0] = Math.min(extent[0], point[0]); - extent[1] = Math.min(extent[1], point[1]); - extent[2] = Math.max(extent[2], point[0]); - extent[3] = Math.max(extent[3], point[1]); - } - } - return (x, y) -> x >= extent[0] - TOLERANCE && x <= extent[2] + TOLERANCE - && y >= extent[1] - TOLERANCE && y <= extent[3] + TOLERANCE - && (winding(rings, x, y) != 0 || nearAnEdge(rings, x, y)); + Shape shape = outline instanceof ShapeOutline.Polygon polygon + ? DocxInkOutline.polygon(polygon.points(), box) + : DocxInkOutline.path(((ShapeOutline.Path) outline).segments(), box); + return new Edges(DocxInkOutline.rings(shape))::holds; } /** A box whose corners are rounded, each radius clamped to half the smaller side. */ @@ -186,83 +205,108 @@ private static Region roundedRegion(Region bounds, double left, double bottom, d private static List inkOf(PlacedFragment fragment, boolean croppedToAnEllipse, LetterReach letters) { Object payload = fragment.payload(); List ink = new ArrayList<>(); - if (payload instanceof ShapeClipBeginPayload || payload instanceof ShapeClipEndPayload - || payload instanceof TransformBeginPayload || payload instanceof TransformEndPayload + if (payload instanceof TransformBeginPayload || payload instanceof TransformEndPayload || payload instanceof AnchorMarkerPayload || payload instanceof BookmarkMarkerPayload || payload instanceof LayoutAnchorPayload) { return ink; } if (payload instanceof ShapeFragmentPayload shape) { - DocumentCornerRadius radius = shape.cornerRadius() == null ? DocumentCornerRadius.ZERO : shape.cornerRadius(); - if (shape.fillColor() != null) { - DocxInkOutline.roundedBox(ink, fragment, 0, radius); - } - SideBorders sides = shape.sideBorders(); - if (sides != null && sides.hasAny()) { - // Each side is a line of its own, ended flat at the box's corners, in place of - // the stroke round it. - double left = fragment.x(); - double bottom = fragment.y(); - double right = left + fragment.width(); - double top = bottom + fragment.height(); - side(ink, sides.top(), left, top, right, top); - side(ink, sides.right(), right, top, right, bottom); - side(ink, sides.bottom(), left, bottom, right, bottom); - side(ink, sides.left(), left, top, left, bottom); - } else if (halfOf(shape.stroke()) > 0) { - DocxInkOutline.roundedBox(ink, fragment, halfOf(shape.stroke()), radius); - } + box(ink, fragment, shape); } else if (payload instanceof EllipseFragmentPayload ellipse) { - double reach = halfOf(ellipse.stroke()); - if (ellipse.fillColor() != null || reach > 0) { - DocxInkOutline.ellipse(ink, fragment, reach); + Shape outline = DocxInkOutline.ellipse(fragment.x(), fragment.y(), fragment.width(), fragment.height()); + if (ellipse.fillColor() != null) { + DocxInkOutline.filled(ink, outline); } + DocxInkOutline.stroked(ink, outline, widthOf(ellipse.stroke()), DocumentLineCap.BUTT, DocumentLineJoin.MITER); } else if (payload instanceof LineFragmentPayload line) { - double half = halfOf(line.stroke()); - if (half > 0) { - DocxInkOutline.stroke(ink, List.of( - new double[]{fragment.x() + line.startX(), fragment.y() + line.startY()}, - new double[]{fragment.x() + line.endX(), fragment.y() + line.endY()}), - false, half, line.lineCap(), DocumentLineJoin.MITER); - } + DocxInkOutline.stroked(ink, DocxInkOutline.line(fragment.x() + line.startX(), fragment.y() + line.startY(), + fragment.x() + line.endX(), fragment.y() + line.endY()), + widthOf(line.stroke()), line.lineCap(), DocumentLineJoin.MITER); } else if (payload instanceof PathFragmentPayload path) { - double half = halfOf(path.stroke()); - for (DocxInkOutline.Run run : DocxInkOutline.flatten(path.segments(), fragment)) { - if (path.fillColor() != null) { - DocxInkOutline.filled(ink, run.points()); - } - if (half > 0) { - DocxInkOutline.stroke(ink, run.points(), run.closed(), half, path.lineCap(), path.lineJoin()); - } + Shape outline = DocxInkOutline.path(path.segments(), fragment); + if (path.fillColor() != null) { + DocxInkOutline.filled(ink, outline); } + DocxInkOutline.stroked(ink, outline, widthOf(path.stroke()), path.lineCap(), path.lineJoin()); } else if (payload instanceof PolygonFragmentPayload polygon) { - double half = halfOf(polygon.stroke()); - if (!polygon.points().isEmpty()) { - List ring = DocxInkOutline.ring(polygon.points(), fragment); - if (polygon.fillColor() != null) { - DocxInkOutline.filled(ink, ring); - } - if (half > 0) { - DocxInkOutline.stroke(ink, ring, true, half, DocumentLineCap.BUTT, DocumentLineJoin.MITER); - } + Shape outline = DocxInkOutline.polygon(polygon.points(), fragment); + if (polygon.fillColor() != null) { + DocxInkOutline.filled(ink, outline); } + DocxInkOutline.stroked(ink, outline, widthOf(polygon.stroke()), DocumentLineCap.BUTT, DocumentLineJoin.MITER); } else if (payload instanceof ParagraphFragmentPayload paragraph) { text(ink, fragment, paragraph, letters); - } else if (payload instanceof ImageFragmentPayload && croppedToAnEllipse) { - DocxInkOutline.ellipse(ink, fragment, 0); + } else if (payload instanceof ImageFragmentPayload image) { + double[] drawn = drawn(fragment, image); + DocxInkOutline.filled(ink, croppedToAnEllipse + ? DocxInkOutline.ellipse(drawn[0], drawn[1], drawn[2], drawn[3]) + : DocxInkOutline.box(drawn[0], drawn[1], drawn[0] + drawn[2], drawn[1] + drawn[3])); } else { - // A picture, a barcode, a table's row, and anything else: its box. - DocxInkOutline.roundedBox(ink, fragment, 0, DocumentCornerRadius.ZERO); + // A barcode, a table's row, and anything else: its box. + DocxInkOutline.filled(ink, DocxInkOutline.box(fragment, DocumentCornerRadius.ZERO)); } return ink; } + /** + * A box's paint, as the page paints it: none for a box of no size; its fill, a fill of a + * gradient alone being none the file draws; and either each of its side borders as a line of + * its own, ended flat at its corners, or its stroke round it. + */ + private static void box(List ink, PlacedFragment fragment, ShapeFragmentPayload shape) { + if (!(fragment.width() > 0) || !(fragment.height() > 0)) { + return; + } + DocumentCornerRadius radius = shape.cornerRadius() == null ? DocumentCornerRadius.ZERO : shape.cornerRadius(); + Shape outline = DocxInkOutline.box(fragment, radius); + if (shape.fillColor() != null) { + DocxInkOutline.filled(ink, outline); + } + SideBorders sides = shape.sideBorders(); + if (sides != null && sides.hasAny()) { + double left = fragment.x(); + double bottom = fragment.y(); + double right = left + fragment.width(); + double top = bottom + fragment.height(); + side(ink, sides.top(), left, top, right, top); + side(ink, sides.right(), right, top, right, bottom); + side(ink, sides.bottom(), left, bottom, right, bottom); + side(ink, sides.left(), left, top, left, bottom); + } else { + DocxInkOutline.stroked(ink, outline, widthOf(shape.stroke()), DocumentLineCap.BUTT, DocumentLineJoin.MITER); + } + } + + private static void side(List ink, Stroke stroke, double x1, double y1, double x2, double y2) { + DocxInkOutline.stroked(ink, DocxInkOutline.line(x1, y1, x2, y2), widthOf(stroke), + DocumentLineCap.BUTT, DocumentLineJoin.MITER); + } + + /** + * Where the page draws a picture, {@code {x, y, width, height}}: fitted inside its box and + * centred there where it is contained, as the file writes it at that size too; across its box + * otherwise, covering it or stretched over it. + */ + private static double[] drawn(PlacedFragment fragment, ImageFragmentPayload image) { + double[] box = {fragment.x(), fragment.y(), fragment.width(), fragment.height()}; + ImageData data = image.imageData(); + if (image.fitMode() != DocumentImageFitMode.CONTAIN || data == null || data.getMetadata() == null) { + return box; + } + double sourceWidth = Math.max(1, data.getMetadata().width()); + double sourceHeight = Math.max(1, data.getMetadata().height()); + double scale = Math.min(fragment.width() / sourceWidth, fragment.height() / sourceHeight); + double width = sourceWidth * scale; + double height = sourceHeight * scale; + return new double[]{fragment.x() + (fragment.width() - width) / 2, + fragment.y() + (fragment.height() - height) / 2, width, height}; + } + /** * Each line of a paragraph, across the width it was set at and from its letters' tops to - * their feet on the baseline the page sets it on — over its whole line where its letters' - * reach is not known, or is said to run past the line the layout measured for them: an - * outline read in other units than the layout's, as a face the PDF stands another in for - * gives, is not to be trusted. + * their feet on the baseline the page sets it on, wherever that is — past the line, for a + * title set tighter than its face or a line seated at its foot — or over its whole line where + * its letters' reach is not known. */ private static void text(List ink, PlacedFragment fragment, ParagraphFragmentPayload paragraph, LetterReach letters) { @@ -275,74 +319,109 @@ private static void text(List ink, PlacedFragment fragment, ParagraphF for (ParagraphLine line : paragraph.lines()) { if (line.width() > 0) { double start = ParagraphLineGeometry.lineStartX(paragraph.align(), innerX, innerWidth, line.width()); - double lineBottom = lineTop - line.lineHeight(); - double baseline = ParagraphLineGeometry.baselineY(lineTop, line.lineHeight(), - line.baselineOffsetFromBottom()); double[] reach = letters.of(paragraph, line); - if (reach == null || baseline + reach[0] > lineTop + TOLERANCE - || baseline - reach[1] < lineBottom - TOLERANCE) { - DocxInkOutline.box(ink, start, lineBottom, start + line.width(), lineTop); + if (reach == null) { + DocxInkOutline.filled(ink, DocxInkOutline.box(start, lineTop - line.lineHeight(), + start + line.width(), lineTop)); } else if (reach[0] + reach[1] > 0) { - DocxInkOutline.box(ink, start, baseline - reach[1], start + line.width(), baseline + reach[0]); + double baseline = ParagraphLineGeometry.baselineY(lineTop, line.lineHeight(), + line.baselineOffsetFromBottom()); + DocxInkOutline.filled(ink, DocxInkOutline.box(start, baseline - reach[1], + start + line.width(), baseline + reach[0])); } } lineTop = ParagraphLineGeometry.nextLineTop(lineTop, line.lineHeight(), paragraph.lineGap()); } } - /** One side of a box, drawn as a line of its own. */ - private static void side(List ink, Stroke stroke, double x1, double y1, double x2, double y2) { - double half = halfOf(stroke); - if (half > 0) { - DocxInkOutline.stroke(ink, List.of(new double[]{x1, y1}, new double[]{x2, y2}), false, half, - DocumentLineCap.BUTT, DocumentLineJoin.MITER); + /** + * The edges of a clip's outline, indexed by height so that a point is tested against the + * edges at its own height rather than every edge of a long path. + */ + private static final class Edges { + private final double left; + private final double bottom; + private final double right; + private final double top; + private final double bandHeight; + private final List> bands = new ArrayList<>(); + + Edges(List> rings) { + List edges = new ArrayList<>(); + double minX = Double.POSITIVE_INFINITY; + double minY = Double.POSITIVE_INFINITY; + double maxX = Double.NEGATIVE_INFINITY; + double maxY = Double.NEGATIVE_INFINITY; + for (List ring : rings) { + for (int index = 0; index < ring.size(); index++) { + double[] from = ring.get(index); + double[] to = ring.get((index + 1) % ring.size()); + edges.add(new double[]{from[0], from[1], to[0], to[1]}); + minX = Math.min(minX, from[0]); + minY = Math.min(minY, from[1]); + maxX = Math.max(maxX, from[0]); + maxY = Math.max(maxY, from[1]); + } + } + left = minX; + bottom = minY; + right = maxX; + top = maxY; + int count = Math.max(1, Math.min(MAX_BANDS, edges.size() / 8)); + bandHeight = maxY > minY ? (maxY - minY) / count : 1; + for (int band = 0; band < count; band++) { + bands.add(new ArrayList<>()); + } + for (double[] edge : edges) { + int from = band(Math.min(edge[1], edge[3]) - TOLERANCE); + int to = band(Math.max(edge[1], edge[3]) + TOLERANCE); + for (int band = from; band <= to; band++) { + bands.get(band).add(edge); + } + } + } + + private int band(double y) { + return Math.max(0, Math.min(bands.size() - 1, (int) Math.floor((y - bottom) / bandHeight))); } - } - /** The non-zero winding number of the rings round a point. */ - private static int winding(List> rings, double x, double y) { - int winding = 0; - for (List ring : rings) { - for (int index = 0; index < ring.size(); index++) { - double[] from = ring.get(index); - double[] to = ring.get((index + 1) % ring.size()); - if (from[1] <= y) { - if (to[1] > y && cross(from, to, x, y) > 0) { + /** Inside the outline by the non-zero rule, or within the tolerance of its edge. */ + boolean holds(double x, double y) { + if (!(x >= left - TOLERANCE && x <= right + TOLERANCE && y >= bottom - TOLERANCE && y <= top + TOLERANCE)) { + return false; + } + int winding = 0; + for (double[] edge : bands.get(band(y))) { + if (nearTheEdge(edge, x, y)) { + return true; + } + if (edge[1] <= y) { + if (edge[3] > y && cross(edge, x, y) > 0) { winding++; } - } else if (to[1] <= y && cross(from, to, x, y) < 0) { + } else if (edge[3] <= y && cross(edge, x, y) < 0) { winding--; } } + return winding != 0; } - return winding; - } - /** Which side of the line from one point to another a point stands, by sign. */ - private static double cross(double[] from, double[] to, double x, double y) { - return (to[0] - from[0]) * (y - from[1]) - (x - from[0]) * (to[1] - from[1]); - } + /** Which side of an edge a point stands, by sign. */ + private static double cross(double[] edge, double x, double y) { + return (edge[2] - edge[0]) * (y - edge[1]) - (x - edge[0]) * (edge[3] - edge[1]); + } - /** Whether a point is within the tolerance of an edge of the rings. */ - private static boolean nearAnEdge(List> rings, double x, double y) { - for (List ring : rings) { - for (int index = 0; index < ring.size(); index++) { - double[] from = ring.get(index); - double[] to = ring.get((index + 1) % ring.size()); - double dx = to[0] - from[0]; - double dy = to[1] - from[1]; - double length = dx * dx + dy * dy; - double t = length == 0 ? 0 : Math.max(0, Math.min(1, ((x - from[0]) * dx + (y - from[1]) * dy) / length)); - if (Math.hypot(x - from[0] - t * dx, y - from[1] - t * dy) <= TOLERANCE) { - return true; - } - } + private static boolean nearTheEdge(double[] edge, double x, double y) { + double dx = edge[2] - edge[0]; + double dy = edge[3] - edge[1]; + double length = dx * dx + dy * dy; + double t = length == 0 ? 0 : Math.max(0, Math.min(1, ((x - edge[0]) * dx + (y - edge[1]) * dy) / length)); + return Math.hypot(x - edge[0] - t * dx, y - edge[1] - t * dy) <= TOLERANCE; } - return false; } - private static double halfOf(Stroke stroke) { - return stroke == null ? 0 : Math.max(0, stroke.width()) / 2; + private static double widthOf(Stroke stroke) { + return stroke == null ? 0 : Math.max(0, stroke.width()); } private static double square(double value) { diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInk.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInk.java index 4bce5386e..04593c530 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInk.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInk.java @@ -6,6 +6,7 @@ import com.demcha.compose.engine.render.pdf.PdfFont; import com.demcha.compose.font.FontLibrary; import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType1Font; import org.apache.pdfbox.pdmodel.font.PDVectorFont; import java.awt.geom.Rectangle2D; @@ -73,4 +74,27 @@ static double[] of(ParagraphLine line, FontLibrary fonts) { } return new double[]{above, below}; } + + /** + * Whether the letters of a line are read in the units the layout measured them in: not + * where a face is a Type 1 face the PDF does not embed, as the standard fonts are. PDFBox + * reads such a face's outlines through a font standing in for it, in that font's own units + * — Helvetica's capitals came out twice as tall through a 2048-unit stand-in — and which + * stand-in it finds depends on the fonts installed where it runs. + * + * @param line the laid-out line + * @param fonts the fonts the layout measured it with + */ + static boolean readInTheLayoutsUnits(ParagraphLine line, FontLibrary fonts) { + for (ParagraphSpan span : line.spans()) { + if (span instanceof ParagraphTextSpan text) { + PdfFont font = fonts.getFont(text.textStyle().fontName(), PdfFont.class).orElse(null); + if (font != null && font.fontType(text.textStyle().decoration()) instanceof PDType1Font type1 + && !type1.isEmbedded()) { + return false; + } + } + } + return true; + } } diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInkOutline.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInkOutline.java index 9429bb5c3..d5dc77858 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInkOutline.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInkOutline.java @@ -7,266 +7,169 @@ import com.demcha.compose.document.style.DocumentPathSegment; import com.demcha.compose.document.style.ShapePoint; +import java.awt.BasicStroke; +import java.awt.Shape; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Line2D; +import java.awt.geom.Path2D; +import java.awt.geom.PathIterator; +import java.awt.geom.Rectangle2D; import java.util.ArrayList; import java.util.List; /** - * Where a drawing's paint runs: points along the outer edge of a fill or a stroke, as the PDF - * paints it, measured on the page with y up (see {@link DocxClipInk}). + * Where a drawing's paint runs, as the PDF paints it: the outlines of what it fills and strokes, + * built as the page builds them and measured on the page with y up (see {@link DocxClipInk}). * - *

A fill runs to its outline. A stroke runs half its width either side of each of its lines; - * round a round join, out to the point of a mitred one where the PDF's miter limit keeps it, and - * no further than its lines' edges at a bevel; at an open run's ends, nothing past a butt cap, - * half its width past a square one and round a round one. Points along an edge stand no further - * apart than {@link #STEP}, and a curve is taken at {@link #CURVE_STEPS} points along it.

+ *

A stroke is the area {@link BasicStroke} makes of it — with its cap, its join and the PDF's + * miter limit of 10, as {@code DocxShapePictures} strokes an inline shape — rather than geometry + * of its own. Outlines are flattened, and points along them stand no further apart than + * {@link #STEP}, so a clip that is not convex is measured along each edge too.

*/ final class DocxInkOutline { - /** The PDF's default miter limit, which the page paints every join with. */ - private static final double MITER_LIMIT = 10; + /** The PDF's default miter limit, which the page strokes every join with. */ + private static final float MITER_LIMIT = 10f; + + /** How far a flattened curve may stray from the true one, in points. */ + private static final double FLATNESS = 0.01; /** The longest step between two points measured along an edge. */ private static final double STEP = 2; - /** The points a cubic curve is measured at. */ - private static final int CURVE_STEPS = 16; - - /** The points an ellipse or a round end is measured at. */ - private static final int ROUND_STEPS = 32; - - /** The cosine of the least turn a round join is measured round: ten degrees. */ - private static final double MIN_TURN_COSINE = Math.cos(Math.toRadians(10)); + /** The control distance of a quarter circle's Bézier arc, as the PDF draws a rounded corner. */ + private static final double ARC = 0.552284749831; private DocxInkOutline() { } - /** - * A sub-path, as the points it runs through. - * - * @param points the points, a curve's taken along its length - * @param closed whether a close ends it - */ - record Run(List points, boolean closed) { + /** Points along the edge of a filled shape. */ + static void filled(List ink, Shape shape) { + for (List ring : rings(shape)) { + for (int index = 0; index < ring.size(); index++) { + edge(ink, ring.get(index), ring.get((index + 1) % ring.size())); + } + } } - /** The edge of a filled run: the points along it, its closing edge with them. */ - static void filled(List ink, List points) { - for (int index = 0; index < points.size(); index++) { - edge(ink, points.get(index), points.get((index + 1) % points.size())); + /** Points along the edge of the area a stroke of {@code width} paints along a shape. */ + static void stroked(List ink, Shape shape, double width, DocumentLineCap cap, DocumentLineJoin join) { + if (width > 0) { + filled(ink, new BasicStroke((float) width, capOf(cap), joinOf(join), MITER_LIMIT).createStrokedShape(shape)); } } /** - * The edge of a stroke along a run of points. A run of one point is a dot, which a round or - * square cap draws. - * - * @param half the stroke's half width + * A shape's sub-paths, flattened, each as the points it runs through; a sub-path's close + * is implied. */ - static void stroke(List ink, List points, boolean closed, double half, - DocumentLineCap cap, DocumentLineJoin join) { - List run = new ArrayList<>(); - for (double[] point : points) { - if (run.isEmpty() || !same(run.get(run.size() - 1), point)) { - run.add(point); - } - } - if (closed && run.size() > 1 && same(run.get(0), run.get(run.size() - 1))) { - run.remove(run.size() - 1); - } - if (run.isEmpty()) { - return; - } - if (run.size() == 1) { - if (!closed) { - end(ink, run.get(0), new double[]{1, 0}, half, cap); - end(ink, run.get(0), new double[]{-1, 0}, half, cap); - } - return; - } - int count = run.size(); - int lines = closed ? count : count - 1; - for (int index = 0; index < lines; index++) { - double[] from = run.get(index); - double[] to = run.get((index + 1) % count); - double[] normal = normal(from, to); - List along = new ArrayList<>(); - edge(along, from, to); - along.add(to); - for (double[] point : along) { - ink.add(new double[]{point[0] + normal[0] * half, point[1] + normal[1] * half}); - ink.add(new double[]{point[0] - normal[0] * half, point[1] - normal[1] * half}); - } - } - for (int index = closed ? 0 : 1; index < (closed ? count : count - 1); index++) { - double[] before = run.get((index - 1 + count) % count); - double[] corner = run.get(index); - double[] after = run.get((index + 1) % count); - if (join == DocumentLineJoin.ROUND) { - // Where a curve's points turn by a few degrees, the two lines' edges already - // meet its arc within a few hundredths of the stroke's half width. - if (turns(before, corner, after)) { - round(ink, corner, half); - } - } else if (join == DocumentLineJoin.MITER) { - miterTip(ink, before, corner, after, half); + static List> rings(Shape shape) { + List> rings = new ArrayList<>(); + List current = null; + double[] point = new double[6]; + for (PathIterator it = shape.getPathIterator(null, FLATNESS); !it.isDone(); it.next()) { + int segment = it.currentSegment(point); + if (segment == PathIterator.SEG_MOVETO) { + current = new ArrayList<>(); + rings.add(current); + current.add(new double[]{point[0], point[1]}); + } else if (segment == PathIterator.SEG_LINETO && current != null) { + current.add(new double[]{point[0], point[1]}); } } - if (!closed) { - end(ink, run.get(0), direction(run.get(1), run.get(0)), half, cap); - end(ink, run.get(count - 1), direction(run.get(count - 2), run.get(count - 1)), half, cap); - } + return rings; } - /** - * A box grown by {@code reach}: a rounded corner round its arc, its radius grown the same, and - * a square one to its point, as a mitred stroke draws it. - */ - static void roundedBox(List ink, PlacedFragment fragment, double reach, DocumentCornerRadius radius) { - double left = fragment.x(); - double bottom = fragment.y(); - double right = left + fragment.width(); - double top = bottom + fragment.height(); + /** A box, its corners rounded as the PDF rounds them, each radius clamped to half its smaller side. */ + static Shape box(PlacedFragment fragment, DocumentCornerRadius radius) { + double x = fragment.x(); + double y = fragment.y(); + double right = x + fragment.width(); + double top = y + fragment.height(); double half = Math.min(fragment.width(), fragment.height()) / 2; - double[][] corners = { - {right, top, 1, 1, Math.min(radius.topRight(), half)}, - {left, top, -1, 1, Math.min(radius.topLeft(), half)}, - {left, bottom, -1, -1, Math.min(radius.bottomLeft(), half)}, - {right, bottom, 1, -1, Math.min(radius.bottomRight(), half)}}; - List ring = new ArrayList<>(); - for (double[] corner : corners) { - double r = Math.max(0, corner[4]); - if (r == 0) { - ring.add(new double[]{corner[0] + corner[2] * reach, corner[1] + corner[3] * reach}); - continue; - } - double cx = corner[0] - corner[2] * r; - double cy = corner[1] - corner[3] * r; - for (int step = 0; step <= ROUND_STEPS / 4; step++) { - double angle = Math.PI / 2 * step / (ROUND_STEPS / 4); - ring.add(new double[]{cx + corner[2] * (r + reach) * Math.cos(angle), - cy + corner[3] * (r + reach) * Math.sin(angle)}); - } - } - filled(ink, ring); + double topLeft = clamp(radius.topLeft(), half); + double topRight = clamp(radius.topRight(), half); + double bottomRight = clamp(radius.bottomRight(), half); + double bottomLeft = clamp(radius.bottomLeft(), half); + Path2D.Double path = new Path2D.Double(); + path.moveTo(x + topLeft, top); + path.lineTo(right - topRight, top); + path.curveTo(right - topRight + topRight * ARC, top, right, top - topRight + topRight * ARC, + right, top - topRight); + path.lineTo(right, y + bottomRight); + path.curveTo(right, y + bottomRight - bottomRight * ARC, right - bottomRight + bottomRight * ARC, y, + right - bottomRight, y); + path.lineTo(x + bottomLeft, y); + path.curveTo(x + bottomLeft - bottomLeft * ARC, y, x, y + bottomLeft - bottomLeft * ARC, x, y + bottomLeft); + path.lineTo(x, top - topLeft); + path.curveTo(x, top - topLeft + topLeft * ARC, x + topLeft - topLeft * ARC, top, x + topLeft, top); + path.closePath(); + return path; } - static void box(List ink, double left, double bottom, double right, double top) { - filled(ink, List.of(new double[]{left, bottom}, new double[]{right, bottom}, - new double[]{right, top}, new double[]{left, top})); + static Shape box(double left, double bottom, double right, double top) { + return new Rectangle2D.Double(left, bottom, right - left, top - bottom); } - /** The ellipse a box holds, its axes grown by {@code reach}. */ - static void ellipse(List ink, PlacedFragment fragment, double reach) { - double a = fragment.width() / 2 + reach; - double b = fragment.height() / 2 + reach; - double cx = fragment.x() + fragment.width() / 2; - double cy = fragment.y() + fragment.height() / 2; - int steps = ROUND_STEPS * 2; - for (int step = 0; step < steps; step++) { - double angle = 2 * Math.PI * step / steps; - ink.add(new double[]{cx + a * Math.cos(angle), cy + b * Math.sin(angle)}); - } + static Shape ellipse(double left, double bottom, double width, double height) { + return new Ellipse2D.Double(left, bottom, width, height); } - /** A polygon's normalized points, set in a box. */ - static List ring(List points, PlacedFragment box) { - List ring = new ArrayList<>(points.size()); + static Shape line(double x1, double y1, double x2, double y2) { + return new Line2D.Double(x1, y1, x2, y2); + } + + /** A polygon's normalized points, set in a box and closed. */ + static Shape polygon(List points, PlacedFragment box) { + Path2D.Double path = new Path2D.Double(Path2D.WIND_NON_ZERO); for (ShapePoint point : points) { - ring.add(new double[]{box.x() + point.x() * box.width(), box.y() + point.y() * box.height()}); + double x = box.x() + point.x() * box.width(); + double y = box.y() + point.y() * box.height(); + if (path.getCurrentPoint() == null) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + if (path.getCurrentPoint() != null) { + path.closePath(); } - return ring; + return path; } - /** A path's normalized segments, set in a box, each sub-path as the points it runs through. */ - static List flatten(List segments, PlacedFragment box) { - List runs = new ArrayList<>(); - List current = null; - double[] start = null; + /** A path's normalized segments, set in a box; what comes before its first move draws nothing. */ + static Shape path(List segments, PlacedFragment box) { + Path2D.Double path = new Path2D.Double(Path2D.WIND_NON_ZERO); for (DocumentPathSegment segment : segments) { if (segment instanceof DocumentPathSegment.MoveTo move) { - // A move alone draws nothing; a sub-path needs a segment. - if (current != null && current.size() > 1) { - runs.add(new Run(current, false)); - } - start = at(box, move.x(), move.y()); - current = new ArrayList<>(); - current.add(start); - } else if (current == null) { - // A path opens with a move; anything before one draws nothing. + path.moveTo(xOf(box, move.x()), yOf(box, move.y())); + } else if (path.getCurrentPoint() == null) { continue; } else if (segment instanceof DocumentPathSegment.LineTo line) { - current.add(at(box, line.x(), line.y())); + path.lineTo(xOf(box, line.x()), yOf(box, line.y())); } else if (segment instanceof DocumentPathSegment.CubicTo cubic) { - double[] from = current.get(current.size() - 1); - double[] first = at(box, cubic.control1X(), cubic.control1Y()); - double[] second = at(box, cubic.control2X(), cubic.control2Y()); - double[] to = at(box, cubic.x(), cubic.y()); - for (int step = 1; step <= CURVE_STEPS; step++) { - double t = (double) step / CURVE_STEPS; - double u = 1 - t; - current.add(new double[]{ - u * u * u * from[0] + 3 * u * u * t * first[0] + 3 * u * t * t * second[0] + t * t * t * to[0], - u * u * u * from[1] + 3 * u * u * t * first[1] + 3 * u * t * t * second[1] + t * t * t * to[1]}); - } + path.curveTo(xOf(box, cubic.control1X()), yOf(box, cubic.control1Y()), + xOf(box, cubic.control2X()), yOf(box, cubic.control2Y()), + xOf(box, cubic.x()), yOf(box, cubic.y())); } else if (segment instanceof DocumentPathSegment.Close) { - current.add(start); - runs.add(new Run(current, true)); - // What follows a close without a move starts again where the sub-path did. - current = new ArrayList<>(); - current.add(start); + path.closePath(); } } - if (current != null && current.size() > 1) { - runs.add(new Run(current, false)); - } - return runs; + return path; } - /** Whether a run turns at a point by more than ten degrees. */ - private static boolean turns(double[] before, double[] corner, double[] after) { - double[] in = direction(before, corner); - double[] out = direction(corner, after); - return in[0] * out[0] + in[1] * out[1] < MIN_TURN_COSINE; - } - - /** The end of an open stroke, running out along {@code outward} from its last point. */ - private static void end(List ink, double[] point, double[] outward, double half, DocumentLineCap cap) { + private static int capOf(DocumentLineCap cap) { if (cap == DocumentLineCap.ROUND) { - round(ink, point, half); - } else if (cap == DocumentLineCap.SQUARE) { - double x = point[0] + outward[0] * half; - double y = point[1] + outward[1] * half; - ink.add(new double[]{x - outward[1] * half, y + outward[0] * half}); - ink.add(new double[]{x + outward[1] * half, y - outward[0] * half}); - } - } - - /** The point a mitred join runs to, where the miter limit keeps it rather than bevelling it. */ - private static void miterTip(List ink, double[] before, double[] corner, double[] after, double half) { - double[] in = direction(before, corner); - double[] out = direction(corner, after); - // The tip lies along the difference of the two directions, past the outer side of the turn. - double tipX = in[0] - out[0]; - double tipY = in[1] - out[1]; - double tipLength = Math.hypot(tipX, tipY); - if (tipLength < 1e-9) { - return; - } - // The miter runs 1 / sin(θ/2) half-widths past the corner, θ the angle between the lines; - // sin(θ/2) is half the length of the sum of the two directions. - double ratio = 2 / Math.hypot(in[0] + out[0], in[1] + out[1]); - if (!(ratio <= MITER_LIMIT)) { - return; + return BasicStroke.CAP_ROUND; } - ink.add(new double[]{corner[0] + tipX / tipLength * half * ratio, corner[1] + tipY / tipLength * half * ratio}); + return cap == DocumentLineCap.SQUARE ? BasicStroke.CAP_SQUARE : BasicStroke.CAP_BUTT; } - /** Points round {@code centre} at {@code radius}. */ - private static void round(List ink, double[] centre, double radius) { - for (int step = 0; step < ROUND_STEPS; step++) { - double angle = 2 * Math.PI * step / ROUND_STEPS; - ink.add(new double[]{centre[0] + radius * Math.cos(angle), centre[1] + radius * Math.sin(angle)}); + private static int joinOf(DocumentLineJoin join) { + if (join == DocumentLineJoin.ROUND) { + return BasicStroke.JOIN_ROUND; } + return join == DocumentLineJoin.BEVEL ? BasicStroke.JOIN_BEVEL : BasicStroke.JOIN_MITER; } /** The points from {@code from} towards {@code to}, no further apart than {@link #STEP}. */ @@ -278,24 +181,15 @@ private static void edge(List into, double[] from, double[] to) { } } - /** The unit direction from one point to another, or none where they meet. */ - private static double[] direction(double[] from, double[] to) { - double dx = to[0] - from[0]; - double dy = to[1] - from[1]; - double length = Math.hypot(dx, dy); - return length == 0 ? new double[]{0, 0} : new double[]{dx / length, dy / length}; - } - - private static double[] normal(double[] from, double[] to) { - double[] along = direction(from, to); - return new double[]{-along[1], along[0]}; + private static double clamp(double radius, double half) { + return Math.max(0, Math.min(radius, half)); } - private static double[] at(PlacedFragment box, double x, double y) { - return new double[]{box.x() + x * box.width(), box.y() + y * box.height()}; + private static double xOf(PlacedFragment box, double x) { + return box.x() + x * box.width(); } - private static boolean same(double[] first, double[] second) { - return Math.abs(first[0] - second[0]) < 1e-9 && Math.abs(first[1] - second[1]) < 1e-9; + private static double yOf(PlacedFragment box, double y) { + return box.y() + y * box.height(); } } diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index 27242971f..3122cd30b 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -2092,9 +2092,19 @@ && paintOf(candidate).isEmpty(), * fragments, named on the table. A clip that cuts nothing — an icon drawn inside its box, a * disc's initials, a photo filling its circle — loses nothing and is not named. A picture * filling the ellipse that clips it is cropped to that ellipse in the file (see - * {@link #fillsItsEllipse}), and is measured as cropped. + * {@link #fillsItsEllipse}), and is measured as cropped. With no layout behind the export, + * every node that clips is named, its cut not measured. */ private void reportClipCut(DocumentNode node) { + if (layout.isEmpty()) { + // With no layout there is nothing to measure by: a node that clips is named as one + // that may cut, rather than taken for one that cuts nothing. + com.demcha.compose.document.style.ClipPolicy policy = clipPolicyOf(node); + if (policy != null && policy != com.demcha.compose.document.style.ClipPolicy.OVERFLOW_VISIBLE) { + reportClipLost(node, policy, " — with no layout behind the export, whether they do is not measured"); + } + return; + } List clips = layout.clipsOf(node); if (clips.isEmpty()) { return; @@ -2106,23 +2116,40 @@ private void reportClipCut(DocumentNode node) { fragment -> fragment.payload() instanceof com.demcha.compose.document.layout.payloads.ImageFragmentPayload && cropped.contains(fragment.path()), this::letterReach)) { - boolean toItsOutline = ((com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload) - clip.opening().payload()).policy() == com.demcha.compose.document.style.ClipPolicy.CLIP_PATH; - if (node instanceof ShapeContainerNode || node instanceof com.demcha.compose.document.node.LayerStackNode) { - report.add(DocxExportReport.Severity.APPROXIMATED, - node instanceof ShapeContainerNode ? "clipped shape container" : "clipped layer stack", - layout.pathOf(node), "its clip is not in the file, so what its layers paint past its " - + (toItsOutline ? "outline" : "box") + " is written whole"); - } else { + if (node instanceof TableNode) { report.add(DocxExportReport.Severity.APPROXIMATED, "clipped cell content", layout.pathOf(node), "a clip composed in its cells is not in the file, so what is painted past it is " + "written whole"); + } else { + reportClipLost(node, ((com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload) + clip.opening().payload()).policy(), ""); } return; } } } + /** The clip a layer stack or a shape container sets, or {@code null} for any other node. */ + private static com.demcha.compose.document.style.ClipPolicy clipPolicyOf(DocumentNode node) { + if (node instanceof ShapeContainerNode container) { + return container.clipPolicy(); + } + if (node instanceof com.demcha.compose.document.node.LayerStackNode stack) { + return stack.clipToBounds() ? com.demcha.compose.document.style.ClipPolicy.CLIP_BOUNDS : null; + } + return null; + } + + private void reportClipLost(DocumentNode node, com.demcha.compose.document.style.ClipPolicy policy, String unmeasured) { + report.add(DocxExportReport.Severity.APPROXIMATED, + node instanceof ShapeContainerNode ? "clipped shape container" + : node instanceof com.demcha.compose.document.node.LayerStackNode ? "clipped layer stack" + : "clipped " + node.nodeKind(), + layout.pathOf(node), "its clip is not in the file, so what its layers paint past its " + + (policy == com.demcha.compose.document.style.ClipPolicy.CLIP_PATH ? "outline" : "box") + + " is written whole" + unmeasured); + } + /** * How far a laid-out line's letters reach above and below the baseline the page sets it on, * read from their glyphs' outlines (see {@link DocxInk}) and moved as the page seats the @@ -2132,6 +2159,10 @@ private void reportClipCut(DocumentNode node) { private double[] letterReach(com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload paragraph, com.demcha.compose.document.layout.payloads.ParagraphLine line) { try { + // A standard face, not embedded, is read through a stand-in in that font's units. + if (!DocxInk.readInTheLayoutsUnits(line, measuredFonts())) { + return null; + } double[] reach = DocxInk.of(line, measuredFonts()); if (reach == null) { return null; @@ -6210,7 +6241,7 @@ private void writeShapeContainer(XWPFDocument document, ShapeContainerNode node, if (shapeContainerWarned.compareAndSet(false, true)) { LOG.warn("docx.export.shape-container-fallback " + "outline='{}' clipPolicy={} — DOCX has no graphics-state clip; " - + "rendering layers inline without clipping. " + + "rendering layers inline; the report names a clip that cuts them. " + "(One warning per export; use the PDF backend for full fidelity.)", node.outline().getClass().getSimpleName(), node.clipPolicy()); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInkTest.java index 1edce466e..b07d78b9e 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInkTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInkTest.java @@ -1,5 +1,6 @@ package com.demcha.compose.document.backend.semantic.docx; +import com.demcha.compose.document.image.DocumentImageFitMode; import com.demcha.compose.document.layout.PlacedFragment; import com.demcha.compose.document.layout.payloads.EllipseFragmentPayload; import com.demcha.compose.document.layout.payloads.ImageFragmentPayload; @@ -24,6 +25,7 @@ import com.demcha.compose.document.style.DocumentTransform; import com.demcha.compose.document.style.ShapeOutline; import com.demcha.compose.document.style.ShapePoint; +import com.demcha.compose.engine.components.content.ImageData; import com.demcha.compose.engine.components.content.shape.Stroke; import com.demcha.compose.engine.components.content.text.TextDecoration; import com.demcha.compose.engine.components.content.text.TextStyle; @@ -217,6 +219,31 @@ void aPathOutlineCutsWhatCrossesItsSlantedSide() { assertThat(cuts(clip, at(0, 0, W, H / 2, shape(0)))).isTrue(); } + @Test + void aContainedPictureIsMeasuredWhereItIsDrawnInItsBox() { + // A 100×20 picture contained in a 60×70 box: the page draws it 60×12, centred in the box. + PlacedFragment clip = clip(new ShapeOutline.Rectangle(W, H), ClipPolicy.CLIP_BOUNDS); + ImageData wide = ImageData.create(png(100, 20)); + + assertThat(cuts(clip, at(0, -5, 60, 70, new ImageFragmentPayload(wide, DocumentImageFitMode.CONTAIN, null, null)))) + .isFalse(); + assertThat(cuts(clip, at(0, -5, 60, 70, new ImageFragmentPayload(wide, DocumentImageFitMode.STRETCH, null, null)))) + .as("stretched over its box").isTrue(); + } + + @Test + void whatAClipInsideCutsAwayIsThatClipsLoss() { + // A disc inside the box holds a bar running past both: the disc cuts it, inside the box. + PlacedFragment clip = clip(new ShapeOutline.Rectangle(W, H), ClipPolicy.CLIP_BOUNDS); + PlacedFragment disc = at(20, 10, 40, 40, new ShapeClipBeginPayload(new ShapeOutline.Ellipse(40, 40), + ClipPolicy.CLIP_PATH, "root/card/disc")); + PlacedFragment bar = at(30, -40, 20, 140, shape(0)); + PlacedFragment closed = at(20, 10, 0, 0, new ShapeClipEndPayload("root/card/disc")); + + assertThat(cuts(clip, disc, bar, closed)).isFalse(); + assertThat(cuts(clip, disc, closed, bar)).as("the bar after the disc closes").isTrue(); + } + @Test void markersPaintNothingAndATransformIsNotApplied() { PlacedFragment clip = clip(new ShapeOutline.Rectangle(W, H), ClipPolicy.CLIP_BOUNDS); @@ -245,8 +272,9 @@ void aLineOfTextIsMeasuredFromItsLettersTopsToTheirFeet() { assertThat(DocxClipInk.cuts(clip, List.of(label), fragment -> false, UNKNOWN)) .as("its whole line, where its letters' reach is not known").isTrue(); assertThat(DocxClipInk.cuts(clip, List.of(at(0, 0.5, W, 9, paragraph(40, 9, Padding.zero()))), - fragment -> false, (paragraph, line) -> new double[]{20, 0})) - .as("its whole line, inside the chip, where its letters are said to run past it").isFalse(); + fragment -> false, UNKNOWN)).as("a line inside the chip").isFalse(); + assertThat(DocxClipInk.cuts(clip, List.of(at(0, 0.5, W, 9, paragraph(40, 9, Padding.zero()))), + fragment -> false, tall)).as("its letters where they reach, past their line").isTrue(); assertThat(DocxClipInk.cuts(clip, List.of(at(0, -1, W, 12, paragraph(W + 10, 12, Padding.zero()))), fragment -> false, digits)).as("a label set wider than its chip").isTrue(); assertThat(DocxClipInk.cuts(clip, List.of(at(0, -1, W, 12, paragraph(40, 12, null))), @@ -286,6 +314,16 @@ private static PlacedFragment at(double x, double y, double width, double height return new PlacedFragment("root/card/layer", 0, 0, X + x, Y + y, width, height, null, null, payload); } + private static byte[] png(int width, int height) { + try (java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) { + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(width, height, + java.awt.image.BufferedImage.TYPE_INT_RGB), "png", out); + return out.toByteArray(); + } catch (java.io.IOException failure) { + throw new IllegalStateException(failure); + } + } + private static ShapeFragmentPayload shape(double strokeWidth) { return new ShapeFragmentPayload(Color.ORANGE, strokeWidth > 0 ? new Stroke(Color.BLACK, strokeWidth) : null, DocumentCornerRadius.ZERO, null, null, null, null); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipReportTest.java index 1f01153d5..bbca8e8d2 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipReportTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxClipReportTest.java @@ -8,6 +8,7 @@ import com.demcha.compose.document.dsl.ShapeBuilder; import com.demcha.compose.document.dsl.ShapeContainerBuilder; import com.demcha.compose.document.image.DocumentImageData; +import com.demcha.compose.document.image.DocumentImageFitMode; import com.demcha.compose.document.node.DocumentNode; import com.demcha.compose.document.node.LayerAlign; import com.demcha.compose.document.style.ClipPolicy; @@ -28,7 +29,7 @@ /** * The clip of a layer stack or a shape container, and the transform of a container whose outline - * draws nothing, are in the report wherever the export loses them. + * draws nothing, are in the report on each path the export writes them by. * *

A Word file has no clip a container can set round its layers: what the page cut away was * written whole, and the report named a container's clip on one path only — and there of every @@ -57,7 +58,7 @@ void aLayerStackNamesTheClipThatCutsItsLayers() throws Exception { } @Test - void aClipThatCutsNothingIsNotNamed() throws Exception { + void aClipThatCutsNothingOrNoClipAtAllIsNotNamed() throws Exception { DocxExportReport inside = reportOf(page -> page .addParagraph("Above") .addLayerStack(stack -> stack.name("Stack").clipToBounds() @@ -77,7 +78,7 @@ void aClipThatCutsNothingIsNotNamed() throws Exception { .center(box(40, 40)).build()) .addParagraph("Below")); - // A rota's shift chip, set as CobaltRota sets it: its label's line two points taller than + // A rota's shift chip in CobaltRota's face: its label's line two points taller than // the chip, its digits inside it. DocxExportReport chip = reportOf(page -> page .addParagraph("Above") @@ -89,14 +90,26 @@ void aClipThatCutsNothingIsNotNamed() throws Exception { .build()) .addParagraph("Below")); + // The same label in Helvetica, which the PDF does not embed: its line fits the chip, and + // it is measured by its line, not by outlines read through a stand-in in other units. + DocxExportReport standard = reportOf(page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Chip").roundedRect(90, 12, 4).fillColor(SURFACE) + .clipPolicy(ClipPolicy.CLIP_PATH) + .center(new ParagraphBuilder().text("08:00-16:00") + .textStyle(DocumentTextStyle.DEFAULT.withSize(8.2)).build()) + .build()) + .addParagraph("Below")); + assertThat(inside.bySubject()).doesNotContainKeys("clipped layer stack", "clipped shape container"); assertThat(chip.bySubject()).doesNotContainKeys("clipped layer stack", "clipped shape container"); + assertThat(standard.bySubject()).doesNotContainKeys("clipped layer stack", "clipped shape container"); assertThat(notClipping.bySubject()).doesNotContainKeys("clipped layer stack", "clipped shape container"); assertThat(overflowVisible.bySubject()).doesNotContainKeys("clipped layer stack", "clipped shape container"); } @Test - void aDrawingClippedToItsCircleNamesTheCornersItShows() throws Exception { + void aDrawingPastItsCircleOrItsBoxNamesTheClip() throws Exception { // A square filling a disc: the page rounds its corners off, the file draws them. DocxExportReport report = reportOf(page -> page .addParagraph("Above") @@ -217,6 +230,57 @@ void aClipComposedInATableCellIsNamedOnTheTable() throws Exception { assertThat(fitting.bySubject()).doesNotContainKey("clipped cell content"); } + @Test + void aContainedPictureIsMeasuredWhereThePageDrawsIt() throws Exception { + // A wide picture contained in a box taller than its tile: the page draws it a strip + // across the tile's middle, and the file writes it that size. + DocxExportReport report = reportOf(page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Tile").rectangle(60, 60).fillColor(SURFACE) + .clipPolicy(ClipPolicy.CLIP_BOUNDS) + .center(new ImageBuilder().name("Logo").source(DocumentImageData.fromBytes(png(100, 20))) + .size(60, 70).fitMode(DocumentImageFitMode.CONTAIN).build()) + .build()) + .addParagraph("Below")); + + assertThat(report.bySubject()).doesNotContainKey("clipped shape container"); + } + + @Test + void anOuterClipIsNotNamedForWhatAClipInsideItCutsAway() throws Exception { + // A disc in a card holds a bar running past both: the disc cuts it inside the card, and + // only the disc's clip loses anything. + DocxExportReport report = reportOf(page -> page + .addParagraph("Above") + .addLayerStack(card -> card.name("Card").clipToBounds() + .layer(box(100, 100), LayerAlign.TOP_LEFT, 0) + .layer(new ShapeContainerBuilder().name("Disc").circle(40).fillColor(SURFACE) + .clipPolicy(ClipPolicy.CLIP_PATH).center(box(40, 160)).build(), + LayerAlign.CENTER, 1)) + .addParagraph("Below")); + + assertThat(report.bySubject()).doesNotContainKey("clipped layer stack"); + assertThat(detailOf(report, "clipped shape container")).isEqualTo(PAST_ITS_OUTLINE); + } + + @Test + void withNoLayoutEveryClipIsNamedItsCutNotMeasured() throws Exception { + // A bare export has nothing to measure by: a clip is named whether it cuts or not. + DocxExportReport report = DocxExports.reportWithoutLayout(400, 600, 40, page -> page + .addParagraph("Above") + .add(new ShapeContainerBuilder().name("Badge").circle(40).fillColor(INK) + .center(new ParagraphBuilder().text("JR").build()).build()) + .addLayerStack(stack -> stack.name("Icon").clipToBounds().layer(box(20, 20))) + .add(new ShapeContainerBuilder().name("Free").circle(40).fillColor(INK) + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE).center(box(10, 10)).build()) + .addParagraph("Below")); + + assertThat(detailOf(report, "clipped shape container")).isEqualTo(PAST_ITS_OUTLINE + + " — with no layout behind the export, whether they do is not measured"); + assertThat(detailOf(report, "clipped layer stack")).isEqualTo(PAST_ITS_BOX + + " — with no layout behind the export, whether they do is not measured"); + } + @Test void aTurnedContainerWhoseOutlineDrawsNothingNamesItsTransform() throws Exception { DocxExportReport unpainted = reportOf(page -> page @@ -280,8 +344,12 @@ private static String bodyOf(Consumer content) throws Exception } private static byte[] png() { + return png(20, 20); + } + + private static byte[] png(int width, int height) { try (java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) { - javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(20, 20, + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(width, height, java.awt.image.BufferedImage.TYPE_INT_RGB), "png", out); return out.toByteArray(); } catch (Exception failure) { diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExports.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExports.java index 4aa6a9ab2..405bd42b4 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExports.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExports.java @@ -54,6 +54,20 @@ static XWPFDocument withoutLayout(double pageWidth, double pageHeight, double ma return new XWPFDocument(new ByteArrayInputStream(docx)); } + /** The report of an export with no layout behind it, the way a bare caller makes one. */ + static DocxExportReport reportWithoutLayout(double pageWidth, double pageHeight, double margin, + Consumer content) throws Exception { + Captured captured = new Captured(); + java.util.concurrent.atomic.AtomicReference report = + new java.util.concurrent.atomic.AtomicReference<>(); + try (DocumentSession session = session(pageWidth, pageHeight, margin, content)) { + session.export(captured); + new DocxSemanticBackend(report::set).export(captured.graph, + new SemanticExportContext(captured.canvas, List.of(), null, null)); + } + return report.get(); + } + /** Exports with neither a layout nor a canvas, as a caller holding only the graph does. */ static XWPFDocument withoutCanvas(Consumer content) throws Exception { Captured captured = new Captured(); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java index ee7e0238a..eb6384ecf 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java @@ -129,7 +129,7 @@ private record Entry(Fate fate, String note) { "margin:REPORTED:its left side; its right moves nothing in a paragraph set from the left", "transform:REPORTED", "anchor:WRITTEN"); node(LayerStackNode.class, "name:INERT", "layers:WRITTEN", "padding:WRITTEN", "margin:WRITTEN", - "clipToBounds:REPORTED:where it cuts what its layers paint"); + "clipToBounds:REPORTED:where it cuts what its layers paint; composed in a table cell, on its table"); node(LineNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "startX:WRITTEN", "startY:WRITTEN", "endX:WRITTEN", "endY:WRITTEN", "stroke:WRITTEN", "linkTarget:REPORTED", From bbc1d2341c39edd9286a4b470cab63f195ff65e5 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 6 Oct 2026 13:28:20 +0100 Subject: [PATCH 3/3] refactor(docx): find how a clipped fragment paints by its payload's class, each kind its own method --- .../backend/semantic/docx/DocxClipInk.java | 115 ++++++++++++------ 1 file changed, 75 insertions(+), 40 deletions(-) diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java index 6beaca2a1..22895633a 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java @@ -203,49 +203,84 @@ private static Region roundedRegion(Region bounds, double left, double bottom, d /** Points on the outer edge of what a fragment paints, or none for a marker. */ private static List inkOf(PlacedFragment fragment, boolean croppedToAnEllipse, LetterReach letters) { - Object payload = fragment.payload(); List ink = new ArrayList<>(); - if (payload instanceof TransformBeginPayload || payload instanceof TransformEndPayload - || payload instanceof AnchorMarkerPayload || payload instanceof BookmarkMarkerPayload - || payload instanceof LayoutAnchorPayload) { - return ink; + // The payloads are records, so a fragment's own class finds how it paints; a barcode, a + // table's row and anything else paint their box. + PAINTERS.getOrDefault(fragment.payload().getClass(), DocxClipInk::boxOf) + .paint(ink, fragment, croppedToAnEllipse, letters); + return ink; + } + + /** How one kind of fragment paints. */ + @FunctionalInterface + private interface Painter { + void paint(List ink, PlacedFragment fragment, boolean croppedToAnEllipse, LetterReach letters); + } + + /** A marker: it paints nothing. */ + private static final Painter NOTHING = (ink, fragment, cropped, letters) -> { + }; + + private static final java.util.Map, Painter> PAINTERS = java.util.Map.ofEntries( + java.util.Map.entry(ShapeFragmentPayload.class, + (ink, fragment, cropped, letters) -> box(ink, fragment, (ShapeFragmentPayload) fragment.payload())), + java.util.Map.entry(EllipseFragmentPayload.class, (ink, fragment, cropped, letters) -> ellipse(ink, fragment)), + java.util.Map.entry(LineFragmentPayload.class, (ink, fragment, cropped, letters) -> line(ink, fragment)), + java.util.Map.entry(PathFragmentPayload.class, (ink, fragment, cropped, letters) -> path(ink, fragment)), + java.util.Map.entry(PolygonFragmentPayload.class, (ink, fragment, cropped, letters) -> polygon(ink, fragment)), + java.util.Map.entry(ParagraphFragmentPayload.class, + (ink, fragment, cropped, letters) -> text(ink, fragment, (ParagraphFragmentPayload) fragment.payload(), + letters)), + java.util.Map.entry(ImageFragmentPayload.class, (ink, fragment, cropped, letters) -> picture(ink, fragment, cropped)), + java.util.Map.entry(TransformBeginPayload.class, NOTHING), + java.util.Map.entry(TransformEndPayload.class, NOTHING), + java.util.Map.entry(AnchorMarkerPayload.class, NOTHING), + java.util.Map.entry(BookmarkMarkerPayload.class, NOTHING), + java.util.Map.entry(LayoutAnchorPayload.class, NOTHING)); + + private static void boxOf(List ink, PlacedFragment fragment, boolean cropped, LetterReach letters) { + DocxInkOutline.filled(ink, DocxInkOutline.box(fragment, DocumentCornerRadius.ZERO)); + } + + private static void ellipse(List ink, PlacedFragment fragment) { + EllipseFragmentPayload ellipse = (EllipseFragmentPayload) fragment.payload(); + Shape outline = DocxInkOutline.ellipse(fragment.x(), fragment.y(), fragment.width(), fragment.height()); + if (ellipse.fillColor() != null) { + DocxInkOutline.filled(ink, outline); } - if (payload instanceof ShapeFragmentPayload shape) { - box(ink, fragment, shape); - } else if (payload instanceof EllipseFragmentPayload ellipse) { - Shape outline = DocxInkOutline.ellipse(fragment.x(), fragment.y(), fragment.width(), fragment.height()); - if (ellipse.fillColor() != null) { - DocxInkOutline.filled(ink, outline); - } - DocxInkOutline.stroked(ink, outline, widthOf(ellipse.stroke()), DocumentLineCap.BUTT, DocumentLineJoin.MITER); - } else if (payload instanceof LineFragmentPayload line) { - DocxInkOutline.stroked(ink, DocxInkOutline.line(fragment.x() + line.startX(), fragment.y() + line.startY(), - fragment.x() + line.endX(), fragment.y() + line.endY()), - widthOf(line.stroke()), line.lineCap(), DocumentLineJoin.MITER); - } else if (payload instanceof PathFragmentPayload path) { - Shape outline = DocxInkOutline.path(path.segments(), fragment); - if (path.fillColor() != null) { - DocxInkOutline.filled(ink, outline); - } - DocxInkOutline.stroked(ink, outline, widthOf(path.stroke()), path.lineCap(), path.lineJoin()); - } else if (payload instanceof PolygonFragmentPayload polygon) { - Shape outline = DocxInkOutline.polygon(polygon.points(), fragment); - if (polygon.fillColor() != null) { - DocxInkOutline.filled(ink, outline); - } - DocxInkOutline.stroked(ink, outline, widthOf(polygon.stroke()), DocumentLineCap.BUTT, DocumentLineJoin.MITER); - } else if (payload instanceof ParagraphFragmentPayload paragraph) { - text(ink, fragment, paragraph, letters); - } else if (payload instanceof ImageFragmentPayload image) { - double[] drawn = drawn(fragment, image); - DocxInkOutline.filled(ink, croppedToAnEllipse - ? DocxInkOutline.ellipse(drawn[0], drawn[1], drawn[2], drawn[3]) - : DocxInkOutline.box(drawn[0], drawn[1], drawn[0] + drawn[2], drawn[1] + drawn[3])); - } else { - // A barcode, a table's row, and anything else: its box. - DocxInkOutline.filled(ink, DocxInkOutline.box(fragment, DocumentCornerRadius.ZERO)); + DocxInkOutline.stroked(ink, outline, widthOf(ellipse.stroke()), DocumentLineCap.BUTT, DocumentLineJoin.MITER); + } + + private static void line(List ink, PlacedFragment fragment) { + LineFragmentPayload line = (LineFragmentPayload) fragment.payload(); + DocxInkOutline.stroked(ink, DocxInkOutline.line(fragment.x() + line.startX(), fragment.y() + line.startY(), + fragment.x() + line.endX(), fragment.y() + line.endY()), + widthOf(line.stroke()), line.lineCap(), DocumentLineJoin.MITER); + } + + private static void path(List ink, PlacedFragment fragment) { + PathFragmentPayload path = (PathFragmentPayload) fragment.payload(); + Shape outline = DocxInkOutline.path(path.segments(), fragment); + if (path.fillColor() != null) { + DocxInkOutline.filled(ink, outline); } - return ink; + DocxInkOutline.stroked(ink, outline, widthOf(path.stroke()), path.lineCap(), path.lineJoin()); + } + + private static void polygon(List ink, PlacedFragment fragment) { + PolygonFragmentPayload polygon = (PolygonFragmentPayload) fragment.payload(); + Shape outline = DocxInkOutline.polygon(polygon.points(), fragment); + if (polygon.fillColor() != null) { + DocxInkOutline.filled(ink, outline); + } + DocxInkOutline.stroked(ink, outline, widthOf(polygon.stroke()), DocumentLineCap.BUTT, DocumentLineJoin.MITER); + } + + private static void picture(List ink, PlacedFragment fragment, boolean croppedToAnEllipse) { + double[] drawn = drawn(fragment, (ImageFragmentPayload) fragment.payload()); + DocxInkOutline.filled(ink, croppedToAnEllipse + ? DocxInkOutline.ellipse(drawn[0], drawn[1], drawn[2], drawn[3]) + : DocxInkOutline.box(drawn[0], drawn[1], drawn[0] + drawn[2], drawn[1] + drawn[3])); } /**