From 46c4f64ddebd1f814f3c7712b58f6a8cf0661821 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 19 Oct 2026 00:41:32 +0100
Subject: [PATCH] fix(docx): ask a zone's page predicate of the layout's own
pages
Word paginates for itself, so a page zone's appliesTo predicate is
asked over sample pages, a document of six pages or of the layout's own
count where that is more, and sorted into Word's first, even and other
pages. A shorter layout's pages were asked as pages of that six-page
document, while the page asks each of its pages as one of as many as
the layout ran to. A predicate that reads the total then answered
otherwise for the export than for the page. On a one-page document a
footer for one-page documents only (page.total() == 1) was not written,
with nothing said, and one for longer documents only (page.total() > 1)
was written on the one page, which the page draws none of. Measured in
Word and LibreOffice on Windows, both showed the second and not the
first.
DocxPageClasses still asks the six-page sample, which decides whether
the predicate follows Word's kinds at all. A layout of fewer than six
pages is now asked as well, as a document of its own, each page as the
page asks it; those answers decide the kinds of page the layout has,
and the sample's the rest. Both editors then show the first footer on
the one page, 19.92 and 19.96pt from its foot where the page sets it
19.95, and the second on none; on two pages a footer for two-page
documents only is on both and one for more than two pages on neither.
A document of six pages or more, and an export with no layout, is
asked as before.
---
CHANGELOG.md | 16 ++++
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 10 +++
.../semantic/docx/DocxPageClasses.java | 52 +++++++++----
.../semantic/docx/DocxPageClassesTest.java | 26 +++++++
.../semantic/docx/DocxPageZoneKindsTest.java | 76 +++++++++++++++++++
6 files changed, 168 insertions(+), 14 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 47d2d061b..a532890df 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,22 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export writes a page zone whose predicate reads the page total on the pages the page
+ draws it on.** Word paginates for itself, so a zone's `appliesTo` predicate is asked over
+ sample pages, the document's own and more up to the sixth, and sorted into Word's first, even
+ and other pages. Every sample page was asked as one of at least six, the document's own
+ included. So on a one-page document a footer for one-page documents only
+ (`page -> page.total() == 1`) was not written, with nothing said, and one for longer documents
+ only (`page.total() > 1`) was written on the one page, which the page draws none of. Measured
+ in Word and LibreOffice on Windows, both showed the second footer and not the first. A
+ layout of fewer than six pages is now asked as well, each of its pages as the page asks it,
+ one of as many as the layout ran to. Those answers decide the kinds of page it has, and the
+ six-page sample's the rest; a predicate that answers otherwise for two pages of a kind in
+ either is written on every page and reported, as before. Both
+ editors show the first footer on the one page, 19.92 and 19.96pt from its foot where the page
+ sets it 19.95, and the second on none. On a two-page document a footer for two-page documents
+ only is on both pages with no note, and one for documents of more than two pages on neither.
+ A document of six pages or more is asked as before.
- **A DOCX export names a table its rules stand taller in Word.** Word makes room for a table's
horizontal rules in their rows, and the page draws them over the cells' edges; the export takes
that room off a cell's padding, and where the padding is thinner than its share of the rules,
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index e1fa1350b..e5e940eb9 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -118,7 +118,7 @@ honour an option ignores it (documented contract).
| Page backgrounds (`DocumentSession.pageBackgrounds`, `PageBackgroundFill` — full page, columns, bands) | ✅ `DocumentPageBackgrounds` adds each fill as a shape fragment under every page's content, drawn by the ordinary shape handler | ✅ the same fragments, drawn as shapes on every slide | ⚠️ `DocxPageBackgrounds` — each fill is a rectangle anchored to the page, behind the text. A section of more than one page, or with a header or footer, carries them in every header part it has (default, first page, even pages), so they are drawn on every page; a section without a header gets an empty one against the page edge to carry them, and a later section without fills gets an empty header of its own rather than inheriting them. On a page with no top margin LibreOffice still sets the first line about 3pt lower under that header. A section of one page with neither draws them from the body instead — in the first cell's paragraph when the page opens with a table — and its lines stand where the page sets them in both editors (the seven sidebar CVs stood 2.6 to 3.3pt low in LibreOffice); they are on that page alone, so a page an editor's text runs onto has none. A fill's alpha is carried as the shape's. A two-column layout still flows its columns one after the other, so a column fill can stand beside text that is not its column's |
| Watermark (front/back layers) | ✅ `PdfWatermarkRenderer` | ✅ `PptxChromeRenderer` (per-slide shape at the PDF placement math; behind-content applies before fragments, so no z-order surgery) | ❌ (not written; reported `DROPPED`, `watermark`) |
| Repeating headers / footers | ✅ `PdfHeaderFooterRenderer` — the zone's `fontName` is resolved through the document's own `FontLibrary`, so a zone draws in the family the author named; unnamed means standard-14 Helvetica, and a code point that family cannot encode is substituted with `?` exactly as body text is | ✅ `PptxChromeRenderer` (positioned per-slide text boxes; `{page}` / `{pages}` / `{date}` tokens with the numbering window rules). The named family reaches the slide run through `PptxFontMapping.familyFor`, and the same family measures the slots — a run measured against one face and typeset in another lands off-centre | ✅ `DocxSemanticBackend.writeBand` (`DocxTextBands`) — one line of a Word header or footer part: the left slot, the centre slot at a centre tab and the right slot at a right tab against the margins; `{page}` / `{pages}` as `PAGE` / `NUMPAGES` (`SECTIONPAGES` per section) fields with the roman or alphabetic switch; `{date}` as the date of the export; the separator as the paragraph's border, a translucent one flattened against white and reported (`translucency`), one wider or thinner than Word draws held to its range, one of no width drawn at Word's thinnest and one below zero not written, each reported (`border width`); the header or footer distance from the band's geometry, baseline within 0.1pt in LibreOffice; a band sharing its kind with another band or a page zone stands in a frame (`w:framePr`) at its own height; `showOnFirstPage(false)` or counting from page 2 → an empty first-page part. A band starting after page 2, numbers not counting from 1 on page 1, and a band alone of its kind reaching past the page margin (written as a negative margin, so that Word holds the body at it as the page does; LibreOffice moves the body clear of it) are reported |
-| Page zones (node subtree in the band) | ✅ Spliced into the layout graph by `DocumentPageZones`, so the ordinary fragment handlers draw it — no zone-specific code in the backend | ✅ Same splice, same reason: `PptxFixedLayoutBackend.renderGraph` draws every fragment of the graph | ✅ Written into a real `w:ftr` / `w:hdr` part. The band's children become runs on one Word line: a paragraph contributes its runs, an `ImageNode` an inline picture at the size the page draws it — contained or cropped to cover its box as the page draws it, its link its run's — a flex spacer becomes the right tab stop, and `PageContext.pageNumber()` / `pageTotal()` become live `PAGE` / `NUMPAGES` fields. Other node kinds are skipped, logged once a kind and reported once each (`DROPPED`, `page zone content`); a zone row's own fill, outline and side borders are reported as `row paint`. Word sets the line's parts one after another from the left margin and, after the first spacer, against the right margin, on one baseline, its tallest part's, in a zone of one line a one-line part the page sets on a baseline of its own raised or lowered to it by `w:position`, in whole half points — lowered only as far as the line holds below its baseline, a fifth of it or as far as its tallest part reaches; LibreOffice raises a run about a seventh further than Word, does not raise a page field and stands every picture on the baseline —; the page sets each by the zone's padding, a row's columns and gap, a paragraph's alignment and a part's own sides (a page field's alignment moves nothing: its box is a point wider than its number). The `page zone` note counts the parts the page sets elsewhere — read from the layout's zone fragments: a part stands where the page sets it when its line starts, or against the right margin ends, within a point and a half of Word's, on the baseline Word sets it on, in one line and with no prefix before it on the left side; past a part whose width Word does not keep, or a zone whose nodes the page names or nests otherwise, where a part stands is said to be not measured — and names a zone paragraph's right-to-left direction, prefix letters, fitted size where it is not measured, markdown marks written as letters, a markdown heading taller than its line, outline entry, anchor (no bookmark) and a picture set off the baseline, which the line does not carry, and a picture part's transform, outline entry and anchor. The line is an exact line as tall as the tallest part's line on the page — taller for a picture in it, a part written at a larger size than the page's or a part raised above it — standing as far from its edge as puts that part's baseline — a picture's foot — where the page has it (a lone or tallest part within 0.1pt in Word and LibreOffice, measured on 8pt and 18pt Lato headers and 8pt Lato footers), a lone part's padding and margin above and below included; a tallest part of more lines than one is as many exact lines, a footer's standing as many lines further from its edge; lines reaching past the page margin by more than half a point are held there with a negative margin and named; a zone the layout measures that shares its kind with another page zone stands in a frame (`w:framePr`) at its own height, at least its lines tall; a zone the page draws otherwise on the page it is measured on than it is written — other text, face, size or pictures — is not measured, its line Word's, and a zone whose content is none for every page Word writes it on is not written and named, as is one building none for the first page or the pages between, where the page draws it. Because Word paginates, `PageContext.number()` refuses here rather than baking a number that would be wrong on every page but one. The content is built for the first page and for the pages after it (`PageContext.unpaginated(first, last)`): built otherwise for the first, it becomes the title page's part (`w:titlePg`) and the ordinary one, a measured one framed where the two cannot share one distance; built otherwise for the last page, it is written there as built for the pages between and reported. A zone's `appliesTo` predicate is asked over sample pages (`DocxPageClasses`) and, when it follows Word's first / even / other pages, becomes the matching part — `w:titlePg` for the first page, `w:evenAndOddHeaders` for even pages — with an empty part on the pages it skips; a predicate that picks pages within a kind (the last page) is written on every page and reported |
+| Page zones (node subtree in the band) | ✅ Spliced into the layout graph by `DocumentPageZones`, so the ordinary fragment handlers draw it — no zone-specific code in the backend | ✅ Same splice, same reason: `PptxFixedLayoutBackend.renderGraph` draws every fragment of the graph | ✅ Written into a real `w:ftr` / `w:hdr` part. The band's children become runs on one Word line: a paragraph contributes its runs, an `ImageNode` an inline picture at the size the page draws it — contained or cropped to cover its box as the page draws it, its link its run's — a flex spacer becomes the right tab stop, and `PageContext.pageNumber()` / `pageTotal()` become live `PAGE` / `NUMPAGES` fields. Other node kinds are skipped, logged once a kind and reported once each (`DROPPED`, `page zone content`); a zone row's own fill, outline and side borders are reported as `row paint`. Word sets the line's parts one after another from the left margin and, after the first spacer, against the right margin, on one baseline, its tallest part's, in a zone of one line a one-line part the page sets on a baseline of its own raised or lowered to it by `w:position`, in whole half points — lowered only as far as the line holds below its baseline, a fifth of it or as far as its tallest part reaches; LibreOffice raises a run about a seventh further than Word, does not raise a page field and stands every picture on the baseline —; the page sets each by the zone's padding, a row's columns and gap, a paragraph's alignment and a part's own sides (a page field's alignment moves nothing: its box is a point wider than its number). The `page zone` note counts the parts the page sets elsewhere — read from the layout's zone fragments: a part stands where the page sets it when its line starts, or against the right margin ends, within a point and a half of Word's, on the baseline Word sets it on, in one line and with no prefix before it on the left side; past a part whose width Word does not keep, or a zone whose nodes the page names or nests otherwise, where a part stands is said to be not measured — and names a zone paragraph's right-to-left direction, prefix letters, fitted size where it is not measured, markdown marks written as letters, a markdown heading taller than its line, outline entry, anchor (no bookmark) and a picture set off the baseline, which the line does not carry, and a picture part's transform, outline entry and anchor. The line is an exact line as tall as the tallest part's line on the page — taller for a picture in it, a part written at a larger size than the page's or a part raised above it — standing as far from its edge as puts that part's baseline — a picture's foot — where the page has it (a lone or tallest part within 0.1pt in Word and LibreOffice, measured on 8pt and 18pt Lato headers and 8pt Lato footers), a lone part's padding and margin above and below included; a tallest part of more lines than one is as many exact lines, a footer's standing as many lines further from its edge; lines reaching past the page margin by more than half a point are held there with a negative margin and named; a zone the layout measures that shares its kind with another page zone stands in a frame (`w:framePr`) at its own height, at least its lines tall; a zone the page draws otherwise on the page it is measured on than it is written — other text, face, size or pictures — is not measured, its line Word's, and a zone whose content is none for every page Word writes it on is not written and named, as is one building none for the first page or the pages between, where the page draws it. Because Word paginates, `PageContext.number()` refuses here rather than baking a number that would be wrong on every page but one. The content is built for the first page and for the pages after it (`PageContext.unpaginated(first, last)`): built otherwise for the first, it becomes the title page's part (`w:titlePg`) and the ordinary one, a measured one framed where the two cannot share one distance; built otherwise for the last page, it is written there as built for the pages between and reported. A zone's `appliesTo` predicate is asked over sample pages (`DocxPageClasses`) — a layout of fewer than six pages also over its own, each as one of as many as it ran to, which decide its kinds of page — and, when it follows Word's first / even / other pages, becomes the matching part — `w:titlePg` for the first page, `w:evenAndOddHeaders` for even pages — with an empty part on the pages it skips; a predicate that picks pages within a kind (the last page) is written on every page and reported |
| Protection / encryption | ✅ `PdfDocumentPostProcessor` | ❌ (ignored with a one-time warning — no OOXML encryption support planned) | ❌ (not written, so the file opens unprotected; reported `DROPPED`, `protection`) |
| Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a (not written; reported `DROPPED`, `viewer preferences`) |
| Debug guide lines / node labels | ✅ `PdfGuideLinesRenderer`, `PdfNodeLabelRenderer` | ❌ (ignored with a one-time warning — render through the PDF backend to see overlays) | n/a |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 0093f5a7e..d89060e8f 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -1297,6 +1297,16 @@ so the predicate is asked over sample pages and sorted into those kinds:
A predicate that picks pages within a kind — the last page, the third — has no Word part.
Such a zone is written on every page and the export report says so.
+The sample is a document of six pages, or of as many as the layout ran to where that is more.
+A shorter layout is asked as well, each of its pages as the page asks it: one page of as many
+as the layout ran to. Those answers decide the kinds of page the layout has, and the sample's
+the rest; a predicate that answers otherwise for two pages of a kind in either has no Word part.
+So a predicate that reads the total is answered for the layout's pages as the page answers it.
+A footer on one-page documents only, `page -> page.total() == 1`, is the title page's footer of
+a one-page document. One on documents of more than two pages, `page.total() > 2`, is on neither
+page of a two-page document: it is the footer of the odd pages after the first, which that
+document has none of, and the document states different even and odd pages for it.
+
The same kinds hold for what a zone builds. Its content is built for the first page and for
the pages after it — and for the last, to name what it builds otherwise there — in a context
that still refuses the numbers (`PageContext.unpaginated(first, last)`), so content built
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageClasses.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageClasses.java
index dc75b3634..6609af9ac 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageClasses.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageClasses.java
@@ -20,9 +20,14 @@
* ones. One that does not — the last page, the third — has no Word equivalent, and is
* reported as such rather than guessed.
*
- * The sample runs to the document's own page count and to at least {@value #SAMPLE}
+ *
The sample is a document of the layout's own page count and of at least {@value #SAMPLE}
* pages, so every kind is asked more than once where it can be: a predicate that tells the
- * third page from the fifth shows up as one that does not follow the kinds.
+ * third page from the fifth shows up as one that does not follow the kinds. A layout of fewer
+ * pages is asked as well, each of its pages as the page asks it — one of as many as the layout
+ * ran to — and its answers decide the kinds of page it has; the sample's, the rest. A predicate
+ * that reads the total — a footer on one-page documents only — then answers for the layout's
+ * pages as the page does, where the sample alone would ask them as pages of another
+ * document.
*
* @author Artem Demchyshyn
*/
@@ -51,19 +56,46 @@ private DocxPageClasses() {
* @param pageCount how many pages the layout ran to, or 0 when it is unknown
* @return the kinds it is drawn on — every kind for a zone with no predicate, none for a
* zone drawn on no page — or {@code null} when its predicate does not answer
- * alike for every page of a kind, or refuses one of the sample pages
+ * alike for every page of a kind, of the sample or of the layout, or refuses one of
+ * the pages it is asked about
*/
static Set of(DocumentPageZone zone, int pageCount) {
if (zone.getAppliesTo() == null) {
return EnumSet.allOf(PageClass.class);
}
- int total = Math.max(pageCount, SAMPLE);
+ Map answers = answers(zone, Math.max(pageCount, SAMPLE));
+ if (answers == null) {
+ return null;
+ }
+ if (pageCount > 0 && pageCount < SAMPLE) {
+ // The layout's own pages, asked as the page asks them, decide the kinds they are of.
+ Map laidOut = answers(zone, pageCount);
+ if (laidOut == null) {
+ return null;
+ }
+ answers.putAll(laidOut);
+ }
+ Set drawnOn = EnumSet.noneOf(PageClass.class);
+ answers.forEach((kind, applies) -> {
+ if (applies) {
+ drawnOn.add(kind);
+ }
+ });
+ return drawnOn;
+ }
+
+ /**
+ * What a zone's predicate answers for each kind of page of a document so many pages long,
+ * each page asked as one of them; {@code null} where it answers otherwise for two pages of a
+ * kind, or refuses a page.
+ */
+ private static Map answers(DocumentPageZone zone, int pages) {
Map answers = new EnumMap<>(PageClass.class);
- for (int number = 1; number <= total; number++) {
+ for (int number = 1; number <= pages; number++) {
PageClass kind = classOf(number);
boolean applies;
try {
- applies = zone.appliesTo(PageContext.paginated(number, total));
+ applies = zone.appliesTo(PageContext.paginated(number, pages));
} catch (RuntimeException pageItWasNeverAskedAbout) {
// The sample can run past the document's own pages, and a predicate written
// for those pages — one looking something up by page number — may refuse a
@@ -75,13 +107,7 @@ static Set of(DocumentPageZone zone, int pageCount) {
return null;
}
}
- Set drawnOn = EnumSet.noneOf(PageClass.class);
- answers.forEach((kind, applies) -> {
- if (applies) {
- drawnOn.add(kind);
- }
- });
- return drawnOn;
+ return answers;
}
/** The kind Word files a 1-based page number under. */
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageClassesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageClassesTest.java
index 8728e0b36..da6b9810c 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageClassesTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageClassesTest.java
@@ -60,6 +60,32 @@ void thePredicateIsAskedAboutAsManyPagesAsTheDocumentHas() {
assertThat(DocxPageClasses.of(zone(firstTwelve), 20)).isNull();
}
+ @Test
+ void theDocumentsOwnPagesAreAskedOfItsOwnPageCount() {
+ // The page asks each of its pages as one of as many as the layout ran to, and those
+ // answers decide the kinds of page the layout has; the sample, a document of six, the rest.
+ assertThat(DocxPageClasses.of(zone(page -> page.total() == 1), 1))
+ .as("a footer on a one-page document only, which the page draws on its page")
+ .containsExactly(PageClass.FIRST);
+ assertThat(DocxPageClasses.of(zone(page -> page.total() > 1), 1))
+ .as("one on longer documents only, which the page draws on none of its one page")
+ .containsExactly(PageClass.EVEN, PageClass.LATER_ODD);
+ assertThat(DocxPageClasses.of(zone(page -> page.isFirst() && !page.isLast()), 1)).isEmpty();
+ assertThat(DocxPageClasses.of(zone(page -> page.total() == 1), 2)).isEmpty();
+ assertThat(DocxPageClasses.of(zone(page -> page.total() == 2), 2))
+ .as("drawn on both pages of a two-page document; a later odd page it has none of")
+ .containsExactly(PageClass.FIRST, PageClass.EVEN);
+ assertThat(DocxPageClasses.of(zone(page -> page.total() > 2), 2))
+ .as("drawn on neither page, only on the later odd pages, which it has none of")
+ .containsExactly(PageClass.LATER_ODD);
+ assertThat(DocxPageClasses.of(zone(page -> page.total() == 3), 3)).isEqualTo(EnumSet.allOf(PageClass.class));
+ assertThat(DocxPageClasses.of(zone(page -> page.total() > 3), 3)).isEmpty();
+ assertThat(DocxPageClasses.of(zone(page -> page.total() == 4 && page.number() == 2), 4))
+ .as("the second of its two even pages only")
+ .isNull();
+ assertThat(DocxPageClasses.of(zone(page -> page.total() == 8), 8)).isEqualTo(EnumSet.allOf(PageClass.class));
+ }
+
@Test
void pageNumbersAreFiledAsWordFilesThem() {
assertThat(DocxPageClasses.classOf(1)).isEqualTo(PageClass.FIRST);
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageZoneKindsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageZoneKindsTest.java
index 8257973c0..fc36b97b3 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageZoneKindsTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageZoneKindsTest.java
@@ -113,6 +113,72 @@ void aPredicateWordHasNoPartForIsWrittenOnEveryPageAndReported() throws Exceptio
});
}
+ @Test
+ void aZoneOnOnePageDocumentsOnlyIsOnTheOnePage() throws Exception {
+ List notes = new ArrayList<>();
+ byte[] docx;
+ try (DocumentSession session = onePage(footer("Single", page -> page.total() == 1))) {
+ docx = session.export(DocxSemanticBackend.builder().reportSink(report -> notes.addAll(report.notes())).build());
+ }
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ CTSectPr section = bodySection(document);
+
+ assertThat(section.isSetTitlePg()).isTrue();
+ assertThat(footerText(document, section, STHdrFtr.FIRST))
+ .as("the page draws it on its one page, asked as one page of one")
+ .isEqualTo("Single");
+ assertThat(footerText(document, section, STHdrFtr.DEFAULT)).isBlank();
+ }
+ assertThat(notes).noneSatisfy(note -> assertThat(note.subject()).isEqualTo("page zone"));
+ }
+
+ @Test
+ void aZoneOnLongerDocumentsOnlyIsNotOnTheOnePage() throws Exception {
+ try (XWPFDocument document = export(onePage(footer("Longer", page -> page.total() > 1)))) {
+ CTSectPr section = bodySection(document);
+
+ assertThat(section.isSetTitlePg()).isTrue();
+ assertThat(footerText(document, section, STHdrFtr.FIRST))
+ .as("the page draws it on none of its one page")
+ .isBlank();
+ }
+ }
+
+ @Test
+ void aZoneOnTwoPageDocumentsOnlyIsOnBothPagesOfOne() throws Exception {
+ List notes = new ArrayList<>();
+ byte[] docx;
+ try (DocumentSession session = twoPages(footer("Pair", page -> page.total() == 2))) {
+ docx = session.export(DocxSemanticBackend.builder().reportSink(report -> notes.addAll(report.notes())).build());
+ }
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ CTSectPr section = bodySection(document);
+
+ assertThat(footerText(document, section, STHdrFtr.FIRST)).isEqualTo("Pair");
+ assertThat(footerText(document, section, STHdrFtr.EVEN)).isEqualTo("Pair");
+ assertThat(footerText(document, section, STHdrFtr.DEFAULT))
+ .as("a later odd page, which a document of two pages has none of")
+ .isBlank();
+ }
+ assertThat(notes)
+ .as("drawn on both pages, as the page draws it, its predicate follows the pages Word has")
+ .noneSatisfy(note -> assertThat(note.subject()).isEqualTo("page zone"));
+ }
+
+ @Test
+ void aZoneOnLongerDocumentsOnlyIsOnNeitherPageOfTwo() throws Exception {
+ try (XWPFDocument document = export(twoPages(footer("Longer", page -> page.total() > 2)))) {
+ CTSectPr section = bodySection(document);
+
+ assertThat(footerText(document, section, STHdrFtr.FIRST)).isBlank();
+ assertThat(footerText(document, section, STHdrFtr.EVEN)).isBlank();
+ assertThat(footerText(document, section, STHdrFtr.DEFAULT))
+ .as("the footer of the later odd pages, which a document of two pages has none of")
+ .isEqualTo("Longer");
+ assertThat(evenAndOddPages(document)).isTrue();
+ }
+ }
+
@Test
void aZoneThatSkipsTheFirstPageIsPlacedWhereTheOtherPagesDrawIt() throws Exception {
long everyPage;
@@ -160,6 +226,16 @@ private static DocumentSession twoPages(DocumentPageZone zone) {
return session;
}
+ private static DocumentSession onePage(DocumentPageZone zone) {
+ DocumentSession session = GraphCompose.document()
+ .pageSize(300, 200)
+ .margin(DocumentInsets.of(30))
+ .create();
+ session.chrome().zone(zone);
+ session.pageFlow(page -> page.addParagraph(p -> p.text("Line")));
+ return session;
+ }
+
private static DocumentPageZone header(String text, Predicate appliesTo) {
DocumentPageZone zone = DocumentPageZone.header(20, page -> new RowBuilder()
.addParagraph(p -> p.text(text))