Operand

do no harem.

gram: docs

> ./packages/core/src/editor/computeLayout.ts

/**
* The pure layout COMPUTE pass shared by the React and Vue adapters — issue
* #696 Tier 2, the clean half of the engine spine.
*
* This is the 6-step pass from React's `useLayoutPipeline` minus the DOM paint
* + scroll/event side-effects (which stay adapter-side, where the framework
* timing lives): PM doc → flow blocks → measure → header/footer resolve →
* margin extension → `layoutDocument` (+ two-pass footnote stabilization) →
* footnote render items. It is pure (no DOM, no refs, no rAF) and returns
* everything the adapter needs to paint.
*
* The one injected seam is `measureBlocks` — each adapter passes its own
* measurer (React's is caching), same pattern as `measureBlocksWithFloats`.
* `getHfPmDoc` is the HF-unification seam (prefer the persistent PM doc over
* re-parsing `HeaderFooter.content`).
*/
import type { EditorState } from 'prosemirror-state';
import type { Node as PMNode } from 'prosemirror-model';
import {
layoutDocument,
type ColumnLayout,
type FlowBlock,
type FootnoteContent,
type Layout,
type Measure,
type PageMargins,
} from '../layout-engine';
import {
toFlowBlocks,
computePerBlockWidths,
demoteBlockLikeFloatingTables,
collectFootnoteRefs,
convertHeaderFooterToContent,
convertHeaderFooterPmDocToContent,
buildFootnoteContentMap,
buildFootnoteRenderItems,
stabilizeFootnoteLayout,
extendMarginsForHeaderFooter,
twipsToPixels,
type FloatPageGeometry,
} from '../layout-bridge';
import {
pageGeometryFromPage,
type FootnoteRenderItem,
type HeaderFooterContent,
} from '../layout-painter';
import type {
Document,
HeaderFooter,
SectionProperties,
StyleDefinitions,
Theme,
Watermark,
} from '../types/document';
interface PageSizePx {
w: number;
h: number;
}
/** Adapter-supplied block measurer (React's is caching). */
export type MeasureBlocksFn = (
blocks: FlowBlock[],
contentWidth: number | number[],
pageGeometry?: FloatPageGeometry
) => Measure[];
export interface ComputeLayoutInputs {
state: EditorState;
document: Document | null;
pageSize: PageSizePx;
margins: PageMargins;
columns: ColumnLayout | undefined;
finalPageSize: PageSizePx;
finalMargins: PageMargins;
finalColumns: ColumnLayout | undefined;
pageGap: number;
contentWidth: number;
theme: Theme | null | undefined;
styles: StyleDefinitions | null | undefined;
sectionProperties: SectionProperties | null | undefined;
finalSectionProperties: SectionProperties | null | undefined;
/** Resolved HF objects for the section (default + first-page). */
headerContent: HeaderFooter | null | undefined;
footerContent: HeaderFooter | null | undefined;
firstPageHeaderContent: HeaderFooter | null | undefined;
firstPageFooterContent: HeaderFooter | null | undefined;
measureBlocks: MeasureBlocksFn;
/** HF unification: the persistent PM doc for an HF, or null to re-parse content. */
getHfPmDoc: (hf: HeaderFooter) => PMNode | null | undefined;
}
export interface LayoutComputation {
blocks: FlowBlock[];
measures: Measure[];
layout: Layout;
headerContentForRender: HeaderFooterContent | undefined;
footerContentForRender: HeaderFooterContent | undefined;
firstPageHeaderForRender: HeaderFooterContent | undefined;
firstPageFooterForRender: HeaderFooterContent | undefined;
hasTitlePg: boolean;
watermark: Watermark | undefined;
headerDistancePx: number | undefined;
footerDistancePx: number | undefined;
pageBorders: SectionProperties['pageBorders'] | undefined;
footnotesByPage: Map<number, FootnoteRenderItem[]> | undefined;
}
/**
* Run the pure layout compute pass (the 6 steps in this file's header), lifted
* verbatim from `useLayoutPipeline`. The adapter performs the DOM paint
* (`renderPages`), scroll-restore, `painter:painted`, and state writeback with
* the returned values.
*/
export function computeLayout(inputs: ComputeLayoutInputs): LayoutComputation {
const {
state,
document,
pageSize,
margins,
columns,
finalPageSize,
finalMargins,
finalColumns,
pageGap,
contentWidth,
theme,
styles,
sectionProperties,
finalSectionProperties,
headerContent,
footerContent,
firstPageHeaderContent,
firstPageFooterContent,
measureBlocks,
getHfPmDoc,
} = inputs;
// Step 1: PM doc → flow blocks.
const pageContentHeight = pageSize.h - margins.top - margins.bottom;
const blocks = toFlowBlocks(state.doc, { theme, pageContentHeight });
// Step 2: Measure all blocks (per-section widths; full measure for float context).
const blockWidths = computePerBlockWidths(
blocks,
{ pageSize, margins, columns },
{ pageSize: finalPageSize, margins: finalMargins, columns: finalColumns }
);
// Step 1.5: Demote full-width "floating" tables to inline. A positioned table
// that leaves no room for text to wrap beside it (a common full-width contract
// form table) is block-like in Word/Google Docs — it paginates across pages.
// Our floating path instead paints it as one overflowing fragment AND makes
// the next paragraph skip past the whole table height (a wrap zone), stranding
// it off-page. Clearing `floating` here — before measure and layout — routes
// it through `layoutTable` (which breaks rows across pages) and suppresses the
// wrap zone. Purely a layout transform on the ephemeral FlowBlocks; the PM doc
// and the saved DOCX keep the original floating table.
demoteBlockLikeFloatingTables(blocks, blockWidths, contentWidth);
const measures = measureBlocks(
blocks,
blockWidths,
pageGeometryFromPage({ size: pageSize, margins })
);
// Step 2.5: Footnote references.
const footnoteRefs = collectFootnoteRefs(blocks);
const hasFootnotes = footnoteRefs.length > 0 && !!document?.package?.footnotes;
// Step 2.75: Header/footer content for rendering (needed before layout to
// compute effective margins when HF content exceeds available space).
const hfMetricsHeader = { section: 'header' as const, pageSize, margins };
const hfMetricsFooter = { section: 'footer' as const, pageSize, margins };
const defaultTabStopTwips = state.doc.attrs?.defaultTabStopTwips as number | null;
const hfOptions = { styles, theme, measureBlocks, defaultTabStopTwips };
// HF unification phase 1: prefer the persistent PM doc when mounted.
const convertHf = (
hf: HeaderFooter | null | undefined,
metrics: typeof hfMetricsHeader | typeof hfMetricsFooter
): HeaderFooterContent | undefined => {
if (!hf) return undefined;
const pmDoc = getHfPmDoc(hf);
if (pmDoc) {
return convertHeaderFooterPmDocToContent(pmDoc, contentWidth, metrics, hfOptions);
}
return convertHeaderFooterToContent(hf, contentWidth, metrics, hfOptions);
};
const headerContentForRender = convertHf(headerContent, hfMetricsHeader);
const footerContentForRender = convertHf(footerContent, hfMetricsFooter);
const hasTitlePg = sectionProperties?.titlePg === true;
const firstPageHeaderForRender = hasTitlePg
? convertHf(firstPageHeaderContent, hfMetricsHeader)
: undefined;
const firstPageFooterForRender = hasTitlePg
? convertHf(firstPageFooterContent, hfMetricsFooter)
: undefined;
// Watermark rides PM state as a doc attr (so it's undoable).
const watermark = (state.doc.attrs?.watermark as Watermark | null) ?? undefined;
// Margin extension — push body clear of the header/footer bands (Word grows
// the band when in-flow content exceeds the authored margin). Shared core
// helper: uses in-flow `flowHeight` so page/margin-anchored floats (e.g. a
// letterhead) don't push the body (issue #705), with a content-area clamp;
// mutates each `sectionBreak.margins` in place.
const { margins: effectiveMargins, finalMargins: effectiveFinalMargins } =
extendMarginsForHeaderFooter({
pageSize,
margins,
finalMargins,
bodyBlocks: blocks,
headers: [headerContentForRender, firstPageHeaderForRender],
footers: [footerContentForRender, firstPageFooterForRender],
warn: (msg) => console.warn(`[computeLayout] ${msg}`),
});
// Step 3: Layout onto pages (two-pass when footnotes exist).
const bodyBreakType = finalSectionProperties?.sectionStart as
| 'continuous'
| 'nextPage'
| 'evenPage'
| 'oddPage'
| undefined;
const layoutOpts = {
pageSize,
margins: effectiveMargins,
finalPageSize,
finalMargins: effectiveFinalMargins,
columns: finalColumns,
bodyBreakType,
pageGap,
};
let layout: Layout;
let pageFootnoteMap = new Map<number, number[]>();
let footnoteContentMap = new Map<number, FootnoteContent>();
if (hasFootnotes) {
const pass1Layout = layoutDocument(blocks, measures, layoutOpts);
footnoteContentMap = buildFootnoteContentMap(
document!.package.footnotes!,
footnoteRefs,
contentWidth,
{
styles: styles ?? undefined,
theme: theme ?? null,
measureBlocks,
defaultTabStopTwips,
}
);
const stabilized = stabilizeFootnoteLayout({
blocks,
measures,
layoutOpts,
footnoteRefs,
footnoteContentMap,
initialLayout: pass1Layout,
});
layout = stabilized.layout;
pageFootnoteMap = stabilized.pageFootnoteMap;
} else {
layout = layoutDocument(blocks, measures, layoutOpts);
}
const footnotesByPage = hasFootnotes
? buildFootnoteRenderItems(pageFootnoteMap, footnoteContentMap, document)
: undefined;
return {
blocks,
measures,
layout,
headerContentForRender,
footerContentForRender,
firstPageHeaderForRender,
firstPageFooterForRender,
hasTitlePg,
watermark,
// Nullish, not truthy: an explicit `w:header="0"` must paint the header at
// the page top, not fall back to the painter's 0.5in default (#740).
headerDistancePx:
sectionProperties?.headerDistance != null
? twipsToPixels(sectionProperties.headerDistance)
: undefined,
footerDistancePx:
sectionProperties?.footerDistance != null
? twipsToPixels(sectionProperties.footerDistance)
: undefined,
pageBorders: sectionProperties?.pageBorders,
footnotesByPage: footnotesByPage?.size ? footnotesByPage : undefined,
};
}