Flow-block generation + page stacking under the hard page-break rule.
Module oconvPdfStack | Source packages/front/office/oconv/src/write/pdf/stack.js | Deps — none | Worker-safe yes
Resolve
import { ModuleRuntime } from '@awacloud/fw/core/runtime.js';
import { fw_require, modules } from '@awacloud/oconv';
const runtime = new ModuleRuntime();
runtime.registerAll(fw_require);
runtime.registerAll(modules);
const oconvPdfStack = runtime.resolve('oconvPdfStack');
Composed internally by ir-to-pdf (layoutDocument);
resolving it directly needs a hand-built ctx (see Examples).
API
| Member | Signature | Returns | Throws |
|---|---|---|---|
INDENT_STEP |
number |
18 — points of indent added per nesting level |
— |
DELEGATED_KINDS |
string[] |
['list', 'codeBlock', 'table', 'image'] |
— |
flowBlocks |
(irDoc: object, ctx: Ctx) => FlowBlock[] |
flattened linear flow | Error oconv: pdf stack needs ctx.<member> |
stackPages |
(blocks: FlowBlock[], layout: object) => {pages: Page[], losses: object[]} |
paginated result | — |
layoutDocument |
(irDoc: object, ctx: Ctx) => {pages: Page[], losses: object[], blocks: FlowBlock[]} |
flow + stack, one call | Error oconv: pdf stack needs ctx.<member> |
laidOutText |
(pages: Page[]) => string |
oracle helper: full placement-order text | — |
unaccounted |
(blocks: FlowBlock[], pages: Page[], losses: object[]) => string[] |
'<index>:<kind>' per unaccounted DRAWABLE block, [] when the accounting invariant holds |
— |
Ctx (built by the caller): measurer, layout, the RESOLVED
linebreak API (mandatory — flowBlocks throws
oconv: pdf stack needs ctx.linebreak without it, since this module is
contractually dependencies: []), losses (the single loss sink), render
(the dispatcher for list/codeBlock/table/image), and the re-entry
fields indent/indexPrefix/ruleX. The renderers additionally call
ctx.flowChildren to flow a nested block list (a list item's children, a
table cell's blocks): the facade builds it, bound to each renderer's own
context, and it is not needed to call flowBlocks itself.
Examples
The sessions below build the stack's child context by hand, with the same
members ir-to-pdf assembles for it (measurer, layout, column,
sizeFor, leading, linebreak, losses, index, render). The
render stub stands in for the facade's dispatcher: like the real one, it
answers a kind it has no renderer for with a layout/unhandled-block loss.
Every IR below is built with oconvIr.node / oconvIr.doc, so it passes
oconvIr.validate.
Flow + stack a two-block document
const { node, doc, validate } = runtime.resolve('oconvIr');
const metrics = runtime.resolve('oconvPdfMetrics');
const box = runtime.resolve('oconvPdfBox');
const linebreak = runtime.resolve('oconvPdfLinebreak');
const measurer = metrics.createMeasurer();
const layout = box.resolveLayout();
const ctx = {
measurer, layout, column: layout.column, sizeFor: layout.sizeFor,
leading: layout.leading, linebreak, losses: [], index: null,
render: (block) => ({ items: [], losses: [{ code: 'layout/unhandled-block', detail: { kind: block.kind } }] })
};
const ir = doc([
node('heading', { level: 1 }, [node('run', { text: 'Title' })]),
node('paragraph', {}, [node('run', { text: 'Body text.' })])
]);
validate(ir).ok; // true
const { pages, losses, blocks } = oconvPdfStack.layoutDocument(ir, ctx);
pages.length; // 1
pages[0].items.length; // 2 (heading line + paragraph line)
oconvPdfStack.unaccounted(blocks, pages, losses); // [] — the invariant holds
Executed against the live package (2026-10-06): validate(ir).ok === true,
pages.length === 1, pages[0].items.length === 2,
unaccounted(...) is [].
Probe — an oversized single block is clipped, never split
const tallText = Array.from({ length: 2000 }, (_, i) => 'word' + i).join(' ');
const irTall = doc([node('paragraph', {}, [node('run', { text: tallText })])]);
const out = oconvPdfStack.layoutDocument(irTall, { ...ctx, losses: [] });
out.pages.length; // 1 — still one page: the stacker never creates a second
// page to hold the excess, it clips at the bottom of
// the one the block already started
out.losses[0].code; // 'layout/block-clipped'
out.losses[0].clippedLines; // 160 (measured, this fixture)
Executed against the live package (2026-10-06): a 2000-word single
paragraph (taller than one page's content height at 11pt/1.32 leading)
produces exactly one page and one layout/block-clipped loss with
clippedLines: 160.
Probe — a delegated kind with no renderer is a recorded loss
const irList = doc([
node('list', { ordered: false }, [
node('listItem', {}, [node('paragraph', {}, [node('run', { text: 'item' })])])
])
]);
const delegated = oconvPdfStack.layoutDocument(irList, { ...ctx, losses: [] });
delegated.losses[0].code; // 'layout/unhandled-block'
delegated.losses[0].kind; // 'list'
oconvPdfStack.unaccounted(delegated.blocks, delegated.pages, delegated.losses); // [] — named by a loss, so accounted
Executed against the live package (2026-10-06): the list block places no
item, the stub's layout/unhandled-block loss is stamped with the block's
index and kind, and the accounting invariant still holds because the
block is named by that loss.
Notes
- The hard page-break rule: a block whose total extent does not fit
the remaining space starts a NEW page. A block TALLER than
layout.contentHeightcannot fit any page — it is clipped at the page bottom (never split, never spilled onto a second page) andlayout/block-clippedis recorded with{clippedLines}plus a prosedetailstring. - A heading's
keepTogetheris NOT widow/orphan control (the typesetter refuses that): it is a cheap "no heading alone at the page bottom" rule — if the heading plus ONE leading line of the next block do not fit, both move to the next page. Nothing is ever moved because of where a PARAGRAPH's own lines land. - The accounting invariant is a test oracle, not a comment:
unaccounted(blocks, pages, losses)returns[]exactly when every DRAWABLE flow block (isDrawable: has lines with ink, OR is a delegated kind, OR is anhr) either placed at least one item or is named by a loss record (loss.indexorloss.detail.index). - Determinism: pure functions, no clock, no randomness, no ambient
state — verified end-to-end by a
JSON.stringify× 2 byte-identity test.@awacloud/pdf'sdocument/builder.jsreads no clock either (its only date handling applies to CALLER-supplied metadata), so the property holds downstream too. list/codeBlock/table/imageare DELEGATED toctx.render— this module owns onlyheading/paragraph/blockquote/hr, so extending a renderer never touches this file.- A
heading/paragraph's inline children are PARTITIONED: theruns flow as the text block, and every inlineimageis delegated in source order right after it, at index<block>.i<n>— before this split, an inline image simply vanished with no item and no loss. - Pure module,
dependencies: []. Capture-free (fw/no-factory-capture): everything this module composes with (measurer, layout, linebreaker, render dispatcher) arrives as a call-time argument onctx.
See also
ir-to-pdf— builds the realCtx(measurer, layout, therenderdispatcher,flowChildrenre-entry) and callslayoutDocument.pdf/linebreak— the mandatoryctx.linebreak.pdf/render/list,pdf/render/code,pdf/render/table,pdf/render/image— the delegated renderers.pdf-writer.md—layout/block-clippedandlayout/unhandled-blockin the audience-facing loss-code table.