Skip to content

Fix five layout defects found against Word: blank pages, nested tables, list counters, table widths, column balancing - #252

Open
ikashapov wants to merge 7 commits into
nerdy-pro:mainfrom
ikashapov:fix-layout-defects-228-232
Open

ikashapov wants to merge 7 commits into
nerdy-pro:mainfrom
ikashapov:fix-layout-defects-228-232

Conversation

@ikashapov

Copy link
Copy Markdown
Contributor

Fix five layout defects found against Word: blank pages, nested tables, list counters, table widths, column balancing

Five issues, one PR, because they were found together — a user converted a batch of real documents on Windows, compared every page against Word 2024 LTSC, and sent the diffs. Each fix is its own commit and can be read on its own.

Where the spec left a question open, it was settled by measuring Word 16.0 (Office 2024 LTSC) rather than by choosing a reading: eleven probe documents, each isolating one decision, rendered in Word and read back through its object model. The numbers are in the commit messages and in the test files.

#228 — a bookmark-only paragraph was charged two lines

Fragment::occupies_line already said a bookmark draws nothing; nothing acted on it. The paragraph mark's synthetic LineBreak is injected at the front of the fragment vector, so a paragraph of pure annotations reached the fitter as [LineBreak, Bookmark], and fit_lines emitted the stranded bookmark as a second line that line_height_for then charged a full line box.

Word writes the automatic _GoBack bookmark into almost every document it saves, usually into the trailing empty paragraph, so a last page full to within one line gained a blank page. fit_lines now widens the previous line's range instead of opening a new one; the bookmark still reaches line_emit and still becomes a PDF named destination.

#229 — a nested table was measured against the sheet

clamp_auto_grid_to_page took the caller's width only as a floor under a paper-edge limit. For a top-level table that slack is deliberate — 40 corpus tables declare a grid a few points past the text column and Word draws them into the margin. For a nested table the caller's width is the host cell's content box, computed correctly and then discarded: 019_nested_tables.docx drew its nested rows 12 pt past the outer border and over the neighbouring cell's text.

build_table now takes a containment ceiling — None at the two top-level call sites, the cell's inner width at the nested one. §17.4.57 floating tables keep the page limit until a Word render says whether Word confines one.

#230 — list counters were keyed by the instance, not the definition

§17.9.2's w:abstractNum is the list and carries the sequence; w:num is an instance of it, and declaring a second instance is how a document applies w:startOverride. Counting per instance split one list in two: a specification document numbered its last two headings 6 and 7 where Word numbers 9 and 10.

Measured: two instances over one abstract, alternating, give 1. 2. 3. 4. 5. in Word — one sequence. An instance carrying startOverride=8, used twice, gives 1. 2. 8. 9. 10. 11. — the restart fires once, at first use. So the counter moved to the abstract definition and w:startOverride became the one-shot §17.9.28 describes, carried beside the level's own w:start rather than written over it. A second commit records what numbering-direct-indent.docx settles: instantiating a level as an ancestor also spends the restart.

#231 — table widths

Two independent defects in the same resolution code.

The cell-margin extension that models Word's autofit-to-window fired for every table at or past full width. Word does not extend one that asks for more than the window: for a 104% table it saves a grid summing to 1.04 × the text extents with no margin term. The gate is now an equality; a real document's table moves from 549.7 pt back onto the 539.9 pt margin.

An autofit table also kept its declared w:tblGrid and never read w:tcW. Word resolves the §17.4.63/§17.4.71 conflict toward the preferences: with a 2000/4000/4080 grid and tcW 1000/3000/6080 — where the grid, the preferences and an equal division each predict something different — Word draws 50 / 150 / 304 pt. Preferences under the text column are not stretched; over it they are scaled down. tblLayout fixed, a missing preference and a spanned cell all keep the grid.

Word's content-minimum rule (a column widened past its preference to fit an unbreakable token) is measured in the test file and deliberately not implemented here.

#232 — columns were never balanced, and w:sep was never drawn

The flow had one height budget per page and no second pass, so the last page of a column run was filled greedily and the following section pushed onto a new page. Word balances at a continuous break and only there: six short paragraphs come out 3 / 3 under <w:type w:val="continuous"/>, and all six in column 1 under nextPage or at the end of the document.

PageLayoutState grows column_bottom beside bottom — content asks the column budget, footnotes and the §17.6.22 handoff keep the page's own bottom — and layout_document estimates the balanced height, then bisects up toward the greedy bottom if a line does not fit.

w:sep was parsed and read by nothing. Word draws a hairline down the gutter centre from the body top to the bottom of the deepest column, and draws none at all when the second column took no content.

Verification

  • cargo test --all — 2745 passed, 0 failed; cargo fmt --check, clippy --all-targets --all-features -D warnings, cargo doc -D warnings, --no-default-features and --features capi all clean.
  • Every fix is spec-first: the tests were written against Word's measured behaviour and watched fail before the change.
  • The reported documents were re-rendered and compared page by page: the numbering document now reads 9 and 10, the nested table stays inside its cell, the 104% table ends on the margin, the column run balances onto one page, and the trailing _GoBack paragraph no longer costs a page.

Closes #228, closes #229, closes #230, closes #231, closes #232

§17.3.1.29 gives a paragraph exactly one line when nothing in it draws, and
§17.13.6.1's bookmarks draw nothing: they are range annotations with no glyphs,
no advance and no line box. `Fragment::occupies_line` already says so.

Nothing acted on it. `MarkLine::of` injects the paragraph mark's synthetic
`LineBreak` at the front of the fragment vector — deliberately, so
`split_at_page_breaks` cuts `[LineBreak, PageBreak]` correctly — so a paragraph
of pure annotations arrives at the fitter as `[LineBreak, Bookmark]`. The break
closes the mark's line and strands the bookmark, which `fit_lines` then emitted
as a second line; it measures zero, and `line_height_for` substitutes the
default height for a zero-height line, so the phantom line was charged a full
line box. The paragraph advanced 22.998 pt where `<w:p/>` advances 11.499.

That is not an exotic shape. Word writes the automatic `_GoBack` bookmark into
almost every document it saves, usually into the trailing empty paragraph, so a
last page full to within one line gained a whole blank page — which is what a
user's image-plus-trailing-paragraph document did: two pages where Word gives
one, the second carrying only the header and footer.

Widen the previous line's range instead of opening a new one. The bookmark
stays inside a line and still reaches `line_emit`, which turns it into a PDF
named destination, so internal cross-references keep working; `line_emit`
already skips trailing bookmarks when it measures visible width and when it
decides justification, so nothing else moves. A paragraph with no previous line
keeps its single line and never collapses to zero height.

Closes nerdy-pro#228
§17.4.63's auto-width branch drew its limit at the paper edge and took the
width the caller offered only as a floor under it. For a top-level table that
slack is deliberate: 40 corpus tables declare a grid a few points past the text
column, Word draws them into the right margin, and `tests/table_auto_width.rs`
pins that they keep it.

For a nested table the caller's width is not an offer, it is the host cell's
content box — computed correctly here, §17.4.42's cell margins subtracted
correctly, passed down correctly, and then discarded. `019_nested_tables.docx`
showed the result: a 5040-twip grid kept verbatim inside a 4800-twip cell, its
row rules drawn 12 pt past the outer border and over the neighbouring cell's
text. Word scales it to the cell. A sharper probe than the fixture: a nested
grid declaring 600 pt inside a 240 pt cell came out at 558.5 pt and ran off the
612 pt sheet, which is the one thing the page guard exists to prevent.

Thread a containment ceiling into `build_table` — `None` at the two top-level
call sites, `Some(inner_width)` at the nested one, which already had the value
— and let the auto branch clamp to it. The overflow arm needed no change:
`compute_column_widths` scales the declared columns proportionally, which is
exactly the 4800/5040 factor Word's render shows. A nested grid that already
fits is left verbatim; the clamp only ever narrows.

§17.4.57 floating tables are carved out. A nested `w:tblpPr` table goes through
the same path, and whether Word confines a floating table to its host cell is
not measured, so it keeps today's behaviour rather than being moved silently.
A test pins that, and flipping it is one line once the Word render exists.

Closes nerdy-pro#229
§17.4.63 measures a `pct` table width against "the text extents of the page",
and nothing in it adds the cell margins. The margin extension this engine
applies models something else and real: Word's autofit-to-window widens a table
that *fills* the window by its cell margins and pulls it left by one, so the
cell text — not the cell box — lands on the body text's edge.
`tests/table_geometry_sizing.rs` pins that at `w:w="5000"` and it stays pinned.

The gate was written `>=`, so the extension also fired above full width, where
Word does not apply it: for a 104% table Word saves a `<w:tblGrid>` summing to
1.04 × the text extents, with no margin term — an extension would have made
Word write 1.04 × (extents + margins). The 2.3% of extra width was enough to
give a header cell a 75.59 pt content box where Word gives 73.55, so a 74.70 pt
string stopped wrapping where Word wraps it, and it pushed the table's right
edge past the right margin. On the user's document the right edge moves from
549.7 pt back to 539.9 pt, onto the margin.

Make the gate an equality. Below and above full width the base is now plainly
`available_width`; only the equality case carries the heuristic.

This is one half of nerdy-pro#231. The other half — an autofit table resolving its
columns from `w:tcW` rather than keeping the declared grid — waits on a Word
render of an autofit table whose `tcW` are unequal, because the corpus fixture
cannot tell "Word granted each preference" from "Word divided the text column
equally".

Refs nerdy-pro#231
§17.9.2: `w:abstractNum` is the list and carries the sequence; `w:num` is an
instance of it, and its reason to exist is §17.9.7's level overrides — a
document applies `w:startOverride` by declaring a second instance rather than
by duplicating the definition. Keying the counters on `w:numId` therefore split
one list in two whenever an author did that.

A user's specification document showed it: its Heading-1 style carries
`numId 1`, one later heading carries `numId 2` (the same abstract, with
`startOverride=8`), and the two headings after that go back to the style. Word
draws 1…5, 8, 9, 10. This engine drew 1…5, 8, 6, 7 — the style's counter had
been frozen at 5 while the second instance ran its own sequence.

Measured against Word 16.0 (Office 2024 LTSC) on two probe documents:
  * two instances over one abstract, no overrides, alternating paragraphs →
    1. 2. 3. 4. 5. — one sequence, not two;
  * an instance with `startOverride=8` used twice → 1. 2. 8. 9. 10. 11. — the
    restart fires once, at first use, and afterwards that instance simply
    continues the shared sequence.

So the counter moves to the abstract definition, and `w:startOverride` becomes
what §17.9.28 describes: a one-shot restart, carried beside the level's own
`w:start` rather than written over it. `BuildState` gains the set of
instance/level pairs whose restart has already fired, and `DocumentPosition`
rolls it back with the counters — a speculative pass that measured a header
must not consume an override the real pass still needs.

`ResolvedNumbering` now carries the abstract id alongside the levels, because
nothing downstream could otherwise know that two instances are one list.

Closes nerdy-pro#230
§17.4.63 and §17.4.71 carry the same paragraph verbatim — every width in a
table is "preferred", the table "shall satisfy the shared columns as specified
by the tblGrid", and "the table layout algorithm can require a preference to be
overridden" — so the spec states the conflict and leaves it there. This engine
made the grid the invariant; Word resolves it toward the preferences whenever
§17.4.52's layout is autofit, which is the default when `<w:tblLayout>` is
absent.

Measured on Word 16.0 (Office 2024 LTSC) with a 2000/4000/4080 twip grid and a
504 pt text column:

  * `tcW` 1000/3000/6080 (summing to the text column) → 50 / 150 / 304 pt. The
    grid, the preferences and an equal division each predict something
    different here, and Word draws the preferences.
  * `tcW` 1000/2000/3000 (under it) → 50 / 100 / 150 pt: no stretch to fill.
  * `tcW` 4000/4000/4000 (over it) → 168.2 / 168.15 / 168.15: scaled down to it.

So an autofit table whose every column states a `dxa` preference is resolved
from those, clamped to its container and never stretched. `017_wrapping_edge_
cases.docx` is the corpus case: its twelve cells all prefer 3360 twips against
a declared 1440/1728/5760 grid, and the narrow column was drawing a token in
four lines where Word draws two.

Three shapes deliberately keep the declared grid: `<w:tblLayout w:type="fixed"/>`
(§17.4.52's instruction not to autofit, which is what the 39 tables of
`KAB_2026-03-25` and the rest of `tests/table_auto_width.rs`'s corpus evidence
rely on), a table where any column states no preference, and one containing a
spanned cell — a `tcW` covering several columns says nothing about how to
divide it.

Word also raises a column to its content minimum and takes the difference from
the others; the same probe measured it (`tcW` 500/3000/6580 with a long word
came back 39.65 / 149.25 / 315.6). That needs a min-content measurement per
cell and is not in this change; the test file records the numbers.

Refs nerdy-pro#231
§17.9.28's restart is one-shot, and the shot is fired the first time the
instance *touches* the level — not the first time an item sits directly on it.

`test-files/numbering-direct-indent.docx` is the case that settles it: its first
item is `ilvl=2` on an instance that overrides level 0, and Word's render
continues 2. 3. 4. at the top level afterwards rather than restarting at 1.
Seeding an ancestor counter is therefore a use of that level, and consumes the
restart along with it. Without this the deep item seeds level 0 at 1 and the
first top-level item then fires the override and *sets* it to 1 again, which is
what `numbering_deep_level_instantiates_ancestor_counters` caught.

The two readings agree everywhere the override differs from the abstract's own
`w:start`, and differ only in this shape, which is why the fixture had to
answer it.

Refs nerdy-pro#230
Two §17.6.4 gaps, both measured on Word 16.0 (Office 2024 LTSC).

**Balancing.** The flow had one height budget per page and no second pass, so
the last page of a column run was filled greedily: column 1 packed to the page
bottom, column 2 ending wherever the content ran out, and the following section
pushed onto a new page. `013_columns.docx` showed it — column 1 reached 736.8 pt
against column 2's 394.6, and "Back to one column" went to page 2 where Word
keeps everything on page 1.

Word balances at a continuous break and only there. Three probe documents, six
short paragraphs each: closed by `<w:type w:val="continuous"/>` Word splits them
3 / 3 and puts the tail section directly beneath; closed by `nextPage`, or
simply ending the document, it leaves all six in column 1. So the greedy pass is
right everywhere else, and this adds a second pass over the one page that needs
a target height.

`PageLayoutState` grows `column_bottom` beside `bottom`: every reader deciding
whether *content* fits now asks the column budget, while footnote reservation
and the §17.6.22 handoff keep the page's own bottom — a balanced page still
puts its notes at the real bottom and still hands the next section the right
resume cursor. `layout_document` estimates the balanced height as the total
content height over the column count (Word's own starting estimate) and, if a
line does not fit at that height, bisects up toward the greedy bottom the first
pass already proved fits, taking the shortest height that does not spill.

An empty section-terminal paragraph — the structural marker carrying the
`w:sectPr`, which draws nothing — is measured against the page bottom rather
than the balanced one, because Word keeps it on the balanced page: its own
render puts the mark at the foot of column 2 with the columns still 3 / 3.

**The `w:sep` rule.** `w:sep` was parsed onto `model::Columns::separator` and
read by nothing. Word draws a hairline down the gutter centre — x = 305.76 on a
gutter centred at 306 — running from the body top to the bottom of the deepest
column, the used height rather than the page's. In both probes where the
content never left column 1 it drew no rule at all, so an unoccupied column gets
no divider. `PageConfig` carries the flag and each page paints its gutters as it
is committed.

Closes nerdy-pro#232
ikashapov added a commit to ikashapov/dxpdf that referenced this pull request Oct 14, 2026
One squashed commit carrying the tree of integration/2026-10-14, which was
previously a chain of ten merges ending at ac5787e (kept locally as
archive/integration-2026-10-14-merges). Base: upstream main 3a5c7ca.

Integrated, each at the head of its open upstream pull request:

- nerdy-pro#252  fix-layout-defects-228-232 (a40d5ce) — issues nerdy-pro#228–nerdy-pro#232: bookmark-only
        paragraphs, nested-table width, list instances over one abstract
        numbering, table width resolution (pct and tcW autofit), column
        balancing and separators
- nerdy-pro#183  issue-150-wmf-svg (6e5aa99) — WMF/EMF and SVG images
- nerdy-pro#184  issue-151-pgborders-docgrid (27204b3) — page borders, document grid
- nerdy-pro#186  issue-153-shaped-clusters (65739f6) — shaped text clusters
- nerdy-pro#187  issue-154-tracked-changes (6982b34) — tracked changes and comments
- nerdy-pro#185  issue-155-smartart-charts (3683223) — SmartArt and charts
- nerdy-pro#256  issue-253-del-instr-text (0656a52) — w:delInstrText tolerated
- nerdy-pro#257  issue-250-image-hyperlinks (cb5ce2b) — a:hlinkClick on pictures
- nerdy-pro#258  issue-188-font-absent-cost (9a63bf0) — no deep catalogue tier before
        the substitution table, no theme script-font preload
- nerdy-pro#259  issue-158-auto-hyphenation (9bfbae2) — w:autoHyphenation

Beyond the union of those branches, the integration itself changed:
- README keeps one row per feature after the merges; the matrix tally is
  recomputed from the rows.
- nerdy-pro#253's tests assert that a deleted field code is never drawn and never run
  text, because nerdy-pro#154 keeps w:del runs instead of dropping them at parse.
- nerdy-pro#250's push_link_over sits after nerdy-pro#151's resolve_line_metrics; nerdy-pro#158's
  HyphenationCtx.measure and rejoin_hyphenated take &dyn SplitMeasure (nerdy-pro#153).

Verified on this exact tree: cargo fmt, clippy and doc clean; cargo test
2977 passed / 0 failed (skipping the capi library freshness guards);
--no-default-features and --features capi build; the twelve-document
harness passes 12/12.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment