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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,41 @@ follow semantic versioning; release dates are ISO 8601.

### Public API

- **The DOCX export report names what it used to leave out in silence, or misnamed.** Three
kinds of loss reached the Word file with no more than a log line, or nothing, so a caller
reading the report was told the document lost nothing, and a fourth was reported for the wrong
reason:
- a row's fill, outline and side borders (`RowBuilder.fillColor`, `stroke`, `borders`): the
row is written as a table with no shading or borders, and a page zone's row as one line.
Now `DROPPED`, `row paint`, naming which of them, and `rounded` where a corner radius
rounds them; a row of no columns is named where the layout gives it height, as the page
paints only then. A row composed in a table cell is `DROPPED` where the cell drew none of
its paint — round text it never draws any — and `APPROXIMATED` where the table drew
shapes, since a cell's paint is drawn where it frames no text and the layout does not say
which box is the row's;
- anything in a page zone but paragraphs, page fields and spacers, such as a logo: now
`DROPPED`, `page zone content`, once for each, though a zone is written into each kind of
header or footer it is given — two logos of no name are two notes, and in a file of several
sections each names its section;
- a watermark, a protection and viewer preferences: now `DROPPED`, `watermark` (once per
section that sets one), `protection` and `viewer preferences` (once for the file) — a file
asked to be protected opens and edits without a password, and the report says so;
- a node kind of the caller's own (a `DocumentNode` with its own `NodeDefinition`) was
reported as dropped geometry "with no semantic Word analogue". It is now named as a kind
the export does not know, losing its text, its pictures and what it holds — counted where
the node lists its children; the shapes it paints itself are still drawn, and say the
same. A page field in the body, not a page zone, is dropped for that reason rather than
for its geometry.

Which kinds are the export's own is decided by the node's package, exactly: a caller's node
in a package under the same root is not taken for a built-in one. A new test,
`DocxNodeFieldLedgerTest`, records for every field of every node class in the node and layout
packages — nested and non-public ones included — and every output option, whether the export
writes it in the body, reports it, has nothing to carry, or leaves a gap that names what is
lost: 29 fields have nothing to carry, 55 node fields are gaps, and so are page zones, where
a paragraph's alignment, spacing and direction and a row's columns are not written. A node
class that is not a record, or a kind or field added to the engine, fails it until someone
decides.
- **A DOCX timeline's dots, and drawings centred on a line, move with their text too.** A
drawing in a table cell went into that cell only when the cell held it across, and a dot
set in a column of its own beside its entry's text — `CharcoalGold`'s timeline, a row of
Expand Down
8 changes: 4 additions & 4 deletions docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,11 @@ 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) |
| Page backgrounds (`DocumentSession.pageBackgrounds`, `PageBackgroundFill` — full page, columns, bands) | ✅ `DocumentPageBackgrounds` adds each fill as a shape fragment under every page's content, drawn by the ordinary shape handler | ✅ the same fragments, drawn as shapes on every slide | ⚠️ `DocxPageBackgrounds` — each fill is a rectangle anchored to the page, behind the text. A section of more than one page, or with a header or footer, carries them in every header part it has (default, first page, even pages), so they are drawn on every page; a section without a header gets an empty one against the page edge to carry them, and a later section without fills gets an empty header of its own rather than inheriting them. On a page with no top margin LibreOffice still sets the first line about 3pt lower under that header. A section of one page with neither draws them from the body instead — in the first cell's paragraph when the page opens with a table — and its lines stand where the page sets them in both editors (the seven sidebar CVs stood 2.6 to 3.3pt low in LibreOffice); they are on that page alone, so a page an editor's text runs onto has none. A fill's alpha is carried as the shape's. A two-column layout still flows its columns one after the other, so a column fill can stand beside text that is not its column's |
| Watermark (front/back layers) | ✅ `PdfWatermarkRenderer` | ✅ `PptxChromeRenderer` (per-slide shape at the PDF placement math; behind-content applies before fragments, so no z-order surgery) | ❌ |
| Watermark (front/back layers) | ✅ `PdfWatermarkRenderer` | ✅ `PptxChromeRenderer` (per-slide shape at the PDF placement math; behind-content applies before fragments, so no z-order surgery) | ❌ (not written; reported `DROPPED`, `watermark`) |
| Repeating headers / footers | ✅ `PdfHeaderFooterRenderer` — the zone's `fontName` is resolved through the document's own `FontLibrary`, so a zone draws in the family the author named; unnamed means standard-14 Helvetica, and a code point that family cannot encode is substituted with `?` exactly as body text is | ✅ `PptxChromeRenderer` (positioned per-slide text boxes; `{page}` / `{pages}` / `{date}` tokens with the numbering window rules). The named family reaches the slide run through `PptxFontMapping.familyFor`, and the same family measures the slots — a run measured against one face and typeset in another lands off-centre | ✅ `DocxSemanticBackend.writeBand` (`DocxTextBands`) — one line of a Word header or footer part: the left slot, the centre slot at a centre tab and the right slot at a right tab against the margins; `{page}` / `{pages}` as `PAGE` / `NUMPAGES` (`SECTIONPAGES` per section) fields with the roman or alphabetic switch; `{date}` as the date of the export; the separator as the paragraph's border; the header or footer distance from the band's geometry, baseline within 0.1pt in LibreOffice; a band sharing its kind with another band or a page zone stands in a frame (`w:framePr`) at its own height; `showOnFirstPage(false)` or counting from page 2 → an empty first-page part. A band starting after page 2, numbers not counting from 1 on page 1, and a band alone of its kind reaching past the page margin (written as a negative margin, so that Word holds the body at it as the page does; LibreOffice moves the body clear of it) are reported |
| Page zones (node subtree in the band) | ✅ Spliced into the layout graph by `DocumentPageZones`, so the ordinary fragment handlers draw it — no zone-specific code in the backend | ✅ Same splice, same reason: `PptxFixedLayoutBackend.renderGraph` draws every fragment of the graph | ✅ Written into a real `w:ftr` / `w:hdr` part. The band's children become runs on one Word line: a paragraph contributes its runs, 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 |
| 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, logged once a kind and reported once each (`DROPPED`, `page zone content`); a zone row's own fill, outline and side borders are reported as `row paint`. 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) | ❌ (not written, so the file opens unprotected; reported `DROPPED`, `protection`) |
| Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a (not written; reported `DROPPED`, `viewer preferences`) |
| Debug guide lines / node labels | ✅ `PdfGuideLinesRenderer`, `PdfNodeLabelRenderer` | ❌ (ignored with a one-time warning — render through the PDF backend to see overlays) | n/a |
| Keep a block on one page (`keepTogether()`, `keepWithNext()`) | ✅ resolved by `LayoutCompiler` before any backend runs | ✅ same — the slides are the laid-out pages | ✅ `DocxSemanticBackend.keepOnOnePage` — Word re-paginates, so a block the layout placed on one page is told to stay there: `w:keepLines` on each of its paragraphs and `w:keepNext` on every one but the last (on the last too for `keepWithNext`), a table inside it chained row by row. A block that ran over a page break in the layout is taller than a page and is left to flow, as the layout left it |
| Layer stack — layers drawn over one another (`addLayerStack`) | ✅ each layer's fragments at the place the layout gave it | ✅ same | ⚠️ `DocxSemanticBackend` — Word has no layers, so a stack's children are written one after the other, without their positions. The exception is a stack whose layers are side-by-side columns (`DocxLayerColumns`): every layer a plain container at the stack's top-left corner, the bands their padding leaves either the same or apart. That is written as one table row, a cell per band, the way a two-column CV lays its columns out as layers to draw the name first. Layers sharing a band follow one another in its cell. A spacer the layout placed level with content of another layer of the same band only keeps that content's place, and is not written. The space above a later layer's first block is the gap the page shows below the content before it. What the page draws inside another layer's fill comes after that fill. Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page, with `SidebarPortrait`'s subtitle under its name strip rather than in it; `NavySidebar` runs one line onto a second page. A stack or shape container the page gives no room — its margins taking back its whole height, as `LumaStudioInvoice`'s sidebar over the page's top margin — is laid over the flow (`laidOverTheFlow`): its drawings where the page draws them, each paragraph in a text box in front of the text where the page sets it, nothing in the flow. Not in a table cell or a panel, and not when it holds anything but drawing and plain paragraphs — a link, an anchor, a picture, a list or a table keeps it in the flow as above |
Expand Down
11 changes: 9 additions & 2 deletions docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,9 @@ How it lands:
padding takes back. Unwritten, Word 16 puts the table's edge on the margin and the text a
padding further in (measured).
- **Nesting.** A panel inside a panel is a table inside its cell. So is a row, with no fill
of its own, so the panel shows through it. A table keeps its own cell fills, and a cell
of its own, so the panel shows through it. A row's own fill, outline and side borders
(`RowBuilder.fillColor`, `stroke`, `borders`) are not written yet, in or out of a panel,
and the export report names them (`row paint`). A table keeps its own cell fills, and a cell
no style fills is written white, as the page draws it on the card. Word draws a table's
right border outside its right edge and, on screen, cuts off what passes its cell's edge
and draws the cell's gridline there; a bordered panel reaching its cell's text edge ends its
Expand Down Expand Up @@ -914,7 +916,9 @@ its size, the same matrix the PDF draws, so it scans, with its data as the
picture's description. Its data is part of the picture — changing it means
exporting again — and the report says so, and names a link or a transform on it
as not carried. In a page zone a barcode is still skipped.
Watermarks and protection options are also ignored by the current exporter.
A watermark, a protection and viewer preferences are not written; the export report names
each one the document sets (`watermark`, `protection`, `viewer preferences`), so a file
asked to be protected is not handed back open in silence.

A text header or footer (`session.header(...)` / `session.footer(...)`, a
`DocumentHeaderFooter`) is not: it exports as a line of a real Word header
Expand Down Expand Up @@ -948,6 +952,9 @@ Word header or footer part, with the page number as a live field, and it
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 is written from its paragraphs, page fields and spacers: anything else in it — a
logo, a panel, a table — is not written, and the export report names it once
(`page zone content`).

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,
Expand Down
6 changes: 4 additions & 2 deletions render-docx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,10 @@ What is not written — each one is named in the export report
children in order, without their positions and without the clip; a rotation or scale is
not carried. A layer stack whose layers are side-by-side columns is the exception: it is
written as one table row, a cell per column.
- **In a page zone**, a barcode or a rule.
- **Watermarks and protection options.**
- **Watermarks, protection and viewer preferences**, each named in the export report.
- **A row's own fill, outline and side borders**, named in the export report as `row paint`.
- **In a page zone, anything but paragraphs, page fields and spacers** — a logo, a barcode,
a rule — named in the export report as `page zone content`.
- **`markerGap`** on a hanging-indent list: Word places the item text at its own indent.

Multi-section documents export through `MultiSectionDocument.toDocxBytes()`,
Expand Down
Loading
Loading