Skip to content

feat(footnote): rendering fidelity (SD-2656)#3220

Draft
tupizz wants to merge 4 commits into
mainfrom
tadeu/sd-2656-feature-footnote-rendering-fidelity
Draft

feat(footnote): rendering fidelity (SD-2656)#3220
tupizz wants to merge 4 commits into
mainfrom
tadeu/sd-2656-feature-footnote-rendering-fidelity

Conversation

@tupizz
Copy link
Copy Markdown
Contributor

@tupizz tupizz commented May 11, 2026

Summary

  • Pagination cluster (SD-3049 / 3050 / 3051): body paginator now consults footnote demand at fragment-commit time. NVCA Model SPA: 57 → 53 pages (Word baseline 51, within +5 %).
  • Configuration cluster (SD-2986/B1 + B2, SD-2658, SD-2662): the inline footnote ref and the leading marker inside the footnote body honor w:settings/w:footnotePr/w:numFmt and w:numStart. Custom marks (customMarkFollows="1") emit an empty marker run so the literal symbol renders. Single source of truth between inline ref and leading marker.

Full slice-by-slice walkthrough, measurements, architecture compliance notes, pr-reviewer findings, and repro commands in docs/superdoc-feature-reports/sd-2656-implementation-report.md.

Tickets covered

Ticket Title Status
SD-3049 Body break consults footnote demand for refs anchored on this page
SD-3050 Continuation-aware break (carry-forward demand from prior page) ✅ (safety cap + existing reserve loop convergence)
SD-3051 Stabilise when refs migrate between pages during convergence ✅ (determinism regression test)
SD-2986/B1 Honor w:numFmt
SD-2986/B2 Honor w:numStart
SD-2658 Render custom footnote reference marks (customMarkFollows)
SD-2662 Improve footnote marker styling parity ✅ (closed by shared formatter)
SD-2986/B3beneathText placement Deferred (see report)
SD-2985 — Separator content fidelity Deferred
SD-2660 — Continuation notice Deferred

Headline results

Fixture BEFORE (clean main) AFTER (this PR) Word baseline Δ
harvey-problem-docs/NVCA Model SPA.docx (108 footnote refs) 57 pages 53 pages 51 pages −4
5 other footnote fixtures (basic / multi-col / large-bump / longer-header / pagination_break) 1–3 pages identical n/a 0

Layout-snapshot regression (pnpm test:layout vs superdoc@1.32.0)

  • 535 / 543 docs (98.5 %) unchanged
  • 8 changed docs:
    • 5 unique-change docs — all NVCA-style footnote-rich legal templates (exactly the target population)
    • 3 widespread-only docs — pre-existing schema-evolution patterns (lineCount, textIndentPx)

Pixel diff (pnpm test:visual)

Final tool verdict: "Pixel comparison complete. No visual differences found." Report at devtools/visual-testing/results/2026-05-09-17-27-55-v.1.32.0/webkit/report.html.

Test plan

  • pnpm --filter @superdoc/layout-bridge test --run — 1 211 tests (incl. 3 new footnote tests)
  • pnpm --filter @superdoc/layout-engine test — 649 tests
  • pnpm --filter @superdoc/pm-adapter test --run — 1 796 tests (incl. customMarkFollows + position preservation)
  • pnpm --filter @superdoc/super-editor test --run — 12 699 tests
  • pnpm --filter @superdoc/layout-tests test --run — 294 tests (architecture-boundaries + new formatter parity)
  • pnpm test:layout --reference 1.32.0 — 5 unique-change docs, all in target scope
  • pnpm test:visual — no visual differences
  • Browser repro: Harvey NVCA loads at 53 pages on this branch, 57 pages on clean main

Total unit tests: 16 649, all green.

Architecture compliance

  • Guard C in architecture-boundaries.test.ts enforced: pm-adapter does not import @superdoc/layout-engine at runtime.
  • The shared cardinal formatter in pm-adapter/src/footnote-formatting.ts inlines the format switch instead of importing formatPageNumber. A drift-detection parity test in layout-engine/tests/src/footnote-formatter-parity.test.ts asserts the two implementations agree on cardinals 1–100 for every supported format.

Commits

  1. 1bed2d066feat(layout): footnote-aware body pagination (SD-3049/3050/3051) — paginator state, layout-paragraph break decision, layout-bridge demand map plumbing, 3 new test files
  2. b3b89b6a7feat(footnote): honor w:numFmt / w:numStart + customMarkFollows (SD-2986 SD-2658) — pm-adapter formatter + customMark suppression, super-editor settings readers, leading marker formatting
  3. cb1ca5af1docs(footnote): sd-2656 plan + implementation reportdocs/superdoc-feature-reports/

Files changed

13 modified, 6 added. +635 / −43 LOC (production + tests, excluding the docs commit which adds the plan and report).

packages/layout-engine/layout-bridge/src/incrementalLayout.ts
packages/layout-engine/layout-bridge/test/footnoteBodyDemand.test.ts                    NEW
packages/layout-engine/layout-bridge/test/footnoteContinuationDemand.test.ts            NEW
packages/layout-engine/layout-bridge/test/footnoteRefMigration.test.ts                  NEW

packages/layout-engine/layout-engine/src/index.ts
packages/layout-engine/layout-engine/src/layout-paragraph.ts
packages/layout-engine/layout-engine/src/layout-paragraph.test.ts
packages/layout-engine/layout-engine/src/paginator.ts

packages/layout-engine/pm-adapter/src/converter-context.ts
packages/layout-engine/pm-adapter/src/converters/inline-converters/footnote-reference.ts
packages/layout-engine/pm-adapter/src/converters/inline-converters/footnote-reference.test.ts
packages/layout-engine/pm-adapter/src/footnote-formatting.ts                            NEW

packages/layout-engine/tests/src/footnote-formatter-parity.test.ts                      NEW

packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts
packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts
packages/super-editor/src/editors/v1/document-api-adapters/document-settings.ts
packages/super-editor/src/editors/v1/document-api-adapters/document-settings.test.ts

docs/superdoc-feature-reports/sd-2656-plan.md                                           NEW
docs/superdoc-feature-reports/sd-2656-implementation-report.md                          NEW

pr-reviewer findings (resolved before push)

# Finding Severity Resolution
1 pm-adapter/footnote-formatting.ts imported @superdoc/layout-engine (Guard C violation) 🔴 blocking Inlined format switch; added parity test
2 @superdoc/layout-engine was only devDependency of pm-adapter 🔴 blocking Resolved by #1
3 Dead spans.sort() in demand builder yagni Removed
4 Redundant measureFootnoteBlocks call yagni Removed
5 Convergence loop refreshed bodyHeightById from subset only — could oscillate 🟠 correctness Hoisted allFootnoteIds; all measure calls now use full set
6 Refs inside table-cell paragraphs were missed by demand walk docx-fidelity Walk recurses into table.rows[].cells[].blocks/.paragraph
7 No test that customMarkFollows empty run preserves pmStart/pmEnd testing Test added
8 Endnote OOXML default is lowerRoman, falls back to decimal here docx-fidelity Logged in report (§ 8)

Known limitations (documented in the report)

  • Cross-page block demand: charged to the page where the block's first fragment lands. Acceptable approximation for end-of-paragraph refs (the typical case).
  • Multi-column footnote demand: page-scoped, consistent with the existing page-scoped footnoteReservedByPageIndex. Existing footnoteColumnPlacement.test.ts ensures correctness.
  • Endnote default lowerRoman: falls back to decimal if absent. One-line fix in PresentationEditor.ts when corpus shows demand.
  • w:numRestart per-page / per-section: out of scope; couples numbering to layout output. SD-2986 successor.

Repro

# Page-count check
pnpm dev   # then open the NVCA fixture, evaluate scrollHeight/1126 in DevTools
# expect ≈ 53 on this branch, 57 on clean main

# Layout regression (needs R2 creds — env vars from .claude/skills/pull-test-fixture/.env)
set -a; source .claude/skills/pull-test-fixture/.env; set +a
export SUPERDOC_CORPUS_R2_ACCESS_KEY_ID="$SD_TESTING_R2_ACCESS_KEY_ID"
export SUPERDOC_CORPUS_R2_SECRET_ACCESS_KEY="$SD_TESTING_R2_SECRET_ACCESS_KEY"
pnpm test:layout -- --reference 1.32.0 --no-interactive
pnpm test:visual

tupizz added 3 commits May 11, 2026 09:48
Make the body paginator demand-aware so footnote-heavy documents pack
body content tight to the separator instead of letting the post-hoc
reserve loop leave visible blank space above the footnote band.

Measured on Harvey NVCA Model SPA (108 footnote refs):
- BEFORE: 57 pages
- AFTER:  53 pages
- Word baseline: 51 pages (within +5%)

Mechanism
---------
PageState gains two fields:
  - pageFootnoteReserve      : existing per-page reserve, now exposed
                               to the break decision
  - footnoteDemandThisPage   : accumulator of measured footnote body
                               heights for refs anchored on this page

Paragraph layout consults a new optional callback:
  - getFootnoteDemandForBlockId(blockId): number

The break decision uses an effective bottom:
  additionalDemand = max(0, footnoteDemandThisPage - pageFootnoteReserve)
  effectiveBottom  = state.contentBottom - additionalDemand

Once the convergence loop has set a correct reserve, additionalDemand is
0 and the new code is a no-op. On pass 1 (no reserve), it provides the
tight-packing signal that prevents the body from filling the page only
to be clawed back by a later reserve relayout.

A safety cap clamps additionalDemand so the page always has room for at
least one body line - otherwise an oversized footnote would drive
effectiveBottom below cursorY and the paginator would advanceColumn
indefinitely.

The per-block demand lookup is built once per layoutDocument call. It
walks the block tree, including table cells (rows[].cells[].blocks /
.paragraph), and resolves each ref's pos to the containing top-level
block. Table-cell refs are attributed to the table block, the unit the
body paginator places on a page.

layout-bridge populates bodyHeightById from measures via
refreshBodyHeights and pre-measures every footnote on every convergence
iteration so migrating refs do not drop from the lookup mid-loop.

Tests
-----
- footnoteBodyDemand.test.ts     RED-then-GREEN for block-aware break
                                 + no-op invariant for non-footnote docs
- footnoteContinuationDemand     converged layout reserves carry-forward
                                 demand on the continuation page
- footnoteRefMigration           determinism regression: repeated runs
                                 produce identical page counts, reserves,
                                 and ref to page assignments

Refs: SD-2656 SD-3049 SD-3050 SD-3051

Plan:   docs/plans/sd-2656-footnote-rendering-fidelity.md
Report: docs/plans/sd-2656-implementation-report.md
…986 SD-2658)

Inline footnote references and the leading marker inside the footnote
body now honor the OOXML number format / start configured in
w:settings/w:footnotePr. Custom-mark refs (customMarkFollows="1") emit
an empty marker run so the literal symbol in the next OOXML run
renders as the visible mark.

Supported formats: decimal, upperRoman, lowerRoman, upperLetter,
lowerLetter, numberInDash. Unknown formats fall back to decimal.

Single source of truth between the inline ref and the leading marker:
  pm-adapter/src/footnote-formatting.ts  ->  formatFootnoteCardinal()

Used by:
  pm-adapter/.../converters/inline-converters/footnote-reference.ts
  super-editor/.../layout/FootnotesBuilder.ts

The formatter switch is intentionally inlined (not imported from
@superdoc/layout-engine's formatPageNumber) because pm-adapter sits
upstream of layout-engine in the package graph - see Guard C in
layout-engine/tests/src/architecture-boundaries.test.ts. A drift
detection parity test asserts the two helpers agree on every supported
format for cardinals 1..100:
  layout-engine/tests/src/footnote-formatter-parity.test.ts

Settings readers in super-editor/document-api-adapters/document-settings:
  readFootnoteNumberFormat(settingsRoot): string | null
  readEndnoteNumberFormat(settingsRoot):  string | null
  readFootnoteNumberStart(settingsRoot):  number | null
  readEndnoteNumberStart(settingsRoot):   number | null

PresentationEditor reads all four up-front and threads the values
through ConverterContext.footnoteNumberFormat / .endnoteNumberFormat
and the per-doc cardinal counter is seeded with the configured start.

customMarkFollows handling preserves pmStart/pmEnd on the empty marker
run so click and selection continue to work at the ref position.

Refs: SD-2656 SD-2986 SD-2986/B1 SD-2986/B2 SD-2658 SD-2662
End-to-end documentation for the footnote rendering fidelity epic:

  docs/superdoc-feature-reports/sd-2656-plan.md
    Original implementation plan: ticket inventory across the epic,
    OOXML grounding (§17.11), code surface map with line numbers,
    surgical approach for each slice, RED test scaffolds, falsifiable
    success criteria.

  docs/superdoc-feature-reports/sd-2656-implementation-report.md
    What shipped, with measurements:
      - Harvey NVCA: 57 -> 53 pages (Word baseline 51, +5%)
      - pnpm test:layout vs superdoc@1.32.0:
          535/543 docs (98.5%) byte-identical
          5 unique-change docs, all NVCA-style footnote-rich legal
          templates (the intended scope)
      - pnpm test:visual: "no visual differences found"
      - 16,649 unit tests across 5 packages, all green
    Slice-by-slice walkthrough (SD-3049 / 3050 / 3051 / 2986/B1+B2 /
    2658 / 2662), architecture compliance (Guard C parity test),
    pr-reviewer findings + resolutions, deferred work, repro commands.

Refs: SD-2656
@linear
Copy link
Copy Markdown

linear Bot commented May 11, 2026

SD-2656

@codecov-commenter
Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants