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
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -535,9 +535,10 @@ jobs:

docx-fidelity:
name: DOCX Fidelity (LibreOffice)
# Every template preset exported to DOCX, set by LibreOffice, and held line by
# line to qa/src/test/resources/docx-fidelity/libreoffice-linux*.tsv
# (DocxFidelityCorpusTest): a change that sets any line of any preset further
# Every template preset, and the documents built for shapes no preset has, exported to
# DOCX, set by LibreOffice, and held line by line to
# qa/src/test/resources/docx-fidelity/libreoffice-linux*.tsv
# (DocxFidelityCorpusTest): a change that sets any line of any document further
# from the engine's PDF, down or across, at other words, or on more pages fails here. Gated on
# `docx` — what an export of the corpus is made from.
#
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4230,6 +4230,36 @@ follow semantic versioning; release dates are ISO 8601.

### Tests

- **The DOCX fidelity corpus holds the ways a page breaks inside a panel.**
- Five documents join it, so the gate holds every line it finds of each where the editor set
it, in LibreOffice in CI and in Word on Windows, as it holds the presets:
- `SyntheticDocxCorpus`, a family of documents built for shapes no preset has on its own
fixture: a page break among a card's children and inside a card nested in it; page breaks
in a row's column and in a stack's layer, where the page breaks none; and a bordered card
whose table runs over three pages, the pages after the first set against a larger top
margin. Their text is set in Lato, which the export embeds, so both editors break its lines
at the page's words.
- `ObsidianInvoice` with its line items repeated 12 times — its line items card run over four
pages with other margins from page 2, and the page break after its info cards — and
`MeteredInvoice` with them repeated 9 times, the gap above its closing row carried onto
page 4.
- Measured in Word 16, every line of all five stands on the page the PDF sets it on, nine in
ten within 1.2pt of it down. Across, Word sets the amounts of the bordered card's table 36.4pt
left, the table's first column narrower than its grid, which LibreOffice keeps. In
LibreOffice 24.2 and 26.8 the parts of the card after its explicit breaks stand 3 to 5pt high.
LibreOffice 24.2, CI's, sets each on the pages the PDF does; it sets the line items of the
two long invoices high (`ObsidianInvoice`'s up to 10pt; `MeteredInvoice`'s more so page after
page, by a median of 52 and 87pt on its second and third pages, 16 of its lines on another
page), as it does the presets'
long tables, and holds their last pages, which carry the page break after the cards and the
gap above the closing row, within 0.8pt. LibreOffice
26.8 on Windows carries `ObsidianInvoice`'s info cards to page 5, 17 lines, where Word and
LibreOffice 24.2 keep them on page 4, as it did before the page break after them was fixed;
its baseline holds that as it stands.
- Each baseline takes the five documents' rows alone, from the run of its editor — Linux's from
CI's; the presets' rows stay as recorded. The tests holding `ObsidianInvoice` and
`MeteredInvoice` with their line items repeated take the corpus's helper for that.

- **The DOCX field ledger names what a session hands an export beside the tree.** Every part
of `SemanticExportContext` and of the `LayoutGraph` it carries has a fate, and a part with
none fails the ledger. A session's per-page margins reached the layout, no export read them,
Expand Down
9 changes: 6 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,22 +347,25 @@ Choose the smallest tests that match the change:
and the [per-preset smoke tests](qa/src/test/java/com/demcha/compose/document/templates/cv/presets)
- For DOCX export (`render-docx`) changes:
[DocxFidelityCorpusTest.java](qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxFidelityCorpusTest.java).
It exports every template preset to DOCX, has LibreOffice set each one, and holds every
It exports every template preset to DOCX, with documents built for shapes no preset has on
its own fixture, has LibreOffice set each one, and holds every
line of every document to the drift its baseline records, down and across: a line set further
from the page either way, a line set at other words, or a page more fails it. It needs
LibreOffice and runs only when asked for — install first, as for any standalone `qa` run:
`./mvnw -B -ntp test -f qa/pom.xml -Dtest=DocxFidelityCorpusTest -Dgraphcompose.docxFidelity=libreoffice`.
A change that moves documents nearer the page rewrites the baseline with
`-Dgraphcompose.docxFidelity.update=true`, and commits it with the change: its diff shows
which documents moved. A new preset joins the corpus in its family's `*DocxCorpus` class.
which documents moved. A new preset joins the corpus in its family's `*DocxCorpus` class,
and so does a preset on a longer fixture; a shape no preset has — a page break inside a
panel, a break in a row's column — goes in `SyntheticDocxCorpus`.
CI runs it on Linux (the `DOCX Fidelity` job) against `libreoffice-linux.tsv`, and uploads
what it measured: LibreOffice sets text a little differently per platform, so a change that
moves documents nearer the page takes that artifact's files as the Linux baseline, and its own
run's as the Windows one. Microsoft Word, the editor the export answers to, is not in CI: on
Windows with Word, `scripts/docx-visual/word-fidelity.ps1` (`-Update` to rewrite) exports the
corpus, has a private Word instance convert it, and holds it to `word-windows.tsv` — run it
from PowerShell (pwsh), after the install above, before a DOCX export change is opened.
Each run also writes `measured-<editor>-acceptance.md`: which presets the editor sets as the
Each run also writes `measured-<editor>-acceptance.md`: which documents the editor sets as the
page does — the same pages, 95% of the lines found, a median of 1pt or less, no more than 10%
past 2pt — and what keeps the rest short of it. CI shows LibreOffice's in the job summary.
The corpus also answers for how a document edits in Word.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
import java.util.function.Consumer;

/**
* One document of the DOCX fidelity corpus: a template preset composed on its fixture.
* One document of the DOCX fidelity corpus: a template preset composed on its fixture, or a
* document built for a shape no preset has ({@link SyntheticDocxCorpus}).
*
* @param family the template family, such as {@code cv} or {@code invoice}
* @param name the preset, unique within its family
* @param family the template family, such as {@code cv} or {@code invoice}, or {@code synthetic}
* @param name the preset, or the shape, unique within its family
* @param margin the page margin on A4, in points, or a negative value for the session's own
* page and margins
* @param compose composes the document into a fresh session
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
import static org.assertj.core.api.Assertions.assertThat;

/**
* Every template preset's DOCX, set by an editor, stands no further from the engine's PDF than
* Every template preset's DOCX, and that of each document built for a shape no preset has
* ({@link SyntheticDocxCorpus}), set by an editor, stands no further from the engine's PDF than
* its baseline holds it.
*
* <p>The engine draws each corpus document to PDF and exports it to DOCX; an editor converts the
Expand Down Expand Up @@ -141,7 +142,7 @@ private static void deleteTree(Path dir) throws java.io.IOException {
}
}

/** Every family's presets. */
/** Every family's presets, and the documents built for shapes no preset has. */
static List<DocxCorpusDocument> corpus() {
List<DocxCorpusDocument> corpus = new ArrayList<>();
corpus.addAll(CvDocxCorpus.documents());
Expand All @@ -150,6 +151,7 @@ static List<DocxCorpusDocument> corpus() {
corpus.addAll(ProposalDocxCorpus.documents());
corpus.addAll(ReceiptDocxCorpus.documents());
corpus.addAll(RotaDocxCorpus.documents());
corpus.addAll(SyntheticDocxCorpus.documents());
return corpus;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.demcha.compose.document.templates.fidelity;

import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.api.PageMarginRule;
import com.demcha.compose.document.dsl.ParagraphBuilder;
import com.demcha.compose.document.dsl.SectionBuilder;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.document.table.DocumentTableColumn;
import com.demcha.compose.document.table.DocumentTableStyle;
import com.demcha.compose.font.FontName;

import java.util.List;
import java.util.function.Consumer;

/**
* Documents of the DOCX fidelity corpus built for a shape no template preset has on its own
* fixture, so that an editor setting one off the page fails the gate as a preset would.
*
* <p>Each sets a way the page breaks inside a panel or a column: a page break among a panel's
* children and inside a panel nested in it, page breaks the page sets as boxes of no height in a
* row's column and in a stack's layer, and a card whose table the layout runs over three pages,
* with other margins from the second. Their text is set in a face the export embeds, so both
* editors break its lines at the page's words.</p>
*/
final class SyntheticDocxCorpus {

private static final DocumentTextStyle TEXT = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build();
private static final DocumentTableStyle CELLS = DocumentTableStyle.builder().textStyle(TEXT).build();
private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249);
private static final DocumentColor INNER = DocumentColor.rgb(224, 233, 244);
private static final DocumentColor INK = DocumentColor.rgb(26, 86, 148);
private static final DocumentInsets MARGIN = DocumentInsets.of(36);

private SyntheticDocxCorpus() {
}

/** The documents of the {@code synthetic} family, one for each shape. */
static List<DocxCorpusDocument> documents() {
return List.of(
synthetic("break_in_panel", SyntheticDocxCorpus::breakInAPanel),
synthetic("break_in_column", SyntheticDocxCorpus::breakInAColumn),
synthetic("card_over_pages", SyntheticDocxCorpus::cardOverPages));
}

/**
* A card with a page break among its children, and a card nested in it with a break of its
* own: the card runs over three pages, each part where the page sets it.
*/
private static void breakInAPanel(DocumentSession session) {
session.pageSize(400, 500).margin(MARGIN);
session.pageFlow(page -> page.spacing(8)
.addParagraph(p -> text(p, "Before the card, on the first page."))
.addSection(card -> card.fillColor(SURFACE).stroke(DocumentStroke.of(INK, 1))
.padding(DocumentInsets.of(8)).spacing(6)
.addParagraph(p -> text(p, "The card opens on the first page."))
.addParagraph(p -> text(p, "Its second line stands under the first."))
.addPageBreak(pageBreak -> { })
.addParagraph(p -> text(p, "After the card's own break, on the second page."))
.addSection(inner -> inner.fillColor(INNER).padding(DocumentInsets.of(6)).spacing(4)
.addParagraph(p -> text(p, "The inner card opens under it."))
.addPageBreak(pageBreak -> { })
.addParagraph(p -> text(p, "After the inner card's break, on the third page.")))
.addParagraph(p -> text(p, "The card closes on the third page.")))
.addParagraph(p -> text(p, "After the card, on the third page.")));
}

/**
* Page breaks the page sets as boxes of no height: in a row's column and in a stack's one
* layer. The page goes on with what follows on the same page, and so does the editor.
*/
private static void breakInAColumn(DocumentSession session) {
session.pageSize(400, 500).margin(MARGIN);
session.pageFlow(page -> page.spacing(8)
.addParagraph(p -> text(p, "A row and a stack, each with a break in it."))
.addRow(row -> row.spacing(12)
.addSection(column -> column.spacing(4)
.addParagraph(p -> text(p, "Part A of the column."))
.addPageBreak(pageBreak -> { })
.addParagraph(p -> text(p, "Part B of the column.")))
.addParagraph(p -> text(p, "The column beside it.")))
.addLayerStack(stack -> stack.topLeft(new SectionBuilder().spacing(4)
.addParagraph(p -> text(p, "The layer before its break."))
.addPageBreak(pageBreak -> { })
.addParagraph(p -> text(p, "The layer after its break."))
.build()))
.addParagraph(p -> text(p, "After the row and the stack, on the same page.")));
}

/**
* A bordered card whose table of sixty rows the layout runs over three pages, its header
* repeated at the top of each, the pages after the first set against a larger top margin.
*
* <p>Spaced so that page 1's rows leave room under them for the paragraph that ends a Word
* section at the cut, as gaps of 2 to 6pt and 12 to 16pt do: at 8 or 10pt the export names
* page 2's margins instead, and both editors set page 2 with page 1's. A change to a table's
* metrics can move page 1's last row into that case: the gate then fails this document with
* every line of page 2 about 53pt high. Where the export's report names page 2's margins as
* leaving page 1's rows too near its foot, the fixture is to be re-spaced; where it does not,
* the section at the cut was lost, and the change is at fault.</p>
*/
private static void cardOverPages(DocumentSession session) {
session.pageSize(400, 600).margin(MARGIN);
session.pageMargins(List.of(PageMarginRule.from(2, new DocumentInsets(90, 36, 36, 36))));
session.pageFlow(page -> page.spacing(4)
.addParagraph(p -> text(p, "The card's table runs over three pages."))
.addSection(card -> card.fillColor(SURFACE).stroke(DocumentStroke.of(INK, 1))
.padding(DocumentInsets.of(6))
.addTable(table -> {
table.columns(DocumentTableColumn.fixed(220), DocumentTableColumn.fixed(90))
.defaultCellStyle(CELLS).headerStyle(CELLS)
.header("Item", "Amount").repeatHeader();
for (int row = 1; row <= 60; row++) {
table.row("Line item " + row, String.valueOf(row * 7));
}
}))
.addParagraph(p -> text(p, "After the card, on its last page.")));
}

private static ParagraphBuilder text(ParagraphBuilder paragraph, String text) {
return paragraph.text(text).textStyle(TEXT);
}

private static DocxCorpusDocument synthetic(String name, Consumer<DocumentSession> compose) {
return new DocxCorpusDocument("synthetic", name, -1, compose);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.demcha.compose.document.templates.invoice.presets;

import com.demcha.compose.document.templates.data.invoice.InvoiceServiceLines;
import com.demcha.compose.document.templates.data.invoice.StructuredInvoiceData;
import com.demcha.compose.document.templates.fidelity.DocxCorpusDocument;

import java.util.ArrayList;
import java.util.List;

/** The invoice presets of the DOCX fidelity corpus, with the long fixtures that run to more pages. */
Expand All @@ -27,8 +30,16 @@ public static List<DocxCorpusDocument> documents() {
invoice("luma_studio_long", -1,
s -> LumaStudioInvoice.create().compose(s, LumaStudioInvoiceFixtures.overflowInvoice())),
invoice("metered", -1, s -> MeteredInvoice.create().compose(s, MeteredInvoiceFixtures.invoice())),
// Nine times its line items: the layout carries the gap above the closing row onto page 4.
invoice("metered_long", -1, s -> MeteredInvoice.create()
.compose(s, withLinesRepeated(MeteredInvoiceFixtures.invoice(), 9))),
invoice("merchant", -1, s -> MerchantInvoice.create().compose(s, MerchantInvoiceFixtures.invoice())),
invoice("obsidian", -1, s -> ObsidianInvoice.create().compose(s, ObsidianInvoiceFixtures.invoice())),
// Twelve times its line items: the card holding them runs over four pages, the pages
// after the first set against other margins, and the layout breaks page 5 between
// the info cards and the footer divider.
invoice("obsidian_long", -1, s -> ObsidianInvoice.create()
.compose(s, withLinesRepeated(ObsidianInvoiceFixtures.invoice(), 12))),
invoice("platform", -1, s -> PlatformInvoice.create().compose(s, PlatformInvoiceFixtures.invoice())),
invoice("payments", -1,
s -> PaymentsInvoice.create().compose(s, PaymentsInvoiceFixtures.canonicalInvoice())),
Expand All @@ -38,6 +49,18 @@ public static List<DocxCorpusDocument> documents() {
s -> WorkspaceInvoice.create().compose(s, WorkspaceInvoiceFixtures.canonicalInvoice())));
}

/** The invoice with its line items repeated {@code times}. */
static StructuredInvoiceData withLinesRepeated(StructuredInvoiceData data, int times) {
List<InvoiceServiceLines.Line> lines = new ArrayList<>();
for (int time = 0; time < times; time++) {
lines.addAll(data.serviceLines().lines());
}
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 DocxCorpusDocument invoice(String name, double margin,
java.util.function.Consumer<com.demcha.compose.document.api.DocumentSession> compose) {
return new DocxCorpusDocument("invoice", name, margin, compose);
Expand Down
Loading
Loading