Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,16 @@
*
* <p>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.</p>
* 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]}.</p>
*
* <p>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.</p>
*
* <p><b>Experimental</b> ({@code @Beta}) — see {@code docs/api-stability.md}.</p>
*
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DocumentNode, String> cells = new IdentityHashMap<>();
for (DocumentNode root : graph.roots()) {
if (root != null) {
indexCells(root, paths.get(root), paths, cells);
}
}
paths.putAll(cells);
java.util.Set<DocumentNode> seen = java.util.Collections.newSetFromMap(new IdentityHashMap<>());
java.util.Set<DocumentNode> repeated = java.util.Collections.newSetFromMap(new IdentityHashMap<>());
for (DocumentNode root : graph.roots()) {
Expand Down Expand Up @@ -222,7 +229,8 @@ int pageCount() {
* <p>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.</p>
* 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}).</p>
*/
private static void indexPaths(DocumentNode node,
String parentPath,
Expand All @@ -231,16 +239,67 @@ 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<DocumentNode> children = node.children();
for (int index = 0; index < children.size(); index++) {
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.
*
* <p>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.</p>
*
* @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<DocumentNode, String> flow,
Map<DocumentNode, String> cells) {
List<DocumentNode> 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<DocumentTableCell> 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<DocumentNode, String> flow,
Map<DocumentNode, String> 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();
Expand Down Expand Up @@ -785,11 +844,12 @@ static Map<DocumentNode, String> 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);
Expand Down Expand Up @@ -1288,7 +1348,8 @@ private List<PlacedFragment> 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<PlacedFragment> textFragmentsOf(DocumentNode node) {
List<PlacedFragment> own = fragmentsOf(node);
Expand Down Expand Up @@ -1322,9 +1383,12 @@ private List<PlacedFragment> textFragmentsOf(DocumentNode node) {
private Map<DocumentNode, List<PlacedFragment>> matchComposedText() {
Map<DocumentNode, List<PlacedFragment>> matched = new IdentityHashMap<>();
for (DocumentNode table : paths.keySet()) {
if (!(table instanceof TableNode tableNode)) {
List<PlacedFragment> 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<DocumentNode> composed = new ArrayList<>();
collectComposedText(tableNode, composed);
if (composed.isEmpty()) {
Expand All @@ -1334,7 +1398,7 @@ private Map<DocumentNode, List<PlacedFragment>> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down Expand Up @@ -10901,8 +10900,8 @@ private record UnmarkedBlock(int notesFrom, int notesTo, String subject, String
*
* <p>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.</p>
* 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.</p>
*/
private void nameTheBlocksNoBookmarkMarks() {
for (UnmarkedBlock block : blocksUnmarked) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading