feat(docx): take the export geometry from the resolved layout, ship the fonts, and give the export a way in - #705
Merged
Merged
Conversation
| byte[] key = new byte[16]; | ||
| for (int index = 0; index < key.length; index++) { | ||
| int at = hex.length() - 2 - index * 2; | ||
| key[index] = (byte) Integer.parseInt(hex.substring(at, at + 2), 16); |
|
|
||
| /** Reads a twip measure back, treating an unset one as zero. */ | ||
| private static long twipsOf(Object measure) { | ||
| return measure == null ? 0 : Long.parseLong(String.valueOf(measure)); |
| } | ||
|
|
||
| private static long lineTwips(XWPFParagraph paragraph) { | ||
| return Long.parseLong(String.valueOf(paragraph.getCTP().getPPr().getSpacing().getLine())); |
| if (properties == null || !properties.isSetSpacing() || !properties.getSpacing().isSetBefore()) { | ||
| return 0; | ||
| } | ||
| return Long.parseLong(String.valueOf(properties.getSpacing().getBefore())); |
| if (properties == null || !properties.isSetSpacing() || !properties.getSpacing().isSetAfter()) { | ||
| return 0; | ||
| } | ||
| return Long.parseLong(String.valueOf(properties.getSpacing().getAfter())); |
A semantic export is defined over the authored tree, and some documents export from it that the fixed-layout pipeline refuses: a list item made of inline runs without marker geometry exports as an ordinary Word list item and cannot be laid out at all. Before a backend could ask for the layout that difference never came up. Letting the request turn such a document from "exports" into "throws" would take away something that worked, in exchange for a number the backend wanted as an improvement. So a layout that cannot be compiled is reported once and the export continues without it. SemanticExportContext.layoutGraph() is nullable for exactly this, and a backend that cannot proceed without one says so through requireLayoutGraph(). Tests: a context whose layout compilation throws still reaches export, and hands the backend nothing.
Two of the things that decide how an exported document looks are measurements over the font: how tall a line of text is, and how wide a column came out. This backend has no font runtime, so both were left to Word, and Word answered with its own -- measured against the reference render, a body line came out at 13.9pt where the document says 9.7 and LibreOffice says 12.1, so everything below the first paragraph sat lower than it should and the gap grew with every line. The export now asks for the resolved layout and reads the answers the engine already worked out: line height as w:lineRule="exact", and a table's and a row's columns from the resolved cells and the placed children. Only the starts of a row's children are read; a placed child is as wide as its own content, so its width says nothing about where its column ends. The space around a block is not a measurement and was missing too. A paragraph's margin and padding now become w:spacing before and after, and a container -- which is not a Word object, its children written where it stood -- hands its top edge to the first paragraph inside and its bottom edge to the last. Both add to what a paragraph asks for itself, so nesting sums the way the page does. A container that begins with a table leaves that edge unwritten rather than parking it on whatever paragraph comes next. Measured through Word 16.0, first page: the body now starts at 65.5pt against the reference's 65.2 and sets lines at 9.8 against 9.7, the worst grid cell falls from 72% to 52%, and the largest landmark drift down the page falls from 44pt to 20pt. The editing protocol still passes 7 of 7. Tests: line height on a body paragraph, a row's cells and a list item, and none invented without a layout; vertical spacing on a paragraph, a container's two edges, nesting, and a container of tables; a table and a row taking every column from the layout, beside the cases that stay Word's when there is none. Asking for the layout also reads an image a second time, which is now its own test rather than a surprise.
The recipe said what maps to Word and not which numbers the export gets to know. Names the three the resolved layout supplies, what the space around a block becomes, what asking for the layout costs, and what a document that cannot be laid out falls back to.
A face was named and never shipped. The export declared Lato on its runs and embedded nothing, so on a machine without Lato installed -- which is most of them, this one included -- Word substituted another face, and a substituted face has different glyph widths, so every line breaks somewhere else and the geometry above it stops meaning anything. The package now carries a font table and one obfuscated part per face, for every family the document names that has a file behind it: the bundled families and whatever the session registered. The standard PDF faces are never written, and not because of their terms -- they are names, not files, and a reader gets Word's substitution for them, the same one a PDF viewer applies. Only the faces the document uses travel. A family's four faces are about two and a half megabytes, which the probe paid in full for one line of Lato before the slot was read from the style; it now pays 366 kilobytes for the one face that line needs. A face states its own terms in OS/2, and the format distinguishes "embeddable to read and print" from "embeddable in a document someone will type into". Only the second is written; the first is refused with a line in the log naming the family, rather than shipped and hoped for. Measured through Word 16.0: the exported document now reports Lato in the render, not a substitution, and the Lato paragraph breaks at the same word as the reference, ending within 2.3pt over a 489pt line. The grid metric is unchanged, and honestly so -- one line of this corpus is set in an embeddable family. Tests: the key derivation against the one worked example the format publishes, that only the first 32 bytes are scrambled and come back, each fsType value, a bundled family travelling with the document, only the used faces travelling, a standard face shipping nothing, and the shipped part being the real font behind its scrambled header.
The recipe listed what the export writes and said nothing about the fonts it writes it in, which is the difference between a reader seeing the document and seeing a substitution. Says which families ship, which faces of them, what a face's own terms can veto, and how each one is stored.
… not FontName.HELVETICA_BOLD is a face, and it was written where Word expects a family. Word resolves a family and takes the weight from w:b, so asked for a family by that name it finds none and substitutes -- which is how a document naming its headings by face came out set in something else. It matters more now that faces are shipped: a family named wrong is a font that sits in the package and never gets used. The face resolves to its family through FontLibrary.resolveFamily, the same call the layout makes, and the name written is that family's wordFamily() -- which is what that field is for, so a registered family can carry a Word name that differs from its logical one. The weight is deliberately not taken from the face name. The engine does not take it either: a style naming HELVETICA_BOLD with no decoration lays out regular, measured in the reference render, where the headings of the probe are Helvetica at 21 and 13 points and not bold at all. Writing w:b here would make Word bolder than the page it matches. Two names for one family now weigh as one style when Normal is chosen. They are written identically, so weighing them apart could split one body style in two and elect the lighter half. Measured through Word 16.0: no substituted face remains under visible text -- ArialMT is left only under spaces, and Courier maps to Courier New, which is Word's own pairing. Cells over 25% fall from 33 to 32. Tests: a face name written as its family, that it does not make the run bold, that a decoration still does, and that two names for one family leave the Normal style saying it once. The test pinning the old spelling now pins the new behaviour: a heading states its size and stays silent about a family it shares with the body.
Reaching the Word export meant constructing DocxSemanticBackend, which
means importing the render artifact in the code that builds the
document and carrying that dependency wherever documents are built. A
render backend has not needed that since 2.0 -- the artifact on the
classpath is enough -- and now neither does this one:
session.buildDocx(path);
session.writeDocx(outputStream);
byte[] bytes = session.toDocxBytes();
SemanticBackendProvider and SemanticBackendProviders are the semantic
half of that ServiceLoader path, with the fixed-layout locator's rules,
because a classpath behaves the same way whichever backend is on it: the
format is a case-insensitive key, a missing provider names the artifact
to add, and two providers for one format is reported rather than
resolved -- leaving enumeration order to decide would export one
document differently on two machines.
One rule is deliberately not copied. There is no default-format lookup:
"render this document" has an obvious answer worth defaulting to, and
"export this document semantically" does not, so choosing between two
installed formats would be picking a file type for the caller.
The stream is the caller's and is not closed. The bytes are produced in
full before any are written, because a .docx is a ZIP whose directory
comes last, so a half-written one is unreadable rather than short. A
file is written through the same atomic path as buildPdf, so a failed
export leaves the previous file rather than a damaged one.
Tests: the provider found by either spelling, a fresh backend per
export, bytes/stream/file producing the same document, the stream left
open -- verified by sabotage -- a failed export leaving the old file,
and the locator's own choices: case, missing, duplicate, and a provider
declaring no format at all.
What a Word document cannot hold, this export has always said -- to the
log, one line per kind. A service generating documents for other people
has no log to read: it needs to know whether the file it is about to
send lost a chart, and which one.
A backend built with a sink hands over a DocxExportReport once the bytes
are complete:
session.export(new DocxSemanticBackend(report -> log(report)));
Errors do not travel this way. An export that cannot proceed throws, and
a report is not how a caller finds that out; the sink is called after
the bytes exist, so nobody is told what an export lost by an export that
did not finish.
Two severities, and the difference is the point. DROPPED means the page
draws it and the document does not carry it. APPROXIMATED means it is
there as the nearest thing Word owns -- a panel that keeps its fill and
loses its rounded corners. A caller deciding whether to send the file
needs to tell those apart.
Every occurrence is recorded even though the log still says it once per
kind: asking what the document lost means wanting the three charts it
lost and which three. Each note carries the authored node's path, the
same one the layout graph addresses it by, so a note traces back to the
code that wrote it instead of being guessed at from its text. That
needed one change: the path index was built only alongside a layout, and
is now built always -- a path is not a measurement, and a document the
engine cannot lay out is exactly the one whose report matters.
Recorded: dropped nodes, dropped inline runs, a dropped corner radius, a
clipped shape container, a chart becoming its data table, a face that
may not be embedded with the reason, and an export with no resolved
layout behind it.
Tests: a clean document reporting nothing, a dropped node named with its
path, three drops of one kind recorded three times, an approximation
counted apart from a drop, a chart saying what it became, a note reading
as one line, and the report not changing the document it reports on.
…work This branch states a measured line height on every paragraph, carries the space a block holds above and below itself, and writes a grid the layout resolved, so the same example produces a different document than the one the base carries. Verification: ./mvnw -B -ntp test -f examples/pom.xml, 93 tests, BUILD SUCCESS.
DemchaAV
force-pushed
the
feature/docx-layout-metrics
branch
from
September 22, 2026 12:04
e86bab5 to
c9ab6d8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The Word export writes the document's structure and lets Word lay it out, which is what
makes the result editable — and it meant the export never knew a single measurement. Two of
the things that decide how the file looks are measurements over the font: how tall a line
of text is, and how wide a column came out. Both were Word's to guess, and Word guessed its
own way.
This branch hands the export the layout the engine already compiled, writes the geometry it
can now read, ships the faces the document is set in, and gives a caller a way to reach the
export without naming the backend and to find out what it could not carry. Measured through
Word 16.0 against the reference render: the body now starts at 65.5pt against the
reference's 65.2 and sets lines at 9.8pt against 9.7 — it was 78.0pt and 13.9pt — the worst
grid cell falls from 78.9% to 52.1%, and the largest landmark drift down the first page
falls from 44pt to 20pt. Editing is unchanged at 7 of 7 scenarios.
Why
A substituted face has different glyph widths, so every line breaks somewhere else; a line
height that is 4.2pt too tall pushes everything below it down and the gap grows with every
line; a table sized to its text is a third of the width the page draws it at. Each of those
is invisible in one paragraph and decides the page by the bottom of it. None of them can be
worked out from the authored tree alone — they are the engine's measurements, and the
export had no way to ask for them.
Reaching the export at all meant constructing
DocxSemanticBackend, which means importingthe render artifact in the code that builds the document. A render backend has not needed
that since 2.0.
What changed
Geometry from the resolved layout.
DocxSemanticBackend.requiresResolvedLayout()nowreturns
true, andDocxLayoutMetricsindexes the compiledLayoutGraphagainst the treeit was compiled from — rebuilding
LayoutCompiler.pathForovernode.children(), so nowriter signature has to carry a path around. From it:
w:spacing w:lineRule="exact"— a measurement in points, where a multiple would bemeasured again by the editor against whatever font it substituted;
w:tblLayoutfixed so Word does notre-fit them;
a placed child is as wide as its own content — a paragraph reading "Left" measures a few
points whatever slot it was given — so its width says nothing about where its column ends.
The layout is offered, not imposed.
DocumentRenderingFacadelogs a layout that cannotbe compiled and exports without it. A list item made of inline runs without marker geometry
exports fine and cannot be laid out at all; letting the request turn such a document from
"exports" into "throws" would take away something that worked.
Space around a block is not a measurement and was missing too. A paragraph's
marginand
paddingbecomew:spacingbefore and after; a container — not a Word object, itschildren written where it stood — hands its top edge to the first paragraph inside and its
bottom to the last, added rather than set so nesting sums the way the page does. A container
that begins with a table drops that edge rather than parking it on whatever paragraph comes
next.
Fonts travel with the document.
DocxFontTablewritesword/fontTable.xmland oneobfuscated part per face, for every family the document names that has a file behind it.
Only the faces it uses: a family's four faces are 2.5 MB against 366 KB for the one this
example needs. Only what the face permits:
OS/2fsTypedistinguishes embedding forreading and printing from embedding in a document someone will edit, and only the second is
written. The standard PDF faces ship nothing — they are names, not files.
A run names a family Word has.
FontName.HELVETICA_BOLDis a face, and it was writtenwhere Word expects a family, so Word substituted. It now resolves through
FontLibrary.resolveFamilyand writes that family'swordFamily(). The weight isdeliberately not read from the face name: the engine does not read it either — a style
naming
HELVETICA_BOLDwith nodecorationlays out regular, measured in the referencerender — so writing
w:bwould make Word bolder than the page it matches.Reaching the export.
SemanticBackendProvider/SemanticBackendProvidersare thesemantic half of the
ServiceLoaderpath, with the fixed-layout locator's rules, behindsession.buildDocx(path),session.writeDocx(stream)andsession.toDocxBytes(). One ruleis deliberately not copied — there is no default-format lookup, because "render this
document" has an obvious answer worth defaulting to and "export it semantically" does not.
Knowing what was lost.
new DocxSemanticBackend(report -> …)hands over aDocxExportReportonce the bytes exist: every dropped node and every approximation, eachwith the authored node's path, each marked
DROPPED(the page draws it, the document doesnot carry it) or
APPROXIMATED(it is there as the nearest thing Word owns). Errors do nottravel this way — an export that cannot proceed throws.
Verification
./mvnw -B -ntp clean verifyover the seven-module gate → BUILD SUCCESS, 1784 tests../mvnw -B -ntp test -f examples/pom.xml→ 93 tests, BUILD SUCCESS — the examples moduleis outside the gate and resolves the engine from the local repository, so a change to the
export is invisible there otherwise; the committed Word preview is re-rendered.
render-docx goes from 127 to 175 tests. New suites:
DocxLineHeightTest(the measuredheight on a body paragraph, a row's cells and a list item; none invented without a layout),
DocxVerticalSpacingTest(a paragraph's own box, a container's two edges, nesting summing,and a container of tables dropping the edge it cannot place),
DocxFontEmbeddingTest(thekey derivation against the one worked example the format publishes, that only the first 32
bytes are scrambled and come back, each
fsTypevalue),DocxFontTableTest,DocxFontNamingTest,DocxRowLayoutTest,DocxTableWidthTest,DocxExportReportTest,DocxSessionExportTest. In core,SemanticBackendProvidersTestcovers case, missing,duplicate and a provider declaring no format, and
SemanticExportLayoutResolutionTestgainsa document that cannot be laid out and still exports.
Measured through Word 16.0 and LibreOffice Writer on a two-page probe, with the editing
protocol driven through Word COM: 7 of 7 editing scenarios, the export reporting
Latointhe render rather than a substitution, and its Lato paragraph breaking at the same word as
the reference, ending within 2.3pt over a 489pt line.
Notes
The grid metric does not improve monotonically across the commits, and the landmark
positions are the better instrument: each correct fix exposed the next missing piece and
made the cell number worse before the following one landed. Fixing the row's split alone
raised the worst cell from 53.9% to 67.7%, because the row had been one line too short and
was partly cancelling Word's own line height.
Still open, and measured rather than assumed: about 13pt of the remaining 20pt drift appear
between a shaded card and the next heading, where both edges are written and Word counts the
spacing around a shaded paragraph its own way; a container's horizontal padding has no
paragraph-level equivalent and stays dropped; inline backgrounds — the code chip, the badge —
are not written yet.
Asking for the layout costs a measurement and pagination pass over the document, the same
work a PDF render does, and reads each image a second time.
DocxExportReportTestandDocxImageResolutionTeststate both rather than leaving them to be discovered.Follows #701, which has landed. This branch was replanted onto the squashed result
rather than merged into it, so its ten commits are its own and the diff carries nothing
from the parent.
Lane: canonical —
document.api(session convenience methods, semantic backenddiscovery) and
document.backend.semanticplus its DOCX implementation; no engine, layoutor pagination internals are touched.