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();
+ }
+}