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