diff --git a/CHANGELOG.md b/CHANGELOG.md index 9792b94a0..06d60a6d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,27 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export's report names what a table's cell is composed of by its cell.** + - The layout lays a composed cell's content out under its table and gives it no path of its + own, so every note about a node there carried none: 216 of the corpus's 1384 notes, a rounded + panel's corners, an inline icon or shape drawn as a picture, a nested chip's right border. + Two cells composed alike could not be told apart, nor which cell a note was about. + - `DocxExportReport.Note.path()` now names such a node under its table's path by its cell, + `row[r]/cell[c]` — the row's index among the table's rows and the cell's among the cells + authored for that row, from 0, not its column in the grid — then by its own segment, as + `CobaltRota[0]/Schedule[0]/row[7]/cell[7]/ShiftBlock[0]/row[1]/cell[0]/Chip[0]` for a chip in + a table in a cell. A node that stands in the flow as well keeps the flow's path, and what its + cells hold is named under it. + - A section composed in a cell whose anchor has no bookmark adds that loss to its own note, as + one in the flow does: with its outline entry not written either, it gave two notes, and gives + one. + - The contract names the paths a note may carry that are not a node's: `page N`, and in a + document of several sections `section N` or `section N, page N`; a note about the whole + document or about a node of a page zone has none. + - The file is unchanged: the 62 corpus DOCX are byte for byte the same, and so is the text of + their notes. The 17 notes left without a path are about the whole document: the line that + closes it and a text footer reaching past the margin. + - **A DOCX export splits a body table at the row where a page with other top and bottom margins starts.** - A Word section starts only between blocks. So a page that the layout started among a table's diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 4dc18105b..35912fc7a 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -217,7 +217,16 @@ session.export(new DocxSemanticBackend(report -> notes.addAll(report.notes()))); ``` Each note carries a severity, what it was about, the authored node's path, and what it -means for the document. `DROPPED` means the page draws it and the document does not carry +means for the document. The path is the one the layout graph addresses the node by. What a +table's cell is composed of, which the layout lays out under its table, is named under the +table's path by its cell — `row[r]/cell[c]`, the row's index and the cell's among the cells +authored for that row, from 0, not its column in the grid where a cell before it spans columns +or one above spans rows into its row — so `Invoice[0]/Lines[2]/row[3]/cell[1]/Total[0]` is the +`Total` node in the second cell authored for the fourth row. A note about no one node names what +it is about instead: `page N`, and in a document of several sections `section N` or +`section N, page N`. A note about the whole document, such as the line closing it, or about a +node of a page zone has none. +`DROPPED` means the page draws it and the document does not carry it; `APPROXIMATED` means it is in the document as the nearest thing Word owns — a panel that keeps its fill and loses its rounded corners. Neither is an error: an export that cannot proceed throws, and the report is not how you find that out. diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxExportReport.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxExportReport.java index 67c676735..09f6f23e5 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxExportReport.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxExportReport.java @@ -24,7 +24,16 @@ * *

Every note carries the path of the node it came from, the same path the layout graph * addresses that node by, so a note can be traced back to the authoring code rather than - * guessed at from its text.

+ * guessed at from its text. What a table's cell is composed of, which the layout lays out under + * the table and gives no path of its own, is named under the table's path by its cell — + * {@code row[r]/cell[c]}, the row's index among the table's rows and the cell's among the cells + * authored for that row, from 0, not its column in the grid — then by its own segment, as in + * {@code Invoice[0]/Lines[2]/row[3]/cell[1]/Total[0]}.

+ * + *

A note about no one node says what it is about instead: {@code page N} for a page, and in a + * document of several sections {@code section N} for one of them or {@code section N, page N}. A + * note about the whole document, or about a node of a page zone, which the layout lays out apart + * from the body, has no path.

* *

Experimental ({@code @Beta}) — see {@code docs/api-stability.md}.

* @@ -58,7 +67,9 @@ public enum Severity { * * @param severity whether it is missing or merely different * @param subject what it was, in a few words — {@code "chart"}, {@code "corner radius"} - * @param path the authored node's path, or null when it belongs to the whole export + * @param path the authored node's path; a page's or a section's where the note is about one + * ({@code page N}, {@code section N}, {@code section N, page N}); or null where + * it belongs to the whole export or to a node of a page zone * @param detail what happened and what it means for the document */ public record Note(Severity severity, String subject, String path, String detail) { 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 079338ebd..9bf63ed5d 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 @@ -117,6 +117,13 @@ static DocxLayoutMetrics of(DocumentGraph graph, LayoutGraph layout) { for (int index = 0; index < graph.roots().size(); index++) { indexPaths(graph.roots().get(index), null, index, paths); } + Map cells = new IdentityHashMap<>(); + for (DocumentNode root : graph.roots()) { + if (root != null) { + indexCells(root, paths.get(root), paths, cells); + } + } + paths.putAll(cells); java.util.Set seen = java.util.Collections.newSetFromMap(new IdentityHashMap<>()); java.util.Set repeated = java.util.Collections.newSetFromMap(new IdentityHashMap<>()); for (DocumentNode root : graph.roots()) { @@ -222,7 +229,8 @@ int pageCount() { *

The rule is the compiler's: a node is named by its own name when it has one and by * its kind when it does not, suffixed with its index among its siblings, and joined to * its parent's path with a slash. Separators in a name are replaced the same way, so a - * name carrying one cannot collide with the structure.

+ * name carrying one cannot collide with the structure. What a table's cells hold is not + * walked: the layout gives it no path ({@link #indexCells}).

*/ private static void indexPaths(DocumentNode node, String parentPath, @@ -231,9 +239,7 @@ private static void indexPaths(DocumentNode node, if (node == null) { return; } - String base = node.name() == null || node.name().isBlank() ? node.nodeKind() : node.name().trim(); - String segment = base.replace('\\', '_').replace('/', '_') + "[" + childIndex + "]"; - String path = parentPath == null ? segment : parentPath + "/" + segment; + String path = parentPath == null ? segment(node, childIndex) : parentPath + "/" + segment(node, childIndex); into.put(node, path); List children = node.children(); for (int index = 0; index < children.size(); index++) { @@ -241,6 +247,59 @@ private static void indexPaths(DocumentNode node, } } + /** + * Names what the tables of a tree hold in their cells, which the layout lays out under its + * table and gives no path of its own: under the table's path by its cell, {@code row[r]/cell[c]} + * — the row's index among the table's rows and the cell's among the cells authored for that + * row, not its column in the grid — then by its own segment. No layout path lies under a + * table's, so a node there still finds no placement of its own. + * + *

A node the flow names ({@link #indexPaths}) keeps that path, which the layout knows, and + * what it holds is named under it; one standing in several cells takes the last.

+ * + * @param node a node of the tree, walked from its root + * @param path the node's path + * @param flow the flow's paths + * @param cells where the paths of what cells hold go + */ + private static void indexCells(DocumentNode node, String path, Map flow, + Map cells) { + List children = node.children(); + for (int index = 0; index < children.size(); index++) { + DocumentNode child = children.get(index); + indexCells(child, placeOf(child, path + "/" + segment(child, index), flow, cells), flow, cells); + } + if (node instanceof TableNode table) { + for (int row = 0; row < table.rows().size(); row++) { + List authored = table.rows().get(row); + for (int cell = 0; cell < authored.size(); cell++) { + DocumentNode content = authored.get(cell).content(); + if (content != null) { + indexCells(content, placeOf(content, path + "/row[" + row + "]/cell[" + cell + "]/" + + segment(content, 0), flow, cells), flow, cells); + } + } + } + } + } + + /** A node's path: the flow's where the flow names it, or else this place's, the last it is found at. */ + private static String placeOf(DocumentNode node, String here, Map flow, + Map cells) { + String named = flow.get(node); + if (named != null) { + return named; + } + cells.put(node, here); + return here; + } + + /** A node's name, or its kind where it has none, and its index among its siblings. */ + private static String segment(DocumentNode node, int index) { + String base = node.name() == null || node.name().isBlank() ? node.nodeKind() : node.name().trim(); + return base.replace('\\', '_').replace('/', '_') + "[" + index + "]"; + } + /** @return true when there is no layout behind this index */ boolean isEmpty() { return fragments.isEmpty(); @@ -785,11 +844,12 @@ static Map pathsWithin(DocumentNode root) { } /** - * The path the layout graph addresses a node by, for a note that has to say where in - * the document it came from. + * The path a note names a node by, to say where in the document it came from: the one the + * layout graph addresses it by, or, for what a table's cell is composed of, its table's by its + * cell ({@link #indexPaths}). * * @param node any authored node - * @return its path, or null when this index was built from no graph at all + * @return its path, or null for a node of no tree this index was built from */ String pathOf(DocumentNode node) { return node == null ? null : paths.get(node); @@ -1288,7 +1348,8 @@ private List fragmentsOf(DocumentNode node) { /** * The fragments that hold a node's lines of text: its own, or — for a paragraph or a list - * composed in a table cell, which has no path of its own — the ones its table laid out for it. + * composed in a table cell, which the layout gives no path of its own — the ones its table laid + * out for it. */ private List textFragmentsOf(DocumentNode node) { List own = fragmentsOf(node); @@ -1322,9 +1383,12 @@ private List textFragmentsOf(DocumentNode node) { private Map> matchComposedText() { Map> matched = new IdentityHashMap<>(); for (DocumentNode table : paths.keySet()) { - if (!(table instanceof TableNode tableNode)) { + List own = table instanceof TableNode ? fragmentsOf(table) : List.of(); + if (own.isEmpty()) { + // A table composed in a cell has none: the table holding it lays its lines out. continue; } + TableNode tableNode = (TableNode) table; List composed = new ArrayList<>(); collectComposedText(tableNode, composed); if (composed.isEmpty()) { @@ -1334,7 +1398,7 @@ private Map> matchComposedText() { // still waiting, and each list the first run its first item's opens, so pairing a table // is linear in its cells. TableLines lines = new TableLines(); - for (PlacedFragment fragment : fragmentsOf(table)) { + for (PlacedFragment fragment : own) { if (fragment.payload() instanceof ParagraphFragmentPayload) { lines.add(fragment); } 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 228fb6a52..ae71d26ef 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 @@ -8472,7 +8472,6 @@ private void reportTheLayersOfItsPanel(ShapeContainerNode shape) { + "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)); } @@ -10901,8 +10900,8 @@ private record UnmarkedBlock(int notesFrom, int notesTo, String subject, String * *

The loss goes into the block's own note where it has one, or a note of its own after the * others: a page reference's note is put in where it was counted, after this, and a note put in - * here would move it. A block whose path the layout does not register, composed in a table's - * cell, is not told from the nodes inside it, and has a note of its own.

+ * here would move it. A block composed in a table's cell is told by its path under the table, as + * one in the flow is by its own.

*/ private void nameTheBlocksNoBookmarkMarks() { for (UnmarkedBlock block : blocksUnmarked) { diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java index 36c51f0d4..e3403d94f 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java @@ -73,6 +73,18 @@ void aSectionWithANoteOfItsOwnNamesItsAnchorThere() throws Exception { + LOST); } + @Test + void aSectionComposedInACellWithANoteOfItsOwnNamesItsAnchorThere() throws Exception { + // Told by its path under the table, it has one note, as in the flow. + Exported exported = export(page -> page.addParagraph(p -> p.text("Go there").linkTo("target")) + .addTable(table -> table.autoColumns(1).rowCells(DocumentTableCell.node(new SectionBuilder() + .anchor("target").bookmark(new DocumentBookmarkOptions("Entry")).add(drawing()).build())))); + + assertThat(notesOn(exported.report(), "SectionNode")).singleElement().asString() + .contains("its outline entry is not written") + .endsWith("it writes no paragraph, so " + LOST); + } + @Test void aSectionInAPanelsCellNamesItsAnchor() throws Exception { // What it writes lands in the panel's cell, which holds the panel's paragraph and none of its own. @@ -86,8 +98,8 @@ void aSectionInAPanelsCellNamesItsAnchor() throws Exception { @Test void aSectionInATablesComposedCellHasANoteOfItsOwn() throws Exception { - // Nothing in a composed cell has a path of its own to tell it by: the section inside, with its - // outline entry, keeps its note, and the anchored one round it has its own. + // Each node in a composed cell is told by its own path under the table: the section inside, + // with its outline entry, keeps its note, and the anchored one round it has its own. DocumentNode inner = new SectionBuilder().bookmark(new DocumentBookmarkOptions("Entry")).add(drawing()).build(); Exported exported = export(page -> page.addParagraph(p -> p.text("Go there").linkTo("target")) .addTable(table -> table.autoColumns(1).rowCells(DocumentTableCell.node( diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java index f24ca2bbf..776a36a72 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCarriedWithoutReportTest.java @@ -260,13 +260,11 @@ void cornersWhoseRadiiDifferOnlyPastHalfTheShorterSideAreNotNamed() throws Excep for (String name : List.of("PillShape", "PillContainer")) { assertThat(notesNaming(report, name)).as(name).isNotEmpty().noneMatch(detail -> detail.contains("corners")); } - for (String name : List.of("UnevenShape", "UnevenContainer")) { + for (String name : List.of("UnevenShape", "UnevenContainer", "UnevenCell")) { assertThat(notesNaming(report, name)).as(name).anyMatch(detail -> detail.endsWith(CORNERS)); } - // Composed in a cell, a node's note carries no path: the uneven one's, and not the pill's. - assertThat(notesOn(report, "ShapeContainerNode")) - .filteredOn(detail -> detail.startsWith("composed in a table cell")) - .singleElement().asString().endsWith(CORNERS); + assertThat(notesNaming(report, "PillCell")).as("composed in a cell, named under its table") + .noneMatch(detail -> detail.contains("corners")); } @Test diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellRuleRoomTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellRuleRoomTest.java index bc3e9c9f6..fbd4f2c9f 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellRuleRoomTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellRuleRoomTest.java @@ -160,7 +160,8 @@ void aTableComposedInACellOrWithNoLayoutIsNamedAtTheMostItCanBe() throws Excepti assertThat(composed).singleElement().satisfies(note -> { assertThat(note.detail()).as("its rows not held, an empty cell's line keeps its height: 4.5 + 4.5") .isEqualTo(note("up to 9")); - assertThat(note.path()).as("composed content has no path of its own").isNull(); + assertThat(note.path()).as("composed content is named under its table, by its cell") + .endsWith("TableNode[0]/row[0]/cell[0]/TableNode[0]"); }); assertThat(DocxExports.reportWithoutLayout(500, 400, 30, rows(3, 0)).bySubject().get(SUBJECT)) .as("what the padding does not take needs no layout to tell").extracting(DocxExportReport.Note::detail) diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellPathTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellPathTest.java new file mode 100644 index 000000000..77ffee9d3 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellPathTest.java @@ -0,0 +1,160 @@ +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.dsl.TableBuilder; +import com.demcha.compose.document.layout.DocumentGraph; +import com.demcha.compose.document.node.DocumentNode; +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.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A note on what a table's cell is composed of names it under the table, by its cell: the layout + * lays it out under the table and gives it no path of its own, and a note with none told neither + * the node nor its cell. + */ +class DocxComposedCellPathTest { + + @Test + void aNoteOnWhatACellIsComposedOfNamesItUnderItsTableByItsCell() throws Exception { + DocxExportReport report = DocxExports.report(400, 600, 20, page -> page.addTable(table -> table + .columns(DocumentTableColumn.fixed(120), DocumentTableColumn.fixed(120)) + .rowCells(DocumentTableCell.text("Shift"), DocumentTableCell.text("Hours")) + .rowCells(DocumentTableCell.text("Monday"), DocumentTableCell.node(chip("Morning"))))); + + assertThat(cornerPaths(report)).containsExactly("ContainerNode[0]/TableNode[0]/row[1]/cell[1]/Morning[0]"); + } + + @Test + void twoCellsComposedAlikeAreToldApart() throws Exception { + DocxExportReport report = DocxExports.report(400, 600, 20, page -> page.addTable(table -> table + .columns(DocumentTableColumn.fixed(120), DocumentTableColumn.fixed(120)) + .rowCells(DocumentTableCell.node(chip("Chip")), DocumentTableCell.node(chip("Chip"))))); + + assertThat(cornerPaths(report)).containsExactly("ContainerNode[0]/TableNode[0]/row[0]/cell[0]/Chip[0]", + "ContainerNode[0]/TableNode[0]/row[0]/cell[1]/Chip[0]"); + } + + @Test + void aNoteOnWhatATableComposedInACellHoldsNamesBothCells() throws Exception { + DocxExportReport report = DocxExports.report(400, 600, 20, page -> page.addTable(table -> table + .columns(DocumentTableColumn.fixed(240)) + .rowCells(DocumentTableCell.node(new TableBuilder().name("Shifts").columns(DocumentTableColumn.fixed(200)) + .row("Monday").rowCells(DocumentTableCell.node(chip("Morning"))).build())))); + + assertThat(cornerPaths(report)).containsExactly( + "ContainerNode[0]/TableNode[0]/row[0]/cell[0]/Shifts[0]/row[1]/cell[0]/Morning[0]"); + } + + @Test + void aComposedNodeStillFindsNoPlacementOrFragmentsOfItsOwn() throws Exception { + // Its path is the report's alone: found by it, the export would read the layout differently. + DocumentNode chip = chip("Chip"); + try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow(page -> page.addTable(table -> table.columns(DocumentTableColumn.fixed(120)) + .rowCells(DocumentTableCell.node(chip)))); + DocxLayoutMetrics layout = DocxLayoutMetrics.of(session.documentGraph(), session.layoutGraph()); + + assertThat(layout.pathOf(chip)).isEqualTo("ContainerNode[0]/TableNode[0]/row[0]/cell[0]/Chip[0]"); + assertThat(layout.placement(chip)).isNull(); + assertThat(layout.ownFragments(chip)).isEmpty(); + } + } + + @Test + void aNodeInTheFlowAndInACellKeepsTheFlowsPathAndPlacement() throws Exception { + DocumentNode shared = new ParagraphBuilder().name("Shared").text("Both").build(); + try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow(page -> page.add(shared).addTable(table -> table.columns(DocumentTableColumn.fixed(120)) + .rowCells(DocumentTableCell.node(shared)))); + DocxLayoutMetrics layout = DocxLayoutMetrics.of(session.documentGraph(), session.layoutGraph()); + + assertThat(layout.pathOf(shared)).isEqualTo("ContainerNode[0]/Shared[0]"); + assertThat(layout.placement(shared)).isNotNull(); + } + DocumentNode table = new TableBuilder().columns(DocumentTableColumn.fixed(100)) + .rowCells(DocumentTableCell.node(shared)).build(); + assertThat(pathOf(new SectionBuilder().add(table).add(shared).build(), shared)) + .as("the flow's, coming after the table").isEqualTo("SectionNode[0]/Shared[1]"); + } + + @Test + void aTableInTheFlowAndInAnotherTablesCellNamesWhatItHoldsUnderItsFlowPath() { + DocumentNode chip = chip("Chip"); + DocumentNode inner = new TableBuilder().name("Inner").columns(DocumentTableColumn.fixed(100)) + .rowCells(DocumentTableCell.node(chip)).build(); + DocumentNode outer = new TableBuilder().name("Outer").columns(DocumentTableColumn.fixed(200)) + .rowCells(DocumentTableCell.node(inner)).build(); + DocumentNode root = new SectionBuilder().add(inner).add(outer).build(); + + assertThat(pathOf(root, inner)).isEqualTo("SectionNode[0]/Inner[0]"); + assertThat(pathOf(root, chip)).isEqualTo("SectionNode[0]/Inner[0]/row[0]/cell[0]/Chip[0]"); + } + + @Test + void aCellIsNamedByItsPlaceAmongTheCellsAuthoredForItsRow() { + // A cell spanning columns before it, or rows into its row from above, puts it in a later column. + DocumentNode behindASpan = chip("BehindASpan"); + DocumentNode underASpan = chip("UnderASpan"); + DocumentNode root = new TableBuilder().name("Grid") + .columns(DocumentTableColumn.fixed(50), DocumentTableColumn.fixed(50), DocumentTableColumn.fixed(50)) + .rowCells(DocumentTableCell.text("Wide").colSpan(2), DocumentTableCell.node(behindASpan)) + .rowCells(DocumentTableCell.text("Tall").rowSpan(2), DocumentTableCell.text("B"), + DocumentTableCell.text("C")) + .rowCells(DocumentTableCell.node(underASpan), DocumentTableCell.text("F")) + .build(); + + assertThat(pathOf(root, behindASpan)).as("the second cell, in the third column") + .isEqualTo("Grid[0]/row[0]/cell[1]/BehindASpan[0]"); + assertThat(pathOf(root, underASpan)).as("the first cell authored, in the second column") + .isEqualTo("Grid[0]/row[2]/cell[0]/UnderASpan[0]"); + } + + @Test + void aNodeAtMoreThanOnePlaceIsNamedByTheLastAsInTheFlow() { + DocumentNode chip = chip("Chip"); + DocumentNode twice = new TableBuilder().name("Twice").columns(DocumentTableColumn.fixed(50), + DocumentTableColumn.fixed(50)).rowCells(DocumentTableCell.node(chip), DocumentTableCell.node(chip)) + .build(); + DocumentNode inner = chip("Inner"); + DocumentNode reused = new TableBuilder().columns(DocumentTableColumn.fixed(50)) + .rowCells(DocumentTableCell.node(inner)).build(); + DocumentNode root = new SectionBuilder().add(reused).add(reused).build(); + + assertThat(pathOf(twice, chip)).isEqualTo("Twice[0]/row[0]/cell[1]/Chip[0]"); + assertThat(pathOf(root, reused)).isEqualTo("SectionNode[0]/TableNode[1]"); + assertThat(pathOf(root, inner)).as("under the table's own path") + .isEqualTo("SectionNode[0]/TableNode[1]/row[0]/cell[0]/Inner[0]"); + } + + /** A node's path in a document of one root, with no layout behind it. */ + private static String pathOf(DocumentNode root, DocumentNode node) { + return DocxLayoutMetrics.of(new DocumentGraph(List.of(root)), null).pathOf(node); + } + + private static List cornerPaths(DocxExportReport report) { + return report.notes().stream().filter(note -> note.subject().equals("corner radius")) + .map(DocxExportReport.Note::path).toList(); + } + + private static DocumentNode chip(String name) { + return new ShapeContainerBuilder() + .name(name) + .roundedRect(60, 14, 7) + .fillColor(DocumentColor.rgb(26, 86, 148)) + .center(new ParagraphBuilder().text("09:00").build()) + .build(); + } +} 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 index 39509b3db..72fb7f25f 100644 --- 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 @@ -122,10 +122,12 @@ void layersAtDifferentHeightsAreSetAtTheFirstsAndTheOthersNamed() throws Excepti 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"); + assertThat(exported.offsetNotes()).singleElement().extracting(DocxExportReport.Note::path) + .as("the layer, composed in a table cell, named under its table by its cell").asString() + .endsWith("TableNode[1]/row[0]/cell[0]/ShapeContainerNode[0]/ParagraphNode[1]"); } @Test diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableColumnsLeftToTheEditorTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableColumnsLeftToTheEditorTest.java index 4c22f92ad..6d5e4fa90 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableColumnsLeftToTheEditorTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableColumnsLeftToTheEditorTest.java @@ -40,7 +40,8 @@ void aTableOfAutoColumnsComposedInACellIsNamed() throws Exception { assertThat(notes).singleElement().satisfies(note -> { assertThat(note.severity()).isEqualTo(DocxExportReport.Severity.APPROXIMATED); assertThat(note.detail()).isEqualTo(COMPOSED); - assertThat(note.path()).as("composed content has no path of its own").isNull(); + assertThat(note.path()).as("composed content is named under its table, by its cell") + .endsWith("TableNode[0]/row[0]/cell[1]/TableNode[0]"); }); }