From 4fb813bdc58961b950a3f6e520dfb5eb0b2a09c3 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 24 Oct 2026 11:22:38 +0100 Subject: [PATCH] fix(docx): name every page a page-margin note is about - A note about several pages named the first of them only, and the later ones, which Word sets with the margins in force too, went unnamed: ObsidianInvoice with its line items run onto three pages named page 2, not page 3. The note on a block several pages start inside, on a run of pages whose side margins differ, and on a section of several whose page count a Word section would split now names each page, as "pages 2 and 3", "pages 2, 3 and 5", "pages 2 to 4". - A page-margin note about a page names it, page N or section N, page N, as the report's contract says: the notes on a section written where a page starts, on a page after one whose content reaches its foot, on a page no block starts and on a page's side margins carried their section's path, none in a document of one section. - The contract's page zones, as written: a note about one, or about a node of one, has no path or names its section in a document of several. --- CHANGELOG.md | 18 ++- docs/recipes/docx-export.md | 11 +- .../semantic/docx/DocxExportReport.java | 8 +- .../semantic/docx/DocxPageMarginSections.java | 118 ++++++++++++++---- .../docx/DocxNodeFieldLedgerTest.java | 3 +- .../docx/DocxPageMarginSectionsTest.java | 86 +++++++++++-- 6 files changed, 200 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06d60a6d0..b596c9476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export's page-margin notes name every page they are about.** + - A note about several pages named the first of them only, and the later ones, which Word sets + with the margins in force too, went unnamed: `ObsidianInvoice` with its line items run onto + three pages named page 2, not page 3. The note on a block several pages start inside, on a run + of pages whose side margins differ, and on a section of several whose page count a Word + section would split now names each page, as "the top and bottom margins of pages 2 and 3 + differ from page 1's", three or more in a row as "pages 2 to 4". + - A page-margin note about a page names it, `page N` or `section N, page N`, as the report's + contract says: the note on a section started where a page does, on a page after one whose + content reaches its foot, on a page no block starts and on a page's side margins carried + their section's path, which a document of one section has none of. + - The file is unchanged. + - **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 @@ -23,8 +36,9 @@ follow semantic versioning; release dates are ISO 8601. 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. + document of several sections `section N` or `section N, page N`. A note about the whole + document has none; one about a page zone or a node of one has none or, in a document of + several sections, names its section. - 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 35912fc7a..22b34e92a 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -202,8 +202,10 @@ reference, which the export settles in the paragraph it wrote it in once the bod page after one whose content reaches its foot, under a table or in its rows, starts none either, as the line that would end the section has no room. Neither does a page of a document of several sections whose header or footer counts its section's pages: Word would count -a section of its own apart. The report names each (`page margin`), on the block where there is -one, and Word sets that page with the margins in force. +a section of its own apart. The report names each (`page margin`): on the block where there is +one, in one note naming every page that starts inside it; on the section, naming each of its +pages, where its page count would split; and by the page, `page N`, otherwise. Word sets those +pages with the margins in force. ## Finding out what the export could not carry @@ -224,8 +226,9 @@ authored for that row, from 0, not its column in the grid where a cell before it 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. +`section N, page N`. A note about the whole document, such as the line closing it, has none. One +about a page zone or a node of one has none either, or names its section in a document of +several. `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 09f6f23e5..59591abc9 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 @@ -32,8 +32,9 @@ * *

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.

+ * note about the whole document has no path. One about a page zone, or about a node of one, which + * the layout lays out apart from the body, has none either, or names its section in a document of + * several.

* *

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

* @@ -69,7 +70,8 @@ public enum Severity { * @param subject what it was, in a few words — {@code "chart"}, {@code "corner radius"} * @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 + * it belongs to the whole export; about a page zone or a node of one, null or, + * in a document of several sections, its section * @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/DocxPageMarginSections.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSections.java index 4fb24e067..25880429f 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSections.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSections.java @@ -165,12 +165,27 @@ static Plan plan(XWPFDocument document, int from, Map owner if (!vary(layout)) { return new Plan(opening, List.of(), zoned); } + if (countsItsPages) { + // Word counts each section's own pages, and this section's page count would split. + List differing = new ArrayList<>(); + for (int page = 1; page < layout.pageCount(); page++) { + if (!sameTopAndBottom(layout.marginOf(page), opening)) { + differing.add(page); + } + } + report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, section, notSetWith(differing, 0) + + ": a Word section would count its own pages, where this section's header or footer counts all " + + "of them"); + return new Plan(opening, List.of(), zoned); + } List all = document.getBodyElements(); List elements = new ArrayList<>(all.subList(Math.min(from, all.size()), all.size())); List starts = new ArrayList<>(); Margin inForce = opening; int inForceFrom = 0; DocumentNode lastNamed = null; + int namedAt = -1; + List namedPages = new ArrayList<>(); for (int page = 1; page < layout.pageCount(); page++) { Margin margin = layout.marginOf(page); if (sameTopAndBottom(margin, inForce)) { @@ -179,13 +194,6 @@ static Plan plan(XWPFDocument document, int from, Map owner String differ = "page " + (page + 1) + "'s top and bottom margins differ from page " + (inForceFrom + 1) + "'s"; String notWritten = differ + ", which Word sets it with: "; - if (countsItsPages) { - // Word counts each section's own pages, and this section's page count would split. - report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, section, notWritten - + "a Word section would count its own pages, where this section's header or footer counts " - + "all of them"); - return new Plan(opening, List.of(), zoned); - } Owner inside = spanning(page, elements, owners); XWPFTable table = inside == null ? null : tableOf(inside, elements, owners); Carrier carrier = null; @@ -199,13 +207,25 @@ static Plan plan(XWPFDocument document, int from, Map owner : carrierBefore(at, elements, owners, separators); } if (carrier == null) { - if (inside == null || inside.node() != lastNamed) { - report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, - inside != null ? layout.pathOf(inside.node()) : section, - notWritten + (inside != null ? "page " + (page + 1) + " starts inside this block" - : "no block of the body starts page " + (page + 1)) + if (inside == null) { + report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, pagePath(section, page), + notWritten + "no block of the body starts page " + (page + 1) + ", where the export starts no Word section"); - lastNamed = inside != null ? inside.node() : null; + lastNamed = null; + } else if (inside.node() != lastNamed) { + namedAt = report.size(); + namedPages.clear(); + namedPages.add(page); + report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, layout.pathOf(inside.node()), + notSetWith(namedPages, inForceFrom) + ": page " + (page + 1) + " starts inside this " + + "block, where the export starts no Word section"); + lastNamed = inside.node(); + } else { + // One note for the block, naming each page that starts inside it. + namedPages.add(page); + report.set(namedAt, DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, + layout.pathOf(inside.node()), notSetWith(namedPages, inForceFrom) + + ": each starts inside this block, where the export starts no Word section"); } continue; } @@ -213,9 +233,9 @@ static Plan plan(XWPFDocument document, int from, Map owner double room = layout.lowestContentOn(page - 1) - inForce.bottom(); if (carrier.hairline() && room < ROOM_FOR_THE_END) { report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, - table != null ? layout.pathOf(inside.node()) : section, notWritten + "page " + page - + "'s content reaches its foot, where the paragraph that would end a Word section before page " - + (page + 1) + " would open a page of its own"); + table != null ? layout.pathOf(inside.node()) : pagePath(section, page), + notWritten + "page " + page + "'s content reaches its foot, where the paragraph that would " + + "end a Word section before page " + (page + 1) + " would open a page of its own"); continue; } starts.add(new Start(page, margin, carrier)); @@ -226,9 +246,9 @@ static Plan plan(XWPFDocument document, int from, Map owner ? ", the part after it opening with its header rows" : "") + ", and its page break fixed " + "there, so rows an edit adds above it no longer run onto that page"); } else { - report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, section, differ + ": written as a Word " - + "section that starts where page " + (page + 1) + " does, its page break fixed there, so text " - + "an edit adds above it no longer runs onto that page"); + report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, pagePath(section, page), differ + + ": written as a Word section that starts where page " + (page + 1) + " does, its page break " + + "fixed there, so text an edit adds above it no longer runs onto that page"); } inForce = margin; inForceFrom = page; @@ -239,8 +259,8 @@ static Plan plan(XWPFDocument document, int from, Map owner } /** - * Names each run of pages whose side margins differ from the document's, once: they are not - * written, as the body is written against the document's width. + * Names each run of pages whose side margins differ from the document's, once, and each page of + * it: they are not written, as the body is written against the document's width. */ private static void nameTheSides(DocxLayoutMetrics layout, DocxExportReport.Builder report, String section, Margin sides) { @@ -249,13 +269,17 @@ private static void nameTheSides(DocxLayoutMetrics layout, DocxExportReport.Buil } for (int page = 0; page < layout.pageCount(); page++) { Margin margin = layout.marginOf(page); - boolean differs = !sameSides(margin, sides); - boolean opensARun = page == 0 || !sameSides(margin, layout.marginOf(page - 1)); - if (differs && opensARun) { - report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, section, "page " + (page + 1) - + "'s side margins differ from the document's and are not written: the body is written " - + "against the document's width, and Word sets its text between the document's side margins"); + if (sameSides(margin, sides)) { + continue; } + List run = new ArrayList<>(List.of(page)); + while (page + 1 < layout.pageCount() && sameSides(layout.marginOf(page + 1), margin)) { + run.add(++page); + } + report.add(DocxExportReport.Severity.APPROXIMATED, PAGE_MARGIN, pagePath(section, run.get(0)), + (run.size() == 1 ? "page " + (run.get(0) + 1) + "'s side margins" : "the side margins of " + + pages(run)) + " differ from the document's and are not written: the body is written " + + "against the document's width, and Word sets its text between the document's side margins"); } } @@ -296,6 +320,46 @@ static void apply(XWPFDocument document, Plan plan) { asALaterSection(body, opening, margin, plan.zoned()); } + /** What a note about one page of a section names: the page, after its section in a document of several. */ + private static String pagePath(String section, int page) { + return (section == null ? "" : section + ", ") + "page " + (page + 1); + } + + /** + * Some pages' top and bottom margins, and those Word sets them with: "page 3's top and bottom + * margins differ from page 1's, which Word sets it with", or "the top and bottom margins of pages + * 3 and 4 differ from page 1's, which Word sets them with". + */ + private static String notSetWith(List pages, int inForceFrom) { + String from = " differ from page " + (inForceFrom + 1) + "'s, which Word sets "; + return pages.size() == 1 ? "page " + (pages.get(0) + 1) + "'s top and bottom margins" + from + "it with" + : "the top and bottom margins of " + pages(pages) + from + "them with"; + } + + /** + * Pages counted from 0, named from 1: "pages 2 and 3", "pages 2, 3 and 5", and three or more in a + * row as "pages 2 to 4". + */ + private static String pages(List pages) { + List named = new ArrayList<>(); + for (int first = 0; first < pages.size(); ) { + int last = first; + while (last + 1 < pages.size() && pages.get(last + 1) == pages.get(last) + 1) { + last++; + } + if (last - first >= 2) { + named.add((pages.get(first) + 1) + " to " + (pages.get(last) + 1)); + } else { + for (int page = first; page <= last; page++) { + named.add(String.valueOf(pages.get(page) + 1)); + } + } + first = last + 1; + } + return "pages " + (named.size() == 1 ? named.get(0) + : String.join(", ", named.subList(0, named.size() - 1)) + " and " + named.get(named.size() - 1)); + } + /** The last block written before a page that runs onto it, if one does. */ private static Owner spanning(int page, List elements, Map owners) { Owner spanning = null; diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java index 806948931..9b2df3197 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java @@ -286,7 +286,8 @@ record Entry(Fate fate, String note) { "fragments:WRITTEN:where the nodes' entries say", "pageGeometry:REPORTED:a page's side margins, written as the document's, named where they differ; " + "top and bottom margins that differ from those Word sets a page with, where it starts inside a " - + "block, after a page whose content reaches its foot under a table, or in a document of sections " + + "block or where no block starts it, after a page whose content reaches its foot under a table, or in " + + "a document of sections " + "whose header or footer counts a section's pages, named; any other is written, a later page's top " + "and bottom margins as a Word section starting where it does"); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSectionsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSectionsTest.java index be1e6924f..372b8e9a5 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSectionsTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageMarginSectionsTest.java @@ -255,8 +255,10 @@ void aPageReferenceInARowThatWouldMoveKeepsTheTableWhole() throws Exception { assertThat(exported.report().notes()).filteredOn(note -> note.subject().equals("page margin")) .singleElement().satisfies(note -> { assertThat(note.path()).endsWith("TableNode[1]"); - assertThat(note.detail()).endsWith("page 2 starts inside this block, where the export starts no " - + "Word section"); + assertThat(note.detail()).as("each page the table runs onto") + .isEqualTo("the top and bottom margins of pages 2 and 3 differ from page 1's, which Word " + + "sets them with: each starts inside this block, where the export starts no " + + "Word section"); }); } @@ -298,6 +300,71 @@ private static List bookmarks(XWPFTable table) { return names; } + @Test + void aBlockSeveralPagesStartInsideNamesEachInItsOneNote() throws Exception { + // Word sets each with page 1's margins; named for page 2 alone, page 3's went unsaid. + Exported exported = export(List.of(PageMarginRule.from(2, LATER)), page -> page.addSection(panel -> { + panel.fillColor(com.demcha.compose.document.style.DocumentColor.rgb(238, 242, 247)); + for (int line = 0; line < 70; line++) { + panel.addParagraph("Line " + line); + } + })); + + assertThat(exported.lastNames()).as("four pages").hasSize(4); + assertThat(exported.report().notes()).filteredOn(note -> note.subject().equals("page margin")) + .singleElement().satisfies(note -> { + assertThat(note.path()).endsWith("SectionNode[0]"); + assertThat(note.detail()).isEqualTo("the top and bottom margins of pages 2 to 4 differ from page " + + "1's, which Word sets them with: each starts inside this " + + "block, where the export starts no Word section"); + }); + } + + @Test + void theRunsOfPagesStartingInsideABlockAreNamedAsRuns() throws Exception { + // Page 4 has page 1's margins again, which Word sets it with: the note leaves it out. + Exported exported = export(List.of(PageMarginRule.page(2, LATER), PageMarginRule.page(3, LATER), + PageMarginRule.page(5, LATER)), page -> page.addSection(panel -> { + panel.fillColor(com.demcha.compose.document.style.DocumentColor.rgb(238, 242, 247)); + for (int line = 0; line < 100; line++) { + panel.addParagraph("Line " + line); + } + })); + + assertThat(exported.lastNames().size()).as("five pages or more").isGreaterThanOrEqualTo(5); + assertThat(exported.notes()).singleElement().asString().startsWith("the top and bottom margins of pages 2, " + + "3 and 5 differ from page 1's"); + } + + @Test + void aNoteAboutAPageNamesThePage() throws Exception { + Exported written = export(List.of(PageMarginRule.from(2, LATER)), page -> lines(page, "Line", 60)); + Exported sides = export(List.of(PageMarginRule.from(2, DocumentInsets.symmetric(36, 90))), + page -> lines(page, "Line", 60)); + + assertThat(written.report().notes()).filteredOn(note -> note.subject().equals("page margin")) + .extracting(DocxExportReport.Note::path).containsExactly("page 2"); + assertThat(sides.report().notes()).filteredOn(note -> note.subject().equals("page margin")) + .extracting(DocxExportReport.Note::path).containsExactly("page 2"); + } + + @Test + void aNoteAboutAPageOfASectionOfSeveralNamesTheSectionAndThePage() throws Exception { + DocumentSession body = GraphCompose.document().pageSize(400, 400).margin(FIRST).create(); + body.pageMargins(List.of(PageMarginRule.from(2, LATER))); + body.pageFlow(page -> lines(page, "Line", 60)); + DocumentSession cover = GraphCompose.document().pageSize(400, 400).margin(FIRST).create(); + cover.pageFlow(page -> page.addParagraph("Cover")); + AtomicReference report = new AtomicReference<>(); + try (com.demcha.compose.document.api.MultiSectionDocument document = GraphCompose.documents() + .section(cover).section(body).create()) { + document.export(new DocxSemanticBackend(report::set)); + } + + assertThat(report.get().notes()).filteredOn(note -> note.subject().equals("page margin")) + .extracting(DocxExportReport.Note::path).containsExactly("section 2, page 2"); + } + @Test void aPageStartingInsideAPanelIsNamed() throws Exception { Exported exported = export(List.of(PageMarginRule.from(2, LATER)), page -> page.addSection(panel -> { @@ -476,6 +543,8 @@ void aPageFullToItsFootUnderATableStartsNoSectionAfterIt() throws Exception { assertThat(exported.paragraphsEndingASection()).isEmpty(); assertThat(exported.notes()).singleElement().asString().endsWith("page 1's content reaches its foot, where " + "the paragraph that would end a Word section before page 2 would open a page of its own"); + assertThat(exported.report().notes()).filteredOn(note -> note.subject().equals("page margin")) + .extracting(DocxExportReport.Note::path).containsExactly("page 2"); } @Test @@ -525,9 +594,10 @@ void otherSideMarginsFromAPageOnAreNamedNotWritten() throws Exception { assertThat(exported.paragraphsEndingASection()).as("its top and bottom are page 1's").isEmpty(); assertThat(exported.body().getPgMar().getLeft()).isEqualTo(java.math.BigInteger.valueOf(36 * 20)); - assertThat(exported.notes()).singleElement().asString().isEqualTo("page 2's side margins differ from the " - + "document's and are not written: the body is written against the document's width, and Word " - + "sets its text between the document's side margins"); + assertThat(exported.lastNames()).as("three pages").hasSize(3); + assertThat(exported.notes()).singleElement().asString().isEqualTo("the side margins of pages 2 and 3 differ " + + "from the document's and are not written: the body is written against the document's width, and " + + "Word sets its text between the document's side margins"); } @Test @@ -550,8 +620,10 @@ void aSectionOfADocumentWhoseTextFooterCountsItsPagesKeepsOneSetOfMargins() thro body.pageFlow(page -> lines(page, "Line", 60)); List notes = sectionedNotes(body); - assertThat(notes).singleElement().asString().endsWith("a Word section would count its own pages, where this " - + "section's header or footer counts all of them"); + assertThat(notes).singleElement().asString().as("each page it would set otherwise") + .startsWith("the top and bottom margins of pages 2 to ") + .endsWith("which Word sets them with: a Word section would count its own pages, where this " + + "section's header or footer counts all of them"); } @Test