diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e373ef8..ea69baac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export sets a panel's layer where its alignment sets it.** A filled or outlined + rectangle or rounded rectangle with text, composed in a table cell, is written as a panel, a + table of one cell, and its cell was always written centred. Measured in Word and LibreOffice on + Windows, in a 100×60 panel, a label set at the top-left corner stood at the middle, 22.5pt low. + One set at the bottom-right corner stood at the middle and at the left, 46pt left of the + page's. Neither was named. + - The cell now sets what it writes at the top, the middle or the foot, as its first written + layer is set. A paragraph is held in across to its box where its own alignment does not lean + the way its layer's does, or where an offset moves it across. Its width is read off the lines + its table laid out for it. Both labels stand where the page sets them, in both editors. + - What the cell cannot set is named (`layer offset`): + - a written layer after the first, which the cell writes under the layers before it; + - an offset down or up; + - a box set past the shape's right edge, or left of it; + - a layer set across by a box the layout does not give: a layer other than a paragraph, or a + paragraph whose lines are not found. - **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 diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 08c4b14c0..568a4705e 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. 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 | +| 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 shape container written as a panel in a table cell sets what its cell holds at the top, middle or foot as its first written layer is set, holds a paragraph across to its box, and names a written layer after the first (written under the ones before it), an offset down or up, a box past the shape's side, and a layer set across by a box the layout does not give (`layer 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 4efc724b1..4c4deb593 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -980,8 +980,16 @@ recolour what is under it in Word and it keeps the colour it was flattened to. held at least its outline's height less the borders both editors draw outside it where its padding does not hold its top border, with its layers inside, its corners squared and reported — a rota's shift chips keep their colour and their - size. A one-line label the shape centres top to bottom, on a line taller - than the outline leaves it, is written that much shorter, down to the + size. Its cell sets what it writes at the top, the middle or the foot, where the + first written layer's alignment sets that layer. A paragraph whose own alignment does + not lean the way its layer's does, or that an offset moves across, is held in to its + box: the cell's room less the width its table laid its lines out at, set at the + alignment's share and moved by its offset. What the cell cannot set where the page does + is named (`layer offset`): a written layer after the first, which the cell writes under + the layers before it; an offset down or up; a box set past the shape's right edge or + left of it; and a layer set across by a box the layout does not give — not a paragraph, + or one whose lines are not found. A one-line label the shape centres top to bottom, on + a line taller than the outline leaves it, is written that much shorter, down to the room Word leaves the cell's content — the outline less the margins written and the borders, which Word keeps outside its content, as wide as it draws them — its text seated 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 a2fa13ef3..acfbb80a0 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 @@ -6705,7 +6705,8 @@ private void writePanel(XWPFDocument document, DocumentNode node, ContainerPaint onANewPage = true; } List piece = pieces.get(index); - writePanelPiece(document, node, paint, () -> writeChildren(document, piece, spacingOf(node)), + ShapeContainerNode shape = node instanceof ShapeContainerNode container ? container : null; + writePanelPiece(document, node, paint, () -> writeChildren(document, piece, spacingOf(node), false, shape), index == 0, index == pieces.size() - 1, onANewPage); } } @@ -6737,7 +6738,7 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container owePendingSpacingAfter(carriedSpacingBefore + (first ? margin.top() : 0)); carriedSpacingBefore = 0; double topNotTaken = 0; - // A shape's layers are centred in it, and its outline holds its height whole, its borders + // A shape's layers are set inside it, and its outline holds its height whole, its borders // drawn inside it (holdThePanelsHeight): nothing of it reaches past its box. boolean heldWhole = node instanceof ShapeContainerNode; if (first) { @@ -6830,9 +6831,14 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container if ((node.keepTogether() || node instanceof RowNode) && layout.onOnePage(node)) { table.getRow(0).setCantSplitRow(true); } - if (node instanceof ShapeContainerNode) { - // The page centres a shape's layers in it, in the outline's height the row is held to. - cell.setVerticalAlignment(XWPFTableCell.XWPFVertAlign.CENTER); + if (node instanceof ShapeContainerNode shape) { + // The page sets a shape's layers at its top, middle or foot, in the outline's height the + // row is held to, and Word sets the cell's content there: a label set at the top-left + // corner stood at the middle, 22.5pt low. + XWPFTableCell.XWPFVertAlign vertical = verticalOfItsLayers(shape); + if (vertical != null) { + cell.setVerticalAlignment(vertical); + } } cell.removeParagraph(0); @@ -6854,7 +6860,7 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container long outerRaised = panelRaisedFromItsPadding; panelsFirstLineKeeps = Math.max(0, toTwips(topNotTaken) - cellMargin(cell, true)); // What the padding takes of it is no room either (paddingAbove). A shape's layers are - // centred in the outline it holds whole, and its first line rises into nothing of it. + // set inside the outline it holds whole, and its first line rises into nothing of it. panelsMarginKeeps = Math.min(cellMargin(cell, true), toTwips(topNotTaken)); panelsPaddingGives = first && last && !(node instanceof ShapeContainerNode); panelRaisedFromItsPadding = 0; @@ -6933,7 +6939,7 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container // leaves no room under the content to take it. Its padding below takes it, the line // hanging into it as on the page; what follows takes the rest out of the space between // them, as below a row (writeRow). A card's border is the card's padding's to take - // first. A shape's layers are centred in the outline's height it holds, which takes it. + // first. A shape's layers are set inside the outline's height it holds, which takes it. double hang = heldWhole ? 0 : cellOverhang; double carried = takeTheCarriedBorder(heldWhole ? 0 : carriedBorder); double fromThePaddingBelow = takeOffTheCellMargin(cell, false, hang + carried); @@ -7000,7 +7006,7 @@ private static double takeOffTheCellMargin(XWPFTableCell cell, boolean top, doub * 12pt line, held a point above its content's height, stood a point taller in both than the * page's. So a panel set from the top holds the page's height less its padding, its margins * and borders coming round its content where the space round it and its padding take them - * ({@link #outsideTheRow}). A shape container, its layers centred in it, holds its outline's + * ({@link #outsideTheRow}). A shape container, its layers set inside it, holds its outline's * height whole, its margins and borders off its content, as the page draws them * inside its outline: CobaltRota's outlined chips, 9.2pt outlines with a 1.125pt border, stood * 10.3pt tall in Word and 10.2pt in LibreOffice with neither border taken off. The borders come @@ -7271,6 +7277,17 @@ private void writeChildren(XWPFDocument document, List children, d */ private void writeChildren(XWPFDocument document, List children, double spacing, boolean lastFollowed) throws Exception { + writeChildren(document, children, spacing, lastFollowed, null); + } + + /** + * Writes a flow's children one after another, each held in across where its shape sets it + * when they are the layers of a shape written as a panel ({@link #holdInItsPanel}). + * + * @param panel the shape whose layers they are, or {@code null} + */ + private void writeChildren(XWPFDocument document, List children, double spacing, + boolean lastFollowed, ShapeContainerNode panel) throws Exception { for (int index = 0; index < children.size(); index++) { DocumentNode child = children.get(index); if (index > 0 && spacing > 0 && !(child instanceof PageBreakNode)) { @@ -7282,7 +7299,19 @@ private void writeChildren(XWPFDocument document, List children, d if (index + 1 < children.size() && keepGoesUnheld(child, children.get(index + 1))) { keepsUnheld.add(child); } - writeNode(document, child); + if (panel == null) { + writeNode(document, child); + continue; + } + double outerLeft = insetLeft; + double outerRight = insetRight; + holdInItsPanel(panel, child); + try { + writeNode(document, child); + } finally { + insetLeft = outerLeft; + insetRight = outerRight; + } } } @@ -7563,6 +7592,175 @@ private ParagraphNode composedLabelOf(DocumentNode node) { return label; } + /** The shape's layer that holds a node, or {@code null}. */ + private static com.demcha.compose.document.node.LayerStackNode.Layer layerOf(ShapeContainerNode shape, + DocumentNode node) { + for (com.demcha.compose.document.node.LayerStackNode.Layer layer : shape.layers()) { + if (layer.node() == node) { + return layer; + } + } + return null; + } + + /** The share of the room across a layer's alignment sets it at: 0 left, 0.5 centred, 1 right. */ + private static double shareAcross(com.demcha.compose.document.node.LayerAlign align) { + return switch (align) { + case TOP_LEFT, CENTER_LEFT, BOTTOM_LEFT -> 0; + case TOP_CENTER, CENTER, BOTTOM_CENTER -> 0.5; + case TOP_RIGHT, CENTER_RIGHT, BOTTOM_RIGHT -> 1; + }; + } + + /** The share of the room down a layer's alignment sets it at: 0 top, 0.5 middle, 1 foot. */ + private static double shareDown(com.demcha.compose.document.node.LayerAlign align) { + return switch (align) { + case TOP_LEFT, TOP_CENTER, TOP_RIGHT -> 0; + case CENTER_LEFT, CENTER, CENTER_RIGHT -> 0.5; + case BOTTOM_LEFT, BOTTOM_CENTER, BOTTOM_RIGHT -> 1; + }; + } + + /** Whether a shape's layer is written: anything but drawing, which its table draws. */ + private boolean writtenInItsPanel(com.demcha.compose.document.node.LayerStackNode.Layer layer) { + return !onlyDrawn(layer.node()); + } + + /** + * Where a shape written as a panel sets what it writes in its cell, top to bottom: where its + * first written layer stands; {@code null} for the top, Word's own default. A panel writes one + * at least ({@link #writtenAsAPanel}); with none, the middle. + */ + private XWPFTableCell.XWPFVertAlign verticalOfItsLayers(ShapeContainerNode shape) { + for (com.demcha.compose.document.node.LayerStackNode.Layer layer : shape.layers()) { + if (writtenInItsPanel(layer)) { + double share = shareDown(layer.align()); + return share == 0 ? null : share == 1 ? XWPFTableCell.XWPFVertAlign.BOTTOM + : XWPFTableCell.XWPFVertAlign.CENTER; + } + } + return XWPFTableCell.XWPFVertAlign.CENTER; + } + + /** + * Holds a layer of a shape written as a panel in across where the shape sets it, from the + * width the lines its table laid out for it take. + * + *

The page sets a layer narrower than the shape at its alignment's side or middle: a label + * set at the bottom-right corner stood at the left in Word and LibreOffice. A paragraph whose + * own alignment leans the same way, unmoved, already stands there. Any other is held to its + * box, the shape's room less its width set at its alignment's share and moved by its offset. + * What is not measured is named ({@link #reportTheLayersOfItsPanel}).

+ */ + private void holdInItsPanel(ShapeContainerNode shape, DocumentNode node) { + com.demcha.compose.document.node.LayerStackNode.Layer layer = layerOf(shape, node); + if (layer == null || !(node instanceof ParagraphNode paragraph) || !writtenInItsPanel(layer) + || standsAsItsLayer(paragraph, layer)) { + return; + } + double[] slot = slotInItsPanel(shape, layer, paragraph); + if (slot != null) { + holdIn(paragraph, slot[0] + paragraph.margin().left(), slot[1] - paragraph.margin().horizontal(), 0, + roomInItsPanel(shape)); + } + } + + /** + * The width a shape written as a panel leaves what it writes: its cell's, its outline less the + * padding written as the cell's margins. + */ + private static double roomInItsPanel(ShapeContainerNode shape) { + return shape.outline().width() - shape.padding().horizontal(); + } + + /** + * Whether a paragraph stands where its layer sets it without being held in: unmoved across, + * and its own alignment leaning the way its layer's does. + */ + private static boolean standsAsItsLayer(ParagraphNode paragraph, + com.demcha.compose.document.node.LayerStackNode.Layer layer) { + if (Math.abs(layer.offsetX()) > 0.5) { + return false; + } + double share = shareAcross(layer.align()); + return share == 0 ? paragraph.align() != TextAlign.CENTER && paragraph.align() != TextAlign.RIGHT + : share == 1 ? paragraph.align() == TextAlign.RIGHT + : paragraph.align() == TextAlign.CENTER; + } + + /** + * Where a paragraph layer's box stands across in the room its panel leaves it, as the page sets + * it: {@code {left, width}}, its margins in, the width read off the lines its table laid out for + * it; {@code null} where none stand in the cell. + */ + private double[] slotInItsPanel(ShapeContainerNode shape, com.demcha.compose.document.node.LayerStackNode.Layer layer, + ParagraphNode paragraph) { + java.util.Optional lines = layout.firstTextBox(paragraph); + if (lines.isEmpty() || !standsInTheComposedCell(lines.get())) { + return null; + } + double outer = lines.get().right() - lines.get().left() + paragraph.padding().horizontal() + + paragraph.margin().horizontal(); + double left = shareAcross(layer.align()) * Math.max(0, roomInItsPanel(shape) - outer) + layer.offsetX(); + return new double[]{left, outer}; + } + + /** + * Names what of a panel's layers Word does not set where the shape sets them: + * + *
    + *
  • a written layer after the first, which its cell writes under the layers before it, + * where the page sets it at their height or another;
  • + *
  • an offset down or up;
  • + *
  • a box the page sets past the shape's right edge, where the cell's text ends — a centred + * or right-aligned paragraph is as wide as the shape, and an offset across moves it out; + * one left of the shape is named where it is held ({@link #holdIn});
  • + *
  • one set across by a box the layout does not give.
  • + *
+ */ + private void reportTheLayersOfItsPanel(ShapeContainerNode shape) { + double down = Double.NaN; + for (com.demcha.compose.document.node.LayerStackNode.Layer layer : shape.layers()) { + if (!writtenInItsPanel(layer)) { + continue; + } + List lost = new ArrayList<>(); + double share = shareDown(layer.align()); + if (Double.isNaN(down)) { + down = share; + } else { + lost.add(share == down + ? "the page sets it at the height of the layers before it, and its cell writes it under them" + : "the page sets it at the " + heightCalled(share) + " of the shape, and its cell sets all " + + "it holds at the " + heightCalled(down) + ", it under the layers before it"); + } + if (Math.abs(layer.offsetY()) > 0.5) { + lost.add("its offset moves it " + pointsOf(Math.abs(layer.offsetY())) + "pt " + + (layer.offsetY() > 0 ? "down" : "up") + ", which its cell does not write"); + } + boolean across = shareAcross(layer.align()) > 0 || Math.abs(layer.offsetX()) > 0.5; + double[] slot = layer.node() instanceof ParagraphNode paragraph && across + && !standsAsItsLayer(paragraph, layer) ? slotInItsPanel(shape, layer, paragraph) : null; + if (slot != null && slot[0] + slot[1] - roomInItsPanel(shape) > 0.5) { + lost.add("the page sets it " + pointsOf(slot[0] + slot[1] - roomInItsPanel(shape)) + + "pt past the shape's right edge, where its cell's text ends"); + } else if (across && slot == null + && !(layer.node() instanceof ParagraphNode paragraph && standsAsItsLayer(paragraph, layer))) { + lost.add("the page sets it across by a box the layout does not give where it is composed in a " + + "table cell (" + layer.node().nodeKind() + "), and it is written from the shape's left"); + } + if (!lost.isEmpty()) { + // Composed in a table cell, it has no path of its own in the layout. + report.add(DocxExportReport.Severity.APPROXIMATED, "layer offset", layout.pathOf(layer.node()), + "written as a panel's content: " + String.join("; ", lost)); + } + } + } + + private static String heightCalled(double share) { + return share == 0 ? "top" : share == 1 ? "foot" : "middle"; + } + /** * Takes what a composed shape's cut label still needs past the height the shape's row holds * out of the margins of the cell the shape is in, half above and half below, as far as they @@ -9567,6 +9765,7 @@ private void writeShapeContainer(XWPFDocument document, ShapeContainerNode node, "written as a panel in its table cell, which carries no transform, so it stands " + "upright at its size"); } + reportTheLayersOfItsPanel(node); writePanel(document, node, new ContainerPaint(node.fillColor(), bordersOf(null, node.stroke()))); return; } @@ -11988,7 +12187,7 @@ private boolean inARowKeptWhole() { * much taller. LibreOffice 24.2 counts the margins inside that height * ({@link #seatTheLabelByItsLine}): a panel or a table row held taller than its text — a * drawing set where the page puts it — comes out taller there by the raise, under half a - * point. A shape's layers are centred in it, and none rises.

+ * point. A shape's layers are set inside it, and none rises.

*/ private long paddingAbove(XWPFParagraph para) { if (panelsPaddingGives && opensThePanel(para)) { 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 9d3df61c2..f9b811911 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 @@ -295,7 +295,10 @@ 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; one set left of its content in a table cell further than the indents round it give", + + "cell, at its first written layer's height, a paragraph held across to its box, and named where " + + "one follows the first, is moved down or up, is set past the shape's side, or is set across by " + + "a box the layout does not give; 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", diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelLayerAlignTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelLayerAlignTest.java new file mode 100644 index 000000000..d57f4c19b --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelLayerAlignTest.java @@ -0,0 +1,247 @@ +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.ParagraphBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.dsl.ShapeContainerBuilder; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; +import com.demcha.compose.document.layout.payloads.ParagraphTextSpan; +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 com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTInd; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr; + +import java.io.ByteArrayInputStream; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * A shape written as a panel in a table cell sets its layers where their alignment sets them. + * + *

Measured in Word 16 and LibreOffice on Windows, in a 100×60 panel: a label set at the + * top-left corner stood at the middle, 22.5pt low, and one set at the bottom-right corner at the + * middle and at the left, 46pt left of the page's — the panel's cell was always written centred. + * Set at the top, the foot or the middle as their layer is, and held in across to their box, both + * stood where the page sets them, in both.

+ * + * @author Artem Demchyshyn + */ +class DocxPanelLayerAlignTest { + + private static final DocumentColor LIGHT = DocumentColor.rgb(220, 230, 245); + private static final double ROOM = 100; + + @Test + void aLayerAtTheTopSetsThePanelsCellAtTheTop() throws Exception { + Exported exported = export(panel(text("TopLeft"), LayerAlign.TOP_LEFT, 0, 0)); + + assertThat(exported.vertical()).as("Word's own default, not written").isNull(); + assertThat(exported.left("TopLeft")).isZero(); + assertThat(exported.right("TopLeft")).as("set from the left as its layer is, not held to its box").isZero(); + assertThat(exported.offsetNotes()).isEmpty(); + } + + @Test + void aLayerAtTheFootSetsThePanelsCellAtTheFootAndItsBoxAtItsSide() throws Exception { + Exported exported = export(panel(text("BotRight"), LayerAlign.BOTTOM_RIGHT, 0, 0)); + + assertThat(exported.vertical()).isEqualTo("bottom"); + assertThat(exported.left("BotRight")).as("held in to its box at the right") + .isCloseTo(Math.round((ROOM - exported.widthOf("BotRight")) * 20), within(2L)); + assertThat(exported.offsetNotes()).isEmpty(); + } + + @Test + void aCentredLayerSetsThePanelsCellAtTheMiddleAndALeftParagraphInItsMiddle() throws Exception { + Exported exported = export(panel(text("Middle"), LayerAlign.CENTER, 0, 0)); + + assertThat(exported.vertical()).isEqualTo("center"); + double width = exported.widthOf("Middle"); + assertThat(exported.left("Middle")).as("its box centred, its text set from the box's left") + .isCloseTo(Math.round((ROOM - width) / 2 * 20), within(2L)); + // On the right, the room to spare a line set from its left is given, and what Word's face + // asks of its measure. + assertThat(exported.right("Middle")).as("held in on the right to its box too") + .isCloseTo(Math.round(((ROOM - width) / 2 - Math.max(2, width * 0.05)) * 20), within(30L)); + } + + @Test + void aParagraphLeaningAsItsLayerIsNotHeldIn() throws Exception { + Exported exported = export(panel(new ParagraphBuilder().text("Middle").align(TextAlign.CENTER).build(), + LayerAlign.CENTER, 0, 0)); + + assertThat(exported.left("Middle")).as("centred by its own alignment").isZero(); + } + + @Test + void anOffsetAcrossMovesTheBox() throws Exception { + Exported exported = export(panel(text("Moved"), LayerAlign.TOP_LEFT, 10, 0)); + + assertThat(exported.left("Moved")).isCloseTo(200L, within(2L)); + assertThat(exported.offsetNotes()).isEmpty(); + } + + @Test + void anOffsetLeftOfThePanelIsWrittenAtItsEdgeAndNamed() throws Exception { + Exported exported = export(panel(text("Moved"), LayerAlign.TOP_LEFT, -6, 0)); + + assertThat(exported.left("Moved")).isZero(); + assertThat(exported.offsetNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .isEqualTo("the page sets it 6pt left of the edge it is written from; in its table cell it is " + + "written at that edge"); + } + + @Test + void anOffsetDownIsNamed() throws Exception { + Exported exported = export(panel(text("Moved"), LayerAlign.TOP_LEFT, 0, 6)); + + assertThat(exported.offsetNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .isEqualTo("written as a panel's content: its offset moves it 6pt down, which its cell does not write"); + } + + @Test + void layersAtDifferentHeightsAreSetAtTheFirstsAndTheOthersNamed() throws Exception { + DocumentNode panel = new ShapeContainerBuilder().rectangle(ROOM, 60).fillColor(LIGHT) + .layer(text("Top"), LayerAlign.TOP_LEFT) + .layer(text("Foot"), LayerAlign.BOTTOM_LEFT) + .build(); + Exported exported = export(panel); + + assertThat(exported.vertical()).as("the first's").isNull(); + // Composed in a table cell, it has no path of its own in the layout. + assertThat(exported.offsetNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .isEqualTo("written as a panel's content: the page sets it at the foot of the shape, and its cell " + + "sets all it holds at the top, it under the layers before it"); + } + + @Test + void layersAtOneHeightAreWrittenOneUnderTheOtherAndNamed() throws Exception { + DocumentNode panel = new ShapeContainerBuilder().rectangle(ROOM, 60).fillColor(LIGHT) + .layer(text("Title"), LayerAlign.TOP_LEFT) + .layer(text("$9"), LayerAlign.TOP_RIGHT) + .build(); + Exported exported = export(panel); + + assertThat(exported.left("$9")).as("held in to the right still").isPositive(); + assertThat(exported.offsetNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .isEqualTo("written as a panel's content: the page sets it at the height of the layers before it, " + + "and its cell writes it under them"); + } + + @Test + void aCentredParagraphMovedAcrossPastTheShapesEdgeIsNamed() throws Exception { + // A centred paragraph is as wide as the shape: moved 10pt right, it runs 10pt past it. + Exported exported = export(panel(new ParagraphBuilder().text("Hi").align(TextAlign.CENTER).build(), + LayerAlign.CENTER, 10, 0)); + + assertThat(exported.left("Hi")).as("moved in 10pt, less half the room to spare a centred line is given") + .isCloseTo(Math.round((10 - Math.max(2, ROOM * 0.05) / 2) * 20), within(2L)); + assertThat(exported.offsetNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .isEqualTo("written as a panel's content: the page sets it 10pt past the shape's right edge, where " + + "its cell's text ends"); + } + + @Test + void aPaddedShapesLayerIsHeldInTheRoomItsPaddingLeaves() throws Exception { + DocumentNode panel = new ShapeContainerBuilder().rectangle(ROOM, 60).fillColor(LIGHT).padding(5) + .position(text("BotRight"), 0, 0, LayerAlign.BOTTOM_RIGHT).build(); + Exported exported = export(panel); + + assertThat(exported.left("BotRight")).as("at the right of the cell's text, its margins the padding") + .isCloseTo(Math.round((ROOM - 10 - exported.widthOf("BotRight")) * 20), within(2L)); + } + + @Test + void aSectionSetAcrossIsWrittenFromTheLeftAndNamed() throws Exception { + DocumentNode section = new SectionBuilder().addParagraph("Inside").build(); + Exported exported = export(panel(section, LayerAlign.TOP_RIGHT, 0, 0)); + + assertThat(exported.offsetNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .isEqualTo("written as a panel's content: the page sets it across by a box the layout does not give " + + "where it is composed in a table cell (SectionNode), and it is written from the shape's " + + "left"); + } + + private static DocumentNode panel(DocumentNode layer, LayerAlign align, double dx, double dy) { + return new ShapeContainerBuilder().rectangle(ROOM, 60).fillColor(LIGHT).position(layer, dx, dy, align).build(); + } + + private static DocumentNode text(String text) { + return new ParagraphBuilder().text(text).build(); + } + + private record Exported(XWPFDocument document, List notes, List fragments) { + + XWPFTableCell panelCell() { + return document.getTables().get(0).getRow(0).getCell(0).getTables().get(0).getRow(0).getCell(0); + } + + /** The panel's cell's vertical alignment, or {@code null} where none is written. */ + String vertical() { + CTTcPr properties = panelCell().getCTTc().getTcPr(); + return properties == null || !properties.isSetVAlign() ? null : properties.getVAlign().getVal().toString(); + } + + long left(String text) { + CTInd indent = indentOf(text); + return indent == null || !indent.isSetLeft() ? 0L : DocxTwips.of(indent.getLeft()); + } + + long right(String text) { + CTInd indent = indentOf(text); + return indent == null || !indent.isSetRight() ? 0L : DocxTwips.of(indent.getRight()); + } + + private CTInd indentOf(String text) { + XWPFParagraph paragraph = panelCell().getParagraphs().stream() + .filter(candidate -> candidate.getText().equals(text)).findFirst().orElseThrow(); + return paragraph.getCTP().isSetPPr() ? paragraph.getCTP().getPPr().getInd() : null; + } + + /** How wide the page lays the paragraph holding some letters out, in points. */ + double widthOf(String letters) { + return fragments.stream() + .filter(fragment -> fragment.payload() instanceof ParagraphFragmentPayload paragraph + && paragraph.lines().stream().flatMap(line -> line.spans().stream()) + .anyMatch(span -> span instanceof ParagraphTextSpan run + && run.text() != null && run.text().contains(letters))) + .findFirst().orElseThrow().width(); + } + + List offsetNotes() { + return notes.stream().filter(note -> note.subject().equals("layer offset")).toList(); + } + } + + private static Exported export(DocumentNode panel) throws Exception { + AtomicReference report = new AtomicReference<>(); + byte[] docx; + List fragments; + try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(72)).create()) { + session.pageFlow(page -> page + .addParagraph("Before") + .addTable(table -> table + .columns(DocumentTableColumn.fixed(120), DocumentTableColumn.fixed(120)) + .rowCells(DocumentTableCell.node(panel), DocumentTableCell.node(text("Beside")))) + .addParagraph("After")); + docx = session.export(new DocxSemanticBackend(report::set)); + fragments = session.layoutGraph().fragments(); + } + assertThat(report.get().notes()).noneMatch(note -> note.subject().equals("measured geometry")); + return new Exported(new XWPFDocument(new ByteArrayInputStream(docx)), report.get().notes(), fragments); + } +}