From 657694bce47814fd5bf15210c7f0c6c1617c867d Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Wed, 23 Sep 2026 15:17:32 +0100
Subject: [PATCH] fix(docx): draw a page zone on the pages its predicate picks
The DOCX export wrote every page zone on every page and only warned
about an appliesTo predicate. The predicate is now asked over sample
pages and sorted into Word's kinds: a first-page zone becomes the
title page's part (w:titlePg), an even- or odd-page zone the even or
default part (w:evenAndOddHeaders, document-wide), and pages a zone
skips get an empty part. A predicate that picks pages within a kind,
or refuses a sample page, is written on every page and reported.
A zone's distance from the page edge is measured on any page it is
drawn on, so a zone that skips the first page is placed where the
layout drew it.
---
CHANGELOG.md | 13 +
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 13 +
.../document/backend/DocxPageZoneTest.java | 17 +-
.../semantic/docx/DocxLayoutMetrics.java | 11 +-
.../semantic/docx/DocxPageClasses.java | 94 +++++++
.../semantic/docx/DocxSemanticBackend.java | 218 ++++++++++++----
.../semantic/docx/DocxPageClassesTest.java | 81 ++++++
.../semantic/docx/DocxPageZoneKindsTest.java | 234 ++++++++++++++++++
9 files changed, 618 insertions(+), 65 deletions(-)
create mode 100644 render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageClasses.java
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageClassesTest.java
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageZoneKindsTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 097bb5005..bb05d1f48 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,19 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A page zone drawn on some pages only lands on the same pages in Word.** The DOCX export
+ wrote every zone on every page and warned when it had an `appliesTo` predicate, so a
+ cover-only header repeated on every page and a footer that skips the cover appeared on it.
+ The predicate is now asked over sample pages and sorted into the kinds of page Word gives a
+ header of its own: a first-page-only zone becomes the section's first-page header (with a
+ title page stated), an every-page-but-the-first zone leaves an empty first-page part, and an
+ even- or odd-page zone becomes the even-page or ordinary part (with different even and odd
+ pages stated for the document). A predicate that picks pages within a kind — the last page —
+ has no Word part; that zone is written on every page and the export report says so. A zone
+ that skips the first page is also placed by where the layout drew it, rather than by its
+ padding. Converted in LibreOffice, a three-page document with a cover header, an even-page
+ header and a not-on-the-cover footer shows the same zones on the same pages as the PDF.
+
- **A multi-section document exports to Word, a section per section.** `MultiSectionDocument`
rendered only to PDF: a cover in one page size and a body in another had no DOCX export at
all. It gains `toDocxBytes()`, `writeDocx(OutputStream)`, `buildDocx(Path)` and
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 12aa776c4..c997a1b92 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -105,7 +105,7 @@ honour an option ignores it (documented contract).
| Metadata (title, author, …) | ✅ `PdfDocumentPostProcessor` | ⚠️ `applyMetadata` in `PptxFixedLayoutBackend` (OPC core properties + extended `Application`; OPC has no producer field, so that value is not representable) | ⚠️ `applyOutputOptions` (OPC core properties — title, author as creator, subject, keywords; OPC has no producer field here either, so that value is not representable) |
| Watermark (front/back layers) | ✅ `PdfWatermarkRenderer` | ✅ `PptxChromeRenderer` (per-slide shape at the PDF placement math; behind-content applies before fragments, so no z-order surgery) | ❌ |
| 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 | ❌ |
-| 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, a flex spacer becomes the right tab stop, and `PageContext.pageNumber()` / `pageTotal()` become live `PAGE` / `NUMPAGES` fields. Other node kinds are skipped and reported on the `docx` logger. Because Word paginates, `PageContext.number()` refuses here rather than baking a number that would be wrong on every page but one |
+| 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, a flex spacer becomes the right tab stop, and `PageContext.pageNumber()` / `pageTotal()` become live `PAGE` / `NUMPAGES` fields. Other node kinds are skipped and reported on the `docx` logger. Because Word paginates, `PageContext.number()` refuses here rather than baking a number that would be wrong on every page but one. 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 |
| Protection / encryption | ✅ `PdfDocumentPostProcessor` | ❌ (ignored with a one-time warning — no OOXML encryption support planned) | ❌ |
| Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a |
| 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 1d79a243f..db78ef73d 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -360,6 +360,19 @@ sits as far from its page edge as the page puts it — the distance is read
from where the zone's content landed in the resolved layout and written as
`w:pgMar/@w:header` or `@w:footer`, rather than left to Word's 36pt.
+A zone drawn on some pages only (`appliesTo(...)`) lands on the same pages when Word can
+say so. Word has a header and footer for the first page, for even pages and for the rest,
+so the predicate is asked over sample pages and sorted into those kinds:
+
+| Predicate | In Word |
+|---|---|
+| `PageContext::isFirst` | the section's first-page header, with a title page stated |
+| `page -> !page.isFirst()` | the ordinary header, and an empty one on the first page |
+| even or odd page numbers | the even-page header or the ordinary one, with different even and odd pages stated for the whole document |
+
+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 rule of thumb: if the document leans on geometry — shapes, layered
designs, precise placement — export PDF for the reader and DOCX only as
an editable companion.
diff --git a/qa/src/test/java/com/demcha/compose/document/backend/DocxPageZoneTest.java b/qa/src/test/java/com/demcha/compose/document/backend/DocxPageZoneTest.java
index 484732a01..6b05d80b0 100644
--- a/qa/src/test/java/com/demcha/compose/document/backend/DocxPageZoneTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/backend/DocxPageZoneTest.java
@@ -151,14 +151,17 @@ void aPageFieldCarriesItsTextStyleIntoTheWordRun() throws Exception {
}
/**
- * A page predicate is a fixed-layout capability: Word paginates the
- * document, so there is no page to test it against when the zone is
- * written. The worst answer would be dropping the predicate silently; the
- * export keeps the zone on every page — content beats absence — and says
- * what it could not honor.
+ * Word paginates the document itself and has a header and footer only for
+ * the first page, even pages and the rest, so a predicate that picks pages
+ * within those kinds — the last page here — has no Word equivalent. The
+ * worst answer would be dropping the zone silently; the export keeps it on
+ * every page — content beats absence — and says what it could not honor.
+ * (A predicate Word can state, such as every page but the first, becomes
+ * the matching header or footer part instead; {@code DocxPageZoneKindsTest}
+ * in render-docx covers those.)
*/
@Test
- void aPagePredicateCannotBeEvaluatedSoTheZoneLandsEverywhereAndSaysSo() throws Exception {
+ void aPagePredicateWordCannotStateLandsEverywhereAndSaysSo() throws Exception {
ch.qos.logback.classic.Logger backendLog = (ch.qos.logback.classic.Logger)
org.slf4j.LoggerFactory.getLogger(DocxSemanticBackend.class);
ch.qos.logback.core.read.ListAppender seen =
@@ -168,7 +171,7 @@ void aPagePredicateCannotBeEvaluatedSoTheZoneLandsEverywhereAndSaysSo() throws E
try {
byte[] docx = export(DocumentPageZone.builder()
.height(32)
- .appliesTo(page -> !page.isFirst())
+ .appliesTo(page -> page.isLast())
.content(page -> new RowBuilder()
.name("Conditional")
.addParagraph(paragraph -> paragraph.name("Note").text("Confidential"))
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
index 57cbd9a3c..57d079674 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
@@ -242,7 +242,7 @@ boolean onOnePage(DocumentNode node) {
/**
* How far a page zone's content sits from the page edge it belongs to, as laid out on
- * the first page.
+ * the pages it is drawn on.
*
* Word places a footer by the distance from the page's bottom edge to the bottom of
* the footer, and a header by the distance from the top edge to the top of the header.
@@ -252,7 +252,9 @@ boolean onOnePage(DocumentNode node) {
* highest edge of a header's — rather than rebuilt from the band's parts.
*
* Zone fragments are spliced into the graph under {@code @page-zone[page][index]},
- * outside the node paths this index is built from, so they are found by that prefix.
+ * outside the node paths this index is built from, so they are found by that prefix — on
+ * any page, because a zone that skips the first page has nothing on it. A band sits at the
+ * same place on every page it is drawn on, so every page gives the same distance.
*
* @param zoneIndex the zone's position in the session's zone list
* @param header whether it is a header, measured from the top edge
@@ -260,11 +262,12 @@ boolean onOnePage(DocumentNode node) {
* @return the distance in points, or empty when the layout carries no such zone
*/
OptionalDouble zoneDistanceFromEdge(int zoneIndex, boolean header, double pageHeight) {
- String prefix = "@page-zone[0][" + zoneIndex + "]";
+ java.util.regex.Pattern zone =
+ java.util.regex.Pattern.compile("^@page-zone\\[\\d+]\\[" + zoneIndex + "]");
double lowest = Double.POSITIVE_INFINITY;
double highest = Double.NEGATIVE_INFINITY;
for (Map.Entry> entry : fragments.entrySet()) {
- if (!entry.getKey().startsWith(prefix)) {
+ if (!zone.matcher(entry.getKey()).find()) {
continue;
}
for (PlacedFragment fragment : entry.getValue()) {
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
new file mode 100644
index 000000000..dc75b3634
--- /dev/null
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxPageClasses.java
@@ -0,0 +1,94 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.output.DocumentPageZone;
+import com.demcha.compose.document.output.PageContext;
+
+import java.util.EnumMap;
+import java.util.EnumSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Which of Word's kinds of page a page zone is drawn on.
+ *
+ * A zone chooses its pages with a predicate over the page, which the fixed-layout backends
+ * ask once per page they draw. Word does not ask: it paginates on its own and gives a section
+ * three kinds of header and footer — one for the first page, one for even pages, and one for
+ * every other page. So the predicate is asked instead, over sample pages, and the answer is
+ * sorted into those three kinds. A predicate that answers alike for every page of a kind is
+ * one Word can state exactly: the first page only, every page but the first, even pages, odd
+ * 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}
+ * 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.
+ *
+ * @author Artem Demchyshyn
+ */
+final class DocxPageClasses {
+
+ /** The kinds of page Word gives a header or footer of its own. */
+ enum PageClass {
+ /** The first page of a section, when the section states a title page. */
+ FIRST,
+ /** Even pages, when the document states different even and odd pages. */
+ EVEN,
+ /** Odd pages after the first — and every page Word has no other kind for. */
+ LATER_ODD
+ }
+
+ /** The fewest pages a predicate is asked about. */
+ static final int SAMPLE = 6;
+
+ private DocxPageClasses() {
+ }
+
+ /**
+ * The kinds of page a zone is drawn on.
+ *
+ * @param zone the page zone
+ * @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
+ */
+ static Set of(DocumentPageZone zone, int pageCount) {
+ if (zone.getAppliesTo() == null) {
+ return EnumSet.allOf(PageClass.class);
+ }
+ int total = Math.max(pageCount, SAMPLE);
+ Map answers = new EnumMap<>(PageClass.class);
+ for (int number = 1; number <= total; number++) {
+ PageClass kind = classOf(number);
+ boolean applies;
+ try {
+ applies = zone.appliesTo(PageContext.paginated(number, total));
+ } 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
+ // page that does not exist. That says nothing Word can use either way.
+ return null;
+ }
+ Boolean earlier = answers.putIfAbsent(kind, applies);
+ if (earlier != null && earlier != applies) {
+ return null;
+ }
+ }
+ Set drawnOn = EnumSet.noneOf(PageClass.class);
+ answers.forEach((kind, applies) -> {
+ if (applies) {
+ drawnOn.add(kind);
+ }
+ });
+ return drawnOn;
+ }
+
+ /** The kind Word files a 1-based page number under. */
+ static PageClass classOf(int number) {
+ if (number == 1) {
+ return PageClass.FIRST;
+ }
+ return number % 2 == 0 ? PageClass.EVEN : PageClass.LATER_ODD;
+ }
+}
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index 37d2a5658..b3bbf21c2 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -454,9 +454,16 @@ private byte[] write(List sections, Path outputFile) throws Exc
documentDefaultStyle = dominantTextStyle(whole);
currentCell = null;
currentCellWidth = Double.NaN;
- boolean anEarlierHeader = false;
- boolean anEarlierFooter = false;
+ // Kinds of zone an earlier section wrote: Word repeats a section's header and footer
+ // in the sections after it that have none of their own.
+ java.util.Set earlierZones =
+ java.util.EnumSet.noneOf(DocumentHeaderFooterZone.class);
+ boolean evenAndOdd = distinguishesEvenPages(sections);
try (XWPFDocument document = new XWPFDocument()) {
+ if (evenAndOdd) {
+ // A document-wide setting in Word, so every section states its even pages.
+ document.setEvenAndOddHeadings(true);
+ }
for (int index = 0; index < sections.size(); index++) {
SemanticSection section = sections.get(index);
SemanticExportContext context = section.context();
@@ -470,20 +477,8 @@ private byte[] write(List sections, Path outputFile) throws Exc
DocxFontTable.write(document, whole, fonts, report);
applyMetadata(document, metadataOf(sections));
}
- java.util.Set written =
- applyPageZones(document, context.outputOptions().zones());
- boolean header = written.contains(DocumentHeaderFooterZone.HEADER);
- boolean footer = written.contains(DocumentHeaderFooterZone.FOOTER);
- // Word repeats the previous section's header and footer in a section that has
- // none of its own; the page this section draws has none, so it says so.
- if (!header && anEarlierHeader) {
- blankZone(document, true);
- }
- if (!footer && anEarlierFooter) {
- blankZone(document, false);
- }
- anEarlierHeader |= header;
- anEarlierFooter |= footer;
+ earlierZones.addAll(applyPageZones(document, context.outputOptions().zones(),
+ evenAndOdd, earlierZones));
for (DocumentNode root : section.graph().roots()) {
writeNode(document, root);
}
@@ -569,21 +564,20 @@ private void endSection(XWPFDocument document) {
}
/**
- * An empty header or footer for a section that has none of its own, so Word does not
- * repeat the previous section's there.
+ * An empty header or footer for a kind of page the section draws none on, so Word does not
+ * put another one there — the previous section's, or the section's own for other pages.
*
- * Its one paragraph is a point tall and sits against the page edge. Left at Normal's
- * size and Word's default distance it would reach past a narrow margin, and Word would
- * push the body down to make room for a header the page does not draw.
+ * Its one paragraph is a point tall. When the section has no zone of this kind at all,
+ * it also sits against the page edge: left at Word's default distance it would reach past
+ * a narrow margin, and Word would push the body down to make room for a header the page
+ * does not draw. A section that does draw one keeps that one's distance.
*/
- private static void blankZone(XWPFDocument document, boolean header) {
- CTSectPr sectPr = bodySectPr(document);
- XWPFHeaderFooterPolicy policy = new XWPFHeaderFooterPolicy(document, sectPr);
- XWPFHeaderFooter blank = header
- ? policy.createHeader(XWPFHeaderFooterPolicy.DEFAULT)
- : policy.createFooter(XWPFHeaderFooterPolicy.DEFAULT);
+ private static void blankZone(XWPFHeaderFooterPolicy policy, CTSectPr sectPr, boolean header,
+ org.openxmlformats.schemas.wordprocessingml.x2006.main.STHdrFtr.Enum type,
+ boolean againstTheEdge) {
+ XWPFHeaderFooter blank = header ? policy.createHeader(type) : policy.createFooter(type);
collapsed(blank.createParagraph());
- if (sectPr.isSetPgMar()) {
+ if (againstTheEdge && sectPr.isSetPgMar()) {
if (header) {
sectPr.getPgMar().setHeader(BigInteger.ZERO);
} else {
@@ -592,6 +586,33 @@ private static void blankZone(XWPFDocument document, boolean header) {
}
}
+ /**
+ * Whether any section has a zone Word can only place with different even and odd pages.
+ *
+ * Word turns that on for the whole document, not per section, so it is decided before
+ * any section is written.
+ */
+ private static boolean distinguishesEvenPages(List sections) {
+ for (SemanticSection section : sections) {
+ List zones = section.context().outputOptions().zones();
+ if (zones == null) {
+ continue;
+ }
+ int pages = section.context().layoutGraph() == null
+ ? 0
+ : section.context().layoutGraph().totalPages();
+ for (DocumentPageZone zone : zones) {
+ java.util.Set drawnOn = DocxPageClasses.of(zone, pages);
+ if (drawnOn != null
+ && drawnOn.contains(DocxPageClasses.PageClass.EVEN)
+ != drawnOn.contains(DocxPageClasses.PageClass.LATER_ODD)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
/** Makes a paragraph that exists only for Word's structure take a single point. */
private static XWPFParagraph collapsed(XWPFParagraph paragraph) {
CTPPr properties = paragraph.getCTP().isSetPPr()
@@ -673,49 +694,140 @@ private void applyMetadata(XWPFDocument document, DocumentMetadata metadata) {
* would be right on one page and wrong on the others, so a zone that needs one
* places {@code pageNumber()} and gets a live {@code PAGE} field instead.
*
- * A page predicate is the other fixed-layout-only piece: {@code appliesTo}
- * tests a page, and no page exists here to test — Word owns pagination. The
- * zone is written on every page rather than silently skipped, content beating
- * absence, and the export says on the log what it could not honor.
- *
- * @return the kinds of zone written, so a later section knows what it has to blank out
- */
- private java.util.Set applyPageZones(XWPFDocument document,
- List zones) {
+ * A page predicate is the other fixed-layout piece: {@code appliesTo} tests a page,
+ * and Word owns pagination, so there is no page here to test. What Word does have is a
+ * header and footer per kind of page — the first, even ones, the rest — and the
+ * predicate is asked which of those it is drawn on ({@link DocxPageClasses}). A zone
+ * on the first page only becomes the section's first-page header, with the section
+ * stating a title page; one on even pages becomes the even-page header, with the
+ * document stating different even and odd pages. A predicate that does not follow those
+ * kinds is written on every page, content beating absence, and the export reports what
+ * it could not honour.
+ *
+ * A kind of page the section draws no zone of that kind on gets an empty part when
+ * Word would otherwise put something there: the section's own zone for other pages, or
+ * an earlier section's zone, which Word repeats in a section without one.
+ *
+ * @param evenAndOdd whether the document states different even and odd pages
+ * @param earlierZones the kinds of zone an earlier section wrote
+ * @return the kinds of zone this section wrote
+ */
+ private java.util.Set applyPageZones(
+ XWPFDocument document,
+ List zones,
+ boolean evenAndOdd,
+ java.util.Set earlierZones) {
// The page height the zones are measured against is the canvas's, which is what the
// page geometry was written from — not a value parsed back out of the XML.
java.util.Set written =
java.util.EnumSet.noneOf(DocumentHeaderFooterZone.class);
- if (zones == null || zones.isEmpty()) {
+ List sectionZones = zones == null ? List.of() : zones;
+ if (sectionZones.isEmpty() && earlierZones.isEmpty()) {
return written;
}
- // Bound to the section being written, whose properties are the body's until it ends.
- XWPFHeaderFooterPolicy policy = new XWPFHeaderFooterPolicy(document, bodySectPr(document));
- for (int index = 0; index < zones.size(); index++) {
- DocumentPageZone zone = zones.get(index);
- if (zone.getAppliesTo() != null) {
- LOG.warn("docx.zone.pagePredicate zone={} — appliesTo cannot be evaluated in a"
- + " semantic export: Word paginates the document, so there is no page to"
- + " test. The zone is written on every page; per-page chrome needs a"
- + " fixed-layout backend.", zone.getZone());
- }
+ List contents = new ArrayList<>();
+ List> drawnOn = new ArrayList<>();
+ for (DocumentPageZone zone : sectionZones) {
DocumentNode content = zone.getContent() == null
? null
: zone.getContent().apply(PageContext.unpaginated());
- if (content == null) {
+ contents.add(content);
+ drawnOn.add(content == null
+ ? java.util.EnumSet.noneOf(DocxPageClasses.PageClass.class)
+ : pageClassesOf(zone));
+ }
+ boolean titlePage = false;
+ for (int index = 0; index < sectionZones.size(); index++) {
+ java.util.Set classes = drawnOn.get(index);
+ if (contents.get(index) != null
+ && classes.contains(DocxPageClasses.PageClass.FIRST)
+ != classes.contains(DocxPageClasses.PageClass.LATER_ODD)) {
+ titlePage = true;
+ }
+ }
+ // Bound to the section being written, whose properties are the body's until it ends.
+ CTSectPr sectPr = bodySectPr(document);
+ if (titlePage && !sectPr.isSetTitlePg()) {
+ sectPr.addNewTitlePg();
+ }
+ XWPFHeaderFooterPolicy policy = new XWPFHeaderFooterPolicy(document, sectPr);
+ java.util.Set parts = new java.util.HashSet<>();
+ for (int index = 0; index < sectionZones.size(); index++) {
+ DocumentPageZone zone = sectionZones.get(index);
+ DocumentNode content = contents.get(index);
+ java.util.Set classes = drawnOn.get(index);
+ if (content == null || classes.isEmpty()) {
continue;
}
boolean header = zone.getZone() == DocumentHeaderFooterZone.HEADER;
- XWPFHeaderFooter target = header
- ? policy.createHeader(XWPFHeaderFooterPolicy.DEFAULT)
- : policy.createFooter(XWPFHeaderFooterPolicy.DEFAULT);
- writeZoneLine(target, content);
+ for (org.openxmlformats.schemas.wordprocessingml.x2006.main.STHdrFtr.Enum type
+ : partTypes(titlePage, evenAndOdd)) {
+ if (classes.contains(pageClassOf(type))) {
+ writeZoneLine(header ? policy.createHeader(type) : policy.createFooter(type), content);
+ parts.add(zone.getZone() + "/" + type);
+ }
+ }
placeZone(document, zone, index, header);
written.add(zone.getZone());
}
+ for (DocumentHeaderFooterZone kind : DocumentHeaderFooterZone.values()) {
+ if (!written.contains(kind) && !earlierZones.contains(kind)) {
+ continue;
+ }
+ for (org.openxmlformats.schemas.wordprocessingml.x2006.main.STHdrFtr.Enum type
+ : partTypes(titlePage, evenAndOdd)) {
+ if (!parts.contains(kind + "/" + type)) {
+ blankZone(policy, sectPr, kind == DocumentHeaderFooterZone.HEADER, type,
+ !written.contains(kind));
+ }
+ }
+ }
return written;
}
+ /**
+ * The kinds of page a zone is drawn on — every kind when its predicate does not follow
+ * them, which is written down as what the export could not honour.
+ */
+ private java.util.Set pageClassesOf(DocumentPageZone zone) {
+ java.util.Set classes = DocxPageClasses.of(zone, layout.pageCount());
+ if (classes != null) {
+ return classes;
+ }
+ LOG.warn("docx.zone.pagePredicate zone={} — its appliesTo predicate does not follow Word's"
+ + " first, even and odd pages, so the zone is written on every page; per-page"
+ + " chrome of that kind needs a fixed-layout backend.", zone.getZone());
+ report.add(DocxExportReport.Severity.APPROXIMATED, "page zone", null,
+ "its page predicate picks pages Word has no header or footer for — only the first,"
+ + " even and odd pages can differ — so it is written on every page");
+ return java.util.EnumSet.allOf(DocxPageClasses.PageClass.class);
+ }
+
+ /** The header and footer kinds the section uses: the default, and the ones it states. */
+ private static List partTypes(
+ boolean titlePage, boolean evenAndOdd) {
+ List types = new ArrayList<>(3);
+ types.add(XWPFHeaderFooterPolicy.DEFAULT);
+ if (titlePage) {
+ types.add(XWPFHeaderFooterPolicy.FIRST);
+ }
+ if (evenAndOdd) {
+ types.add(XWPFHeaderFooterPolicy.EVEN);
+ }
+ return types;
+ }
+
+ /** The kind of page a Word header or footer type is shown on. */
+ private static DocxPageClasses.PageClass pageClassOf(
+ org.openxmlformats.schemas.wordprocessingml.x2006.main.STHdrFtr.Enum type) {
+ if (type == XWPFHeaderFooterPolicy.FIRST) {
+ return DocxPageClasses.PageClass.FIRST;
+ }
+ return type == XWPFHeaderFooterPolicy.EVEN
+ ? DocxPageClasses.PageClass.EVEN
+ : DocxPageClasses.PageClass.LATER_ODD;
+ }
+
/**
* Puts a header or footer as far from its page edge as the page puts it.
*
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
new file mode 100644
index 000000000..8728e0b36
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageClassesTest.java
@@ -0,0 +1,81 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.backend.semantic.docx.DocxPageClasses.PageClass;
+import com.demcha.compose.document.output.DocumentPageZone;
+import com.demcha.compose.document.output.PageContext;
+import org.junit.jupiter.api.Test;
+
+import java.util.EnumSet;
+import java.util.function.Predicate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A zone's page predicate, sorted into the kinds of page Word gives a header of its own.
+ *
+ * @author Artem Demchyshyn
+ */
+class DocxPageClassesTest {
+
+ @Test
+ void aZoneWithNoPredicateIsOnEveryKindOfPage() {
+ DocumentPageZone zone = DocumentPageZone.footer(20, page -> null);
+
+ assertThat(DocxPageClasses.of(zone, 3)).isEqualTo(EnumSet.allOf(PageClass.class));
+ }
+
+ @Test
+ void predicatesWordCanStateAreSortedIntoItsKinds() {
+ assertThat(classes(PageContext::isFirst)).containsExactly(PageClass.FIRST);
+ assertThat(classes(page -> !page.isFirst())).containsExactly(PageClass.EVEN, PageClass.LATER_ODD);
+ assertThat(classes(page -> page.number() % 2 == 0)).containsExactly(PageClass.EVEN);
+ assertThat(classes(page -> page.number() % 2 == 1)).containsExactly(PageClass.FIRST, PageClass.LATER_ODD);
+ assertThat(classes(page -> false)).isEmpty();
+ }
+
+ @Test
+ void predicatesThatPickPagesWithinAKindHaveNoWordEquivalent() {
+ assertThat(classes(PageContext::isLast)).isNull();
+ assertThat(classes(page -> page.number() == 3)).isNull();
+ assertThat(classes(page -> page.number() <= 4)).isNull();
+ }
+
+ @Test
+ void aPredicateThatRefusesASamplePageSaysNothingWordCanUse() {
+ java.util.List titles = java.util.List.of("Cover", "Body", "Back");
+ Predicate byTitle = page -> !titles.get(page.number() - 1).isEmpty();
+
+ assertThat(DocxPageClasses.of(zone(byTitle), 3))
+ .as("asked about a sixth page of a three-page document, it throws, and the export does not")
+ .isNull();
+ }
+
+ @Test
+ void thePredicateIsAskedAboutAsManyPagesAsTheDocumentHas() {
+ // True on the first twelve pages: every page of a twelve-page document, but not of the
+ // longer sample a shorter document would be asked about.
+ Predicate firstTwelve = page -> page.number() <= 12;
+
+ assertThat(DocxPageClasses.of(zone(firstTwelve), 12)).isEqualTo(EnumSet.allOf(PageClass.class));
+ assertThat(DocxPageClasses.of(zone(firstTwelve), 20)).isNull();
+ }
+
+ @Test
+ void pageNumbersAreFiledAsWordFilesThem() {
+ assertThat(DocxPageClasses.classOf(1)).isEqualTo(PageClass.FIRST);
+ assertThat(DocxPageClasses.classOf(2)).isEqualTo(PageClass.EVEN);
+ assertThat(DocxPageClasses.classOf(3)).isEqualTo(PageClass.LATER_ODD);
+ assertThat(DocxPageClasses.classOf(4)).isEqualTo(PageClass.EVEN);
+ }
+
+ private static java.util.Set classes(Predicate appliesTo) {
+ return DocxPageClasses.of(zone(appliesTo), 2);
+ }
+
+ private static DocumentPageZone zone(Predicate appliesTo) {
+ return DocumentPageZone.footer(20, page -> null)
+ .toBuilder()
+ .appliesTo(appliesTo)
+ .build();
+ }
+}
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
new file mode 100644
index 000000000..8257973c0
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPageZoneKindsTest.java
@@ -0,0 +1,234 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.api.MultiSectionDocument;
+import com.demcha.compose.document.dsl.RowBuilder;
+import com.demcha.compose.document.output.DocumentPageZone;
+import com.demcha.compose.document.output.PageContext;
+import com.demcha.compose.document.style.DocumentInsets;
+import org.apache.poi.ooxml.POIXMLDocumentPart;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFFooter;
+import org.apache.poi.xwpf.usermodel.XWPFHeader;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTHdrFtrRef;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STHdrFtr;
+
+import org.apache.poi.openxml4j.opc.PackagePart;
+import org.apache.poi.openxml4j.opc.PackagingURIHelper;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Predicate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A page zone drawn on some pages only lands on the same pages in Word.
+ *
+ * Word gives a section a header and footer for the first page, for even pages and for the
+ * rest. A zone whose predicate follows those kinds becomes the matching part — with the section
+ * stating a title page, or the document stating different even and odd pages — and every other
+ * kind of page it is not drawn on gets an empty part, so Word does not show the zone there
+ * anyway. The export used to write every such zone on every page.
+ *
+ * @author Artem Demchyshyn
+ */
+class DocxPageZoneKindsTest {
+
+ @Test
+ void aHeaderOnTheFirstPageOnlyIsTheTitlePagesHeader() throws Exception {
+ try (XWPFDocument document = export(twoPages(header("Cover", PageContext::isFirst)))) {
+ CTSectPr section = bodySection(document);
+
+ assertThat(section.isSetTitlePg()).isTrue();
+ assertThat(headerText(document, section, STHdrFtr.FIRST)).isEqualTo("Cover");
+ assertThat(headerText(document, section, STHdrFtr.DEFAULT))
+ .as("the other pages carry an empty header rather than the cover's")
+ .isBlank();
+ }
+ }
+
+ @Test
+ void aFooterOnEveryPageButTheFirstLeavesTheFirstEmpty() throws Exception {
+ try (XWPFDocument document = export(twoPages(footer("Body", page -> !page.isFirst())))) {
+ CTSectPr section = bodySection(document);
+
+ assertThat(section.isSetTitlePg()).isTrue();
+ assertThat(footerText(document, section, STHdrFtr.DEFAULT)).isEqualTo("Body");
+ assertThat(footerText(document, section, STHdrFtr.FIRST)).isBlank();
+ assertThat(evenAndOddPages(document)).isFalse();
+ }
+ }
+
+ @Test
+ void aFooterOnEvenPagesIsTheEvenPagesFooter() throws Exception {
+ try (XWPFDocument document = export(twoPages(footer("Verso", page -> page.number() % 2 == 0)))) {
+ CTSectPr section = bodySection(document);
+
+ assertThat(evenAndOddPages(document)).isTrue();
+ assertThat(footerText(document, section, STHdrFtr.EVEN)).isEqualTo("Verso");
+ assertThat(footerText(document, section, STHdrFtr.DEFAULT)).isBlank();
+ assertThat(section.isSetTitlePg()).isFalse();
+ }
+ }
+
+ @Test
+ void aZoneWithNoPredicateIsWrittenOnceForEveryPage() throws Exception {
+ try (XWPFDocument document = export(twoPages(footer("Always", null)))) {
+ CTSectPr section = bodySection(document);
+
+ assertThat(section.isSetTitlePg()).isFalse();
+ assertThat(evenAndOddPages(document)).isFalse();
+ assertThat(section.getFooterReferenceList()).hasSize(1);
+ assertThat(footerText(document, section, STHdrFtr.DEFAULT)).isEqualTo("Always");
+ }
+ }
+
+ @Test
+ void aPredicateWordHasNoPartForIsWrittenOnEveryPageAndReported() throws Exception {
+ List notes = new ArrayList<>();
+ DocxSemanticBackend backend = DocxSemanticBackend.builder()
+ .reportSink(report -> notes.addAll(report.notes()))
+ .build();
+ byte[] docx;
+ try (DocumentSession session = twoPages(footer("Last", PageContext::isLast))) {
+ docx = session.export(backend);
+ }
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ CTSectPr section = bodySection(document);
+
+ assertThat(section.isSetTitlePg()).isFalse();
+ assertThat(footerText(document, section, STHdrFtr.DEFAULT)).isEqualTo("Last");
+ }
+ assertThat(notes).anySatisfy(note -> {
+ assertThat(note.severity()).isEqualTo(DocxExportReport.Severity.APPROXIMATED);
+ assertThat(note.subject()).isEqualTo("page zone");
+ });
+ }
+
+ @Test
+ void aZoneThatSkipsTheFirstPageIsPlacedWhereTheOtherPagesDrawIt() throws Exception {
+ long everyPage;
+ try (XWPFDocument document = export(twoPages(footer("Body", null)))) {
+ everyPage = DocxTwips.of(bodySection(document).getPgMar().getFooter());
+ }
+ try (XWPFDocument document = export(twoPages(footer("Body", page -> !page.isFirst())))) {
+ assertThat(DocxTwips.of(bodySection(document).getPgMar().getFooter()))
+ .as("measured on the pages the zone is drawn on, not guessed from its padding")
+ .isEqualTo(everyPage);
+ }
+ }
+
+ @Test
+ void everySectionStatesItsEvenPagesOnceOneSectionNeedsThem() throws Exception {
+ byte[] docx;
+ try (MultiSectionDocument document = GraphCompose.documents()
+ .section(twoPages(footer("Verso", page -> page.number() % 2 == 0)))
+ .section(twoPages(footer("Plain", null)))
+ .create()) {
+ docx = document.toDocxBytes();
+ }
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ CTSectPr second = bodySection(document);
+
+ // Word shows the even-page footer on every even page of the document once even and
+ // odd pages differ, so a section whose footer is on every page states it for both.
+ assertThat(footerText(document, second, STHdrFtr.EVEN)).isEqualTo("Plain");
+ assertThat(footerText(document, second, STHdrFtr.DEFAULT)).isEqualTo("Plain");
+ }
+ }
+
+ private static DocumentSession twoPages(DocumentPageZone zone) {
+ DocumentSession session = GraphCompose.document()
+ .pageSize(300, 200)
+ .margin(DocumentInsets.of(30))
+ .create();
+ session.chrome().zone(zone);
+ session.pageFlow(page -> {
+ for (int line = 0; line < 14; line++) {
+ int number = line;
+ page.addParagraph(p -> p.text("Line " + number));
+ }
+ });
+ return session;
+ }
+
+ private static DocumentPageZone header(String text, Predicate appliesTo) {
+ DocumentPageZone zone = DocumentPageZone.header(20, page -> new RowBuilder()
+ .addParagraph(p -> p.text(text))
+ .build());
+ return appliesTo == null ? zone : zone.toBuilder().appliesTo(appliesTo).build();
+ }
+
+ private static DocumentPageZone footer(String text, Predicate appliesTo) {
+ DocumentPageZone zone = DocumentPageZone.footer(20, page -> new RowBuilder()
+ .addParagraph(p -> p.text(text))
+ .build());
+ return appliesTo == null ? zone : zone.toBuilder().appliesTo(appliesTo).build();
+ }
+
+ private static XWPFDocument export(DocumentSession session) throws Exception {
+ byte[] docx;
+ try (session) {
+ docx = session.toDocxBytes();
+ }
+ return new XWPFDocument(new ByteArrayInputStream(docx));
+ }
+
+ /**
+ * Whether the document states different even and odd pages. Read from the settings part
+ * rather than {@code getEvenAndOddHeadings()}, which answers whether the element is there
+ * and not whether it says yes.
+ */
+ private static boolean evenAndOddPages(XWPFDocument document) throws Exception {
+ PackagePart settings = document.getPackage()
+ .getPart(PackagingURIHelper.createPartName("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/word/settings.xml"));
+ String xml;
+ try (InputStream input = settings.getInputStream()) {
+ xml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ java.util.regex.Matcher element =
+ java.util.regex.Pattern.compile("]*)/>").matcher(xml);
+ if (!element.find()) {
+ return false;
+ }
+ String attributes = element.group(1);
+ return !attributes.contains("\"false\"") && !attributes.contains("\"0\"")
+ && !attributes.contains("\"off\"");
+ }
+
+ private static CTSectPr bodySection(XWPFDocument document) {
+ return document.getDocument().getBody().getSectPr();
+ }
+
+ private static String headerText(XWPFDocument document, CTSectPr section, STHdrFtr.Enum type) {
+ return partText(document, section.getHeaderReferenceList(), type, XWPFHeader.class);
+ }
+
+ private static String footerText(XWPFDocument document, CTSectPr section, STHdrFtr.Enum type) {
+ return partText(document, section.getFooterReferenceList(), type, XWPFFooter.class);
+ }
+
+ private static String partText(XWPFDocument document, List references,
+ STHdrFtr.Enum type, Class extends POIXMLDocumentPart> kind) {
+ List ofType = references.stream().filter(ref -> ref.getType() == type).toList();
+ assertThat(ofType).as("one %s part", type).hasSize(1);
+ POIXMLDocumentPart part = document.getRelationById(ofType.get(0).getId());
+ assertThat(part).isInstanceOf(kind);
+ StringBuilder text = new StringBuilder();
+ List paragraphs = part instanceof XWPFHeader header
+ ? header.getParagraphs()
+ : ((XWPFFooter) part).getParagraphs();
+ for (XWPFParagraph paragraph : paragraphs) {
+ text.append(paragraph.getText());
+ }
+ return text.toString().strip();
+ }
+}