/** * Pagination engine for creating a PDF.js-style paginated view from HTML output. * * This module provides client-side pagination that measures rendered content * and flows it across fixed-size page containers based on document dimensions. */ import type { PageDimensions } from "./page-geometry.js"; export type { PageBands, PageDimensions } from "./page-geometry.js"; /** * Headers and footers for a specific section. */ export interface SectionHeaderFooter { /** Default header (used for odd pages or all pages) */ headerDefault?: HTMLElement; /** First page header */ headerFirst?: HTMLElement; /** Even page header */ headerEven?: HTMLElement; /** Default footer (used for odd pages or all pages) */ footerDefault?: HTMLElement; /** First page footer */ footerFirst?: HTMLElement; /** Even page footer */ footerEven?: HTMLElement; /** Measured height of default header in points */ headerDefaultHeight?: number; /** Measured height of first page header in points */ headerFirstHeight?: number; /** Measured height of even page header in points */ headerEvenHeight?: number; /** Measured height of default footer in points */ footerDefaultHeight?: number; /** Measured height of first page footer in points */ footerFirstHeight?: number; /** Measured height of even page footer in points */ footerEvenHeight?: number; } /** * Registry of headers and footers by section index. */ export type HeaderFooterRegistry = Map; /** * A measured content block with metadata for pagination decisions. */ export interface MeasuredBlock { /** The DOM element */ element: HTMLElement; /** Section whose body owns this block after a continuous section transition. */ sectionIndex: number; /** Measured height in points (content + padding + border, excluding margins) */ heightPt: number; /** Top margin in points */ marginTopPt: number; /** Bottom margin in points */ marginBottomPt: number; /** Whether to keep this block with the next one */ keepWithNext: boolean; /** Whether to keep all lines of this block together */ keepLines: boolean; /** Whether to force a page break before this block */ pageBreakBefore: boolean; /** Whether this is a page break marker */ isPageBreak: boolean; /** Whether the block is a Word paragraph whose top margin represents paragraph space-before */ isWordParagraph?: boolean; } /** * Information about a rendered page. */ export interface PageInfo { /** 1-based page number */ pageNumber: number; /** Section index this page belongs to */ sectionIndex: number; /** Page dimensions */ dimensions: PageDimensions; /** The page container element */ element: HTMLElement; } /** * Result of pagination operation. */ export interface PaginationResult { /** Total number of pages */ totalPages: number; /** Array of page information */ pages: PageInfo[]; /** Present only when the caller supplied an exact layoutToken. */ pageMap?: PageMap; } export type PageMapMode = "paginated" | "continuous"; export type PageMapAvailability = "available" | "unavailable"; export type PageMapStory = "body" | "header" | "footer" | "footnote" | "endnote" | "comment"; export interface PageMapRect { /** Page-relative points, independent of viewer zoom/transform. */ x: number; y: number; width: number; height: number; } export interface PageMapPage { pageNumber: number; pageInSection: number; width: number; height: number; sectionIndex?: number; pageName: string; } export interface PageMapFragment { fragmentId: string; /** Canonical collision-safe `kind:scope:unid`, never the bare editor Unid. */ anchorId: string; fragmentIndex: number; pageNumber: number; geometry: PageMapRect; story: PageMapStory; /** Table-cell ownership is orthogonal to story (e.g. a body or footnote table cell). */ inTableCell: boolean; } /** Versioned portable layout contract consumed by DocxSession and remote agent surfaces. */ export interface PageMap { schemaVersion: 1; mode: PageMapMode; availability: PageMapAvailability; documentVersion: number; rendererFingerprint: string; pages: PageMapPage[]; fragments: PageMapFragment[]; } /** Explicit no-pages contract for a continuous viewer. It never estimates page numbers. */ export declare function createUnavailablePageMap(documentVersion: number, rendererFingerprint: string, mode?: "continuous"): PageMap; export interface PageCitationNavigation { navigated: boolean; target?: HTMLElement; pageNumber?: number; fragmentId?: string; unavailableReason?: "citation_unavailable" | "fragment_not_found"; } /** Remove the citation highlight previously applied within this paginated root. */ export declare function clearPageCitationHighlight(root: ParentNode): void; /** * Navigate an exact citation over an already-paginated DOM. The page-qualified fragment id is * authoritative; page + canonical source identity is a compatibility fallback for older v1 DOMs. */ export declare function navigateToPageCitation(root: ParentNode, citation: { availability: PageMapAvailability; anchorId: string; fragments: Array<{ fragmentId: string; pageNumber: number; }>; }, options?: { highlightClass?: string; /** Apply a visible inline highlight when no class is supplied. Default true. */ highlight?: boolean; behavior?: ScrollBehavior; block?: ScrollLogicalPosition; }): PageCitationNavigation; /** * Options for the pagination engine. */ export interface PaginationOptions { /** Scale factor for rendering (1.0 = 100%). Default: 1 */ scale?: number; /** CSS class prefix used in the HTML. Default: "page-" */ cssPrefix?: string; /** Whether to show page numbers. Default: true */ showPageNumbers?: boolean; /** Gap between pages in pixels. Default: 20 */ pageGap?: number; /** * Whether ordinary paragraphs may be fragmented across page boundaries. * Defaults to false for direct PaginationEngine callers; read-only viewer * entry points opt in explicitly. */ fragmentParagraphs?: boolean; /** Cooperative checkpoint for bounded non-yielding browser layout work. */ checkCancellation?: () => void; /** * Skip fragment-identity stamping at the end of paginate(). Callers that mutate the page tree * afterwards (running-story placement) and then call normalizePageMapFragmentIdentities() would * otherwise pay the whole forced-layout pass twice and discard the first result. Default: false. */ deferFragmentIdentities?: boolean; /** * Incremental admission check invoked immediately before each physical page is allocated. * Export callers use this to enforce `finalPages` without constructing an over-limit DOM first. */ checkPageCount?: (prospectivePageCount: number) => void; /** Exact invalidation tokens used to materialize an authoritative PageMap with the result. */ layoutToken?: { documentVersion: number; rendererFingerprint: string; }; } /** * Pagination engine that converts HTML with pagination metadata * into a paginated view with fixed-size page containers. */ /** * Registry of footnotes by ID for per-page distribution. */ export type FootnoteRegistry = Map; export declare class PaginationEngine { private stagingElement; private containerElement; private document; private view; private scale; private cssPrefix; private showPageNumbers; private pageGap; private fragmentParagraphs; private deferFragmentIdentities; private cancellationCheckpoint?; private pageCountCheckpoint?; private createdPageCount; private layoutToken?; private hfRegistry; private footnoteRegistry; private footnoteSeparator; private footnoteContinuationSeparator; private commentMarginRegistry; private footnoteLayoutLikeWord8; private pendingFootnoteContinuation; /** Per-section `w:pgNumType` (start / format), read off the section wrappers. */ private pageNumbering; private lastPages; private expectedPageMapAnchorIds; private state; /** * Creates a new pagination engine. * * @param staging - The staging element or its ID containing the content to paginate * @param container - The container element or its ID where pages will be rendered * @param options - Pagination options */ constructor(staging: HTMLElement | string, container: HTMLElement | string, options?: PaginationOptions); /** * Runs the pagination process. * * @returns PaginationResult with page information */ paginate(): PaginationResult; private checkpoint; /** * Normalize visible fragment identities after a caller applies final standalone styles. This * deliberately runs before the stability barrier; materializePageMap is read-only so PageMap * measurement cannot mutate a tree after it was declared stable. */ normalizePageMapFragmentIdentities(): void; /** * Materialize the last completed browser layout as portable page-relative point geometry. * The caller supplies both invalidation tokens; this engine never guesses a document version * or renderer fingerprint. */ materializePageMap(documentVersion: number, rendererFingerprint: string): PageMap; private normalizeVisiblePageFragments; /** * Intersect an element with every ancestor that establishes an overflow clip before the page * root. getBoundingClientRect() reports layout outside those clips, which is not rendered and * therefore must not satisfy PageMap completeness or inflate portable geometry. */ private intersectWithClippingAncestors; private storyForCanonicalAnchor; /** * Add canonical IDs from an addressable source subtree to the pre-pagination inventory. * Registry wrappers are excluded when scanning staging because selectable registry contents are * inventoried separately. Producers may explicitly mark content that has no visual substrate * with `data-page-map-exclude="true"`; native hidden semantics carry the same signal. */ private collectExpectedSourceAnchors; private isZeroHeightExplicitBreakCarrier; private isDeliberatelyUnrenderedSource; /** * Keep exactly one active bare-Unid editor anchor per source block. Presentation clones use * canonical source identity plus page/fragment qualification instead. */ private qualifyPageFragments; /** * Page flow clones source blocks while the hidden staging tree stays in the document. Any HTML * fragment target copied into a visible page would therefore resolve to its earlier hidden * source. Transfer target ownership to the page presentation after flow is complete; registry * and wrapper IDs that have no visible counterpart remain available to pagination internals. */ private transferVisibleFragmentTargets; /** Read each section's `w:pgNumType` off its wrapper (see {@link SectionPageNumbering}). */ private parsePageNumbering; /** * Fill in the page-number fields inside every page's cloned header/footer. * * A header/footer is authored once and cloned onto each page, so a PAGE field's single cached * result would otherwise show the same number on every page — the whole reason the converter * marks these. `data-field-format` (the field's own `\*` switch) wins over the section's format * when present, which is exactly how Word resolves the two. * * Runs after layout because NUMPAGES cannot be known before the last page exists. The * substituted text can therefore be marginally wider than the cached result the header was * measured with; the header band clips, so the failure mode is a hair of overflow rather than * a layout that disagrees with itself. * * Scoped to the CLONED header/footer regions on purpose. A page-number field in body text is * ordinary run content that the editor may make editable, and committing an edited block writes * back whatever text the DOM holds — rewriting it here would mean a body field commits a number * the document never contained. Body content is also not cloned, so it does not have the problem * this method exists to solve. */ private substitutePageNumberFields; /** * Measures all content blocks in a section. */ private measureBlocks; /** * Flatten the converter's `section.endnotes > ol > li > p` presentation into * ordinary paragraph blocks. This preserves paragraph formatting and canonical * p:en/en:en identities while allowing the existing paragraph fragmenter to * split a long endnote across page boundaries. */ private measureSafeEndnoteBlocks; /** Render the CSS ordered-list formats emitted by the converter after an endnote is flattened. */ private formatOrderedListMarker; /** * Flows a multi-column (`w:cols`) section's children into CSS-multicol container * blocks. Word lays such a section out as N columns inside the same body extent; * a balanced `column-count` container reproduces that geometry, and the paginator * then places each container as one ordinary measured block. A container grows * greedily until its balanced height would exceed the smallest page body available * to the section, so a long columned section still splits across pages at block * boundaries. Each child lands in exactly one container, so anchors never * duplicate. An explicit page break child passes through as its own block, which * ends the current container and lets the normal flow logic turn the page. */ private buildColumnBlocks; /** * Measures one element in the same hidden staging context used for the source blocks. * This is intentionally DOM-based: table row heights cannot be inferred from individual * rows because wrapping and collapsed borders change the height of a fragment. */ private measureElement; /** * Returns the contiguous keep-with-next chain beginning at a block. * * A hard page break or a page-break-before directive is stronger than a * keep-with-next directive, so it terminates the chain. The caller only * keeps a chain together when the whole chain can fit on a fresh page. */ private getKeepWithNextChain; /** * Measures the visible body height of a keep-with-next chain using the same * collapsed-margin rules as normal block placement. The trailing margin is * intentionally excluded, matching the individual block fit check. */ private measureKeepWithNextChainBodyHeight; /** Word paragraphs render as `p`, or as `h1`–`h6` when their style has an outline level. */ private isWordParagraphElement; private shouldSuppressPageTopSpacing; /** * Resolves the part of a block's top margin that consumes the current page. * * In Word's native DOCX layout, paragraph space-before is suppressed when a paragraph is the * first body block on a later page of the SAME section. The first page of a document/section is * the exception and keeps its spacing. Tables and other block margins are not paragraph spacing, * so they continue to use the ordinary CSS collapsing rule. */ private effectiveBlockMarginTop; /** Clone a source block with the same page-top spacing decision used by the height budget. */ private cloneBlockForPage; /** * Finds footnote references introduced by a sequence of blocks, preserving * document order and excluding references already assigned to the page. */ private collectNewFootnoteIds; /** * The shortest body available to a section's first, default, or even page. * A row fragment must fit every variant, otherwise a later header/footer could * send it through the oversized-block fallback again. */ private smallestEffectiveContentHeight; /** * Builds a clone of a simple table wrapper containing a contiguous run of rows. * Complex table features are deliberately rejected by the caller: a split across * merged cells, nested tables, or footnotes cannot be made correct by cloning rows. */ private createSimpleTableFragment; /** * Splits an oversized, ordinary table at row boundaries. This only participates * in the existing oversized-block fallback; unsupported tables keep the previous * overflow behavior rather than risking broken table semantics. */ private trySplitSimpleOversizedTable; /** * DOM endpoints that can finish a paragraph fragment. The flattened UTF-16 * offsets are checked against the browser's Unicode grapheme segmenter, so a * formatting-run boundary can never bisect a surrogate pair, combining * sequence, or joined emoji. NBSP/word-joiner boundaries remain indivisible. * PAGE/NUMPAGES field results are atomic because splitting their marker would * make later substitution duplicate or replace only half of the field. */ private paragraphFragmentEndpoints; /** Browser-native UAX #29 boundaries; null keeps older runtimes conservative. */ private graphemeBoundaryOffsets; /** * Conservative UAX #14/CSS wrapping opportunities used for synthetic page * boundaries. In particular, a formatting-run boundary is not itself a word * boundary, and Japanese opening/closing punctuation stays with its pair. * Arbitrary grapheme breaks are admitted only when the paragraph's CSS asks * the browser to wrap anywhere. */ private isLegalParagraphLineBoundary; /** Never fragment across characters whose line-break meaning is explicitly non-breaking. */ private isNonBreakingTextBoundary; /** * Without Intl.Segmenter, admit only an all-ASCII boundary. This still * fragments ordinary prose while refusing to guess about Unicode clusters. */ private isConservativeFallbackTextBoundary; /** * Whether a range contains visible text after ignoring bidi/zero-width marks. * Paragraph fragmentation deliberately excludes non-textual descendants, so * this is enough to reject empty head or tail fragments. */ private hasVisibleFragmentText; private hasVisibleText; /** * Phase one only handles text-like paragraph descendants. Objects, explicit * line breaks, list markers, notes, and out-of-flow/inline-block content all * require their own line-layout rules and retain the established whole-block * fallback instead of risking broken content or duplicate anchors. */ private canFragmentParagraph; /** * Shared structural gate for body and note paragraph fragmentation. Footnote * first paragraphs are inline beside their marker, so that one known layout * context may opt into an inline root while retaining every descendant and * break-safety restriction used for body text. */ private canRangeFragmentParagraph; /** * A range clone preserves nested inline formatting exactly. Anything that establishes its own * box/layout context is deferred until a future fragmenter can model it accurately. Callers * must invoke this while the paragraph is attached to the styled document. */ private hasRangeFragmentSafeLayout; /** * Builds one range-cloned paragraph fragment. Only the leading fragment keeps * the source paragraph's addressability; continuations must not duplicate an * id/data-anchor in the rendered document. */ private createParagraphFragment; /** * Range clones repeat an inline ancestor when the split lands inside it. Keep * semantic wrappers (links, comments, formatting) on both sides, but retain a * duplicated HTML/editor identity only on the leading fragment. Targets that * occur wholly after the split are absent from `head` and remain on `tail`. */ private reconcileParagraphFragmentIdentities; /** * Range-clone the largest safe prefix accepted by `fits`. The measurement * policy stays with the caller, allowing body blocks and note bands to share * one DOM fragmenter while measuring in their respective layout contexts. */ private splitParagraphAtLargestFit; /** * Splits a simple paragraph at the largest DOM Range endpoint that fits the * currently available body space. The caller then processes the tail normally, * allowing it to fragment again on later pages when necessary. */ private tryFragmentParagraph; /** * Parses the header/footer registry from the staging element. * Also measures heights during parsing for lazy-loading compatibility. */ private parseHeaderFooterRegistry; /** * Parses the footnote registry from the staging element. */ private parseFootnoteRegistry; /** Read Word's optional normal and continuation separator stories. */ private parseFootnoteSeparators; /** Append the exact separator story selected for this initial/continued note band. */ private appendFootnoteSeparator; /** Parses the hidden source notes used to render paginated margin comments. */ private parseCommentMarginRegistry; /** * Extracts footnote reference IDs from an element. */ private extractFootnoteRefs; /** Clone a note child for the continuation queue with its original selector position. */ private cloneFootnoteElementForContinuation; /** Remove identities that belong only to the initial registry presentation shell. */ private makeContinuationShellInert; /** * Build the exact continuation shape shared by measurement and paint. * * Keep the source `.footnote-item > .footnote-content` ancestry: document * CSS, inherited direction/language, and custom classes frequently target * those shells. Only the number and initial HTML/editor identities are * omitted. A hidden sentinel preserves `p:not(:first-of-type)` for a page * whose first carried element was a later source paragraph. */ private createFootnoteContinuationWrapper; /** Build the exact partial-note item shape shared by measurement and paint. */ private createPartialFootnoteItem; /** * Where a note measurement tree is attached. * * A note band is reserved from a measurement and then painted; if the two * happen under different inherited typography the painted notes overflow the * reserve, which is the invisible clipping this engine exists to avoid. The * registry lives in the staging tree but a band paints inside the output * container, so measure there. The host is never a page box, so a page's * `zoom` cannot scale a reserve that is accounted for in unscaled points. */ private noteMeasurementHost; /** * Measures the height of footnotes for given IDs (in points). * Creates a temporary container to measure the footnotes. * @param footnoteIds - IDs of footnotes to measure * @param contentWidth - Width for measurement * @param continuation - Optional continuation content to include first */ private measureFootnotesHeight; /** * Partition a continuation for one page's note band, preferring complete children * and range-fragmenting an eligible paragraph when necessary. Always advances by * at least one element so an indivisible oversized paragraph follows the established * clipped fallback without trapping pagination in a loop. */ private splitContinuationForPage; /** * Splits a footnote element into parts that fit within the available height. * Returns the elements that fit and the elements that need to continue. */ private splitFootnoteToFit; /** * Adds footnotes to a page container, including continuation content. */ private addPageFootnotes; /** Select the section's first/odd/even header from its one-based page position. */ private selectHeader; /** * The story KIND (`"first"` / `"even"` / `"default"`) a page position selects — the same * decision {@link selectHeader} / {@link selectFooter} make, named rather than resolved to * an element, so a page can advertise which OOXML story it is showing. */ private storyKindFor; /** Select the section's first/odd/even footer from its one-based page position. */ private selectFooter; /** * The header, body, and footer bands for one page position. * * The single owner of "where does anything sit vertically on this page" — placement in * {@link createPage}, the body budget the flow loop spends, and the note area's anchor all * read it, so those three cannot disagree about where the body ends. * * Deterministic: it depends only on the section's page setup and the registry's pre-measured * story heights, never on the page's content, which is what keeps it lazy-loading compatible. */ private getPageBands; /** * The measured height of the running story this page position selects, mirroring * {@link selectHeader}/{@link selectFooter}. Zero when the page has no such story. */ private selectStoryHeight; /** * Measures the content height of a header or footer element. * * This is what tells {@link resolvePageBands} whether the story stays inside its margin or * pushes the body, so it must measure the story ALONE — any padding added here would have to * be added to the rendered band too, and the two drifting apart is exactly how a header * silently starts overlapping body text. */ private measureHeaderFooterHeight; /** * Flows measured blocks into page containers. * Implements a single-pass, forward-only algorithm that is compatible with future lazy loading. * Supports footnote continuation - long footnotes can split across pages. */ private flowToPages; /** * Strip block addressing from a header/footer node cloned into a page box. * * A running story is authored ONCE and cloned onto every page, so the clones all carry the same * `data-anchor` — on this document, 42 page boxes claiming one footer paragraph. Left editable, * committing any one of them writes back through that single shared anchor, and the per-page * page-number substitution makes it worse: each clone shows a DIFFERENT number, so a commit * writes that page's number into the story as literal text and destroys the PAGE field. * * Page-box header/footer content is presentation. The docked editing bands * (`editor-headerfooter.ts`) are the addressable affordance, and they exist precisely because a * cloned node cannot be uniquely addressed. */ private makeClonedStoryInert; /** A repeated margin note is presentation, not a second bookmark/link target. */ private makeClonedMarginCommentInert; /** * Resolves floating DrawingML objects after their anchor paragraphs have landed on a page. * * The converter deliberately carries the OOXML bases and offsets as data instead of flattening * them into CSS: page/margin/column bases belong to the page, while paragraph/line/character * bases belong to the laid-out anchor. Once both coordinate systems exist, promote the object * from the clipped text column into the page box and position it in one shared point space. */ private positionDrawingAnchors; private anchorNumber; private horizontalAnchorReference; private verticalAnchorReference; private resolveAnchorAxis; /** Charge a physical page before any page-owned partitioning or DOM allocation. */ private admitPageAllocation; /** * Creates a page container element. */ private createPage; } /** * Convenience function to paginate HTML content. * * @param html - HTML string with pagination metadata * @param container - Container element or ID where pages will be rendered * @param options - Pagination options * @returns PaginationResult * * @example * ```typescript * const html = await convertDocxToHtml(docx, { paginationMode: PaginationMode.Paginated }); * * // Create a container for the paginated view * const container = document.getElementById('viewer'); * * // Parse and paginate * container.innerHTML = html; * const staging = document.getElementById('pagination-staging'); * const pageContainer = document.getElementById('pagination-container'); * * const engine = new PaginationEngine(staging, pageContainer, { scale: 0.8 }); * const result = engine.paginate(); * * console.log(`Document has ${result.totalPages} pages`); * ``` */ export declare function paginateHtml(html: string, container: HTMLElement | string, options?: PaginationOptions): PaginationResult; //# sourceMappingURL=pagination.d.ts.map