Skip to content

[1/3] refactor(painter): consume only ResolvedLayout (SD-2836)#3116

Open
tupizz wants to merge 10 commits intomainfrom
tadeu/sd-2836-1-consume-resolved-layout
Open

[1/3] refactor(painter): consume only ResolvedLayout (SD-2836)#3116
tupizz wants to merge 10 commits intomainfrom
tadeu/sd-2836-1-consume-resolved-layout

Conversation

@tupizz
Copy link
Copy Markdown
Contributor

@tupizz tupizz commented May 4, 2026

Stack

# PR Branch Status
1 this PR tadeu/sd-2836-1-consume-resolved-layout open
2 #3117 tadeu/sd-2836-2-collapse-legacy-api open
3 #3118 tadeu/sd-2836-3-lock-boundary open

Review in order. PR2 and PR3 depend on this one; merge this first.

What changed

First step of the SD-2836 "dumb painter" refactor. Migrates DomPainter's internal pipeline so every render path consumes ResolvedLayout while keeping the legacy public API working as a compatibility shim.

Painter (packages/layout-engine/painters/dom)

  • paint() body and the six top-level rendering methods now operate on ResolvedLayout / ResolvedPage / ResolvedPaintItem.
  • Page-level helpers (decoration sections, virtualization, snapshot, virtualization pins) migrated to ResolvedPage.
  • currentLayout is typed ResolvedLayout | null.
  • Decoration provider callback signature migrated Page -> ResolvedPage (coupled migration; pulled forward into PR1 because painter internals call the provider).

Contracts (packages/layout-engine/contracts)

  • New run-helpers.ts hosting expandRunsForInlineNewlines and sliceRunsForLine (shared between painter and resolved-layout consumers).
  • resolved-layout.ts: fragment: Fragment back-pointer added to ResolvedFragmentItem, ResolvedTableItem, ResolvedImageItem, ResolvedDrawingItem. ResolvedPage gains optional columns and columnRegions.

Super-editor consumer

  • HeaderFooterSessionManager.rebuildRegions(resolvedLayout), updateDecorationProviders(layout, resolvedLayout), #stripFootnoteReserveFromBottomMargin(margins, page: ResolvedPage | null), #computeExpectedSectionType(kind, page: ResolvedPage, ...) migrated to ResolvedPage.
  • PresentationEditor.#updateDecorationProviders(layout, resolvedLayout) plumbed through.

Docs

  • packages/layout-engine/AGENTS.md "DomPainter is Dumb" section updated.

Why this split

This PR is large but focused: every change here is "consume ResolvedLayout instead of Layout/Fragment/PaintInput" inside the painter and its direct callers. The legacy public surface stays callable, so consumers don't break mid-stack. PR2 then collapses that surface; PR3 locks the boundary.

Verification

  • All package tests pass: painter-dom, pm-adapter, layout-bridge, layout-engine, super-editor.
  • HEAD-vs-origin/main corpus snapshot diff: 0 changed / 407 unchanged (rendering-neutral).

Acceptance criteria

  • DomPainter renders only from ResolvedLayout internally
  • Fragment back-pointer available on resolved items for legacy paths
  • Decoration providers receive ResolvedPage
  • No regression in corpus snapshot diff

Test plan

  • CI green
  • Reviewer confirms no painter method still reads from Layout directly
  • PR2 collapses the legacy surface on top
  • PR3 locks the boundary

@tupizz tupizz requested a review from a team as a code owner May 4, 2026 16:49
@linear
Copy link
Copy Markdown

linear Bot commented May 4, 2026

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 33e0471db3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +2451 to 2453
page: ResolvedPage,
pageIndex: number,
kind: 'header' | 'footer',
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve legacy page shape in decoration provider callback

Passing ResolvedPage into header/footer providers changes the runtime object shape that third-party providers receive (Page used to expose fields like size and fragments, while ResolvedPage does not). Any existing provider that reads those legacy fields will now get undefined (or throw) and can misplace or fail to render decorations, even when using the unchanged createDomPainter API surface.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This concern is theoretical and doesn't apply to the current codebase:

  • @superdoc/painter-dom is "private": true — not published to npm.
  • No consumers of createDomPainter or PageDecorationProvider exist outside the layout-engine workspace and super-editor (verified via grep across packages/superdoc/src/, packages/react/src/, and examples/).
  • The only callers are internal: PresentationEditorHeaderFooterSessionManager.createDecorationProviderPresentationPainterAdapter → painter. SuperDoc's public API exposes no hook for consumers to register their own provider, so no third-party Page-shaped provider exists in practice.

The type is re-exported via superdoc's generated .d.ts — a consumer doing a deep import from superdoc/dist/layout-engine/... would see the signature change — but that's not a supported import path.

The whole point of SD-2836 ("dumb painter") is to make ResolvedLayout the single rendering contract, so adapting back to legacy Page at the provider call site would contradict the north-star. Resolving as a non-issue.

tupizz added 8 commits May 4, 2026 14:09
Move the inline-newline run expander from @superdoc/pm-adapter into
@superdoc/contracts. The function is a pure transformation on Run[]
shapes already defined here; relocating it lets painter-dom consume the
helper without depending on pm-adapter (per the SD-2836 acceptance
criterion: no painter-dom imports from pm-adapter or layout-bridge).

pm-adapter chains its public re-export through contracts, keeping the
import path stable until painter-dom is migrated to consume directly.
Move the line-aware run slicer from @superdoc/layout-bridge into
@superdoc/contracts, alongside expandRunsForInlineNewlines. The function
is a pure transformation on Run/Line shapes already defined here;
relocating it lets painter-dom consume the helper without depending on
layout-bridge (per the SD-2836 acceptance criterion).

layout-bridge chains its public re-export through contracts, keeping
the import path stable until painter-dom is migrated to consume directly.

Also restore a TrackedChangeMeta import in pm-adapter's paragraph.test.ts
that the prior commit dropped; the type is still referenced outside the
migrated describe block.
Switch the painter's two cross-package run-helper imports
(expandRunsForInlineNewlines, sliceRunsForLine) to source from
@superdoc/contracts directly, then drop @superdoc/pm-adapter and
@superdoc/layout-bridge from painter-dom's runtime dependencies.

This closes the painter-dom -> pm-adapter / layout-bridge import edge
called out in the SD-2836 acceptance criteria. Architecture-boundary
guards added in [3/3] of this stack will prevent reintroduction.
Add a `fragment: Fragment` field to ResolvedFragmentItem,
ResolvedTableItem, ResolvedImageItem, and ResolvedDrawingItem, and
populate it from the corresponding resolve* helper. The reference is
shared, not copied: items now carry the same Fragment object that lives
on the source page.

This is the precondition for stopping painter iteration over
`page.fragments`. The next commit drops getResolvedFragmentItem and
switches the painter to iterate ResolvedPage.items, reading the source
fragment via `item.fragment` instead of indexing back into the legacy
fragments array.

Includes focused tests in resolveLayout.test.ts asserting back-pointer
identity for each kind.
…ms (SD-2836)

Replace the three `page.fragments.forEach((fragment, index) => ...)`
loops in renderPage / patchPage / initPage with iterations over
`resolvedItems` (the resolved page's items), reading the source
Fragment via the back-pointer added in the previous commit.

Removes:
- getResolvedFragmentItem (parallel-array index lookup into resolved items)
- direct iteration of `page.fragments` from the painter render path

Updates affected hand-rolled resolved-layout tests to populate the new
required `fragment` back-pointer; the painter now treats items without
a fragment as not-yet-renderable rather than indexing back into the
legacy fragments array.
Switch paint()'s top-level reads from input.sourceLayout to
input.resolvedLayout for: layoutEpoch, page count, and the mounted-page
indices.

The local `layout = input.sourceLayout` binding stays for one more
commit because the four legacy-Layout helpers (beginPaintSnapshot,
fullRender, patchLayout, renderHorizontal, renderBookMode,
renderVirtualized) still take a Layout argument. The next commit
migrates those signatures and removes the binding entirely.
…836)

computeSdtBoundaries and computeBetweenBorderFlags previously took both
the raw `fragments` array and the parallel resolvedItems array. They
now take only resolvedItems and read each fragment via the back-pointer
added in [PR1#4]. Updates all four call sites in renderer.ts plus the
between-borders test fixture.

This eliminates the painter's lookup into `page.fragments` from the
helper-call layer, leaving only the deeper-method Layout dependency
(beginPaintSnapshot, fullRender, patchLayout, renderHorizontal,
renderBookMode, renderVirtualized) to migrate next.
Switch DomPainter's internal state and helpers from raw Layout/Page to
ResolvedLayout/ResolvedPage:

* The six top-level legacy-Layout methods (beginPaintSnapshot,
  fullRender, patchLayout, renderHorizontal, renderBookMode,
  renderVirtualized) take ResolvedLayout. paint() drops the
  `const layout = input.sourceLayout` binding and calls every method
  with input.resolvedLayout.
* The page-level helpers (renderPage, createPageState, patchPage,
  renderDecorationsForPage, renderDecorationSection,
  getDecorationAnchorPageOriginY, renderPageRuler,
  renderColumnSeparators) take ResolvedPage and read width/height/
  margins/numberText/etc. directly. Drops every redundant
  `resolvedPage?: ResolvedPage | null` parameter.
* `currentLayout: Layout | null` -> `ResolvedLayout | null`. Strips
  the `(page.size ?? layout.pageSize)` cascades inside virtualization
  + page-iteration code; uses ResolvedPage.width/.height directly.
* Lifts `columns` and `columnRegions` onto ResolvedPage so the
  column-separator renderer can read them without falling back to
  raw Page.

Couples PageDecorationProvider (planned PR2 work) since the painter
now passes ResolvedPage to the third callback argument:

* `PageDecorationProvider`'s `page?` parameter is `ResolvedPage`.
* `HeaderFooterSessionManager.rebuildRegions` takes ResolvedLayout.
  `updateDecorationProviders(layout, resolvedLayout)` plumbed through
  PresentationEditor.
* The provider closure body and internal helpers
  (`#stripFootnoteReserveFromBottomMargin`, `#computeExpectedSectionType`)
  now operate on ResolvedPage; `page?.size?.h` -> `page?.height`.
* `buildLegacyPaintInput` always calls `resolveLayout` so the legacy
  paint(Layout) path produces ResolvedPages even when no body
  blocks/measures are supplied (preserves page-level metadata).

Skips a "decoration item synthesis" describe block that exercises
`paint(Layout)` + `setData` + `setResolvedLayout`. Those legacy entry
points get deleted in the next commit; the block is being preserved as
.skip so the deletion is visible in diff.
@tupizz tupizz force-pushed the tadeu/sd-2836-1-consume-resolved-layout branch from 33e0471 to c5d0b09 Compare May 4, 2026 17:10
Drops the now-unused @superdoc/layout-bridge and @superdoc/pm-adapter
entries from pnpm-lock.yaml so CI's --frozen-lockfile install matches
package.json.
@codecov-commenter
Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…ock (SD-2836)

rebuildRegions now iterates resolvedLayout.pages (was layout.pages). The PresentationEditor test mocked resolveLayout to return empty pages, which caused the header/footer region tests to populate zero regions and bookmark navigation to fail. The mock now synthesizes a minimal ResolvedPage per source Layout page so region tests stay green.
@tupizz tupizz self-assigned this May 4, 2026
@luccas-harbour luccas-harbour self-requested a review May 4, 2026 19:28
Copy link
Copy Markdown
Contributor

@luccas-harbour luccas-harbour left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey @tupizz! I left a few inline comments

also here are some test coverage gaps found by AI:

  1. packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts** (file-level — coverage gap)

rebuildRegions is now the load-bearing entry from ResolvedLayout, but the existing tests in this file set headerRegions manually and bypass it. would be worth a focused test calling rebuildRegions with a synthetic ResolvedLayout that exercises footnoteReserved > 0, per-page height variation, and sectionIndex > 0 so the resolved-side migration has direct coverage.

  1. packages/layout-engine/layout-resolved/src/resolveLayout.test.ts** (file-level — coverage gap)

one gap: the page-level columns/columnRegions fields forwarded at resolveLayout.ts:309-310 aren't asserted anywhere and they feed renderColumnSeparators (user-visible). a one-liner asserting they make it onto ResolvedPage would close that.

const resolved = this.getResolvedPage(pageIndex);
const pageSize = resolved ? { w: resolved.width, h: resolved.height } : (page.size ?? layout.pageSize);
const pageSize = { w: page.width, h: page.height };
const pageState = this.createPageState(page, pageSize, pageIndex);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems that the width and height can be accessed directly from the page parameter so perhaps the pageSize parameter is redundant and could be removed? the same would apply to calls to patchPage()
what do you think?

* Creates new providers based on layout results and sets them on this manager.
*/
updateDecorationProviders(layout: Layout): void {
updateDecorationProviders(layout: Layout, resolvedLayout: ResolvedLayout): void {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateDecorationProviders(layout, resolvedLayout) seems to be the only place where both Layout and ResolvedLayout are used. createDecorationProvider only reads layout.pages[].sectionIndex/number + page.sectionRefs — all available on ResolvedPage.

perhaps this could be changed and the layout parameter dropped?

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