From 36a948750027603df42bb2f91d4377bc9183497f Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 19 Oct 2026 12:48:14 +0100 Subject: [PATCH] fix(docx): stack drawings in Word as the page paints them Word stacks drawings by the place each is given (relativeHeight), a later one over an earlier one, and the export gave them their places in the order it happened to draw them: a stack's or a shape container's layers in the order given, columns' marks after the columns' content. A layer the page stacks under another by its zIndex, given after it, then stood over it. Measured in Word and LibreOffice on Windows, a white dot at zIndex 0 given after a disc at zIndex 1 stood over the disc in both, where the page hides it, in a stack that only draws, a shape container, a band of layers with text and a stack nested in such a band. Only the shape container was named for it. Each drawing now takes its place from where the page paints the fragment it is drawn from: the fragment's index in the layout's paint order, eight places a fragment, its shapes in the order they come (DocxDrawingAnchors.ordered, DocxLayoutMetrics.paintIndexOf). Both editors draw the dot under the disc in all four. The text keeps the order it is written in, a drawing's name its old number, and the shape container is no longer named for the order of its layers. The places order the drawings behind the text among themselves, and those in front of it. In the fidelity corpus 34 of the 62 files change, in those places only: 26 stack as before, and the 8 whose order changes render pixel for pixel as before at 96 dpi in Word and in LibreOffice. The Word export example's DOCX is re-rendered for it. --- CHANGELOG.md | 21 ++ .../examples/word-export-companion.docx | Bin 10243 -> 10244 bytes .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 12 +- .../semantic/docx/DocxDrawingAnchors.java | 73 ++++-- .../backend/semantic/docx/DocxDrawings.java | 23 +- .../semantic/docx/DocxLayoutMetrics.java | 28 +++ .../semantic/docx/DocxSemanticBackend.java | 53 ++--- .../semantic/docx/DocxDrawingAnchorsTest.java | 53 ++++- .../semantic/docx/DocxDrawingsTest.java | 21 +- .../docx/DocxLayerPaintOrderTest.java | 211 ++++++++++++++++++ .../docx/DocxNodeFieldLedgerTest.java | 9 +- 12 files changed, 430 insertions(+), 76 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerPaintOrderTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 1930dbd10..91f150e12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,27 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export stacks its drawings as the page paints them.** Word stacks drawings by the + place each is given (`wp:anchor/@relativeHeight`), a later one over an earlier one. The export + gave them their places in the order it drew them. In the flow it draws a stack's or a shape + container's layers in the order given, and columns' marks after the columns' own content. So + a layer the page stacks under another by its `zIndex` but given after it stood over it. + - Measured in Word and LibreOffice on Windows, a white dot (`zIndex` 0) given after a disc + (`zIndex` 1) stood over the disc in both. The page hides the dot. This held in a stack that + only draws, in a shape container, in a band of layers with text, and in a stack nested in + such a band. + - A shape container whose layers the page stacks in another order was named for it. Every + other case went unsaid. + - Each drawing now takes its place from where the page paints the fragment it is drawn from. + Both editors draw the dot under the disc in all four cases. The text keeps the order it is + written in. + - The places order the drawings behind the text among themselves, and those in front of it. + Word draws every one behind under every one in front: on a painted panel a layer framing + text stays behind while one that does not stands in front, whatever their `zIndex`. + - The shape container is no longer named for its order. + - In the fidelity corpus, 34 of the 62 files change only in those places. 26 of them stack as + before. The other 8 change the order of some drawings, and their pages render pixel for + pixel as before at 96 dpi in Word and in LibreOffice. - **A DOCX export names what a text header or footer shows otherwise in Word.** Measured in Word and LibreOffice on Windows, four things went unsaid: - **A band too shallow for its line.** Its line is written from the page's edge. diff --git a/assets/readme/examples/word-export-companion.docx b/assets/readme/examples/word-export-companion.docx index 146b309d62321e2fb7cb2e6442311dcaa07407d2..c15ef364d5daa3a0f49fcd3556acaa3f132d5164 100644 GIT binary patch delta 3422 zcmZ8kcRU-~`;JYO5~H

PWI)jFxb`o?RTi~^$rD+6i{$--F; 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 diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 47fb4a152..8363a9b7b 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -123,7 +123,7 @@ honour an option ignores it (documented contract). | Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a (not written; reported `DROPPED`, `viewer preferences`) | | Debug guide lines / node labels | ✅ `PdfGuideLinesRenderer`, `PdfNodeLabelRenderer` | ❌ (ignored with a one-time warning — render through the PDF backend to see overlays) | n/a | | Keep a block on one page (`keepTogether()`, `keepWithNext()`) | ✅ resolved by `LayoutCompiler` before any backend runs | ✅ same — the slides are the laid-out pages | ✅ `DocxSemanticBackend.keepOnOnePage` — Word re-paginates, so a block the layout placed on one page is told to stay there: `w:keepLines` on each of its paragraphs and `w:keepNext` on every one but the last (on the last too for `keepWithNext`), a table inside it chained row by row. A block that ran over a page break in the layout is taller than a page and is left to flow, as the layout left it. A body table of one row kept with what follows, its cells holding paragraphs alone, is written with a row that may break (`letTheOneRowTablesKeptWithTheNextBreak`): LibreOffice reads a one-row table whose row may not break as one that may not split, and keeps such a table with nothing | -| Layer stack — layers drawn over one another (`addLayerStack`) | ✅ each layer's fragments at the place the layout gave it | ✅ same | ⚠️ `DocxSemanticBackend` — Word has no layers, so a stack's children are written one after the other, without their positions. The exception is a stack whose layers are side-by-side columns (`DocxLayerColumns`): every layer a plain container at the stack's top-left corner, the bands their padding leaves either the same or apart. That is written as one table row, a cell per band, the way a two-column CV lays its columns out as layers to draw the name first. Layers sharing a band follow one another in its cell. A spacer the layout placed level with content of another layer of the same band only keeps that content's place, and is not written. The space above a later layer's first block is the gap the page shows below the content before it. A layer the page starts above where those before it end — a caption over its photo, a label in a card's corner — is written under them, in a column's cell as in a band, and the report names it; a stack composed in a table cell, or exported with no layout, has no place of its own, and one writing more than one of its layers is named with them not measured. What the page draws inside another layer's fill comes after that fill. Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page; `NavySidebar` runs one line onto a second page. A stack or shape container the page gives no room — its margins taking back its whole height, as `LumaStudioInvoice`'s sidebar over the page's top margin — is laid over the flow (`laidOverTheFlow`): its drawings where the page draws them, each paragraph in a text box in front of the text where the page sets it, nothing in the flow. Not in a table cell or a panel, and not when it holds anything but drawing and plain paragraphs — a link, an anchor, a picture, a list or a table keeps it in the flow as above. A canvas is written as its contents — what it writes one block after another, its drawings where it places them — and holds its height: the room under what it writes is the space below it, and one that only draws holds its whole box where nothing round it — a band before its layer's first block, drawings held whole, an overlay it is a layer of — measures that room already. The report names the places and the width it loses, as it does a column's fixed width narrower than its band, and what it writes running past its height, a drawing in what it writes that takes no room in Word, or a height not measured | +| Layer stack — layers drawn over one another (`addLayerStack`) | ✅ each layer's fragments at the place the layout gave it | ✅ same | ⚠️ `DocxSemanticBackend` — Word has no layers, so a stack's children are written one after the other, without their positions. The exception is a stack whose layers are side-by-side columns (`DocxLayerColumns`): every layer a plain container at the stack's top-left corner, the bands their padding leaves either the same or apart. That is written as one table row, a cell per band, the way a two-column CV lays its columns out as layers to draw the name first. Layers sharing a band follow one another in its cell. A spacer the layout placed level with content of another layer of the same band only keeps that content's place, and is not written. The space above a later layer's first block is the gap the page shows below the content before it. A layer the page starts above where those before it end — a caption over its photo, a label in a card's corner — is written under them, in a column's cell as in a band, and the report names it; a stack composed in a table cell, or exported with no layout, has no place of its own, and one writing more than one of its layers is named with them not measured. What the page draws inside another layer's fill comes after that fill. Drawings stack as the page paints them, by their z-index, each given its place (`wp:anchor/@relativeHeight`) from where the page paints its fragment, whatever order the layers are written in — among the drawings behind the text, and among those in front of it, as Word draws every one behind under every one in front. Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page; `NavySidebar` runs one line onto a second page. A stack or shape container the page gives no room — its margins taking back its whole height, as `LumaStudioInvoice`'s sidebar over the page's top margin — is laid over the flow (`laidOverTheFlow`): its drawings where the page draws them, each paragraph in a text box in front of the text where the page sets it, nothing in the flow. Not in a table cell or a panel, and not when it holds anything but drawing and plain paragraphs — a link, an anchor, a picture, a list or a table keeps it in the flow as above. A canvas is written as its contents — what it writes one block after another, its drawings where it places them — and holds its height: the room under what it writes is the space below it, and one that only draws holds its whole box where nothing round it — a band before its layer's first block, drawings held whole, an overlay it is a layer of — measures that room already. The report names the places and the width it loses, as it does a column's fixed width narrower than its band, and what it writes running past its height, a drawing in what it writes that takes no room in Word, or a height not measured | ## Output surface and lifecycle diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 2434591ff..059584635 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -894,8 +894,16 @@ recolour what is under it in Word and it keeps the colour it was flattened to. export, and a note on each container that writes a layer in the flow. A container whose layers are all drawn — a tile, an icon, a badge holding its glyph — writes none: each layer is drawn where the page draws it, in - source order, and it is not noted unless the page stacks its layers in - another order, by their z-index. The report names the clip — a shape container's, and a layer + the order the page stacks them, by their z-index. Word stacks drawings by + the place each is given, a later one over an earlier one, and every drawing + takes its place from where the page paints it, wherever and whenever the + export draws it: a stack's layers, a band's and columns' marks, a table's + cell drawings. Text is written in the order given. The places order the + drawings behind the text among themselves, and those in front of it: Word + draws every one behind under every one in front, and on a painted panel a + layer framing text stays behind while one that does not stands in front, + whatever their z-index. + 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: 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 diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java index aaab2c75b..85e797f60 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java @@ -57,8 +57,10 @@ * in that cell's paragraph, placed from the paragraph and the cell's text column, so it moves * with its row; the cell holds it whole, so Word's clipping cuts nothing.

* - *

Every shape takes its place in the paint order when it is drawn ({@link #ordered}), not - * when it finds its paragraph.

+ *

Every shape takes its place in the paint order from where the page paints the fragment it + * is drawn from ({@link #ordered}), not when it is drawn or when it finds its paragraph: Word + * stacks drawings by those places, a later one over an earlier one, and the export draws a + * stack's layers, a band's and a table's cells in the order it writes them.

*/ final class DocxDrawingAnchors { @@ -78,8 +80,21 @@ final class DocxDrawingAnchors { private final List offers = new ArrayList<>(); // Identifiers unique in the document, shared with the other drawings the export writes. private final LongSupplier ids; - // The order the shapes are painted in, across the document. - private int order; + // The places given each fragment's shapes in the section so far, by the fragment's place in + // the page's paint order. + private final Map placesTaken = new HashMap<>(); + // The latest place given: a shape drawn from no fragment the page paints is drawn as the + // fragment that place is of. + private int latest = -1; + // How many shapes have been drawn in the document, which numbers their names. + private int drawn; + + /** + * How many places in the paint order a fragment's shapes take, at most: one drawn as more + * shapes than that stacks the rest at its last place. A fragment is drawn as five shapes at + * most ({@link DocxDrawings#of}). + */ + static final int PLACES_PER_FRAGMENT = 8; DocxDrawingAnchors(LongSupplier ids) { this.ids = ids; @@ -94,29 +109,43 @@ void reset() { breakParagraph.clear(); seats.clear(); offers.clear(); - order = 0; + placesTaken.clear(); + latest = -1; + drawn = 0; } /** * A shape and its place in the paint order: a later one is drawn over an earlier one. * * @param shape the shape - * @param order its place, given when it was drawn, whenever it is anchored + * @param order its place, from where the page paints the fragment it is drawn from, whenever + * it is anchored + * @param drawn how many shapes were drawn in the document before it, which numbers its name */ - record Ordered(DocxDrawings.Shape shape, int order) { + record Ordered(DocxDrawings.Shape shape, int order, int drawn) { } /** - * Gives shapes their places in the paint order, in the order they come, as drawn now. + * Gives shapes drawn from one fragment their places in the paint order: after every shape of + * a fragment the page paints before it, and in the order they come among that fragment's. + * + *

A shape is painted where the page paints it, not in the order it is drawn or finds a + * paragraph: a disc a dot is laid under, its layers given dot last, hides the dot as on the + * page; one waiting for its page's first paragraph is not painted over a shape anchored in a + * cell meanwhile, though painted before it.

* - *

A shape is painted in the order it is drawn, not the order it finds a paragraph: one - * waiting for its page's first paragraph would otherwise be painted over a shape anchored - * in a cell meanwhile, though drawn before it.

+ * @param painted where the page paints the fragment they are drawn from, from 0, as the layout + * lists its fragments; -1 where it is none the layout holds, and they are drawn + * as the fragment the latest place given is of, after its shapes so far */ - List ordered(List shapes) { + List ordered(List shapes, int painted) { List ordered = new ArrayList<>(shapes.size()); + int fragment = painted >= 0 ? painted : Math.max(0, latest) / PLACES_PER_FRAGMENT; for (DocxDrawings.Shape shape : shapes) { - ordered.add(new Ordered(shape, order++)); + int taken = placesTaken.merge(fragment, 1, Integer::sum) - 1; + int place = fragment * PLACES_PER_FRAGMENT + Math.min(taken, PLACES_PER_FRAGMENT - 1); + latest = Math.max(latest, place); + ordered.add(new Ordered(shape, place, drawn++)); } return ordered; } @@ -135,10 +164,11 @@ record Owner(String subject, String path) { * Gives shapes their places in the paint order and keeps them for the section's end, which * anchors them (see {@link #endSection}). * - * @param owner what they are drawn for, or {@code null} where no note names them + * @param painted where the page paints the fragment they are drawn from ({@link #ordered}) + * @param owner what they are drawn for, or {@code null} where no note names them */ - void queue(List shapes, Owner owner) { - queueOrdered(ordered(shapes), owner); + void queue(List shapes, int painted, Owner owner) { + queueOrdered(ordered(shapes, painted), owner); } /** Keeps shapes given their places in the paint order for the section's end, as {@link #queue} does. */ @@ -317,6 +347,9 @@ Leftovers endSection(int lastPage, Supplier opening, Supplier droppedWhole = new java.util.LinkedHashMap<>(); droppedFor.forEach((owner, count) -> droppedWhole.put(owner, count.equals(queuedFor.get(owner)))); return new Leftovers(inCells, dropped, droppedWhole); @@ -347,7 +380,7 @@ void anchorInCell(XWPFParagraph carrier, List shapes, DocxDrawings.Cell XWPFRun run = carrier.insertNewRun(0); for (Ordered shape : shapes) { run.getCTR().addNewDrawing().set( - DocxDrawings.drawingInCell(shape.shape(), ids.getAsLong(), shape.order(), origin)); + DocxDrawings.drawingInCell(shape.shape(), ids.getAsLong(), shape.order(), shape.drawn(), origin)); } } @@ -395,8 +428,8 @@ private void anchorBeside(XWPFParagraph paragraph, List shapes) { double rise = placed.place().top() - ordered.shape().top(); double top = rise > 0 && rise <= SLACK ? ordered.shape().top() : placed.place().top(); run.getCTR().addNewDrawing().set(Double.isInfinite(column) - ? DocxDrawings.drawingInParagraph(ordered.shape(), ids.getAsLong(), ordered.order(), top) - : DocxDrawings.drawingInCell(ordered.shape(), ids.getAsLong(), ordered.order(), + ? DocxDrawings.drawingInParagraph(ordered.shape(), ids.getAsLong(), ordered.order(), ordered.drawn(), top) + : DocxDrawings.drawingInCell(ordered.shape(), ids.getAsLong(), ordered.order(), ordered.drawn(), new DocxDrawings.CellOrigin(column, top))); } } @@ -699,7 +732,7 @@ private record Placed(Ordered ordered, Place place) { private void anchor(XWPFParagraph carrier, List shapes) { XWPFRun run = carrier.insertNewRun(0); for (Ordered shape : shapes) { - run.getCTR().addNewDrawing().set(DocxDrawings.drawing(shape.shape(), ids.getAsLong(), shape.order())); + run.getCTR().addNewDrawing().set(DocxDrawings.drawing(shape.shape(), ids.getAsLong(), shape.order(), shape.drawn())); } } } diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawings.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawings.java index 1a68d13f0..e9b1c258d 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawings.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawings.java @@ -331,7 +331,9 @@ private static double held(double radius, double most) { * @return the value for {@code wp:anchor/@relativeHeight} */ static long stackHeight(int order) { - return 251_658_240L + (order + 1L) * 1024L; + // Held to the range LibreOffice reads, a signed 32-bit count, within the attribute's: past + // it, drawings share its top. + return Math.min(251_658_240L + (order + 1L) * 1024L, Integer.MAX_VALUE); } /** @@ -340,10 +342,11 @@ static long stackHeight(int order) { * @param shape the shape * @param id an identifier for the drawing, unique in the document * @param order its place among the drawings: a later one is drawn over an earlier one + * @param drawn how many drawings were drawn before it, which numbers its name * @return the drawing, to be added to a run */ - static CTDrawing drawing(Shape shape, long id, int order) { - return drawing(shape, id, order, null); + static CTDrawing drawing(Shape shape, long id, int order, int drawn) { + return drawing(shape, id, order, drawn, null); } /** @@ -354,12 +357,13 @@ static CTDrawing drawing(Shape shape, long id, int order) { * @param shape the shape * @param id an identifier for the drawing, unique in the document * @param order its place among the drawings: a later one is drawn over an earlier one + * @param drawn how many drawings were drawn before it, which numbers its name * @param origin where the page puts the cell's text column and the paragraph's top, in * points from the page's left and top edges * @return the drawing, to be added to a run of that paragraph */ - static CTDrawing drawingInCell(Shape shape, long id, int order, CellOrigin origin) { - return drawing(shape, id, order, origin); + static CTDrawing drawingInCell(Shape shape, long id, int order, int drawn, CellOrigin origin) { + return drawing(shape, id, order, drawn, origin); } /** @@ -370,12 +374,13 @@ static CTDrawing drawingInCell(Shape shape, long id, int order, CellOrigin origi * @param shape the shape * @param id an identifier for the drawing, unique in the document * @param order its place among the drawings: a later one is drawn over an earlier one + * @param drawn how many drawings were drawn before it, which numbers its name * @param paragraphTop where the page puts the paragraph's top, the space above its first line * included, in points from the page's top edge * @return the drawing, to be added to a run of that paragraph */ - static CTDrawing drawingInParagraph(Shape shape, long id, int order, double paragraphTop) { - return drawing(shape, id, order, new CellOrigin(Double.NaN, paragraphTop)); + static CTDrawing drawingInParagraph(Shape shape, long id, int order, int drawn, double paragraphTop) { + return drawing(shape, id, order, drawn, new CellOrigin(Double.NaN, paragraphTop)); } /** @@ -388,7 +393,7 @@ static CTDrawing drawingInParagraph(Shape shape, long id, int order, double para record CellOrigin(double x, double top) { } - private static CTDrawing drawing(Shape shape, long id, int order, CellOrigin origin) { + private static CTDrawing drawing(Shape shape, long id, int order, int drawn, CellOrigin origin) { long cx = Units.toEMU(shape.width()); long cy = Units.toEMU(shape.height()); boolean picture = shape.kind() == Kind.PICTURE; @@ -416,7 +421,7 @@ private static CTDrawing drawing(Shape shape, long id, int order, CellOrigin ori + "" + "" + "" - + "" + + "" + "" + graphic + ""; 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 f9f71ce6e..17ccdc8f6 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 @@ -77,6 +77,8 @@ final class DocxLayoutMetrics { private Map> textByPage; // Each page's fragments in paint order, indexed on first use — see clipsOf. private Map> paintedByPage; + // Each fragment's place in the paint order, indexed on first use — see paintIndexOf. + private Map paintIndex; // A table's first own row on each page, filled on first use — see colourUnderCell. private final Map> firstRows = new IdentityHashMap<>(); // The node at each path, indexed on first use — see aRowsOwnFill. @@ -132,6 +134,32 @@ static DocxLayoutMetrics of(DocumentGraph graph, LayoutGraph layout) { return new DocxLayoutMetrics(paths, repeated, fragments, placed, layout.fragments(), layout.totalPages()); } + /** + * Where the page paints a fragment among all of the layout's: a later one is painted over an + * earlier one, as the layout lists them — a stack's layers by their z-index. + * + * @return the fragment's place, from 0, or -1 for one the layout does not hold + */ + int paintIndexOf(PlacedFragment fragment) { + if (paintIndex == null) { + Map index = new IdentityHashMap<>(); + for (int place = 0; place < painted.size(); place++) { + index.putIfAbsent(painted.get(place), place); + } + paintIndex = index; + } + return paintIndex.getOrDefault(fragment, -1); + } + + /** + * Where the page paints a node's own content: its first own fragment's place + * ({@link #paintIndexOf}), or -1 where the layout holds none. + */ + int paintIndexOf(DocumentNode node) { + List own = ownFragments(node); + return own.isEmpty() ? -1 : paintIndexOf(own.get(0)); + } + /** Walks every place a node stands — in the flow, and in a table's composed cells — adding those seen twice. */ private static void countPlaces(DocumentNode node, java.util.Set seen, java.util.Set repeated) { 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 f5b6d6980..805d092e6 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 @@ -4023,8 +4023,9 @@ private void writeOverTheFlow(XWPFDocument document, DocumentNode node) throws E /** * A node's children in the order the page paints them: a stack's or a container's layers - * by their z-index, the ones sharing one in the order they were given. Drawings are stacked - * in the order they are queued, so a fill a later layer lies over is drawn under it. + * by their z-index, the ones sharing one in the order they were given. Drawings stack where + * the page paints them whatever order they are drawn in ({@link DocxDrawingAnchors#ordered}); + * a node laid over the flow writes its text boxes in this order too. */ private static List inPaintOrder(DocumentNode node) { List layers = @@ -4081,7 +4082,8 @@ private void writeTextOverTheFlow(XWPFDocument document, ParagraphNode node) { x -= spare; } anchors.queue(List.of(DocxDrawings.Shape.textBox(x, top, width + spare, height, placed.startPage(), - paragraphXml(para))), new DocxDrawingAnchors.Owner(node.nodeKind(), layout.pathOf(node))); + paragraphXml(para))), layout.paintIndexOf(node), + new DocxDrawingAnchors.Owner(node.nodeKind(), layout.pathOf(node))); List lost = new ArrayList<>(); lost.add("laid over the flow, which gives it no room: set in a text box where the page sets it"); lost.addAll(paragraphLost(node, roomLostInItsBox(node), null)); @@ -4675,7 +4677,7 @@ private boolean drawAndNameOwnFragments(DocumentNode node) { // A badge holding its initials: in front, the text being its own. List shapes = DocxDrawings.of(fragment, canvasHeight).stream() .map(shape -> shape.holding(badgeText).inFront()).toList(); - queueDrawings(shapes, false); + queueDrawings(shapes, false, layout.paintIndexOf(fragment)); drew |= !shapes.isEmpty(); front |= !shapes.isEmpty(); unequalCorners |= !shapes.isEmpty() && roundsCornersUnequally(fragment); @@ -4816,10 +4818,16 @@ private boolean queueDrawing(com.demcha.compose.document.layout.PlacedFragment f if (Double.isNaN(canvasHeight)) { return false; } - return queueDrawings(DocxDrawings.of(fragment, canvasHeight), front); + return queueDrawings(DocxDrawings.of(fragment, canvasHeight), front, layout.paintIndexOf(fragment)); } - private boolean queueDrawings(List shapes, boolean front) { + /** + * Draws shapes where the page puts them, stacked where the page paints them among the rest. + * + * @param painted where the page paints the fragment they are drawn from + * ({@link DocxLayoutMetrics#paintIndexOf}), -1 where it is unknown + */ + private boolean queueDrawings(List shapes, boolean front, int painted) { if (front) { // A painted panel is a shaded cell, and both editors paint a cell's shading over // what lies behind the text: in front of it, the shape shows. @@ -4827,11 +4835,11 @@ private boolean queueDrawings(List shapes, boolean front) { } DrawingCell cell = drawingCell; if (cell != null && !shapes.isEmpty() && shapes.stream().allMatch(cell::holds)) { - anchors.anchorInCell(cell.carrier(), anchors.ordered(shapes), cell.origin()); + anchors.anchorInCell(cell.carrier(), anchors.ordered(shapes, painted), cell.origin()); drewInCell = true; return true; } - anchors.queue(shapes, drawingFor); + anchors.queue(shapes, painted, drawingFor); return !shapes.isEmpty(); } @@ -9602,10 +9610,9 @@ private void writeShapeContainer(XWPFDocument document, ShapeContainerNode node, } if (blocksWritten == blocksBefore) { carriedSpacingBefore = carriedFromOutside; - if (holdsMovedContent(node) || paintsOutOfSourceOrder(node)) { + if (holdsMovedContent(node)) { // Its text, moved into an earlier layer's stand-in, is in the flow all the - // same; and drawings are drawn in source order, where the page stacks them - // by their z-index. + // same. Its drawings stack where the page paints them (DocxDrawingAnchors). reportLayersWrittenInline(node); } } else { @@ -9626,29 +9633,14 @@ private void writeShapeContainer(XWPFDocument document, ShapeContainerNode node, * order, held in to where it sets them, rather than laid over its outline. 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. One that writes nothing — its layers all drawn where the - * page puts them, a tile or an icon — is not named, unless the page stacks its layers in - * another order than they are given ({@link #paintsOutOfSourceOrder}). + * page puts them, a tile or an icon — is not named: its drawings stack as the page paints + * them, by their z-index, whatever order they are given in. */ private void reportLayersWrittenInline(ShapeContainerNode node) { report.add(DocxExportReport.Severity.APPROXIMATED, "shape container", layout.pathOf(node), "its layers are written inline, one after another in source order"); } - /** - * Whether the page paints a node's layers in another order than they are given, as their - * z-index sets them: the export writes and draws them in the order given. - */ - private static boolean paintsOutOfSourceOrder(DocumentNode node) { - List painted = inPaintOrder(node); - List given = node.children(); - for (int index = 0; index < painted.size() && index < given.size(); index++) { - if (painted.get(index) != given.get(index)) { - return true; - } - } - return false; - } - /** * How far a shape container sets its layers in from the top and the bottom of its content, * where the layout placed them all on its page; {@code {0, 0}} for one the layout did not @@ -13271,7 +13263,7 @@ private void drawWhereThePagePutsIt(XWPFDocument document, ImageNode image, byte DocxDrawingAnchors.Owner outerOwner = drawingFor; drawingFor = new DocxDrawingAnchors.Owner(image.nodeKind(), layout.pathOf(image)); try { - queueDrawings(List.of(picture), inFront); + queueDrawings(List.of(picture), inFront, layout.paintIndexOf(image)); } finally { drawingFor = outerOwner; } @@ -13804,7 +13796,8 @@ private CellDrawing drawCellDrawing(TableNode table) { if (!shapes.isEmpty() || DocxCellDrawings.drawnKind(fragment) != null) { // Its place in the paint order is the table's, wherever it is anchored. pending.add(new CellFragment(fragment, anchors.ordered( - frames ? shapes : shapes.stream().map(DocxDrawings.Shape::inFront).toList()))); + frames ? shapes : shapes.stream().map(DocxDrawings.Shape::inFront).toList(), + layout.paintIndexOf(fragment)))); drew |= !shapes.isEmpty(); } } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java index 18cf66617..2bb38c321 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java @@ -614,9 +614,54 @@ private static DocxDrawingAnchors anchors() { return new DocxDrawingAnchors(ids::getAndIncrement); } - /** Queues shapes no note names, as drawing here is drawn for nothing. */ + /** Queues shapes no note names, from no fragment of a layout, as drawing here is drawn for nothing. */ private static void queue(DocxDrawingAnchors anchors, List shapes) { - anchors.queue(shapes, null); + anchors.queue(shapes, -1, null); + } + + @Test + void shapesTakeTheirPlacesWhereThePagePaintsTheirFragments() { + DocxDrawingAnchors anchors = anchors(); + + // Drawn first, a fragment the page paints third stacks over one it paints first. + List third = anchors.ordered(List.of(dot(30, 300), dot(40, 300)), 2); + List first = anchors.ordered(List.of(dot(50, 300)), 0); + List fromNone = anchors.ordered(List.of(dot(60, 300)), -1); + + assertThat(first.get(0).order()).isLessThan(third.get(0).order()); + assertThat(third.get(0).order()).as("a fragment's shapes in the order they come") + .isLessThan(third.get(1).order()); + assertThat(fromNone.get(0).order()).as("from no fragment, after the latest place given") + .isGreaterThan(third.get(1).order()); + assertThat(anchors.ordered(List.of(dot(30, 300)), 2).get(0).order()) + .as("the same fragment drawn again, after its shapes so far, that one among them") + .isGreaterThan(fromNone.get(0).order()) + .isLessThan(3 * DocxDrawingAnchors.PLACES_PER_FRAGMENT); + } + + @Test + void aFragmentDrawnAsMoreShapesThanItHasPlacesForStacksTheRestAtItsLast() { + DocxDrawingAnchors anchors = anchors(); + List many = java.util.Collections.nCopies(DocxDrawingAnchors.PLACES_PER_FRAGMENT + 2, + dot(30, 300)); + + List ordered = anchors.ordered(many, 1); + List next = anchors.ordered(List.of(dot(30, 300)), 2); + + assertThat(ordered.get(ordered.size() - 1).order()).isEqualTo(2 * DocxDrawingAnchors.PLACES_PER_FRAGMENT - 1); + assertThat(next.get(0).order()).as("the next fragment's still above").isGreaterThan(ordered.get(ordered.size() - 1).order()); + } + + @Test + void aSectionCountsItsFragmentsAfresh() { + XWPFDocument document = new XWPFDocument(); + DocxDrawingAnchors anchors = anchors(); + anchors.ordered(List.of(dot(30, 300), dot(30, 300)), 0); + anchors.endSection(1, null, document::createParagraph); + + assertThat(anchors.ordered(List.of(dot(30, 300)), 0).get(0).order()) + .as("the next section's first fragment, its first place") + .isZero(); } @Test @@ -627,8 +672,8 @@ void whatADrawingIsDrawnForIsToldWhetherNothingOrOnlyPartOfItWasCarried() { anchors.paragraphOn(0, text(document, "First"), true); DocxDrawingAnchors.Owner split = new DocxDrawingAnchors.Owner("timeline rail", "Root[0]"); DocxDrawingAnchors.Owner lost = new DocxDrawingAnchors.Owner("ShapeNode", "Root[0]/Cover[1]"); - anchors.queue(List.of(dot(30, 300), onPage(1, dot(30, 300))), split); - anchors.queue(List.of(onPage(1, dot(60, 300))), lost); + anchors.queue(List.of(dot(30, 300), onPage(1, dot(30, 300))), -1, split); + anchors.queue(List.of(onPage(1, dot(60, 300))), -1, lost); DocxDrawingAnchors.Leftovers leftovers = anchors.endSection(2, null, document::createParagraph); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java index 63d4ae5b5..04ebcb311 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java @@ -933,11 +933,11 @@ void aContainerWhoseLayersAreAllDrawnIsNotSaidToWriteThemInline() throws Excepti } @Test - void aContainerWhoseLayersThePageStacksInAnotherOrderIsStillNamed() throws Exception { - // The page lays the disc over the dot by their z-index; the export draws them in the - // order given, the dot over the disc. + void aContainerWhoseLayersThePageStacksInAnotherOrderIsStackedSoAndNotNamed() throws Exception { + // The page lays the disc over the dot by their z-index, the dot given last; the export + // stacks them as the page paints them. AtomicReference report = new AtomicReference<>(); - try (XWPFDocument ignored = export(report, session -> session.pageFlow(page -> page + try (XWPFDocument document = export(report, session -> session.pageFlow(page -> page .addLayerStack(stack -> stack.name("Stack") .layer(new ShapeContainerBuilder().name("Stacked").rectangle(40, 40) .layer(new com.demcha.compose.document.dsl.EllipseBuilder().circle(36) @@ -946,10 +946,17 @@ void aContainerWhoseLayersThePageStacksInAnotherOrderIsStillNamed() throws Excep .fillColor(DocumentColor.WHITE).build(), LayerAlign.CENTER, 0) .build(), LayerAlign.TOP_LEFT) .layer(new ParagraphBuilder().text("Beside the stack").build(), LayerAlign.TOP_RIGHT))))) { + String xml = document.getDocument().xmlText(); + java.util.regex.Matcher anchor = java.util.regex.Pattern.compile( + "relativeHeight=\"(\\d+)\".*? heights = new java.util.HashMap<>(); + while (anchor.find()) { + heights.put(Long.parseLong(anchor.group(2)), Long.parseLong(anchor.group(1))); + } - assertThat(report.get().notes()).filteredOn(note -> note.subject().equals("shape container")) - .extracting(DocxExportReport.Note::path) - .singleElement().asString().matches(".*/Stacked\\[\\d+]"); + assertThat(heights.get(org.apache.poi.util.Units.toEMU(8) * 1L)).as("the dot under the disc") + .isLessThan(heights.get(org.apache.poi.util.Units.toEMU(36) * 1L)); + assertThat(report.get().notes()).noneMatch(note -> note.subject().equals("shape container")); } } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerPaintOrderTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerPaintOrderTest.java new file mode 100644 index 000000000..2fb8239c0 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerPaintOrderTest.java @@ -0,0 +1,211 @@ +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.EllipseBuilder; +import com.demcha.compose.document.dsl.LayerStackBuilder; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.dsl.ShapeBuilder; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.LayerAlign; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.poi.util.Units; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Drawings stack in Word as the page paints them. + * + *

Word stacks a document's drawings by the place each is given, a later one over an earlier + * one; the export gives each the place the page paints its fragment at, whatever order it writes + * them in. Measured in Word and LibreOffice on Windows: a white dot given after a disc, the dot at + * z-index 0 and the disc at 1, stood over the disc in both when the places followed the order the + * export drew them in, where the page hides it under the disc; given the page's places, it stood + * under it in both, in a stack and in a shape container that only draw, in a band of layers whose + * text is written in the order given, and in a stack that only draws nested in such a band.

+ * + * @author Artem Demchyshyn + */ +class DocxLayerPaintOrderTest { + + private static final DocumentColor ACCENT = DocumentColor.rgb(30, 90, 200); + private static final DocumentColor LIGHT = DocumentColor.rgb(220, 230, 245); + private static final Pattern ANCHOR = Pattern.compile( + "relativeHeight=\"(\\d+)\".*? page.addLayerStack(stack -> discAndDot(stack))); + + assertThat(exported.heightOf(8)).as("the dot under the disc").isLessThan(exported.heightOf(36)); + } + + @Test + void aShapeContainerThatOnlyDrawsStacksItsLayersByTheirZIndex() throws Exception { + Exported exported = export(page -> page.addContainer(container -> container.rectangle(80, 60) + .fillColor(LIGHT) + .layer(disc(36, ACCENT), LayerAlign.CENTER, 1) + .layer(disc(8, DocumentColor.WHITE), LayerAlign.CENTER, 0))); + + assertThat(exported.heightOf(80)).as("the outline under its layers").isLessThan(exported.heightOf(8)); + assertThat(exported.heightOf(8)).as("the dot under the disc").isLessThan(exported.heightOf(36)); + } + + @Test + void layersOfOneZIndexStackInTheOrderGiven() throws Exception { + Exported exported = export(page -> page.addLayerStack(stack -> stack + .layer(disc(36, ACCENT), LayerAlign.CENTER, 0) + .layer(disc(8, DocumentColor.WHITE), LayerAlign.CENTER, 0))); + + assertThat(exported.heightOf(36)).isLessThan(exported.heightOf(8)); + } + + @Test + void aBandStacksItsDrawnLayersByTheirZIndexWhileItsTextKeepsItsOrder() throws Exception { + Exported exported = export(page -> page.addLayerStack(stack -> stack + .layer(new ShapeBuilder().size(120, 40).fillColor(ACCENT).build(), LayerAlign.TOP_LEFT, 1) + .layer(new ShapeBuilder().size(60, 20).fillColor(DocumentColor.WHITE).build(), LayerAlign.TOP_LEFT, 0) + .position(new ParagraphBuilder().text("First").build(), 0, 10, LayerAlign.TOP_LEFT, 3) + .position(new ParagraphBuilder().text("Second").build(), 0, 24, LayerAlign.TOP_LEFT, 2))); + + assertThat(exported.heightOf(60)).as("the white box under the accent one").isLessThan(exported.heightOf(120)); + assertThat(exported.document().getDocument().getBody().xmlText().indexOf(">First<")) + .as("its text written in the order given") + .isLessThan(exported.document().getDocument().getBody().xmlText().indexOf(">Second<")); + } + + @Test + void aStackThatOnlyDrawsInsideABandStacksByItsZIndex() throws Exception { + Exported exported = export(page -> page.addLayerStack(stack -> stack + .layer(new ParagraphBuilder().text("Experience").build(), LayerAlign.TOP_LEFT) + .layer(new LayerStackBuilder().name("Icon").layer(disc(36, ACCENT), LayerAlign.CENTER, 1) + .layer(disc(8, DocumentColor.WHITE), LayerAlign.CENTER, 0).build(), LayerAlign.TOP_RIGHT))); + + assertThat(exported.heightOf(8)).as("the dot under the disc").isLessThan(exported.heightOf(36)); + } + + @Test + void aStackComposedInATableCellStacksByItsZIndex() throws Exception { + Exported exported = export(page -> page.addTable(table -> table + .columns(com.demcha.compose.document.table.DocumentTableColumn.fixed(80), + com.demcha.compose.document.table.DocumentTableColumn.fixed(120)) + .rowCells(com.demcha.compose.document.table.DocumentTableCell.node( + new LayerStackBuilder().layer(disc(36, ACCENT), LayerAlign.CENTER, 1) + .layer(disc(8, DocumentColor.WHITE), LayerAlign.CENTER, 0).build()), + com.demcha.compose.document.table.DocumentTableCell.text("Beside")))); + + assertThat(exported.heightOf(8)).as("the dot under the disc").isLessThan(exported.heightOf(36)); + } + + @Test + void aTableCellsDrawingStandsUnderALayerThePageLaysOverTheTable() throws Exception { + // The bar is given first and drawn first, the table's cell drawing after it; the page paints + // the table under the bar. + Exported exported = export(page -> page.addLayerStack(stack -> stack + .layer(new ShapeBuilder().size(200, 40).fillColor(LIGHT).build(), LayerAlign.TOP_LEFT, 1) + .layer(new com.demcha.compose.document.dsl.TableBuilder() + .columns(com.demcha.compose.document.table.DocumentTableColumn.fixed(80), + com.demcha.compose.document.table.DocumentTableColumn.fixed(120)) + .rowCells(com.demcha.compose.document.table.DocumentTableCell.node(disc(20, ACCENT)), + com.demcha.compose.document.table.DocumentTableCell.text("Beside")) + .build(), LayerAlign.TOP_LEFT, 0))); + + // The cell sizes its disc to its own width: the disc is the one drawing besides the bar. + long bar = exported.heightOf(200); + assertThat(exported.heights()).hasSize(2); + assertThat(exported.heights().entrySet()).as("the cell's disc under the bar") + .filteredOn(drawing -> drawing.getKey() != Units.toEMU(200)) + .singleElement().satisfies(disc -> assertThat(disc.getValue()).isLessThan(bar)); + } + + @Test + void aTitleAndItsDatesStackTheirDrawingsByTheirZIndex() throws Exception { + Exported exported = export(page -> page.addLayerStack(stack -> stack + .layer(new ShapeBuilder().size(300, 18).fillColor(ACCENT).build(), LayerAlign.TOP_LEFT, 1) + .layer(new ShapeBuilder().size(340, 22).fillColor(LIGHT).build(), LayerAlign.TOP_LEFT, 0) + .layer(new ParagraphBuilder().text("Engineer").build(), LayerAlign.TOP_LEFT, 2) + .layer(new ParagraphBuilder().text("2020 - 2024").build(), LayerAlign.TOP_RIGHT, 2))); + + assertThat(exported.heightOf(340)).as("the background under the highlight").isLessThan(exported.heightOf(300)); + } + + @Test + void columnsStackTheirDrawnLayersWhereThePagePaintsThem() throws Exception { + // A backdrop given first under the columns, and two marks over them, the later one under. + Exported exported = export(page -> page.addLayerStack(stack -> stack.name("Columns") + .layer(new ShapeBuilder().size(100, 50).fillColor(LIGHT).build(), LayerAlign.TOP_LEFT, 0) + .layer(column("Sidebar", 0, 240, side -> side.addParagraph("Contact") + .addCircle(30, ACCENT)), LayerAlign.TOP_LEFT, 1) + .layer(column("Main", 120, 0, main -> main.addParagraph("Experience")), LayerAlign.TOP_LEFT, 1) + .layer(new ShapeBuilder().size(40, 12).fillColor(ACCENT).build(), LayerAlign.TOP_RIGHT, 3) + .layer(new ShapeBuilder().size(20, 6).fillColor(DocumentColor.WHITE).build(), LayerAlign.TOP_RIGHT, 2))); + + assertThat(exported.document().getTables()).as("written as columns").hasSize(1); + assertThat(exported.heightOf(100)).as("the backdrop under the sidebar's mark").isLessThan(exported.heightOf(30)); + assertThat(exported.heightOf(30)).as("the sidebar's mark under the marks over the columns") + .isLessThan(exported.heightOf(20)); + assertThat(exported.heightOf(20)).as("the later mark under the earlier").isLessThan(exported.heightOf(40)); + } + + private static LayerStackBuilder discAndDot(LayerStackBuilder stack) { + return stack.layer(disc(36, ACCENT), LayerAlign.CENTER, 1) + .layer(disc(8, DocumentColor.WHITE), LayerAlign.CENTER, 0); + } + + private static DocumentNode disc(double diameter, DocumentColor fill) { + return new EllipseBuilder().circle(diameter).fillColor(fill).build(); + } + + private static DocumentNode column(String name, double insetLeft, double insetRight, + Consumer content) { + SectionBuilder layer = new SectionBuilder(); + layer.name(name).spacing(0).padding(new DocumentInsets(0, insetRight, 0, insetLeft)); + layer.addSection(name + "Content", section -> content.accept(section.spacing(0))); + return layer.build(); + } + + private record Exported(XWPFDocument document, List notes, Map heights) { + + /** The stacking height of the drawing as wide as so many points. */ + long heightOf(double width) { + Long height = heights.get(Units.toEMU(width) * 1L); + assertThat(height).as("a drawing %spt wide, among drawings %s EMU wide", width, heights.keySet()).isNotNull(); + return height; + } + } + + private static Exported export(Consumer content) throws Exception { + AtomicReference report = new AtomicReference<>(); + byte[] docx; + try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) { + session.pageFlow(page -> { + page.addParagraph("Before"); + content.accept(page); + page.addParagraph("After"); + }); + docx = session.export(new DocxSemanticBackend(report::set)); + } + assertThat(report.get().notes()).noneMatch(note -> note.subject().equals("measured geometry")); + XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx)); + Map heights = new HashMap<>(); + Matcher anchor = ANCHOR.matcher(document.getDocument().xmlText()); + while (anchor.find()) { + heights.put(Long.parseLong(anchor.group(2)), Long.parseLong(anchor.group(1))); + } + return new Exported(document, report.get().notes(), heights); + } +} 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 fadfe891c..464c35686 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 @@ -191,7 +191,9 @@ private record Entry(Fate fate, String note) { "anchor:WRITTEN"); node(LayerStackNode.class, "name:INERT", "layers:REPORTED:one the page starts above where the layers before it end, which is written under " - + "them; composed in a table cell or with no layout, more than one of them written, not measured", + + "them; composed in a table cell or with no layout, more than one of them written, not measured; " + + "their drawings stack as the page paints them, by their z-index, among those behind the text " + + "and among those in front of it", "padding:WRITTEN", "margin:WRITTEN", "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", @@ -289,8 +291,9 @@ private record Entry(Fate fate, String note) { + "written as a panel in a table cell, its corners square; one no shape draws, or composed in a " + "table cell with nowhere to draw it, dropped", "layers:REPORTED:written inline one after another in source order where it writes them in the " - + "flow, or stacked in another order than given; drawn where the page puts them, unreported, where " - + "they are all drawing in source order; a panel's in its cell", + + "flow; drawn where the page puts them, unreported, where they are all drawing, stacked as the " + + "page paints them among those behind the text and among those in front of it; a panel's in its " + + "cell", "clipPolicy:REPORTED:where it cuts what its layers paint; composed in a table cell, on its table", "fillColor:REPORTED:its translucency, as a panel's, flattened against the colour under it; " + "drawn, its alpha is written; any other is written",