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

### Public API

- **A DOCX export cuts a panel whose table the layout breaks over pages into a table a page.**
- The layout breaks a table in a panel between rows, repeats its header rows at the top of the
next page and goes on with the panel there, no padding above it. The export wrote the panel as
one table of one cell: Word broke its row where its lines ran out, and repeats no header row of
a table nested in a cell. Measured in Word 16 and LibreOffice 24.2 on `ObsidianInvoice` and
`SubscriptionInvoice` with their line items set over two and three pages, the second page
stood 24 to 30pt high in Word, its header missing, and over three pages a row stood a page
early in both editors.
- Such a panel — the table one of its own children, and the only one the layout breaks over
pages — is now a table a page, cut at the row each page starts with: the part after a cut
opens with copies of the table's header rows and has no top padding, a part before one has no
bottom padding, as the page sets the rows down to the page's foot, and a hairline paragraph
between the parts breaks the page before itself. In both invoices, and in cards padded 6 and
12pt over two and three pages, every line now stands on the page the layout sets it on in both
editors. A part after a cut stands about its top border's width lower than the page sets it,
as Word and LibreOffice draw the border inside the row: 1.1pt under a 1pt border, 2.8pt under
`SubscriptionInvoice`'s ruled top.
- The page breaks are fixed there, so rows an edit adds above a cut no longer run onto the next
page, and the report names the cut. A page is not cut, and is named, where the panel starts it
outside its table, where the table cannot be split at that row — its header rows holding an
anchored card's bookmark among them — or where the page above sets the rows too near its foot
for the borders Word sets inside the part; a panel cut at no page is named as written whole.
Anything else the layout moves or breaks over a page inside a panel — a block kept together, a
stack, a paragraph — is left to Word, as before.
- No document of the corpus has such a panel; its export is unchanged.

- **A DOCX export writes no page break where the page breaks none: in a row's column, a table's
composed cell, a layer stack's layer, a shape container's layer or a canvas.**
- The page places each of these as a box on one page: a page break in one is a box of no height,
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 @@ -122,7 +122,7 @@ honour an option ignores it (documented contract).
| 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 layer stack or a shape container in the flow is kept so too, as the layout moves it to a new page whole, a band of layers included; the first block of a band the layout moves to a new page — a paragraph, a picture, a list, a title and its dates set as one line or a spacer, a backdrop drawn before it or not — holds the space above it there, as one in the flow does: in a line kept with it, or in a spacer's own line; a table, a row or a panel opening it holds the band's top edge so with its own, the gap before the band left at the foot of the page above. A list or such a line the layout moves to a new page in the flow holds its space above there as a paragraph does. 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. A body table of one row kept with what follows, its cells holding paragraphs alone, is written with a row that may break (`letTheOneRowTablesKeptWithTheNextBreak`): LibreOffice reads a one-row table whose row may not break as one that may not split, and keeps such a table with nothing |
| 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 layer stack or a shape container in the flow is kept so too, as the layout moves it to a new page whole, a band of layers included; the first block of a band the layout moves to a new page — a paragraph, a picture, a list, a title and its dates set as one line or a spacer, a backdrop drawn before it or not — holds the space above it there, as one in the flow does: in a line kept with it, or in a spacer's own line; a table, a row or a panel opening it holds the band's top edge so with its own, the gap before the band left at the foot of the page above. A list or such a line the layout moves to a new page in the flow holds its space above there as a paragraph does. 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. A body table of one row kept with what follows, its cells holding paragraphs alone, is written with a row that may break (`letTheOneRowTablesKeptWithTheNextBreak`): LibreOffice reads a one-row table whose row may not break as one that may not split, and keeps such a table with nothing. A panel in the body the layout breaks over pages inside a table it holds is a table a page, cut at the row each page starts with (`cutThePanelsAtTheirPages`): Word repeats no header row of a table nested in a cell, so each part after a cut opens with copies of the table's header rows, where it has them, and the page breaks between the parts are fixed |
| 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, to the block's content: what the block holds above that content past the gap — its margin, its padding, a contained picture's room — comes out of its own edge (`DocxLayerColumns.resume`) where it is a paragraph, a picture, a list or a table in its layer's own flow with its edge above at zero or more, and a list keeps its own edges after the gap, as a table does. Each layer is held to its box across, past the stack's edge where the page sets it past it, by an indent below zero; in a table cell no further left than the indents round it give, the rest named. The one layer of a stack of one layer is held the same way, its offset across kept and its measure with it; in a table cell, composed in a table's cell with no place of its own, and for an offset down or up, which moves it without making room for it, it is written where the stack starts and named — not where a band or a column's layer sets it at the page's distance below what is above it, which holds the offset. A shape container written as a panel in a table cell keeps its padding round its outline, as the page does — the table's indent and the space above and below it; where its table ends its cell, Word sets the cell's content at its middle or foot without the space below, which a centred cell takes off the space above, the rest named — sets what its cell holds at the top, middle or foot as its first written layer is set, holds a paragraph across to its box, and names a written layer after the first (written under the ones before it), an offset down or up, a box past the shape's side, and a layer set across by a box the layout does not give (`layer offset`). A layer whose content the page starts above where those before it end — a caption over its photo, a label in a card's corner, not a block giving back its edge whose padding or contained picture's room alone reaches there — is written under them, in a column's cell as in a band, and the report names it; a stack composed in a table cell, or exported with no layout, has no place of its own, and one writing more than one of its layers is named with them not measured. What the page draws inside another layer's fill comes after that fill. Drawings stack as the page paints them, by their z-index, each given its place (`wp:anchor/@relativeHeight`) from where the page paints its fragment, whatever order the layers are written in — among the drawings behind the text, and among those in front of it, as Word draws every one behind under every one in front. Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page; `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. A canvas is written as its contents — what it writes one block after another, its drawings where it places them — and holds its height: the room under what it writes is the space below it, and one that only draws holds its whole box where nothing round it — a band before its layer's first block, drawings held whole, an overlay it is a layer of — measures that room already. The report names the places and the width it loses, as it does a column's fixed width narrower than its band, and what it writes running past its height, a drawing in what it writes that takes no room in Word, or a height not measured |

## Output surface and lifecycle
Expand Down
23 changes: 23 additions & 0 deletions docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,29 @@ How it lands:
an anchor's wrapper inside a panel is not cut; the page breaks no page in an alignment either,
and the export still writes that break — in its place in the body, after the table in a
panel.
- **Over pages.** A panel in the body that the layout breaks over pages inside a table it holds
— one of its own children, and the only one it breaks over pages — is a table a page: cut at
the row each page starts with, the part after a cut opening with copies of the table's header
rows and no padding above, as the page goes on with it, a part before a cut with no padding
below, as the page sets the rows down to the page's foot, and a hairline paragraph between the
parts breaking the page before itself, which adds no page where the part above fills its own.
Written as one table, Word broke the panel's row where its lines ran out and repeated no header
row of the table nested in its cell (measured in Word 16 and LibreOffice 24.2:
`ObsidianInvoice`'s and `SubscriptionInvoice`'s line items over two pages stood 24 to 30pt
high in Word on the second, its header missing, and over three pages a row stood a page early
in both; cut, every line stands on the page the layout sets it on, there and in cards padded 6
and 12pt). The page breaks are fixed there, and the report names the cut; a part after a cut
stands about its top border's width lower than the page sets it, Word and LibreOffice drawing
the border inside the row where the page strokes it on the panel's edge (1.1pt under a 1pt
border, 2.8pt under `SubscriptionInvoice`'s ruled top). A panel is not cut where the page it
runs onto starts outside its table, where that page's first row continues a merged cell or a row
from it on holds a field, where the table's header rows hold a bookmark — an anchored card's —,
a drawing, a numbered item or a field, nor where the page above sets the panel's rows nearer its
foot than the borders Word sets inside the part there take, and a point for the editor's lines,
where Word would set the last row on a page of its own; the report names each such page, and a
panel cut at no page as written whole. Anything else the layout moves or breaks over
a page inside a panel — a block kept together, a stack, a paragraph — is left to Word, a block
kept named as above.
- **Measured.** Cards stacked in a row's columns, panels nested in its table, and a card set in
from a filled panel's text stand where the page sets them in Word 16 and LibreOffice on
Windows, as far as the left edge and the right end above say.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.demcha.compose.document.templates.invoice.presets;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend;
import com.demcha.compose.document.layout.PlacedNode;
import com.demcha.compose.document.templates.data.invoice.InvoiceServiceLines;
import com.demcha.compose.document.templates.data.invoice.StructuredInvoiceData;
import org.apache.poi.xwpf.usermodel.IBodyElement;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.junit.jupiter.api.Test;

import java.io.ByteArrayInputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiConsumer;

import static org.assertj.core.api.Assertions.assertThat;

/**
* An invoice whose line items the layout sets over pages inside a card writes the card as a table a
* page, each opening with the line items' header, as the page repeats it.
*
* <p>Measured in Word 16: written as one table, {@code ObsidianInvoice}'s and
* {@code SubscriptionInvoice}'s line items stood 24 to 30pt high on the second page, their header
* missing, and over three pages one row stood a page early, in LibreOffice 24.2 too.</p>
*/
class InvoicePanelOverPagesDocxTest {

@Test
void obsidiansLineItemsCardIsATableAPage() throws Exception {
assertCutAtItsPages(ObsidianInvoiceFixtures.invoice(), 3, (s, d) -> ObsidianInvoice.create().compose(s, d),
"LineItemsCard", "Description");
assertCutAtItsPages(ObsidianInvoiceFixtures.invoice(), 6, (s, d) -> ObsidianInvoice.create().compose(s, d),
"LineItemsCard", "Description");
}

@Test
void subscriptionsRuledLineItemsAreATableAPage() throws Exception {
assertCutAtItsPages(SubscriptionInvoiceFixtures.invoice(), 6,
(s, d) -> SubscriptionInvoice.create().compose(s, d), "LineItemsRuled", "DESCRIPTION");
}

/**
* Exports the invoice with its line items repeated {@code times} and checks that the card the
* layout breaks over pages is as many tables as it has pages, each after the first set apart by
* a paragraph that breaks the page before itself and opening with the line items' header.
*/
private static void assertCutAtItsPages(StructuredInvoiceData data, int times,
BiConsumer<DocumentSession, StructuredInvoiceData> preset,
String card, String header) throws Exception {
List<InvoiceServiceLines.Line> lines = new ArrayList<>();
for (int time = 0; time < times; time++) {
lines.addAll(data.serviceLines().lines());
}
StructuredInvoiceData longer = new StructuredInvoiceData(data.brand(), data.supplier(), data.masthead(),
data.billTo(), data.shipTo(), data.summary(),
new InvoiceServiceLines(data.serviceLines().columns(), lines), data.totals(), data.payment(),
data.notes(), data.currencyCode());
int pages;
byte[] docx;
try (DocumentSession session = GraphCompose.document().create()) {
preset.accept(session, longer);
PlacedNode placed = session.layoutGraph().nodes().stream()
.filter(node -> card.equals(node.semanticName())).findFirst().orElseThrow();
pages = placed.endPage() - placed.startPage() + 1;
docx = session.export(new DocxSemanticBackend());
}
assertThat(pages).as("the premise: the layout sets %s over pages", card).isGreaterThan(1);
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
List<IBodyElement> body = document.getBodyElements();
List<XWPFTable> parts = new ArrayList<>();
for (int index = 0; index < body.size(); index++) {
if (body.get(index) instanceof XWPFParagraph between && between.isPageBreak()) {
parts.add((XWPFTable) body.get(index + 1));
}
}
assertThat(parts).as("%s's parts after the first, %d lines", card, lines.size()).hasSize(pages - 1);
for (XWPFTable part : parts) {
XWPFTable items = part.getRow(0).getCell(0).getTables().get(0);
// The header's cells may hold tables of their own, which a cell's text leaves out.
String first;
try (org.apache.xmlbeans.XmlCursor row = items.getRow(0).getCtRow().newCursor()) {
first = row.getTextValue();
}
assertThat(first).as("its header first").contains(header);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,24 @@ private Map<String, Double> rowHeightsOf(DocumentNode table) {
});
}

/**
* Where the lowest of a table's own rows on a page ends, measured up from the page's foot as
* {@link #lowestContentOn} measures.
*
* @param table the table node
* @param page the page, from 0
* @return the height above the page's foot, or NaN where the table has no row of its own there
*/
double lowestRowOn(DocumentNode table, int page) {
double lowest = Double.NaN;
for (PlacedFragment fragment : ownRows(table)) {
if (fragment.pageIndex() == page) {
lowest = Double.isNaN(lowest) ? fragment.y() : Math.min(lowest, fragment.y());
}
}
return lowest;
}

/**
* The index part of each placed row's name, kept as the text the layout wrote rather than
* parsed: a row is looked up by writing its index the same way, so a name that only
Expand Down
Loading
Loading