Skip to content

fix(render): keep a paginated footnote's first-paragraph indentation (#1130) - #1152

Merged
JSv4 merged 6 commits into
mainfrom
fix/issue-1130
Oct 16, 2026
Merged

JSv4 merged 6 commits into
mainfrom
fix/issue-1130

Conversation

@JSv4

@JSv4 JSv4 commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Fixes #1130

Why

In the paginated view, a footnote whose first paragraph has a hanging indent (w:left="360" w:hanging="360") wrapped every line to the left edge of the note area. The continuation lines should start 18pt in, as they do in Word and LibreOffice. On main every line started at 0pt.

The cause was the HTML structure. The converter put the note number beside the note content, then forced the first paragraph to display: inline so the number and text would share a line. An inline paragraph ignores its own margins and text-indent, so its indentation was lost.

How it works

  • Number placement. In Word, the number is a run (w:footnoteRef) at the start of the note's first paragraph. The converter now does the same: the number becomes the first child of the first <p>. Paginated endnotes already worked this way and now share one helper, AddNoteNumberToFirstParagraph. Every note paragraph is an ordinary block again, so its own indentation and spacing apply. The number declares line-height: 0 itself, which keeps the Export: footnote text sits 1.2 pt above Word's since #850 #955 fix; it used to inherit that from the note item.
  • Notes split across pages. When the paginator splits a note, it copied the number into the split piece. The number now travels inside the first paragraph, so the paginator copies only a number that sits outside the content. Without this the number appeared twice.
  • Editor. The paginated editor treats .footnote-number / .endnote-number as generated chrome, like list markers and citation markers. They are not editable and never committed as note text. When a note edit re-renders its paragraph, the editor carries the number over to the new paragraph. A single-paragraph render does not include the number.
  • Spacing after at the bottom of the note area. Because the first paragraph is now a block, it keeps its spacing after, which is 6pt in the common FootnoteText style. Between notes this is correct: LibreOffice separates notes the same way. Below the last note it is not. LibreOffice ends the last note line on the bottom margin, while we reserved 6pt there. One generated rule removes the spacing after for the note area's last paragraph only. The paginator measures and paints the same element tree, so its height reservation follows.

Measurements (NVCA model charter, TestFiles/NVCA-Model-COI.docx)

The coordinator asked whether the extra pages are faithful. LibreOffice 25.8.7.3 values come from pdftotext -bbox-layout on its PDF. Docxodus values come from Range client rects in the paginated view, in points from the page top. The bottom margin is at 720pt.

LibreOffice Docxodus, first commit only Docxodus, this PR
Gap between consecutive notes 6pt (page 4: note 2 ends, note 3 starts 17.5pt later at an 11.55pt pitch) 6pt 6pt (page 4: 660 → 665.5; page 8: 594 → 599.5, 672 → 677.5)
Bottom of the last note line (pages 4, 7, 8, 10) 720.0 713.3 (6pt lifted) 719.3

Page counts:

Main This PR LibreOffice 25.8.7.3
Paginated editor 51 53 48
Paginated view not measured 52 48
  • Where the +2 pages come from. Removing note-paragraph spacing entirely brings the count back down, so the 6pt gaps between notes are the whole difference. LibreOffice shows the same gaps, so that height is faithful. Main already took 51 pages against LibreOffice's 48.
  • The pre-existing gap is tracked in Paginated NVCA charter takes 52 pages where LibreOffice takes 48 #1151. Note line pitch is 12pt here against 11.55pt in LibreOffice (+3.9%). Body line pitch is 12.75pt against 12.65pt (+0.8%). Neither explains the whole gap. The issue includes page 4 screenshots of both renderers.
  • Corner cases. The findings are recorded in docs/ooxml_corner_cases.md.

Validation

Tests that failed without the fix:

  • 6 .NET converter tests on main's converter.
  • GeneratedCssEscapingTests.FootnoteRulesSurviveSerialization. It replaces the test that asserted the old, dead first-of-type rule.
  • 3 Playwright geometry specs on main's build:
    • a hanging first paragraph (line starts 0,0,0,…);
    • a multi-paragraph note;
    • a hanging note split across pages.
  • The split spec also failed with only the paginator change reverted (two numbers instead of one).
  • The editor spec failed with only the chrome selector reverted, and again before the repaint carry-over existed.
  • The trailing-spacing spec failed before its rule (6.75pt of slack under the last note).

Regression guards that also pass on main:

Results with the fix:

  • Full .NET suite: 6758 passed. The one failure is the flaky DocxSessionIncrementalIndexOracleTests (DocxSessionIncrementalIndexOracleTests snapshot-sharing assertions fail intermittently in CI #1141); it passes on rerun.
  • 199 Playwright specs passed, covering pagination, page maps, the editor's note and paginated specs, session notes and standalone export.
  • Release build has 0 warnings. The edited test files add no test-project warnings (baseline 436).
  • npm-export and the visual-parity benchmark were not run locally.

Adversarial review

One round, before opening:

  • Editor note edit dropped the number. Re-rendering a single block has no whole-note context. This is fixed by carrying the number across the swap, and covered by a test that edits, undoes and redoes.
  • Trailing spacing at the band bottom. The first commit reserved 6pt below every page's notes. This was found by measuring against LibreOffice, and is fixed with a test. The rule also applies to multi-paragraph last notes, which had the same problem before this PR.
  • Nit, left as is. p:last-of-type also zeroes the spacing after of a paragraph followed by a trailing table, or of each cell's last paragraph, in the band's last note.
  • Checked, no change needed:
    • raised numbers (w:position) still split by range;
    • the web (non-paginated) note list is untouched.

Second review round (after opening)

A second adversarial pass over the opened PR, after rebasing onto main (the only conflict was the two sides' CHANGELOG entries; WmlToHtmlConverter.cs merged cleanly with the twip-precision and right-to-left border fixes).

Fixed:

  • The number could be deleted from the editor. Moving the number inside the editable paragraph made it a non-editable island that Chromium deletes as a unit when a caret deletion reaches it: Backspace with the caret right after it, Delete with the caret right before it, or typing over a selection that spans it. Nothing in the session re-creates the number (it is the renderer's, not run text), and the repaint copied it from the live paragraph, so once gone it stayed gone until a remount. Now a beforeinput guard refuses caret deletions whose target range touches the number, Backspace at the start of a numbered note paragraph is a no-op (the note is its own story, so there is nothing to merge into), and each wired block keeps a pristine copy of its number that the repaint falls back to when a replaced selection took it out. Two new Playwright tests drive the real keyboard through both gestures; both failed on the previous bundle (the number was gone after the commit).
  • A heading-styled first note paragraph lost its number to the next paragraph. The helper looked for the first <p>, but a paragraph whose style carries an outline level is written as <h1>–<h6>, so the number landed in the second paragraph. The helper now accepts every element the converter writes a paragraph as. A new .NET test builds such a note and failed before the fix.
  • Stale comments and a dead branch. line-metrics.ts still looked for a number rendered beside the content, a shape the converter no longer emits; the lookup is gone and the one hand-written fixture that relied on it (the Later footnote paragraphs gain an inset and spacing because paginated output inherits web note CSS #1120 exact-baseline guard) now uses the in-paragraph shape, so it exercises the real path. The continuation wrapper and the .footnote-item CSS comments described the removed first-paragraph-inline rule.

Considered, left as is:

  • The paragraph fragmenter's forced-progress path can emit a fragment that holds only the number, but only when a single note line is taller than the whole fresh note band (60% of the body), where the previous shape clipped equally badly.
  • The band-bottom margin-bottom: 0 rule is CSS keyed on :last-child, so it also zeroes the paragraph before a trailing table in the last note; moving it into the paginator is a larger change than this fix. Opened as a follow-up if wanted.
  • A first note paragraph's spacing before is now a real block margin under the separator; only w:after was measured against LibreOffice. FootnoteText rarely carries spacing before.
  • The visual-parity ratchet does not run on pull requests; its two note fixtures are single-note documents the spacing changes cannot reach, and the number's move within the line does not change its ink.

Validation after the round:

  • Full .NET suite on the rebased branch: 6870 passed, 2 skipped, 1 failed. The failure is TrackedRangeDeletion_ScalesWithTheRange, a scaling-time test in code this PR does not touch; it ran under a machine load of 40 and passes alone.
  • Release build: 0 warnings. The WASM build also compiles the converter change.
  • Playwright, against the rebuilt runtime and bundles: 19 spec files covering pagination, notes, page maps, standalone export and the paginated editor, all passing, including the charter spec still at 53 pages on the rebased code. One run under the same machine load produced timeouts; every affected file was rerun clean.
  • The two new editor tests failed on the previous bundle (no number after the commit), and the new .NET test failed before its fix (the number sat in the second paragraph).

JSv4 added 6 commits October 16, 2026 00:30
…aph (#1130)

The note number was a sibling of the note content and the first paragraph
was forced display:inline to share its line, so that paragraph ignored its
own margins and text-indent: a hanging-indented note wrapped its later lines
to the band edge. The number is now the first content of the first paragraph,
as Word's reference-mark run is, and no rule overrides a note paragraph's
display. Partial notes no longer copy a nested number, and the editor treats
note numbers as chrome and keeps them across a block repaint.
…1130)

With the first note paragraph an ordinary block, its spacing after now
separates notes, as in LibreOffice, but the band's last paragraph also kept
it at the bottom margin, lifting every page's notes 6pt where LibreOffice
sets the last note line on the margin. Also covers undo/redo of a note
edit in the paginated editor.
A paragraph whose style carries an outline level is written as a heading
element, not a <p>, so the helper that opens a paginated note's first
paragraph with its number skipped it and put the number into the next
paragraph instead. Accept every element the converter writes a paragraph
as. The .footnote-item comment also still said the number inherits the
item's zero line height; it declares its own now.
The number is now a non-editable island inside the editable note
paragraph, and the browser deletes such an island as a unit when a caret
deletion reaches it: Backspace just after it, Delete just before it, or
typing over a selection that spans it. Nothing in the session re-creates
the number (it is the renderer's, not run text), and the repaint copied
it from the live paragraph, so once gone it stayed gone until a remount.

Refuse caret deletions whose target range touches the number, make
Backspace at the start of a numbered note paragraph a no-op (the note is
its own story, so there is nothing to merge into), and keep a pristine
copy of each wired block's number for the repaint to fall back to when a
replaced selection took it out. Two Playwright tests drive the real
keyboard through both gestures; both failed before this change.
…paragraph (#1130)

The converter no longer renders a note number beside the note content,
so the line-metrics lookup for that shape was dead and the one
hand-written fixture that relied on it (the #1120 exact-baseline guard)
exercised a path real output cannot reach. The fixture now uses the
in-paragraph shape, the lookup is gone, and the continuation wrapper's
comments no longer describe the removed first-paragraph-inline rule.
@JSv4
JSv4 force-pushed the fix/issue-1130 branch 2 times, most recently from 017c86f to a24e194 Compare October 16, 2026 06:29
@JSv4
JSv4 merged commit 01f47de into main Oct 16, 2026
17 checks passed
@JSv4
JSv4 deleted the fix/issue-1130 branch October 16, 2026 12:36
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.

The first paginated note paragraph ignores its own indentation because it is forced inline

1 participant