From d921bb8ff224003634e971032efdea49cf3e4f03 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 24 Oct 2026 09:14:24 +0100 Subject: [PATCH 1/2] fix(docx): name what a table's cell is composed of by its cell in the report - 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. Two cells composed alike could not be told apart, nor which cell a note was about. - DocxLayoutMetrics names such a node under its table's path by its cell, row[r]/cell[c] (the cell's index among those authored for the row), then by its own segment, nested tables included. No layout path reads so, so the node still finds no placement of its own. The cell paths are merged in only where the flow names nothing: a node in the flow too keeps the flow's path, and one at several places the last. - Told by its path, a composed node's losses go into one note, as a flow node's do. - 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. --- CHANGELOG.md | 17 ++ docs/recipes/docx-export.md | 8 +- .../semantic/docx/DocxExportReport.java | 6 +- .../semantic/docx/DocxLayoutMetrics.java | 41 ++++- .../semantic/docx/DocxSemanticBackend.java | 5 +- .../docx/DocxAnchorWithoutABookmarkTest.java | 16 +- .../docx/DocxCarriedWithoutReportTest.java | 2 +- .../semantic/docx/DocxCellRuleRoomTest.java | 3 +- .../docx/DocxComposedCellPathTest.java | 150 ++++++++++++++++++ .../docx/DocxPanelLayerAlignTest.java | 4 +- .../DocxTableColumnsLeftToTheEditorTest.java | 3 +- 11 files changed, 236 insertions(+), 19 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellPathTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 9792b94a0..4e8b6b1d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ 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. + - Told by its path, a composed node's losses go into one note, as a node's in the flow do: a + section in a cell whose outline entry is not written and whose anchor has no bookmark gave + two notes, and gives one. + - 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..12514446c 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -217,7 +217,13 @@ 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 — +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 the whole document, such as the line closing it, 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..11c1ff219 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,11 @@ * *

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]}.

* *

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

* 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..bb575e6af 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 @@ -114,9 +114,11 @@ static DocxLayoutMetrics of(DocumentGraph graph, LayoutGraph layout) { return EMPTY; } Map paths = new IdentityHashMap<>(); + Map composed = new IdentityHashMap<>(); for (int index = 0; index < graph.roots().size(); index++) { - indexPaths(graph.roots().get(index), null, index, paths); + indexPaths(graph.roots().get(index), null, index, paths, composed); } + composed.forEach(paths::putIfAbsent); 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()) { @@ -223,11 +225,21 @@ int pageCount() { * 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.

+ * + *

What a table's cell is composed of is laid out under the table, and the layout gives it + * no path of its own: it is named under the table's 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, into {@code composed}. No layout path + * reads so, so a node there still finds no placement of its own. A node standing at more than + * one place takes the last, in the flow as in a cell; one that stands in the flow too keeps the + * flow's path, which the layout knows, as the caller merges {@code composed} in only where the + * flow names nothing.

*/ private static void indexPaths(DocumentNode node, String parentPath, int childIndex, - Map into) { + Map into, + Map composed) { if (node == null) { return; } @@ -237,7 +249,16 @@ private static void indexPaths(DocumentNode node, into.put(node, path); List children = node.children(); for (int index = 0; index < children.size(); index++) { - indexPaths(children.get(index), path, index, into); + indexPaths(children.get(index), path, index, into, composed); + } + if (node instanceof TableNode table) { + for (int row = 0; row < table.rows().size(); row++) { + List cells = table.rows().get(row); + for (int cell = 0; cell < cells.size(); cell++) { + indexPaths(cells.get(cell).content(), path + "/row[" + row + "]/cell[" + cell + "]", 0, + composed, composed); + } + } } } @@ -780,16 +801,19 @@ int zoneFirstPage(int zoneIndex, java.util.function.IntPredicate pages) { */ static Map pathsWithin(DocumentNode root) { Map paths = new IdentityHashMap<>(); - indexPaths(root, null, 0, paths); + Map composed = new IdentityHashMap<>(); + indexPaths(root, null, 0, paths, composed); + composed.forEach(paths::putIfAbsent); return paths; } /** - * 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 +1312,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); 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..e4d6e11fb 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 @@ -263,7 +263,7 @@ void cornersWhoseRadiiDifferOnlyPastHalfTheShorterSideAreNotNamed() throws Excep for (String name : List.of("UnevenShape", "UnevenContainer")) { 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. + // Composed in a cell, the uneven one's note names its corners, and the pill's does not. assertThat(notesOn(report, "ShapeContainerNode")) .filteredOn(detail -> detail.startsWith("composed in a table cell")) .singleElement().asString().endsWith(CORNERS); 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..b6b53926f --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellPathTest.java @@ -0,0 +1,150 @@ +package com.demcha.compose.document.backend.semantic.docx; + +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.node.DocumentNode; +import com.demcha.compose.document.style.DocumentColor; +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 java.util.Map; + +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(report.notes()).filteredOn(note -> note.subject().equals("corner radius")) + .singleElement().satisfies(note -> + assertThat(note.path()).isEqualTo("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(report.notes()).filteredOn(note -> note.subject().equals("corner radius")) + .extracting(DocxExportReport.Note::path) + .containsExactly("ContainerNode[0]/TableNode[0]/row[0]/cell[0]/Chip[0]", + "ContainerNode[0]/TableNode[0]/row[0]/cell[1]/Chip[0]"); + } + + @Test + void aTableComposedInACellNamesItsOwnCellsUnderItsCell() { + DocumentNode chip = chip("Chip"); + DocumentNode inner = new TableBuilder().columns(DocumentTableColumn.fixed(100)) + .row("Top").rowCells(DocumentTableCell.node(chip)).build(); + DocumentNode outer = new TableBuilder().name("Outer").columns(DocumentTableColumn.fixed(200)) + .rowCells(DocumentTableCell.node(inner)).build(); + + Map paths = DocxLayoutMetrics.pathsWithin(outer); + + assertThat(paths.get(inner)).isEqualTo("Outer[0]/row[0]/cell[0]/TableNode[0]"); + assertThat(paths.get(chip)).isEqualTo("Outer[0]/row[0]/cell[0]/TableNode[0]/row[1]/cell[0]/Chip[0]"); + assertThat(paths.values().stream().toList()).as("what the chip holds, under it").contains( + "Outer[0]/row[0]/cell[0]/TableNode[0]/row[1]/cell[0]/Chip[0]/ParagraphNode[0]"); + } + + @Test + void aNodeThatStandsInTheFlowTooKeepsTheFlowsPath() { + // The layout knows the flow's path: a node found by the cell's would find no placement. + DocumentNode shared = new ParagraphBuilder().name("Shared").text("Both").build(); + DocumentNode table = new TableBuilder().columns(DocumentTableColumn.fixed(100)) + .rowCells(DocumentTableCell.node(shared)).build(); + + Map before = DocxLayoutMetrics.pathsWithin(new SectionBuilder() + .add(shared).add(table).build()); + Map after = DocxLayoutMetrics.pathsWithin(new SectionBuilder() + .add(table).add(shared).build()); + + assertThat(before.get(shared)).isEqualTo("SectionNode[0]/Shared[0]"); + assertThat(after.get(shared)).isEqualTo("SectionNode[0]/Shared[1]"); + assertThat(List.of(before.get(table), after.get(table))) + .containsExactly("SectionNode[0]/TableNode[1]", "SectionNode[0]/TableNode[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 (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document() + .pageSize(400, 600).margin(com.demcha.compose.document.style.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 aNodeInTheFlowAndInACellKeepsTheFlowsPlacement() throws Exception { + DocumentNode shared = new ParagraphBuilder().name("Shared").text("Both").build(); + try (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document() + .pageSize(400, 600).margin(com.demcha.compose.document.style.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(); + } + } + + @Test + void aCellAfterOneSpanningColumnsIsNamedByItsPlaceAmongTheRowsCells() { + DocumentNode chip = chip("Chip"); + DocumentNode table = new TableBuilder().name("Grid") + .columns(DocumentTableColumn.fixed(50), DocumentTableColumn.fixed(50), DocumentTableColumn.fixed(50)) + .rowCells(DocumentTableCell.text("Wide").colSpan(2), DocumentTableCell.node(chip)).build(); + + assertThat(DocxLayoutMetrics.pathsWithin(table).get(chip)).as("the second cell, in the third column") + .isEqualTo("Grid[0]/row[0]/cell[1]/Chip[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(); + + assertThat(DocxLayoutMetrics.pathsWithin(twice).get(chip)).isEqualTo("Twice[0]/row[0]/cell[1]/Chip[0]"); + Map paths = DocxLayoutMetrics.pathsWithin(new SectionBuilder() + .add(reused).add(reused).build()); + assertThat(paths.get(reused)).isEqualTo("SectionNode[0]/TableNode[1]"); + assertThat(paths.get(inner)).as("under the table's own path").isEqualTo( + "SectionNode[0]/TableNode[1]/row[0]/cell[0]/Inner[0]"); + } + + 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]"); }); } From ee7dd29720907f31a05fd0556f2a9d4602bb9de9 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 24 Oct 2026 09:46:52 +0100 Subject: [PATCH 2/2] fix(docx): name a table's cells under the path the table keeps - What a table's cells hold is named once the flow is named (indexCells), under the table's own path: a table in the flow and in another table's cell named what it held under that cell, the table itself under the flow. pathsWithin, which the page-zone lookups use, is the layout's paths alone again. - The text match skips a table with no fragments of its own: one composed in a cell, whose text the table holding it lays out. - The report's contract names the paths that are not a node's: page N, section N and section N, page N; and that a note about the whole document or a page zone's node has none. The cell index is the cell's among those authored for the row, which a row span from above moves on too. - CHANGELOG: only an anchor's loss joins a composed node's note. - Tests: through the export, a table in a cell; a table in the flow and in a cell; a row span; the composed capsules told apart by path. --- CHANGELOG.md | 12 +- docs/recipes/docx-export.md | 9 +- .../semantic/docx/DocxExportReport.java | 9 +- .../semantic/docx/DocxLayoutMetrics.java | 95 +++++++++----- .../docx/DocxCarriedWithoutReportTest.java | 8 +- .../docx/DocxComposedCellPathTest.java | 118 ++++++++++-------- 6 files changed, 156 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e8b6b1d4..06d60a6d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,14 @@ follow semantic versioning; release dates are ISO 8601. `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. - - Told by its path, a composed node's losses go into one note, as a node's in the flow do: a - section in a cell whose outline entry is not written and whose anchor has no bookmark gave - two notes, and gives one. + 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. diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 12514446c..35912fc7a 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -220,9 +220,12 @@ Each note carries a severity, what it was about, the authored node's path, and w 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 — -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 the whole document, such as the line closing it, has none. +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 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 11c1ff219..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 @@ -30,6 +30,11 @@ * 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}.

* * @param notes everything worth telling the caller, in the order it was found @@ -62,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 bb575e6af..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 @@ -114,11 +114,16 @@ static DocxLayoutMetrics of(DocumentGraph graph, LayoutGraph layout) { return EMPTY; } Map paths = new IdentityHashMap<>(); - Map composed = new IdentityHashMap<>(); for (int index = 0; index < graph.roots().size(); index++) { - indexPaths(graph.roots().get(index), null, index, paths, composed); + indexPaths(graph.roots().get(index), null, index, paths); } - composed.forEach(paths::putIfAbsent); + 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()) { @@ -224,44 +229,77 @@ 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.

- * - *

What a table's cell is composed of is laid out under the table, and the layout gives it - * no path of its own: it is named under the table's 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, into {@code composed}. No layout path - * reads so, so a node there still finds no placement of its own. A node standing at more than - * one place takes the last, in the flow as in a cell; one that stands in the flow too keeps the - * flow's path, which the layout knows, as the caller merges {@code composed} in only where the - * flow names nothing.

+ * 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, int childIndex, - Map into, - Map composed) { + Map into) { 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++) { - indexPaths(children.get(index), path, index, into, composed); + indexPaths(children.get(index), path, index, into); + } + } + + /** + * 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 cells = table.rows().get(row); - for (int cell = 0; cell < cells.size(); cell++) { - indexPaths(cells.get(cell).content(), path + "/row[" + row + "]/cell[" + cell + "]", 0, - composed, composed); + 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(); @@ -801,9 +839,7 @@ int zoneFirstPage(int zoneIndex, java.util.function.IntPredicate pages) { */ static Map pathsWithin(DocumentNode root) { Map paths = new IdentityHashMap<>(); - Map composed = new IdentityHashMap<>(); - indexPaths(root, null, 0, paths, composed); - composed.forEach(paths::putIfAbsent); + indexPaths(root, null, 0, paths); return paths; } @@ -1347,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()) { @@ -1359,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/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 e4d6e11fb..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, the uneven one's note names its corners, and the pill's does not. - 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/DocxComposedCellPathTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellPathTest.java index b6b53926f..77ffee9d3 100644 --- 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 @@ -1,17 +1,20 @@ 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 java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -29,9 +32,7 @@ void aNoteOnWhatACellIsComposedOfNamesItUnderItsTableByItsCell() throws Exceptio .rowCells(DocumentTableCell.text("Shift"), DocumentTableCell.text("Hours")) .rowCells(DocumentTableCell.text("Monday"), DocumentTableCell.node(chip("Morning"))))); - assertThat(report.notes()).filteredOn(note -> note.subject().equals("corner radius")) - .singleElement().satisfies(note -> - assertThat(note.path()).isEqualTo("ContainerNode[0]/TableNode[0]/row[1]/cell[1]/Morning[0]")); + assertThat(cornerPaths(report)).containsExactly("ContainerNode[0]/TableNode[0]/row[1]/cell[1]/Morning[0]"); } @Test @@ -40,52 +41,27 @@ void twoCellsComposedAlikeAreToldApart() throws Exception { .columns(DocumentTableColumn.fixed(120), DocumentTableColumn.fixed(120)) .rowCells(DocumentTableCell.node(chip("Chip")), DocumentTableCell.node(chip("Chip"))))); - assertThat(report.notes()).filteredOn(note -> note.subject().equals("corner radius")) - .extracting(DocxExportReport.Note::path) - .containsExactly("ContainerNode[0]/TableNode[0]/row[0]/cell[0]/Chip[0]", - "ContainerNode[0]/TableNode[0]/row[0]/cell[1]/Chip[0]"); + 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 aTableComposedInACellNamesItsOwnCellsUnderItsCell() { - DocumentNode chip = chip("Chip"); - DocumentNode inner = new TableBuilder().columns(DocumentTableColumn.fixed(100)) - .row("Top").rowCells(DocumentTableCell.node(chip)).build(); - DocumentNode outer = new TableBuilder().name("Outer").columns(DocumentTableColumn.fixed(200)) - .rowCells(DocumentTableCell.node(inner)).build(); - - Map paths = DocxLayoutMetrics.pathsWithin(outer); - - assertThat(paths.get(inner)).isEqualTo("Outer[0]/row[0]/cell[0]/TableNode[0]"); - assertThat(paths.get(chip)).isEqualTo("Outer[0]/row[0]/cell[0]/TableNode[0]/row[1]/cell[0]/Chip[0]"); - assertThat(paths.values().stream().toList()).as("what the chip holds, under it").contains( - "Outer[0]/row[0]/cell[0]/TableNode[0]/row[1]/cell[0]/Chip[0]/ParagraphNode[0]"); - } - - @Test - void aNodeThatStandsInTheFlowTooKeepsTheFlowsPath() { - // The layout knows the flow's path: a node found by the cell's would find no placement. - DocumentNode shared = new ParagraphBuilder().name("Shared").text("Both").build(); - DocumentNode table = new TableBuilder().columns(DocumentTableColumn.fixed(100)) - .rowCells(DocumentTableCell.node(shared)).build(); - - Map before = DocxLayoutMetrics.pathsWithin(new SectionBuilder() - .add(shared).add(table).build()); - Map after = DocxLayoutMetrics.pathsWithin(new SectionBuilder() - .add(table).add(shared).build()); + 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(before.get(shared)).isEqualTo("SectionNode[0]/Shared[0]"); - assertThat(after.get(shared)).isEqualTo("SectionNode[0]/Shared[1]"); - assertThat(List.of(before.get(table), after.get(table))) - .containsExactly("SectionNode[0]/TableNode[1]", "SectionNode[0]/TableNode[0]"); + 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 (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document() - .pageSize(400, 600).margin(com.demcha.compose.document.style.DocumentInsets.of(20)).create()) { + 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()); @@ -97,10 +73,10 @@ void aComposedNodeStillFindsNoPlacementOrFragmentsOfItsOwn() throws Exception { } @Test - void aNodeInTheFlowAndInACellKeepsTheFlowsPlacement() throws Exception { + void aNodeInTheFlowAndInACellKeepsTheFlowsPathAndPlacement() throws Exception { DocumentNode shared = new ParagraphBuilder().name("Shared").text("Both").build(); - try (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document() - .pageSize(400, 600).margin(com.demcha.compose.document.style.DocumentInsets.of(20)).create()) { + 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()); @@ -108,17 +84,42 @@ void aNodeInTheFlowAndInACellKeepsTheFlowsPlacement() throws Exception { 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 aCellAfterOneSpanningColumnsIsNamedByItsPlaceAmongTheRowsCells() { + void aTableInTheFlowAndInAnotherTablesCellNamesWhatItHoldsUnderItsFlowPath() { DocumentNode chip = chip("Chip"); - DocumentNode table = new TableBuilder().name("Grid") + 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(chip)).build(); + .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(DocxLayoutMetrics.pathsWithin(table).get(chip)).as("the second cell, in the third column") - .isEqualTo("Grid[0]/row[0]/cell[1]/Chip[0]"); + 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 @@ -130,13 +131,22 @@ void aNodeAtMoreThanOnePlaceIsNamedByTheLastAsInTheFlow() { 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); + } - assertThat(DocxLayoutMetrics.pathsWithin(twice).get(chip)).isEqualTo("Twice[0]/row[0]/cell[1]/Chip[0]"); - Map paths = DocxLayoutMetrics.pathsWithin(new SectionBuilder() - .add(reused).add(reused).build()); - assertThat(paths.get(reused)).isEqualTo("SectionNode[0]/TableNode[1]"); - assertThat(paths.get(inner)).as("under the table's own path").isEqualTo( - "SectionNode[0]/TableNode[1]/row[0]/cell[0]/Inner[0]"); + 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) {