diff --git a/CHANGELOG.md b/CHANGELOG.md index 91f150e12..00e373ef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export sets a layer across where its offset moves it.** Measured in Word and + LibreOffice on Windows, a stack's one layer moved 40pt in stood at the stack's left edge in + both. A band's layer the page sets 20pt left of the stack stood at the stack's edge, the page + margin, in both. Neither was named. + - A layer is now held to its box on both sides, past the stack's edge where the page sets it + there, by an indent below zero. Both editors set the two layers where the page does, and a + stack's one layer of three lines moved 40pt in breaks at the page's words in both. + - A table cell's text starts no further left than the cell's own. There a layer stands left only + as far as the indents round it give, and the rest is named (`layer offset`). A stack's one + layer moved across in a table cell is written where the stack starts, and named. So is one + composed in a table's cell, where the stack has no place of its own in the layout. + - An offset down or up moves a stack's one layer without making room for it, which Word's flow + cannot do. It is written where the stack starts, as before, and now named (`layer offset`), + except where a band sets it at the page's distance below what is above it. - **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 diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 8363a9b7b..08c4b14c0 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. 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 | +| 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. Each layer is held to its box across, past the stack's edge where the page sets it past it, by an indent below zero; in a table cell no further left than the indents round it give, the rest named. The one layer of a stack of one layer is held the same way, its offset across kept and its measure with it; in a table cell, composed in a table's cell with no place of its own, and for an offset down or up, which moves it without making room for it, it is written where the stack starts and named — not where a band or a column's layer sets it at the page's distance below what is above it, which holds the offset. 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 059584635..4efc724b1 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -856,7 +856,12 @@ recolour what is under it in Word and it keeps the colour it was flattened to. where the lines its table laid out for it stand and a picture where the alignment sets one of its written width; any other block there, or one inside a panel or another table in its cell, is written from the cell's left and named, a drawing aside, which its table - draws where the page draws it. Drawing is drawn as a shape where the + draws where the page draws it. A layer the page sets past the stack's edge stands out + past it as far, by an indent below zero, its box's own width and no more: a stack at the + page margin with a layer offset 20pt left of it has that layer in the margin. In a table + cell, whose text starts no further left than the cell's own, it stands left only as far + as the indents round it give, and the report names the rest (`layer offset`); its right + side stays at the edge. Drawing is drawn as a shape where the page draws it (see "What is skipped") and does not count as content. A later layer the page starts above where the layers before it end — a caption over the foot of its photo, a label in a card's corner, text over text — is written under them, as @@ -869,6 +874,18 @@ recolour what is under it in Word and it keeps the colour it was flattened to. them writes in the flow, its note says whether the page lays one over another is not measured. A layer the page starts under the layers before it, but nearer them than its own margin above it, is written that margin below them, unnamed. +- **A layer stack of one layer → its layer, where the page sets it across.** It lays + nothing over anything and is written as its contents, in the flow. Its layer is held + to its box in the stack, as a band's layer is: an offset across moves it whole, past + the stack's other edge as far, and it keeps the page's measure, its lines breaking at + the page's words. In a table cell, whose text Word holds inside the cell, it is written + where the stack starts; so it is composed in a table's cell, where the stack has no place + of its own in the layout and its offset is named as given. An offset down or up moves + the layer without making room for it, over what follows the stack or what comes before + it, which Word's flow cannot do: it is written where the stack starts. The report names + each (`layer offset`), but not a layer that only draws, which is drawn where the page + draws it, nor one written where a band or a layer of a column sets the next block at the + page's distance below what is above it: that distance holds the offset. - **Text at the left and the right of one band → one line split by a tab.** A container or a layer stack holding exactly two single-line paragraphs, level with one another and apart across the page — a CV entry's title and its dates — is one 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 805d092e6..a2fa13ef3 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 @@ -7333,6 +7333,14 @@ private void writeContainerBody(XWPFDocument document, DocumentNode node) throws // alignment puts it: a portrait centred in a sidebar stood at the sidebar's left edge. if (node instanceof com.demcha.compose.document.node.AlignNode align) { placeAcross(align, align.child()); + } else if (isOneLayer(node)) { + // So does a stack's one layer, where the page sets it in the stack: a line moved 40pt in + // stood at the stack's left edge. Moved whole, it runs as far past the stack's other + // side, which a table cell's text does not: there it is written where the stack starts. + if (currentCell == null) { + placeAcross(node, node.children().get(0)); + } + reportTheLayerMoved(node); } try { writeChildren(document, node.children(), spacingOf(node), followedInFlow.remove(node)); @@ -16394,6 +16402,57 @@ private void placeAcross(DocumentNode stack, DocumentNode layer) { holdIn(layer, placed.placementX(), placed.placementWidth(), contentLeft, contentRight); } + /** + * Names the one layer of a stack whose offset Word does not write, which it writes where the + * stack starts. + * + * + */ + private void reportTheLayerMoved(DocumentNode stack) { + DocumentNode layer = stack.children().get(0); + if (onlyDrawn(layer)) { + return; + } + com.demcha.compose.document.layout.PlacedNode box = layout.placement(stack); + com.demcha.compose.document.layout.PlacedNode placed = layout.placement(layer); + if (box == null || placed == null) { + com.demcha.compose.document.node.LayerStackNode.Layer given = + ((com.demcha.compose.document.node.LayerStackNode) stack).layers().get(0); + if (!layout.isEmpty() && (Math.abs(given.offsetX()) > 0.5 || Math.abs(given.offsetY()) > 0.5)) { + report.add(DocxExportReport.Severity.APPROXIMATED, "layer offset", layout.pathOf(layer), + "with no place of its own in the layout, it is written where the stack starts, its offset of " + + pointsOf(given.offsetX()) + "pt across and " + pointsOf(given.offsetY()) + "pt down not " + + "written"); + } + return; + } + List moved = new ArrayList<>(); + double across = placed.placementX() - layer.margin().left() - (box.placementX() + stack.padding().left()); + if (currentCell != null && Math.abs(across) > 0.5) { + moved.add(pointsOf(Math.abs(across)) + "pt " + (across > 0 ? "right" : "left") + + " of the stack's edge, in a table cell, whose text Word holds inside it"); + } + double lower = box.placementY() + box.placementHeight() - stack.padding().top() - layer.margin().top() + - (placed.placementY() + placed.placementHeight()); + if (Double.isNaN(resumeSpacing) && Math.abs(lower) > 0.5) { + moved.add(pointsOf(Math.abs(lower)) + "pt " + (lower > 0 ? "lower" : "higher") + + " than its stack starts, without making room for it"); + } + if (!moved.isEmpty()) { + report.add(DocxExportReport.Severity.APPROXIMATED, "layer offset", layout.pathOf(layer), + "the page sets it " + String.join(", and ", moved) + "; Word writes it where the stack starts"); + } + } + /** * Holds an alignment's child in across where the page puts it in a table's cell built from a * node, where neither has a place of its own in the layout. @@ -16508,7 +16567,8 @@ private boolean standsInTheComposedCell(DocxLayoutMetrics.TextBox lines) { /** * Holds a block in across, from the edges it is written between to where the layout placed - * it, with room to spare for a paragraph on the side its text does not lean on. + * it, with room to spare for a paragraph on the side its text does not lean on; out past an + * edge where the layout placed it past it. * * @param layer the block * @param placedX the page position of the left edge of its box, where the layout put it @@ -16520,7 +16580,27 @@ private void holdIn(DocumentNode layer, double placedX, double placedWidth, double contentLeft, double contentRight) { double left = placedX - layer.margin().left() - contentLeft; double right = contentRight - (placedX + placedWidth + layer.margin().right()); - if (!(left > 0.5) && !(right > 0.5)) { + // A block the page sets past an edge stands out past it as far, where a paragraph's indent + // and a table's take a side below zero: a band's layer set left of the stack stands in the + // margin, and a layer moved whole keeps the page's measure, its lines breaking where the + // page breaks them. A cell's text starts no further left than the cell's own (applyInset): + // there a block stands left only as far as the indents round it give, and what it cannot is + // named. Its right side is held at the edge, as a line the page sets past it is given its + // room by the paragraph itself (letTheLineStandOut). + double pastLeft = left < -0.5 ? left : 0; + double pastRight = right < -0.5 && currentCell == null ? right : 0; + double floor = currentCell != null ? -Math.max(0, insetLeft + cellTextShift) : Double.NEGATIVE_INFINITY; + if (pastLeft - floor < -0.5) { + // A drawing is drawn where the page draws it, whatever the indents. + if (!onlyDrawn(layer)) { + report.add(DocxExportReport.Severity.APPROXIMATED, "layer offset", layout.pathOf(layer), + "the page sets it " + pointsOf(-pastLeft) + "pt left of the edge it is written from; " + + "in its table cell " + (floor < -0.5 ? "it stands only " + pointsOf(-floor) + + "pt left of that edge" : "it is written at that edge")); + } + pastLeft = floor; + } + if (!(left > 0.5) && !(right > 0.5) && pastLeft == 0 && pastRight == 0) { return; } // Only a paragraph's own box needs room to spare: a container's paragraphs take theirs @@ -16535,8 +16615,10 @@ private void holdIn(DocumentNode layer, double placedX, double placedWidth, } else { right -= slack; } - insetLeft += Math.max(0, left); - insetRight += Math.max(0, right); + // Past an edge it keeps its box's own: room to spare there widened a layer moved 40pt in by + // 12.8pt, and its three lines took two in Word and in LibreOffice. + insetLeft += pastLeft < 0 ? pastLeft : Math.max(0, left); + insetRight += pastRight < 0 ? pastRight : Math.max(0, right); } /** diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerOffsetTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerOffsetTest.java new file mode 100644 index 000000000..ed1cbbe3b --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerOffsetTest.java @@ -0,0 +1,288 @@ +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.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.node.TextAlign; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTInd; + +import java.io.ByteArrayInputStream; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * A layer stands across where its offset moves it. + * + *

Measured in Word 16 and LibreOffice on Windows: a stack's one layer moved 40pt in stood at + * the stack's left edge in both, and a band's layer the page sets 20pt left of the stack stood at + * the stack's edge, the page margin, in both. Held to its box, in by the offset and out past an + * edge by an indent below zero, each stood where the page sets it, in both, and a layer of three + * lines moved 40pt in broke at the page's words. An offset down or up moves a layer without making + * room for it, which the flow Word writes cannot do: that is named.

+ * + * @author Artem Demchyshyn + */ +class DocxLayerOffsetTest { + + private static final DocumentColor LIGHT = DocumentColor.rgb(220, 230, 245); + private static final String WRAPPING = "An offset layer of several words that wraps onto a second and a third line"; + + @Test + void aStacksOneLayerMovedAcrossKeepsThePagesMeasure() throws Exception { + Exported exported = export(page -> page.addLayerStack(stack -> stack + .position(text(WRAPPING), 40, 0, LayerAlign.TOP_LEFT))); + + XWPFParagraph moved = exported.paragraph(WRAPPING); + assertThat(left(moved)).as("held in 40pt").isCloseTo(800L, within(2L)); + assertThat(right(moved)).as("and out 40pt past the right edge, as wide as the page sets it") + .isCloseTo(-800L, within(2L)); + assertThat(exported.offsetNotes()).as("set as the page sets it").isEmpty(); + } + + @Test + void aStacksOneLayerTheOffsetMovesDownIsNamedAndWrittenWhereTheStackStarts() throws Exception { + Exported exported = export(page -> page.addLayerStack(stack -> stack + .position(text("Moved"), 40, 10, LayerAlign.TOP_LEFT))); + + assertThat(left(exported.paragraph("Moved"))).as("held in 40pt still").isCloseTo(800L, within(2L)); + assertThat(exported.paragraph("Moved").getSpacingBefore()).as("written where the stack starts") + .isLessThanOrEqualTo(0); + assertThat(exported.offsetNotes()).singleElement().satisfies(note -> { + assertThat(note.severity()).isEqualTo(DocxExportReport.Severity.APPROXIMATED); + assertThat(note.path()).endsWith("ParagraphNode[0]"); + assertThat(note.detail()).isEqualTo("the page sets it 10pt lower than its stack starts, without making " + + "room for it; Word writes it where the stack starts"); + }); + } + + @Test + void aStacksOneLayerTheOffsetMovesUpIsNamedHigher() throws Exception { + Exported exported = export(page -> page.addLayerStack(stack -> stack + .position(text("Moved"), 0, -6, LayerAlign.TOP_LEFT))); + + assertThat(exported.paragraph("Moved").getCTP().getPPr().getInd()).as("not moved across").isNull(); + assertThat(exported.offsetNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .asString().startsWith("the page sets it 6pt higher than its stack starts"); + } + + @Test + void aStacksOneLayerSetInByItsMarginAndTheStacksPaddingIsNotNamed() throws Exception { + Exported exported = export(page -> page.addLayerStack(stack -> stack.padding(6) + .layer(new ParagraphBuilder().text("Set in").margin(DocumentInsets.top(8)).build(), LayerAlign.TOP_LEFT))); + + assertThat(exported.paragraph("Set in").getText()).as("the premise: written").isEqualTo("Set in"); + assertThat(exported.offsetNotes()).isEmpty(); + } + + @Test + void aStacksOneLayerThatOnlyDrawsIsDrawnWhereThePageDrawsItUnnamed() throws Exception { + Exported exported = export(page -> page.addLayerStack(stack -> stack + .position(new ShapeBuilder().size(40, 10).fillColor(LIGHT).build(), 30, 10, LayerAlign.TOP_LEFT))); + + assertThat(exported.offsetNotes()).isEmpty(); + } + + @Test + void aStacksOneLayerThatOnlyDrawsInABandIsDrawnWhereThePageDrawsItUnnamed() throws Exception { + // In a band it is written with the band's layer, not held as room in the flow. + DocumentNode mark = new LayerStackBuilder() + .position(new ShapeBuilder().size(40, 10).fillColor(LIGHT).build(), 30, 10, LayerAlign.TOP_LEFT) + .build(); + Exported exported = export(page -> page.addLayerStack(stack -> stack + .layer(new ShapeBuilder().size(200, 60).fillColor(LIGHT).build(), LayerAlign.TOP_LEFT) + .layer(new SectionBuilder().addParagraph("Title").add(mark).build(), LayerAlign.TOP_LEFT))); + + assertThat(exported.paragraph("Title")).as("the premise: the band is written").isNotNull(); + assertThat(exported.offsetNotes()).isEmpty(); + } + + @Test + void aStacksOneLayerABandWritesFirstIsSetDownByTheBandUnnamed() throws Exception { + // The band sets the page's distance down to its first block, the offset in it. + DocumentNode label = new LayerStackBuilder().position(text("Label"), 0, 10, LayerAlign.TOP_LEFT).build(); + Exported exported = export(page -> page.addLayerStack(stack -> stack + .layer(new ShapeBuilder().size(200, 60).fillColor(LIGHT).build(), LayerAlign.TOP_LEFT) + .layer(label, LayerAlign.TOP_LEFT))); + + assertThat(exported.paragraph("Label").getSpacingBefore()).as("set the offset down").isGreaterThan(150); + assertThat(exported.offsetNotes()).isEmpty(); + } + + @Test + void aBandsLayerSetLeftOfTheStackStandsOutPastItsEdge() throws Exception { + Exported exported = export(page -> page.addLayerStack(stack -> stack + .layer(new ShapeBuilder().size(200, 60).fillColor(LIGHT).build(), LayerAlign.TOP_LEFT) + .position(text("Left"), -20, 5, LayerAlign.TOP_LEFT) + .position(text("Right"), 120, 30, LayerAlign.TOP_LEFT) + .layer(new ParagraphBuilder().text("Flush").align(TextAlign.RIGHT).build(), LayerAlign.BOTTOM_LEFT))); + + assertThat(left(exported.paragraph("Left"))).as("20pt out into the margin").isCloseTo(-400L, within(2L)); + assertThat(left(exported.paragraph("Right"))).as("and the one inside held in").isCloseTo(2400L, within(2L)); + CTInd flush = exported.paragraph("Flush").getCTP().getPPr().getInd(); + assertThat(flush == null || !flush.isSetLeft() ? 0L : DocxTwips.of(flush.getLeft())) + .as("one set at the edge stays inside it, its room to spare on its left").isGreaterThanOrEqualTo(0L); + assertThat(exported.offsetNotes()).isEmpty(); + } + + @Test + void aBandsLayerSetLeftOfItsEdgeInARowsCellIsWrittenAtTheEdgeAndNamed() throws Exception { + Exported exported = export(page -> page.addRow(row -> row.add(leftBand()).addParagraph("Beside"))); + + assertThat(indentOf(exported.cellParagraph("Left"))).as("the cell's text starts at the cell's own").isNull(); + assertThat(exported.offsetNotes()).singleElement().satisfies(note -> { + assertThat(note.path()).endsWith("ParagraphNode[1]"); + assertThat(note.detail()).isEqualTo("the page sets it 20pt left of the edge it is written from; in its " + + "table cell it is written at that edge"); + }); + } + + @Test + void aBandsLayerSetLeftOfItsEdgeInARowsCellStandsAsFarLeftAsItsIndentGoes() throws Exception { + Exported exported = export(page -> page.addRow(row -> row + .add(new SectionBuilder().addParagraph("Head").add(new SectionBuilder() + .padding(new DocumentInsets(0, 0, 0, 12)).add(leftBand()).build()).build()) + .addParagraph("Beside"))); + + assertThat(indentOf(exported.cellParagraph("Left"))).as("12pt in, all of it given back").isNull(); + assertThat(exported.offsetNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .isEqualTo("the page sets it 20pt left of the edge it is written from; in its table cell it stands " + + "only 12pt left of that edge"); + } + + @Test + void aBandsLayerSetLeftOfItsEdgeWithinTheIndentInARowsCellStandsWhereThePageSetsIt() throws Exception { + Exported exported = export(page -> page.addRow(row -> row + .add(new SectionBuilder().addParagraph("Head").add(new SectionBuilder() + .padding(new DocumentInsets(0, 0, 0, 24)).add(leftBand()).build()).build()) + .addParagraph("Beside"))); + + assertThat(indentOf(exported.cellParagraph("Left"))).as("24pt in, 20pt of it given back") + .isCloseTo(80L, within(2L)); + assertThat(exported.offsetNotes()).isEmpty(); + } + + @Test + void aBandsDrawingSetLeftOfItsEdgeInARowsCellIsDrawnWhereThePageDrawsItUnnamed() throws Exception { + DocumentNode band = new LayerStackBuilder() + .layer(new ShapeBuilder().size(120, 40).fillColor(LIGHT).build(), LayerAlign.TOP_LEFT) + .position(new ShapeBuilder().size(4, 40).fillColor(DocumentColor.BLACK).build(), -8, 0, LayerAlign.TOP_LEFT) + .position(text("Text"), 10, 5, LayerAlign.TOP_LEFT) + .build(); + Exported exported = export(page -> page.addRow(row -> row.add(band).addParagraph("Beside"))); + + assertThat(exported.cellParagraph("Text")).as("the premise: the band is written").isNotNull(); + assertThat(exported.offsetNotes()).isEmpty(); + } + + @Test + void aStacksOneLayerMovedAcrossInARowsCellIsWrittenWhereTheStackStartsAndNamed() throws Exception { + DocumentNode moved = new LayerStackBuilder().position(text("Moved"), 30, 0, LayerAlign.TOP_LEFT).build(); + Exported exported = export(page -> page.addRow(row -> row.add(moved).addParagraph("Beside"))); + + assertThat(indentOf(exported.cellParagraph("Moved"))).as("not held in").isNull(); + assertThat(exported.offsetNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .isEqualTo("the page sets it 30pt right of the stack's edge, in a table cell, whose text Word holds " + + "inside it; Word writes it where the stack starts"); + } + + @Test + void aStacksOneLayerMovedInATablesComposedCellIsNamedByItsOffset() throws Exception { + // Composed in a table cell, the stack has no place of its own in the layout. + DocumentNode moved = new LayerStackBuilder().position(text("Moved"), 30, 6, LayerAlign.TOP_LEFT).build(); + Exported exported = export(page -> page.addTable(table -> table + .columns(com.demcha.compose.document.table.DocumentTableColumn.fixed(120), + com.demcha.compose.document.table.DocumentTableColumn.fixed(120)) + .rowCells(com.demcha.compose.document.table.DocumentTableCell.node(moved), + com.demcha.compose.document.table.DocumentTableCell.node(text("Beside"))))); + + assertThat(indentOf(exported.cellParagraph("Moved"))).as("not held in").isNull(); + assertThat(exported.offsetNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .isEqualTo("with no place of its own in the layout, it is written where the stack starts, its offset " + + "of 30pt across and 6pt down not written"); + } + + private static DocumentNode leftBand() { + return new LayerStackBuilder() + .layer(new ShapeBuilder().size(120, 40).fillColor(LIGHT).build(), LayerAlign.TOP_LEFT) + .position(text("Left"), -20, 5, LayerAlign.TOP_LEFT) + .position(text("Under"), 0, 25, LayerAlign.TOP_LEFT) + .build(); + } + + private static DocumentNode text(String text) { + return new ParagraphBuilder().text(text).build(); + } + + private static long left(XWPFParagraph paragraph) { + Long left = indentOf(paragraph); + assertThat(left).as("%s is indented", paragraph.getText()).isNotNull(); + return left; + } + + private static long right(XWPFParagraph paragraph) { + CTInd indent = paragraph.getCTP().getPPr().getInd(); + assertThat(indent != null && indent.isSetRight()).as("%s has a right indent", paragraph.getText()).isTrue(); + return DocxTwips.of(indent.getRight()); + } + + /** The paragraph's left indent in twips, or {@code null} where none is written or it is zero. */ + private static Long indentOf(XWPFParagraph paragraph) { + CTInd indent = paragraph.getCTP().isSetPPr() ? paragraph.getCTP().getPPr().getInd() : null; + if (indent == null || !indent.isSetLeft()) { + return null; + } + long left = DocxTwips.of(indent.getLeft()); + return left == 0 ? null : left; + } + + private record Exported(XWPFDocument document, List notes) { + + XWPFParagraph paragraph(String text) { + return document.getParagraphs().stream().filter(paragraph -> paragraph.getText().equals(text)) + .findFirst().orElseThrow(); + } + + XWPFParagraph cellParagraph(String text) { + return document.getTables().stream() + .flatMap(table -> table.getRows().stream()) + .flatMap(row -> row.getTableCells().stream()) + .flatMap(cell -> cell.getParagraphs().stream()) + .filter(paragraph -> paragraph.getText().equals(text)) + .findFirst().orElseThrow(); + } + + List offsetNotes() { + return notes.stream().filter(note -> note.subject().equals("layer offset")).toList(); + } + } + + 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(72)).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")); + return new Exported(new XWPFDocument(new ByteArrayInputStream(docx)), report.get().notes()); + } +} 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 464c35686..9d3df61c2 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 @@ -193,7 +193,9 @@ private record Entry(Fate fate, String note) { "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; " + "their drawings stack as the page paints them, by their z-index, among those behind the text " - + "and among those in front of it", + + "and among those in front of it; the one of a stack of one layer its offset moves down or up, " + + "or across in a table cell, or at all with no place of its own in the layout, and one set left " + + "of the stack in a table cell further than the indents round it give", "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", @@ -293,7 +295,7 @@ private record Entry(Fate fate, String note) { "layers:REPORTED:written inline one after another in source order where it writes them in the " + "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", + + "cell; one set left of its content in a table cell further than the indents round it give", "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",