Skip to content

Apply w:pgBorders page borders and the w:docGrid line grid - #184

Open
ikashapov wants to merge 1 commit into
nerdy-pro:mainfrom
ikashapov:issue-151-pgborders-docgrid
Open

ikashapov wants to merge 1 commit into
nerdy-pro:mainfrom
ikashapov:issue-151-pgborders-docgrid

Conversation

@ikashapov

Copy link
Copy Markdown
Contributor

Apply w:pgBorders page borders and the w:docGrid line grid

Closes #151. Based on main (b2c0174, v0.5.1); one commit, branch
issue-151-pgborders-docgrid. Independent of the other open branches.

Two section-level features that change how a page looks, filed together
because both are w:sectPr children; implemented as the issue suggested —
independently — and landing together.

w:pgBorders — §17.6.10 (was: not parsed at all)

Parse + model. New PageBorders on SectionProperties (Dup, like its
siblings), attributes kept optional so the document is mirrored as written;
the renderer applies Word's defaults from [MS-OE376] §2.6.10
(offsetFrom="text", zOrder="front") and §17.6.10's own
(display="allPages"). One parsing subtlety: page borders are the only
place @w:val spans all of §17.18.2 ST_Border — the 27 line styles
plus ~165 art names no other border may carry. The val lands as a string
and classifies afterwards, so val="apples" cannot take down the whole
<w:sectPr>; an art edge is carried losslessly (sz reinterpreted as the
whole points the spec gives art borders, not the line styles' eighths).

Render. A new layout::page_borders pass runs in phase 2 beside
header/footer rendering — the only point where a finished page's command
list and its section's PageConfig coexist, and @w:zOrder needs exactly
that pair: painting order is z-order, so front appends after
everything (headers, body, footers) and back prepends. Geometry per the
ECMA prose and the Word↔Writer interop notes (editeng:: BorderDistanceFromWord): space measures to the border edge nearest
the reference
— from the page edge inward, or from the text margin
outward; horizontal edges own the corner squares, vertical edges run
between the bands, the same junction convention the table painter uses.
Edges resolve through the tables' existing convert_model_border, so
double draws its §17.4.38 two-thirds band via the same
(pub(crate)-lifted) emit_border_rect, the other 24 line styles
approximate solid warn-once, and a double page border can never drift
from a double table border.

Declined, and recorded where: art borders draw nothing (the reading
LibreOffice takes) with a warn-once; @shadow/@frame accepted-and-
dropped; w:bordersDoNotSurroundHeader/Footer not read — a text border
always references the configured margins (module doc says why the
configured margin, not the per-page pushed-down content, is the
reference).

w:docGrid — §17.6.5 (was: parsed, then ignored)

The gate that matters most: the pitch applies only when @w:type is
lines/linesAndChars/snapToChars. Every Word document on earth
carries <w:docGrid w:linePitch="360"/> with no type, and §17.18.14
spells out that absent/default means "no document grid" (kept so the
settings survive re-enabling). A test pins this directly, and the 28
existing corpus documents render byte-identically before and after.

The algorithm (LibreOffice's Word-conformed CalcRealHeight, plus
§17.6.5's own overrides): a gridded line consumes ceil(h/pitch) whole
slots — tall text jumps straight to two, Word's signature behavior — with
its natural box vertically centered in them, which is the visible
half-gap above the first line of a Japanese page. lineRule="exact"
escapes (the one override §17.6.5 names); atLeast takes
max(slot, min) except the MSO atLeast-0 quirk (grid off for the
line); auto multiplies the slotted height, clamped below at 1.0 — under
a grid, "1.5 lines" is 1.5 pitches. Registration is by quantized heights
alone (no absolute re-snap), which keeps the advance
position-independent — that is what lets pagination sum precomputed line
heights and still agree with emission to the point.

Where it lives: one function, resolve_line_metrics, feeding the four
sites the module doc already required to agree (fitting, emission, the
border box, the empty-paragraph path — an empty gridded paragraph holds a
slot open, like Word's empty 行). The pitch reaches lines only by being
stamped onto ParagraphStyle in the body page flow, so the spec's scope
comes free: table cells (no w:adjustLineHeightInTable support),
headers/footers, footnotes and shape text never pass the stamp point.
w:pPr/w:snapToGrid (§17.3.1.32) is newly parsed, cascades through the
style hierarchy like its sibling toggles, and opts a paragraph out.
Character pitch (linesAndChars/snapToChars's horizontal half) is not
applied; the module doc records the boundary.

Tests and fixtures

  • test-files/page-borders.docx — the same four deliberately distinct
    edges (each its own colour, width, space) through both offset modes,
    one section each; every edge's band is pinned by colour, and the two
    pages differ only in reference frame. test-files/doc-grid.docx —
    identical content twice: type="lines" linePitch="360" vs the
    type-less grid, making section 2 the ungridded control the assertions
    measure against so no font metric is pinned. Both by
    scripts/make_issue151_fixtures.py, verified with verify_docx.py.
  • tests/page_borders.rs (8): both fixture pages edge-by-edge, display
    selection, zOrder front/back as command order, art/nil/none
    declines, the text default.
  • tests/doc_grid.rs (9): pitch deltas, slot centering as a relation
    between the fixture's two sections, both escapes (exact,
    snapToGrid=0), the type-less no-op, the two-slot jump for 20pt text,
    the empty-paragraph slot, the table-cell exclusion.
  • Unit tests: the grid math table (rounding, boundary epsilon, both
    escapes, atLeast/auto interactions, leading-inclusive slot base),
    PageConfig::from_section gating, the pgBorders schema (art vals,
    defaults-as-None, all four edges), the snapToGrid toggle and cascade.
  • Mutation-checked: six deliberate breaks (grid off, snap ignored,
    type-less applied, display ignored, centering dropped, keepNext
    measure unstamped) each fail their own tests.
  • Corpus: all 28 pre-existing fixtures render byte-identical PDFs
    against main; the two new fixtures verified visually (CoreGraphics
    raster) — page borders in both frames, and the gridded page showing
    the even 18pt pitch against its compact control.

Review

An adversarial multi-agent review of the diff (3 lenses, every finding
independently re-verified against the code) confirmed and led to fixing,
before this PR was opened:

  • keepNext × grid (major, found by all three reviewers): the keepNext
    group pre-measure laid paragraphs out with the unstamped style, so a
    gridded chain was measured at natural heights, held at a page bottom it
    could not fit, and torn. The measure now stamps the same pitch the
    placement path does; a regression test builds the exact page geometry
    where the two disagree.
  • Endnotes page: it continues the last section's flow (§17.11.2), so it
    now wears that section's borders — at the page index after the
    section's own pages, so display="firstPage" does not re-fire on it
    (both asserted).
  • Fixture schema order: CT_SectPr is a strict xsd:sequence; the
    fixtures originally wrote pgBorders/docGrid before pgSz — exactly
    the class of package this parser tolerates and Word refuses (the
    issue-165 lesson). Regenerated in schema order, and the ZIPs are now
    byte-reproducible (pinned timestamps, like the sibling scripts).
  • Smaller: the three ST_PageBorder* § citations were off by three
    (§17.18.62–64, cross-checked against the repo's own ST_PageOrientation
    = §17.18.65); two pre-existing wrong § numbers on
    header_refs/footer_refs that collided with the new correct ones were
    dropped per the AGENTS rule; the README coverage tallies were updated;
    a shared-continuous-page border ownership note (with its open
    Word-reference-render question) landed in the module doc.

Gates

cargo fmt --check, clippy --all-targets -- -D warnings,
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps,
cargo build --no-default-features and the full 46-target suite pass.
No new dependencies; public API unchanged.

@ikashapov
ikashapov force-pushed the issue-151-pgborders-docgrid branch 5 times, most recently from da75014 to 6a48f63 Compare September 16, 2026 09:38
@ikashapov
ikashapov force-pushed the issue-151-pgborders-docgrid branch from 6a48f63 to ca5ada5 Compare September 18, 2026 02:49
Two section-level features that change how a page looks (issue nerdy-pro#151),
independent halves of one w:sectPr.

w:pgBorders (§17.6.10) was not parsed at all. Now: schema + model
(attributes optional, mirrored as written; [MS-OE376] defaults
text/front/allPages applied at render), and a painter pass beside
header/footer rendering — the one point where a finished page and its
section's PageConfig coexist, which @w:zOrder needs since painting
order is z-order (front = after everything, back = before). Geometry
per the spec prose and the Word↔Writer interop notes: space measures
to the border edge nearest the reference — page edge inward, or text
margin outward; horizontal edges own the corners, verticals run
between the bands (the table painter's junction convention). Edges
resolve through the tables' convert_model_border, so double draws its
§17.4.38 two-thirds band and the other line styles approximate solid,
warn-once. The @w:val here spans all of ST_Border including the ~165
art names no other border may carry — those parse losslessly (points,
not eighths, for sz) and decline to draw, the LibreOffice reading,
warn-once. The endnotes page continues the last section's flow
(§17.11.2), so it wears that section's borders at the page index after
the section's own — display="firstPage" does not re-fire there. A
page shared across a continuous break takes the owning section's
borders, the same ownership rule the header selection applies (module
doc records the open Word-reference-render question). shadow/frame are
accepted-and-dropped; bordersDoNotSurroundHeader/Footer are not read.

w:docGrid (§17.6.5) was parsed and ignored. The pitch now applies —
but only when @w:type is lines/linesAndChars/snapToChars: every Word
document carries <w:docGrid w:linePitch="360"/> with no type, and
§17.18.14 spells out that default/absent means no grid (the settings
survive for re-enabling). A gridded line consumes ceil(h/pitch) whole
slots — Word's signature spacing jump for tall text — with its
single-spaced box (max of natural and text height, leading included)
centered in them, per LibreOffice's Word-conformed CalcRealHeight:
lineRule="exact" escapes (the override §17.6.5 itself names),
atLeast takes max(slot, min) except the MSO atLeast-0 quirk, auto
multiplies the slotted height. Registration is by quantized heights
alone, position-independent, which is what lets pagination sum
precomputed line heights and still agree with emission — the grid
rides resolve_line_metrics at the four sites that must agree, and the
keepNext group pre-measure stamps the same pitch, or a gridded chain
would be measured short and torn at page bottoms. Scope is the spec's:
body flow only, stamped onto ParagraphStyle at the body page layout
and nowhere else, so table cells (no adjustLineHeightInTable support),
headers/footers and footnotes stay off by construction; pPr
w:snapToGrid (§17.3.1.32, newly parsed, cascading) opts a paragraph
out. Character pitch is not applied.

Fixtures (scripts/make_issue151_fixtures.py, byte-reproducible,
strict CT_SectPr child order — the issue-165 lesson): page-borders.docx
runs four deliberately distinct edges through both offset modes;
doc-grid.docx carries identical content under type="lines" and under
the type-less grid — the ungridded control the assertions measure
against, so no font metric is pinned. tests/page_borders.rs pins each
edge's band by its own colour in both frames plus display/zOrder/art
and the endnotes page; tests/doc_grid.rs pins pitch deltas, slot
centering as a relation between the two sections, both escapes, the
two-slot jump, the empty-paragraph slot, the table-cell exclusion, and
the keepNext measure. Six deliberate implementation breaks (grid off,
snap ignored, typeless applied, display ignored, centering dropped,
keepNext measure unstamped) each fail their own tests. The 28 existing
corpus documents render byte-identically before and after (modulo the
DATE-field fixture rendering the render date).

Closes nerdy-pro#151
@ikashapov
ikashapov force-pushed the issue-151-pgborders-docgrid branch from ca5ada5 to 27204b3 Compare September 22, 2026 09:39
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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Apply w:pgBorders page borders and the w:docGrid document grid

1 participant