Repository navigation
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
Conversation
§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
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.
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_linealready said a bookmark draws nothing; nothing acted on it. The paragraph mark's syntheticLineBreakis injected at the front of the fragment vector, so a paragraph of pure annotations reached the fitter as[LineBreak, Bookmark], andfit_linesemitted the stranded bookmark as a second line thatline_height_forthen charged a full line box.Word writes the automatic
_GoBackbookmark 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_linesnow widens the previous line's range instead of opening a new one; the bookmark still reachesline_emitand still becomes a PDF named destination.#229 — a nested table was measured against the sheet
clamp_auto_grid_to_pagetook 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.docxdrew its nested rows 12 pt past the outer border and over the neighbouring cell's text.build_tablenow takes a containment ceiling —Noneat 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:abstractNumis the list and carries the sequence;w:numis an instance of it, and declaring a second instance is how a document appliesw: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 carryingstartOverride=8, used twice, gives1. 2. 8. 9. 10. 11.— the restart fires once, at first use. So the counter moved to the abstract definition andw:startOverridebecame the one-shot §17.9.28 describes, carried beside the level's ownw:startrather than written over it. A second commit records whatnumbering-direct-indent.docxsettles: 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:tblGridand never readw:tcW. Word resolves the §17.4.63/§17.4.71 conflict toward the preferences: with a 2000/4000/4080 grid andtcW1000/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:sepwas never drawnThe 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 undernextPageor at the end of the document.PageLayoutStategrowscolumn_bottombesidebottom— content asks the column budget, footnotes and the §17.6.22 handoff keep the page's own bottom — andlayout_documentestimates the balanced height, then bisects up toward the greedy bottom if a line does not fit.w:sepwas 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-featuresand--features capiall clean._GoBackparagraph no longer costs a page.Closes #228, closes #229, closes #230, closes #231, closes #232