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

### Public API

- **A DOCX export writes the margins of a page that starts at a panel's cut as a Word section
there.**
- A page that `session.pageMargins(...)` sets against other top and bottom margins than Word
would set it with starts a Word section with them, where it starts between blocks of the body
or among a body table's rows. Where it started inside a panel, it started none and was named:
Word set it with the margins in force. Measured in Word 16 and LibreOffice 24.2 on a card
whose table runs over three pages, a top margin 54pt larger from page 2 on stood every line of
pages 2 and 3 52.9pt high; margins of 20pt from page 2 on ran the card onto five pages in Word
and four in LibreOffice, where the page sets three.
- Where the export cuts such a panel at the row of its table the page starts with, the
paragraph between the two parts now ends the section: it breaks no page before itself, the
section's own break opening the page, and stands under the part above it. In both editors
every line of the card now stands on the page the page sets it on, lower by the cut's top
border only. `ObsidianInvoice`, whose pages from 2 on have a larger foot, is written so when its
line items run over pages: its cut where page 2 starts ends a section, and the pages after it
are set with page 2's margins.
- Where the page above has room for the cut and not for that paragraph under the part, the
panel is cut there as before and the page is named. A page the panel is not cut at is named,
as before. A panel in a section whose pages share one set of margins, or whose header or footer
counts its own pages, is cut as before.
- In a section whose margins change, a panel's cuts are decided once the section is written,
before the document's page references are settled and its bookmarks kept: a row holding a page
reference leaves the panel uncut at that row's page and every page before it, as any field
does, though one whose anchor has no bookmark ends up as its number; so does a header row
holding a bookmark the export later takes out. A drawing the export anchors in the table's
header row after that stays in the first part, the page it is drawn on, and the part after a
cut opens with a copy of the row without it.
- With its line items repeated 3 to 11 times `ObsidianInvoice` sets every line on its page in
Word and LibreOffice 24.2, as it did. Repeated 12 times, its page 5 starts between its info
cards and its footer divider. With page 1's margins in force on pages 2 to 4, page 5 opened a
Word section of its own, which fixed that break, and Word held the cards on page 4; page 5's
margins are now page 2's, so the editors place that break themselves: Word sets the cards on
page 5 with the divider, because `keepNext` on
the paragraphs inside the cards' cell keeps their table with the paragraph after it, and both
editors drop the divider's 13pt of space above it at a page they break themselves. The same
invoice with one set of margins on every page is set the same way in both editors.
- Where a page the panel runs onto is not cut and a later one is, LibreOffice 24.2 sets the part
after that cut with the margins of the section before: it reads no section's margins after a
table it broke over a page itself. Word reads them.
- No document of the corpus has such a panel; its export is unchanged.

- **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
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture/backend-capability-matrix.md

Large diffs are not rendered by default.

37 changes: 33 additions & 4 deletions docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,35 @@ header rows the layout repeats on each page, and the hairline between the two pa
section. Measured in Word and LibreOffice on Windows, every page holds the lines the layout sets
on it. The report names the split on the table (`page margin`).

A page that starts inside any other block — the lines of a paragraph, a panel — does not start a
section. Nor does a page that starts at a table's row where a cell spans rows across it, where
A page that starts inside a panel the export cuts at the row of its table that page starts with
(*Over pages*, under *What a panel keeps and loses*) ends the section in the paragraph between the
two parts. That paragraph then breaks no page before itself, as the section's own break opens the
page, and stands under the part above it, so the page above must leave room for it under its last
row, above the bottom margin Word sets that page with, as well as for the borders the cut keeps
there; where the page has room for the cut and not for that paragraph as well, the panel is cut as
before and the page is named. In a section whose margins change, a panel's cuts are decided once
the section is written, before the document's page references are settled and its bookmarks kept:
a row holding a page reference leaves the panel uncut at that row's page and every page before it,
as any field does, though one whose anchor has no bookmark ends up as its number; so does a header
row holding a bookmark the export later takes out. A section whose header or footer counts its own
pages starts no Word section for a page's margins, and its panels are cut as in a section of one
set of margins. Measured in Word 16 and LibreOffice 24.2 on a card whose table runs over three
pages: with one set of margins, a top margin 54pt larger from page 2 on stood every line of pages
2 and 3 52.9pt high, and margins of 20pt from page 2 on ran the card onto five pages in Word and
four in LibreOffice; with the section at the cut, every line stands on the page the page sets it
on, lower by the cut's top border only. `ObsidianInvoice` is written so from page 2 on, its larger
foot there. With its line items repeated 3 to 11 times every line stands on its page in both
editors. Repeated 12 times, page 5 starts between its info cards and its footer divider, a page
break the editors place themselves: Word sets the cards on page 5 with the divider, `keepNext` on
the paragraphs inside the cards' cell keeping their table with the paragraph after it, and both
editors drop the divider's 13pt of space above it at a page they break themselves — as they do
with one set of margins on every page. Where a page a panel runs onto is not cut and a later one
is, LibreOffice 24.2 sets the part after that cut with the earlier section's margins: it reads no
section's margins after a table it broke over a page itself. Word reads them.

A page that starts inside any other block — the lines of a paragraph, a panel not cut there —
does not start a section. Nor does a page that starts at a table's row where a cell spans rows
across it, where
only the header rows stand above it, or where a header row holds a bookmark or a drawing, which
the file can hold only once, or a numbered list item, which its copy would number on. Nor where a header row or a row from that one on holds a page
reference, which the export settles in the paragraph it wrote it in once the body is written. A
Expand Down Expand Up @@ -740,8 +767,10 @@ How it lands:
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
parts breaking the page before itself, which adds no page where the part above fills its own;
where the page after the cut has other top and bottom margins, that paragraph ends a Word
section instead (*Margins that change from page to page*, above). 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,70 @@ void subscriptionsRuledLineItemsAreATableAPage() throws Exception {
(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 {
@Test
void obsidiansContinuationMarginsStartAWordSectionAtTheCardsFirstCut() throws Exception {
// From page 2 on the page sets a larger foot; the card's cut where page 2 starts ends a Word
// section with the first page's margins, and the pages after it are set with page 2's.
for (int times : new int[]{3, 6}) {
byte[] docx = export(longer(ObsidianInvoiceFixtures.invoice(), times),
(s, d) -> ObsidianInvoice.create().compose(s, d));
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
List<IBodyElement> body = document.getBodyElements();
List<XWPFParagraph> ending = document.getParagraphs().stream()
.filter(paragraph -> paragraph.getCTP().isSetPPr() && paragraph.getCTP().getPPr().isSetSectPr())
.toList();
assertThat(ending).as("×%d: one section ends, at the card's first cut", times).singleElement()
.satisfies(between -> {
assertThat(body.get(body.indexOf(between) - 1)).isInstanceOf(XWPFTable.class);
assertThat(body.get(body.indexOf(between) + 1)).isInstanceOf(XWPFTable.class);
assertThat(between.isPageBreak()).isFalse();
assertThat(bottomOf(between.getCTP().getPPr().getSectPr()))
.isEqualTo(twips(ObsidianStyles.MARGIN_B_FIRST));
});
assertThat(bottomOf(document.getDocument().getBody().getSectPr()))
.isEqualTo(twips(ObsidianStyles.MARGIN_B_LATER));
}
}
}

private static long bottomOf(org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr section) {
return ((java.math.BigInteger) section.getPgMar().getBottom()).longValue();
}

private static long twips(double points) {
return Math.round(points * 20);
}

/** The invoice with its line items repeated {@code times}. */
private static StructuredInvoiceData longer(StructuredInvoiceData data, int times) {
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(),
return 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());
}

private static byte[] export(StructuredInvoiceData data, BiConsumer<DocumentSession, StructuredInvoiceData> preset)
throws Exception {
try (DocumentSession session = GraphCompose.document().create()) {
preset.accept(session, data);
return session.export(new DocxSemanticBackend());
}
}

/**
* 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, or ends a Word section where the page's
* margins change, 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 {
StructuredInvoiceData longer = longer(data, times);
int pages;
byte[] docx;
try (DocumentSession session = GraphCompose.document().create()) {
Expand All @@ -72,12 +120,16 @@ private static void assertCutAtItsPages(StructuredInvoiceData data, int times,
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));
for (int index = 0; index + 1 < body.size(); index++) {
if (body.get(index) instanceof XWPFParagraph between
&& (between.isPageBreak() || between.getCTP().isSetPPr() && between.getCTP().getPPr().isSetSectPr())
&& body.get(index + 1) instanceof XWPFTable part
&& !part.getRows().isEmpty() && !part.getRow(0).getCell(0).getTables().isEmpty()) {
parts.add(part);
}
}
assertThat(parts).as("%s's parts after the first, %d lines", card, lines.size()).hasSize(pages - 1);
assertThat(parts).as("%s's parts after the first, %d lines", card, longer.serviceLines().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.
Expand Down
Loading
Loading