Core API
Framework-neutral page document and Publication controllers from @imposia/core.
@imposia/core is the canonical page-document runtime: pagination, lifecycle, resolver boundary, extensions, print, and export without React. Everything here runs in the browser only.
Core page document
mountPageDocument
mountPageDocument(
container: HTMLElement,
source: PageSource,
options?: PageDocumentOptions,
): PageDocumentController;The function appends one canonical iframe immediately, starts a staged generation, and returns synchronously. Await ready before reading the first document.
const controller = mountPageDocument(host, { html: "<article><h1>Hello</h1></article>" });
const pageDocument = await controller.ready;PageSource is { html: string; baseUrl?: string } or { lightDom: Element | DocumentFragment; baseUrl?: string }.
PageDocumentOptions field | Type / purpose |
|---|---|
iframeTitle | string; canonical iframe accessible name, default "Document preview". |
blockedResourcePolicy | "warn" (default) or "error"; reject blocked resources before commit in strict mode. |
elements | readonly ElementResolver[]; ordered text/HTML/image preparation rules. |
fontLoading | PageFontLoadingOptions; default "all", optional conservative "unicode-range" filtering. |
tableRowspan | "preserve" (default) or "split"; see Table row splitting. |
css | readonly string[]; additional authored CSS. |
assetResolver | Async resolver for font, image, media, and stylesheet requests. |
page | { size?, orientation?, margin? }; size accepts A3, A4, A5, B4, B5, Letter, Legal, Ledger, or custom width/height. |
limits | Optional input, node, asset, deadline, page, layout-pass, and generated-output limits. |
headerTemplate, footerTemplate | Decoration markup. |
decorateBlankPages | Whether blank pages receive decorations. |
experimental | { footnotes?: boolean; pageFloats?: boolean }; both features are Experimental and fall back with FOOTNOTE_DEFERRED or PAGE_FLOAT_FALLBACK warnings outside their limits. |
extensions | Ordered PageExtension or PublicationExtension values. |
compose | { yieldBudgetMs?: number; scheduler?: () => Promise<void> }; cooperative main-thread pagination with an 8 ms default budget. Use Infinity to disable scheduler handoffs. |
signal | Cancels the initial generation. |
onProgress | Receives pass-local { completedPages, pass, provisional: true } whenever staging allocates a page. A later convergence pass resets the count. |
Scheduler, font, and image waits count toward the wall-clock limits.resourceDeadlineMs. Progress describes provisional staging work; read controller.current only for the latest committed generation.
An AssetResolver receives { url, kind, baseUrl, signal } and resolves to { status: "resolved", bytes, mimeType, resolvedUrl? } or { status: "blocked", reason? }. Implementing one is covered in Load images and fonts.
Extensions
PageExtension and PublicationExtension can also implement synchronous finalizePage(page, context). It runs once per accepted page after Core has resolved decoration and margin boxes but before commit, so page.element is a live measurable page element. page.tableFragments lists continued table fragments with their origin and one-based continuation index. Mutations persist into the committed iframe. It runs for every allocated page, including intentionally inserted blank pages, regardless of decorateBlankPages.
finalizePage must return undefined
A non-undefined return value from finalizePage rejects the whole generation. The hook communicates by mutating the live page element, never by returning a replacement.
In tableRowspan: "split" mode, Core carries reliably measured table and column widths across pages automatically. With the default "preserve" mode, use createTableColgroupExtension() as an opt-in preset for ordinary table continuations. Authored colgroup elements are retained. Tall-row splitting also pins measured cell widths when needed.
PageDocumentController
| Member | Returns | Readiness and errors |
|---|---|---|
ready | Promise<PageDocument> | First commit, or rejection if it cannot commit. |
current | PageDocument | undefined | Previous commit remains during/after a failed update; cleared by destruction. |
update(source, options?) | Promise<PageDocument> | Starts a generation; a newer update aborts the active one. Supports options.signal. |
print() | Promise<void> | Waits for latest work, then prints the latest successful commit through an isolated top-document snapshot. Rejects with no commit/after destruction. |
destroy() | Promise<void> | Aborts generation/export work, removes the iframe, releases resources, and waits for tracked work. Idempotent. |
PageDocument
| Member | Type / return | Meaning |
|---|---|---|
iframe | HTMLIFrameElement | Core-owned canonical frame. |
generation, pageCount | number | Commit number and page count. |
pages | readonly PageMetadata[] | Page number, side, name, blank state, geometry, dimensions, and body text. |
warnings | readonly PageWarning[] | Current-generation diagnostics. |
timings | { totalMs; resourceMs; paginationMs } | Generation timings in milliseconds. |
exportEpub(options) | Promise<Blob> | Waits for latest active work and exports the latest committed semantic source as a reflowable EPUB 3.3 Blob. |
EPUB options require metadata: { title, language, identifier, modified? } and optionally accept signal and { maxEntries?, maxBytes? } limits. The export is semantic: page wrappers, margin furniture, generated counters, and page-only experimental artifacts are excluded.
Warnings
Every committed PageWarning carries a code, a message, and a frozen location with generation, entryId, and page (each undefined when unknown). Extension diagnostics use namespaced EXTENSION_${string} codes and name their extension.
Core utilities
prepareDocument(html: string, options?: PrepareDocumentOptions): PreparedDocument;
pageWarningTargetBounds(document: PageDocument, warning: PageWarning): PageWarningTargetBounds | undefined;
hasPageDocumentFrameSandbox(iframe: HTMLIFrameElement): boolean;
committedFrameGeneration(frameDocument: Document): number | undefined;
selectBlankMarkers(markers: PageSideConstraint[], pages: Map<number, number>): number[];prepareDocumentsynchronously normalizes/sanitizes HTML and returns{ html, headerTemplate?, footerTemplate?, warnings }. Its options areheaderTemplate,footerTemplate, andallowRemoteResources. API decoration options override embedded templates. It parses with the browser's native HTML parser, so it is browser-only (like every other Core API) and warnings are ordered by post-recovery document order.pageWarningTargetBoundsreturns live{ left, top, width, height }in iframe viewport coordinates. It returnsundefinedfor a foreign/unlocated warning.hasPageDocumentFrameSandboxis true only for the exact public sandbox token set (allow-same-originandallow-modals).committedFrameGenerationreturns the generation Core stamped on the canonical frame at commit time, orundefinedwhen the frame has not committed a stamped generation. Use it to tell a not-yet-delivered newer commit apart from real corruption; it does not read or validate page markers.selectBlankMarkersreturns marker IDs needing a blank to satisfy left/right parity, accounting for prior selections. It throws when a marker has no page mapping.
Preview preparation and HTML export (0.7.0)
iframeTitle sets the canonical iframe's accessible name (default: Document preview); the Viewer preserves it. blockedResourcePolicy: "warn" is the default. With "error", any RESOURCE_BLOCKED diagnostic rejects the generation before commit, including resources refused by the resolver, validation, extensions, or the sanitizer. A failed update keeps the previous committed document. Inspect the rejected ImposiaError.code; strict failure uses RESOURCE_BLOCKED without exposing source URLs or resolver messages.
elements is an ordered array of { selector, resolve } rules applied after extension source transforms and before sanitization and pagination. Each rule selects body descendants from the working copy. resolve({ attributes, text, index, signal }) receives a frozen attribute/text snapshot and a zero-based match index, never the live DOM. Return undefined to keep an element, { type: "text", text } to replace its text, { type: "html", html } to replace its contents, or { type: "image", bytes, mimeType, alt } / { type: "image", dataUrl, alt } to replace it with an image. Rules and matches run sequentially; later rules see earlier replacements. Removed descendants are skipped. Returned HTML passes through the normal sanitizer; images pass through MIME, decode, byte-limit, and extension checks. Image replacement copies only class, style, width, height, and id, and uses the supplied alt.
Callbacks must forward signal to their asynchronous work. Cancellation aborts the generation; invalid selectors, callback failures, and invalid data URLs reject with ELEMENT_RESOLUTION_FAILED. The generation-wide resource deadline rejects with RESOURCE_TIMEOUT. Both failures keep the previous commit. assetFromDataUrl(url) decodes a base64 data URL with an explicit MIME type to { status: "resolved", bytes, mimeType }; it does not validate the resulting asset. Non-base64 URLs are not accepted.
fontLoading: { mode: "all" } preserves the default behavior. Opt into "unicode-range" to skip font faces whose declared range cannot be used by source text, decorations, or generated content. additionalText can reserve characters the host expects to need. Missing/invalid ranges and uncertain generated content, quotation marks, counters, or text transforms keep faces conservatively. This is a character-range filter, not a computed font-family or glyph-coverage analysis.
import { exportPageDocumentHtml, mountPageDocument } from "@imposia/core";
const controller = mountPageDocument(host, { html: '<p class="name"></p>' }, {
iframeTitle: "Invoice preview",
blockedResourcePolicy: "error",
elements: [{ selector: ".name", resolve: () => ({ type: "text", text: "Kim" }) }],
fontLoading: { mode: "unicode-range", additionalText: "₩" },
});
const committed = await controller.ready;
const html = await exportPageDocumentHtml(committed, { title: "Invoice" });exportPageDocumentHtml(document, options?: HtmlExportOptions): Promise<string> serializes that committed page generation with a doctype, UTF-8 declaration, and resolved binary assets inlined as data URLs. options.title overrides the title. It excludes Viewer presentation styles and preserves the composed page layout; EPUB remains the reflowable semantic export. A stale, destroyed, or unavailable generation rejects with DOCUMENT_UNAVAILABLE. It does not wait for pending updates. Once export starts, its snapshot survives a subsequent update or destruction. Output is a standalone HTML string, not PDF bytes or a guarantee of identical layout in another browser.
Table row splitting and export pixel ratio
tableRowspan controls how a table whose cells span rows (rowspan) crosses a page. The default, "preserve", keeps each rowspan cluster whole, as before. "split" cuts the cluster between rows, never inside a row: the rows that fit stay on the current page, and the rest continue on the next page under the repeated header. Any other value throws a TypeError when the document is created.
const controller = mountPageDocument(host, { html }, { tableRowspan: "split" });- A merged cell keeps its content once, in the first piece, with
rowspanset to the rows that piece holds. Each later piece is an empty cell of the remaining height, so arowspan="40"cell cut after 29 rows becomesrowspan="29"with the content, then an emptyrowspan="11". Content is never repeated. - Continuation cells are placed by the original table's logical columns, so merged cells that start on different rows, or sit beside
colspancells, keep their columns. The original start row, column,rowspan, andcolspanare recorded before any attribute is rewritten. break-inside: avoidon atrdoes not stop the split. A merged cell that declaresbreak-inside: avoidkeeps its cluster whole, and so does an enclosing element withbreak-inside: avoid. Forced page breaks and header/footer repetition behave as before.- In this mode a plain container such as a
div, or anol > li, that wraps a splittable table is fragmented in place and the table starts in the space left on the page, instead of the whole container moving to the next page. Atomic elements, flex, grid, and multi-column wrappers keep their existing placement. - Fallback: if not even the first row of a cluster fits on a fresh page, or a single row is taller than a page, the remaining rows are kept together, nothing is removed or repeated forever, and the document reports
UNSUPPORTED_LAYOUTandPAGE_OVERFLOW. Splitting the content inside a merged cell is not supported. - Widths: before the rows are taken out,
"split"measures the table and its columns and carries them to every page. The row with the most cells, all withcolspan1, is used when each width is positive and together they match the table width within 2px, and only the table's own rows count, never a nested table's. A missingcolgroupis created; a plaincolgroupof singlecolelements has only its widths updated, keeping classes, backgrounds, and other attributes. Any othercolgroupis left as authored, and a table whose columns cannot be measured reliably is not given guessed widths."preserve"does not measure or change widths.
exportPageDocumentHtml also accepts targetDevicePixelRatio, the device pixel ratio of the renderer that will lay the HTML out, for example a PDF engine. A renderer with a lower ratio rounds a 1px border up to a whole device pixel; the thicker border narrows the cell and can wrap text that fit in the preview. With the option, each side of every td and th border is rounded to the target's pixels (at least one), and the cell's padding gives up the same room (half per side under border-collapse: collapse), plus 1/64px on the left and right. The values are written as !important inline styles on the exported copy only; the preview is not modified.
const html = await exportPageDocumentHtml(committed, { targetDevicePixelRatio: 0.5 });The value must be a positive, finite number; anything else rejects with a TypeError. Omit it for the unchanged export. The caller supplies the ratio, since it depends on the renderer. Scope: cell borders only, not table, block, or SVG borders; a side with no border, or without enough padding to give up, is left as authored. Font sizes and page scale are never changed. In a border-collapse: collapse table the laid-out width of a shared edge is the widest one declared on it, so an edge is corrected only when every cell facing it (and the table's own border, at the outer edge) declares the same width; mixed edges, borders on rows, row groups, or columns, and tables whose cell grid cannot be built are exported as authored.
Table and export behavior in 0.8.1
The page grid keeps the configured body width even next to an oversized table. Single tall rows honor box-decoration-break: slice keeps top decoration on the first piece and bottom decoration on the last piece of the row; clone repeats it. WebKit builds that require the prefix should receive both -webkit-box-decoration-break and box-decoration-break. HTML export measures all pages before freezing inline or stylesheet anchors in their containing block, accounts for the element's own transform, and forces every exported page visible under screen and print media. No new option is needed. The preview's inline style values and lazy-rendering policy are restored after measurement.
Practical examples: Paginate tables and Export standalone HTML.
Core publication
mountPublication
mountPublication(
container: HTMLElement,
snapshot: PublicationSnapshot,
options?: PublicationOptions,
): PublicationController;It composes ordered entries into one page sequence and returns synchronously. Invalid snapshots or Publication extensions can throw during mounting.
const controller = mountPublication(host, {
metadata: { title: "Handbook", language: "en" },
entries: [
{ id: "cover", title: "Cover", html: "<h1>Handbook</h1>" },
{ id: "start", title: "Start", html: "<h1>Start</h1><p>First step.</p>" },
],
});
const publication = await controller.ready;PublicationSnapshot contains metadata: { title, language?, identifier? } and ordered entries. Each entry requires a unique id without whitespace or control characters, a title, optionally a baseUrl, and exactly one of html or lightDom. PublicationOptions contains PageDocumentOptions except that extensions accepts only PublicationExtension[], plus pageNumbering. With pageNumbering: "entry", every entry starts on a new page and counter(page), counter(pages), and the pageNumber/totalPages template tokens count within the entry, so a batch of invoices prints "Page 1 of 2" on each invoice. Page metadata, navigation, search, entry page ranges, extension decoratePage input, and target references stay global. The default "publication" numbers the whole Publication.
PublicationController
| Member | Returns | Readiness and errors |
|---|---|---|
ready | Promise<PublicationDocument> | First committed Publication. |
current | PublicationDocument | undefined | Current commit. |
resolveDestination(id) | PublicationDestination | undefined | Resolves an outline/search ID in the current generation. |
search(query) | readonly PublicationSearchResult[] | Empty before commit; otherwise searches the current index. |
navigate(destination) | void | Throws unless the complete destination matches and exists in the current generation. |
update(snapshot, options?) | Promise<PublicationDocument> | Validates/stages a complete snapshot; supports options.signal. |
print() | Promise<void> | Uses the page controller's latest-successful-commit behavior. |
destroy() | Promise<void> | Destroys underlying page and Publication resources. |
PublicationDocument extends PageDocument with committed metadata, entries, and nested outline. Each entry has an inclusive global pageRange. A PublicationDestination is { id, entryId, page, generation }; it is generation-bound, and navigate rejects a stale value with a typed ImposiaError (STALE_PUBLICATION_DESTINATION). A search result is { entry, page, excerpt, destination }.