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