Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
13 changes: 13 additions & 0 deletions docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ch.qos.logback.classic.spi.ILoggingEvent> seen =
Expand All @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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.
Expand All @@ -252,19 +252,22 @@ boolean onOnePage(DocumentNode node) {
* highest edge of a header's — rather than rebuilt from the band's parts.</p>
*
* <p>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.</p>
* 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.</p>
*
* @param zoneIndex the zone's position in the session's zone list
* @param header whether it is a header, measured from the top edge
* @param pageHeight the page's height in points
* @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<String, List<PlacedFragment>> entry : fragments.entrySet()) {
if (!entry.getKey().startsWith(prefix)) {
if (!zone.matcher(entry.getKey()).find()) {
continue;
}
for (PlacedFragment fragment : entry.getValue()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>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.</p>
*
* @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<PageClass> of(DocumentPageZone zone, int pageCount) {
if (zone.getAppliesTo() == null) {
return EnumSet.allOf(PageClass.class);
}
int total = Math.max(pageCount, SAMPLE);
Map<PageClass, Boolean> 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<PageClass> 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;
}
}
Loading
Loading