Chapter 3.19 · Part II · The craft
Programmatic usage: .postext bundles
Opening, laying out, rendering and creating .postext files from code, bilingual bundles, files from older versions and the low-level API
In short
This page is for people who write code. A .postext file holds a whole book in one file: its text, its settings, its fonts and its pictures. The page shows how to open such a file, lay out its pages and draw them. It shows how to make a new file from your own text, and how to keep two languages of a book in one file. It also explains how files made by older versions of Postext are read.
#Bundles (.postext files)
A .postext file is a whole book in one file: a zip archive holding a preset.json manifest, one markdown file per chapter, the resource payloads (bitmaps, SVGs, PDF print masters) and the font files the configuration names. The Sandbox exports and imports it and the agent skill delivers it. The postext package can create and open it too, so a book can move between those tools and your own program without losing anything.
my-book.postext
├── preset.json manifest: name, locale, chapters, config, resources, fonts
├── chapters/01-dusk.md
├── chapters/02-night.md
├── resources/lantern.svg
└── fonts/ebgaramond-400-normal.woff2
The manifest is described field by field in the Sandbox's Preset bundle format appendix. A file may also carry layouts.json, the Sandbox's page counts, so the book opens already paginated there, or one per edition of a multilingual book (layouts.zh-Hant.json, read first). openBundle ignores them.
The API is exported from postext itself and from the postext/bundle subpath, which adds the low-level helpers. Import from postext when you also render. That way the bundle adapters and the renderers share one module instance, which matters on a CDN such as esm.sh, where every entry point is a separate build.
#Opening a bundle
openBundle takes the file's bytes (a Uint8Array, an ArrayBuffer, or a Blob / File from an <input type="file">) and returns everything the engine and its backends need:
import { openBundle } from 'postext';
const bundle = await openBundle(await file.arrayBuffer(), { locale: 'es' });
bundle.chapters; // [{ title, file, markdown }, …] in book order
bundle.config; // PostextConfig, ready for buildDocument
bundle.resources; // Resource[]
bundle.files; // Map<path, Uint8Array>: every file of the bundle| Field | What it holds |
|---|---|
manifest | The validated preset.json. |
id, name, description | From the manifest. |
locale, locales | The locale the content was read in, and every locale a bilingual bundle carries. options.locale picks one: first the exact tag, then the base language, then the bundle's own locale. |
chapters | { title, file, markdown } per chapter. A chapter without a title in the manifest takes the text of its first # heading. |
config | The default colour palette and the resource types in the bundle's language, then the manifest's config, then the locale's overrides. The bundle's language is locale above, so a bundle in one language gets its own labels whatever options.locale asks for. A manifest that names no language takes the one its config sets (locale, then the hyphenation locale), else options.locale. customFonts lists the bundle's font families. This is the same configuration the Sandbox opens the bundle with. |
resources | The resources, with the captions of the chosen locale. A size missing from the manifest is read from the file. |
fonts | One entry per face: { family, weight, style, format, file, bytes }. |
files | Every file in the archive, keyed by its path. |
thumbnail, canvasScope | The cover picture's path, and how the bundle asks to be viewed: the manifest's view, with the served language's localized[…].view over it. |
start | Where the book starts, for a bundle that holds part of a longer one: the manifest's start, or the localized[…].start of the served language. Absent for a book that starts at its beginning. |
warnings | Non-fatal problems: an unsupported font file, a missing print master. |
A payload's fileId is its path inside the bundle. resource.svg.fileId, resource.bitmap.fileId and every customFonts variant's fileId can be looked up directly in bundle.files. openBundle throws when the bytes are not a zip, when there is no valid preset.json (at the root or under one top-level folder), or when a file the manifest names is missing.
Bundles written by postext 1.4 or earlier
Every manifest createBundle and the Sandbox write carries configVersion: 14: the configuration rules its config was written for. A manifest without it was written by postext 1.4 or earlier, which set eighteen things differently:
- Heading breaks (rules 3): up to 1.4, a
headingsobject with no H1 break had none (see Per-level overrides). - The maths size (rules 4): up to 1.4, formulas came out 1.131 times larger than
fontSizeScalesays (see Formula size). - The space under an inline resource (rules 5): up to 1.4, the text after a
placement.position: 'here'figure or table resumed at the next grid line, with no float gap under it (seelayout.inlineResourceGapunder Layout). - The space around an inline resource inside a box (rules 6): up to 1.4, such a resource sat right against the text of the box around it (see
layout.inlineResourceGapInBoxesunder Layout). - Inline marks in headings (rules 6): up to 1.4, a heading printed the words of its
*italic*,**bold**and other marks in its own plain style (seeheadings.inlineMarksunder Headings). - The size of a drop cap (rules 6): up to 1.4, a design text's
dropCapwith nofontSizewas as tall as all the line boxes it spans, its top above the first line (seedropCapunder Text elements). - The room under a colon line (rules 6): up to 1.4,
keepColonWithListtook one line of room under the line ending in a colon as enough for the list, and a two-line first item the orphan and widow rules keep whole went on to the next column without it (seebodyText.colonListRoom). - The lines a box cut leaves (rules 6): up to 1.4, a box that split inside a paragraph or list item could leave one line of it on a side, as long as each side of the box held its
splitMinLineslines in all (seelayout.boxChildSplitMinLinesunder Layout). - Line breaks at a dash (rules 7): up to 1.4, Knuth-Plass never ended a line after an em or en dash set closed between words (
say—that’s), and the line-by-line breaker of formatted text only between two letters (seebodyText.breakAfterDashesunder Body text). - Ragged text (rules 7): up to 1.4, ragged running text was set line by line, each line filled before the next, whatever
optimalLineBreakingsaid (seebodyText.optimalRaggedunder Body text). - The split under a heading (rules 8): up to 1.4, the paragraph under a heading at the foot of a column kept as many lines as fit there, however few went on to the next column (see
headings.keepWithNextSplitunder Headings). - The space under a
:::paragraphscontainer (rules 8): up to 1.4, the style's space was set under the last paragraph before the grid snap, the next block's space above (a heading'smarginTop) was added under it, and the text's paragraph spacing was left out (seebodyText.paragraphContainerSpacingunder Body text). - Line breaks at a compound's hyphen (rules 8): up to 1.4, Knuth-Plass never ended a justified line after a hyphen between two letters (
well-known) in a paragraph without inline formatting, while it did in one with formatting (seebodyText.breakAfterHyphensunder Body text). - Poems with no separator (rules 9): up to 1.22, a
:::versepoem whose lines carried no||was set as single hemistichs, each line centred (seebodyText.verse.layoutunder Verse). - A first-line indent beside a hanging indent (rules 9): up to 1.22, a paragraph style's
hangingIndentreplaced itsfirstLineIndent, and the first line started atindent(see Paragraph styles). - A backslash at the end of a line (rules 9): up to 1.22, a backslash that ended a line of a paragraph, a quotation or a list item, and
\\before a space, printed, and the lines joined with a space (seebodyText.hardLineBreaksunder Body text). - Code fences (rules 9): up to 1.22, a
```or~~~fence and the lines inside it were read as Markdown: lines joined into paragraphs, a#line became a heading, the fences printed (seecodeStyle.blocksunder Code listings). - Verse turnovers (rules 10): in 1.23, a line of a poem set line by line that was wider than the measure turned over at its natural word spacing, however little it overflowed (see
bodyText.verse.tightenunder Verse).
openBundle and readBundle read such a manifest's config, and each locale's localized configuration, through migrateConfig, which writes out the breaks 1.4 laid out and multiplies the maths scale by 1.131 (its em display margins divided by it). A manifest stamped 3 to 7, written by a 1.5 prerelease, only gets the pins of the rules after its stamp. With 3 that is the maths size, the inline gap, the five pins of rules 6, the two of rules 7 and the three of rules 8; with 4, the inline gap and the pins of rules 6, 7 and 8; with 5, the pins of rules 6, 7 and 8; with 6, the pins of rules 7 and 8; with 7, the pins of rules 8 alone. The split under a heading (pinLegacyHeadingSplit) is written as headings.keepWithNextSplit: 'fill' on the headings the layers leave in force, when a chapter read has a heading and the configuration names no value of its own, keeps headings.keepWithNext on and does not turn bodyText.avoidOrphans off. The compound breaks (pinLegacyHyphenBreaks) are written as bodyText.breakAfterHyphens: false on the bodyText in force, when a chapter read sets a hyphen between two letters and the configuration neither sets it already nor turns optimalLineBreaking off. The space under containers (pinLegacyParagraphContainerSpacing) is written as bodyText.paragraphContainerSpacing: 'add' on the bodyText in force, when the configuration declares a paragraph style (in paragraphStyles or the HTML viewer's overrides), a chapter read opens a :::paragraphs container on a line of its own, and the configuration does not set it already. The dash breaks (pinLegacyDashBreaks) are written as bodyText.breakAfterDashes: false on the bodyText in force, when a chapter read sets an em or en dash closed between words (a letter, a digit or closing punctuation before it, a letter, a digit or an opening bracket or quote after it; a quotation mark before it counts when a letter, a digit, closing punctuation or a no-break space stands before the quote, as in "no"—and, not in said "—Hola; an inline mark touching the dash or the quote, such as the ** of **riddles.**—I, counts on either side) and the configuration does not set it already. The ragged breaking (pinLegacyRaggedBreaking) is written as bodyText.optimalRagged: false on the bodyText in force, when the configuration sets some running text ragged (a textAlign other than 'justify' on the body text, a paragraph style, a box body, the body of a part or the body of a section style (headingStyles[].bodyStyle), or in the HTML viewer's overrides), does not set it already and does not turn optimalLineBreaking off. The gap in boxes (pinLegacyBoxResourceGap) is written as layout.inlineResourceGapInBoxes: false on the layout in force, when a resource is embedded on its own line inside a :::callout of the chapters read and the configuration does not set it already. The box cut (pinLegacyBoxChildCut) is written as layout.boxChildSplitMinLines: 1 on the layout in force, when a chapter read opens a :::callout on a line of its own and the configuration does not set it already. The heading marks (pinLegacyHeadingMarks) are written as headings.inlineMarks: false on the headings the layers leave in force, when a heading of the chapters read carries a mark (*, _, ^, ~, :smallcaps[ or a link in its title) and the configuration names no value of its own. The drop caps (pinLegacyDropCapSize) get their 1.4 size written out as dropCap.fontSize, wherever they sit: in the unit of the element's leading when that is a length, else in the unit of its font size. The colon-line room (pinLegacyColonListRoom) is written as bodyText.colonListRoom: 'line' on the bodyText in force, when a list of the chapters read follows a line ending in a colon (blank lines between them allowed) and the configuration neither names a room nor turns keepColonWithList off. The inline gap (pinLegacyInlineGap) is written as layout.inlineResourceGap: 'above' on the layout the layers leave in force, when a line of the chapters read embeds a resource (::resource{id="…"} alone on its line, as the parser reads it: a mention in running text or in a code span does not count) and the configuration names no gap of its own. The size is pinned on the math the layers leave in force (a locale's own math replaces the shared one), and only when the chapters read have a $: a bundle without maths keeps its config as written. When neither the manifest nor the locale names a math, the one in force is readBundle's baseConfig (the reader's own), and it is pinned too, since 1.4 set the bundle's formulas at that size: a base fontSizeScale: 1.5 reads as 1.5 × 1.1312. The base's heading breaks are taken as they are. An old bundle therefore keeps what these rules laid out, and bundle.config shows the breaks, the maths size, the gaps, the heading marks, the drop-cap sizes, the colon-line room, the box cut, the dash breaks, the compound breaks, the ragged breaking, the split under a heading and the space under containers it is laid out with. The layout fixes of 1.5 have no pin and apply to it as to any book, so a page they touch can still move (see Formula size for the list). A preset.json written by hand for today's rules sets "configVersion": 14; stamping an old bundle's manifest with it is also the one-line way to read it with today's rules (an unversioned bundle then loses its heading-break pin too). A manifest stamped 8, written by postext 1.5 to 1.22, gets the four pins of rules 9 alone, as every older one does too: the verse layout (pinLegacyVerseLayout) is written as bodyText.verse.layout: 'bayt' on the bodyText in force, when a chapter read sets a :::verse poem whose fence names no layout and whose lines carry no hemistich separator and the configuration does not set it already; the paired indents (pinLegacyPairedIndents) drop the firstLineIndent of every paragraph style (in paragraphStyles or the HTML viewer's overrides) that also sets a non-zero hangingIndent; the forced line breaks (pinLegacyHardBreaks) are written as bodyText.hardLineBreaks: false on the bodyText in force, when a chapter read ends a line of a paragraph, a quotation or a list item with a backslash and the block goes on below it, or sets \\ before a space and more text (inline code and maths, display formulas, headings and :::verse poems aside), and the configuration does not set it already; and the code fences (pinLegacyCodeBlocks) are written as codeStyle.blocks: false, when a chapter read opens a ``` or ~~~ fence (three or more, up to three spaces in) and the configuration does not set it already. A manifest stamped 9, written by postext 1.23, gets the pin of rules 10 alone, as every older one does too: the verse turnovers (pinLegacyVerseTightening) are written as bodyText.verse.tighten: false on the bodyText in force, when a chapter read sets a poem line by line (a :::verse fence that names layout=lines, or names no layout over lines with no hemistich separator while the configuration does not set such poems as bayts) and the configuration does not set it already. A manifest stamped 10, written by postext 1.24, gets the pin of rules 11 alone, as every older one does too: the balancing of a character grid (pinLegacyGridBalancing) is written as headings.balancing.enabled: true on the headings in force, when the merged configuration sets cjk.grid.enabled in horizontal text and does not set enabled itself, since 1.24 balanced such a page by default. The rules of 11 also keep book titles from one-character breaks, set circled numbers as Chinese characters and set CJK design text with the body's rules (#637): a manifest stamped 10 or older gets cjk.titleMinChars: 1 (pinLegacyTitleBreaks) when a chapter read sets a title (《 〈 or :book[), cjk.circledNumbers: 'western' (pinLegacyCircledNumbers) when one sets a circled number (U+2460–U+24FF, U+2776–U+2793), and cjk.composeDesignText: false (pinLegacyDesignText) when a chapter read or the configuration itself holds CJK text, each on the cjk in force and only when the configuration does not set it already. They also cut inline tables and set :::columns in the running text (#634): a manifest stamped 10 or older gets tableStyle.splitInline: false (pinLegacyInlineTableSplit) on the tableStyle in force when a chapter read embeds a resource, and layout.flowColumns: false (pinLegacyFlowColumns) on the layout in force when a chapter read opens a :::columns fence on a line of its own, each only when the configuration does not set it already. They offer a float the head of a page-span opener's own column as well (#639): a manifest stamped 10 or older gets layout.floatsUnderOpener: false (pinLegacyOpenerHeadFloats) on the layout in force when the merged configuration sets a heading level or style with span: 'page' and a chapter read has a heading, only when the configuration does not set it already. The rules of 12 let a page on a character grid hold its lines (#717): a manifest stamped 11 or older whose merged configuration turns cjk.grid.enabled on gets bodyText.avoidWidows: true, and in the japan region bodyText.avoidOrphans: true (pinLegacyGridBreakRules), on the bodyText in force, each only when the configuration does not set it already. They also keep two characters of a two-row note on either side of a line break (#720): such a manifest gets cjk.warichu.minChars: 1 (pinLegacyNoteBreaks) on the cjk in force when a chapter read sets a :warichu[ note or the merged configuration sets citations.notes: 'warichu', only when the configuration does not set it already. And in the Japan region a Western word takes the Han–Latin space next to its brackets and signs (#721): a manifest stamped 11 or older whose merged configuration resolves to the Japan region gets cjk.latinSpacingEdge: 'letters' (pinLegacyLatinSpacingEdge) on the cjk in force when a chapter read holds kana or kanji, only when the configuration does not set it already. And a resource set in the text takes placement.captionSide as a column float does (#729): a manifest stamped 11 or older whose merged configuration sets layout.layoutType: 'oneAndHalf' gets layout.inlineCaptionSide: false (pinLegacyInlineCaptionSide) when a chapter read embeds a resource, only when the configuration does not set it already.
The rules of 12 also change how a short column is balanced, and a manifest stamped 11 or older keeps what it was laid out with. The order of the levers (#707): it gets headings.balancing.maxLinesBeforeLoose set to its own maxLinesPerHeading, or 4 when it sets none (pinLegacyLooseOrder), on the headings in force, when a chapter read has a heading and the merged configuration balances its columns, lets a heading take more than two lines and does not set the key already. The line under a figure across several columns (#708): it gets headings.balancing.stretchAfterWideFloats: 'always' (pinLegacyWideFloatStretch) on the headings in force, when a chapter read embeds or cites a resource or floats a box, and the merged configuration balances its columns, does not turn stretchAfterFloats off and does not set the key already. The breaks inside the groups of an index (#712): it gets index.groups.splitMinEntries: 1 (pinLegacyIndexGroupSplit) on the index in force, when a chapter read sets an :::index fence on a line of its own and the configuration does not set the key already. The unit of the level cut (#711): it gets headings.balancing.trailingStep: 'grid' (pinLegacyTrailingStep) on the headings in force, when a chapter read may set text off the body's leading (a ::: fence, a code fence, a quotation, a list, a display formula or a citation), and the merged configuration balances its columns, keeps trailing or beforeSpan on and does not set the key already.
The rules of 12 break a reference's label at its spaces when the label is words (#702). A manifest stamped 11 or older gets crossRefs.breakLabels: false (pinLegacyRefLabelBreaks) on the crossRefs in force, when a chapter read holds a reference (:ref{…}, \ref{…}, \eqref{…} or a pandoc-crossref @id) and the configuration does not name the setting: every label stays the one word it was laid out as.
The rules of 12 shape a word with no letter (46., 1367)) in the script of the text around it (#725). A manifest stamped 11 or older gets bodyText.scriptContext: false (pinLegacyScriptContext) on the bodyText in force, whatever its chapters hold, when the configuration does not name the setting: such a word is shaped alone, as it was laid out. The rule acts in a face that draws its punctuation by script, and which faces a book loads is not known when its manifest is read.
The rules of 13 make the language the text is shaped in a setting (#764), and the rules of 14 make the document's own language its default (#770). A manifest stamped 13 or older gets bodyText.shapingLanguage: 'host' (pinLegacyShapingLanguage) on the bodyText in force, whatever its chapters hold, when the configuration does not name the setting; one that names a value keeps it. Its text is measured in the language of the page that hosts the layout, and in none in a worker, as it was laid out. A manifest stamped 13 was written by postext 1.29: 1.29.1 and 1.29.2 laid a book that names no value out in the host's language, as the pin does; 1.29.0, whose default was 'document', laid it out in the document's with a canvas and a PDF that did not follow the measure, and it reads with 'host' too. A manifest stamped 14 that names no value is laid out in the document's language.
The rules of 13 also draw the box of a task item (- [ ], - [x]): a square the engine draws, with a tick when the item is done, that takes a room of its own before the item's text (#765). A manifest stamped 12 or older gets unorderedLists.taskBox: 'character' (pinLegacyTaskBox) on the unorderedLists in force, when a chapter holds a task item and the configuration does not name the setting. Its box stays the character ☐ or ☑ set in the list's face, and the item's text starts after the width of that character, as it was laid out. That width came from whatever font held the character, a font of the system in most books, and is not known when the manifest is read.
import { CONFIG_VERSION, migrateConfig } from 'postext/bundle';
migrateConfig({ headings: { fontFamily: 'Georgia' } }, undefined, { content: 'A book with no maths.' });
// => { headings: { fontFamily: 'Georgia', levels: [{ level: 1, breakBefore: { enabled: false } }] } }
migrateConfig({ math: { fontSizeScale: 1.2 } }, 3);
// => { math: { fontSizeScale: 1.35746…, marginTop: { value: 0.7072, unit: 'em' }, marginBottom: { value: 0.7072, unit: 'em' } },
// layout: { inlineResourceGap: 'above', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 },
// headings: { inlineMarks: false, keepWithNextSplit: 'fill' }, bodyText: { colonListRoom: 'line', breakAfterDashes: false, breakAfterHyphens: false, verse: { layout: 'bayt', tighten: false }, hardLineBreaks: false }, codeStyle: { blocks: false } }
migrateConfig({ layout: { layoutType: 'single' } }, 4, { content: 'Text.\n\n::resource{id="fig"}' });
// => { layout: { layoutType: 'single', inlineResourceGap: 'above' } }
migrateConfig({ layout: { layoutType: 'single' } }, 5, { content: ':::callout\nText.\n\n::resource{id="fig"}\n:::' });
// => { layout: { layoutType: 'single', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 } }
migrateConfig({ bodyText: { textAlign: 'left' } }, 6, { content: 'I say—that is all.' });
// => { bodyText: { textAlign: 'left', breakAfterDashes: false, optimalRagged: false } }
migrateConfig({ paragraphStyles: [{ id: 'verse' }] }, 7, { content: ':::paragraphs{style="verse"}\nA line.\n:::' });
// => { paragraphStyles: [{ id: 'verse' }], bodyText: { paragraphContainerSpacing: 'add' } }
migrateConfig({ bodyText: { fontFamily: 'Georgia' } }, 7, { content: 'A well-known tale.' });
// => { bodyText: { fontFamily: 'Georgia', breakAfterHyphens: false } }
migrateConfig(config, CONFIG_VERSION); // today's rules: `config` itselfcontent is the markdown the configuration lays out (a string or a list of chapters). Without it the maths size is pinned whenever maths is on, the space under containers whenever the configuration declares a paragraph style, and the two gaps, the heading marks, the colon-line room, the box cut, the dash breaks, the split under a heading and the compound breaks always, since the engine cannot tell whether the book has a formula, a :::paragraphs container, an inline figure, a marked heading, a list introduced by a colon, a box, a closed dash, a heading or a compound. The ragged breaking is pinned by the configuration alone, content or not. Migrate a stored configuration once and store it again under CONFIG_VERSION: the maths pin multiplies the scale, so a configuration migrated twice would grow twice. The verse layout and the code fences are pinned without it too, since the book may hold a :::verse poem with no separator or a fence, and the paired indents are pinned by the configuration alone. So are the 1.23 verse turnovers, since the book may set a poem line by line.
#Laying out and rendering a bundle
Four helpers connect an opened bundle to the engine and the backends:
loadBundleFonts(bundle)registers the bundle's faces withdocument.fonts, and their bytes with the engine's font registry for SVG pictures (registerFontBytes). Await it before laying out, because layout measures text with the fonts the browser has. Families the bundle names but does not carry (Google Fonts) still have to be loaded by you, as in any other document.registerBundleImages(bundle)decodes the pictures for the canvas backend (renderPage,renderToCanvas), video posters included.bundleImageUrl(bundle)is theresourceImageUrlresolver forrenderToHtml, andbundleVideoUrl(bundle)itsresourceVideoUrlresolver for the video files a bundle carries. Both recolour SVG figures whendiagramStyle.singleInkis on, once: they recolour the markup and flag the pictures so that no backend tints them again (see Single ink on canvas and in HTML). Both also embed in each SVG the faces its text names, from the bundle's own fonts first, then from the faces registered with the engine (see Fonts in SVG text);registerBundleImages(bundle, { onWarning })andbundleImageUrl(bundle, { onWarning })report a family with no face.buildBundle(bundle)lays the chapters out in order and returns oneVDTDocumentper chapter. Each chapter continues the one before it: heading and resource counters, the open part, page parity and page numbering. A chapter that prints the contents (:::toc) or the index (:::index) receives the whole book's outline. It takes the same options asbuildDocument, plusconfigto override the bundle's configuration,cacheto share a measurement cache andmetadata(see below).bundleResourceBytes(bundle)andbundleFontProvider(bundle, { decodeWoff2, fallback })are theresourceBytesandfontProvideroptions ofpostext-pdf'srenderToPdf. The font provider picks the nearest weight of the requested style from the bundle. For a.woff2face it needsdecompressWoff2, and for a family the bundle does not carry it callsfallbackwith the renderer's arguments,requestincluded, and passes on whatever it returns. A fallback that fetches only a family'slatinfile prints a Chinese family the bundle does not embed as empty boxes; one that answers with slices, such assliceFontProviderin Chinese, Japanese and Korean fonts, prints it whole.
import { openBundle, loadBundleFonts, registerBundleImages, buildBundle, renderPage,
bundleResourceBytes, bundleFontProvider } from 'postext';
import { renderToPdf, decompressWoff2 } from 'postext-pdf';
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle);
await registerBundleImages(bundle);
const docs = buildBundle(bundle); // one VDTDocument per chapter
const firstPage = renderPage(docs[0].pages[0], docs[0]); // a <canvas>
const pdf = await renderToPdf(docs, { // the whole book
fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
resourceBytes: bundleResourceBytes(bundle),
});To lay out a single chapter yourself, pass bundle.chapters[i].markdown, bundle.resources and bundle.config to buildDocument, as you would for any document.
The book's metadata. As in the Sandbox, the first chapter's front matter is the book's: buildBundle hands its title, author and the rest to every chapter, so {title} and {author} running heads hold on every page and every chapter's doc.metadata carries them. A front-matter block at the top of a later chapter is ignored: it is found by its --- lines and blanked without being parsed, so YAML the parser would reject does no harm. options.metadata supplies values the front matter does not set (the front matter wins). The book's page count reaches every chapter too: {bookTotalPages} prints it, while {totalPages} counts the chapter (see Book page count).
const docs = buildBundle(bundle, { metadata: { author: 'A. Author' } });
docs[3].metadata.title; // the first chapter's `title:`Where the book starts. A bundle may hold part of a longer publication: pages 58 to 61 of an issue, chapter 4 of a textbook. Its start says what comes before it, in the terms of buildDocument's continuation: the pages before the first one (pageIndexOffset, which decides the side page 1 falls on, and with it the mirrored margins and the odd and even running heads), the page numbering in effect (pageNumbering), the heading counters (headings), the open part (part) and the resource, statement, footnote and line counters. createBundle writes it as preset.json's start, openBundle returns it as bundle.start, and buildBundle lays the first chapter out with it, as buildDocument({ markdown, continuation: start }, config) would, and chains the chapters after it from there; {bookTotalPages} counts the pages before the book too. A manifest without start reads as before, and a reader that does not know the field ignores it. bookPageCount is not part of it: the reader counts the pages. In a bundle with several languages, localized[…].start gives one edition a start of its own.
const { bytes } = await createBundle({
name: 'Field notes, chapter 4',
markdown,
config,
// Page 58, an even page, in chapter 4.
start: { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3 } },
});
const bundle = await openBundle(bytes);
bundle.start; // { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3, h2: 0, … } }
const [doc] = buildBundle(bundle);
doc.pages[0].pageLabel; // '58'#Live example: open a bundle
The pen loads a two-chapter sample book (lantern.postext, with its own typeface, an SVG figure and a table) from the repository. It registers the bundle's fonts and pictures, lays the book out with buildBundle and paints every page. Make the PDF renders the same documents with postext-pdf, embedding the bundle's fonts. Choose a .postext file of your own, exported from the Sandbox for example, to see it the same way.
import {
openBundle,
loadBundleFonts,
registerBundleImages,
buildBundle,
bundleResourceBytes,
bundleFontProvider,
renderPage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
// A two-chapter book with its own typeface, an SVG figure and a table.
const SAMPLE = 'https://cdn.jsdelivr.net/gh/drnachio/postext@main/docs/examples/open-bundle/lantern.postext';
const status = document.getElementById('status');
const pdfButton = document.getElementById('pdf');
let current = null;
async function show(data) {
// Chapters, config (fonts wired to the bundle's own files), resources and
// every file, keyed by its path inside the bundle.
const bundle = await openBundle(data);
// Layout measures text with the fonts the browser has: register the
// bundle's faces, and load the Google Fonts it names but does not carry
// (the default running heads use Open Sans; see the pen's CSS).
await loadBundleFonts(bundle);
await document.fonts.load('600 16px "Open Sans"');
await registerBundleImages(bundle);
// One VDTDocument per chapter, each continuing the one before it.
const docs = buildBundle(bundle);
const pages = docs.flatMap((doc) => doc.pages.map((page) => renderPage(page, doc)));
document.getElementById('pages').replaceChildren(...pages);
status.textContent = `${bundle.name} · ${bundle.chapters.length} chapter(s) · ${pages.length} page(s)`
+ (bundle.warnings.length ? ` · ${bundle.warnings.length} warning(s)` : '');
current = { bundle, docs };
pdfButton.disabled = false;
document.getElementById('links').hidden = true;
}
// Fonts the bundle does not carry come from Fontsource.
async function fontsource(family, weight, style) {
const id = family.toLowerCase().replace(/\s+/g, '-');
const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`);
if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${family}`);
return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}
pdfButton.addEventListener('click', async () => {
pdfButton.disabled = true;
status.textContent = 'Rendering the PDF…';
const { bundle, docs } = current;
const bytes = await renderToPdf(docs, {
fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
resourceBytes: bundleResourceBytes(bundle),
});
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
document.getElementById('open').href = url;
document.getElementById('download').href = url;
document.getElementById('links').hidden = false;
status.textContent = `${bundle.name} · ${(bytes.length / 1024).toFixed(0)} KB PDF`;
pdfButton.disabled = false;
});
document.getElementById('file').addEventListener('change', async (event) => {
const file = event.target.files[0];
if (!file) return;
status.textContent = `Opening ${file.name}…`;
await show(file).catch((err) => { status.textContent = `Could not open ${file.name}: ${err.message}`; });
});
const res = await fetch(SAMPLE);
await show(await res.arrayBuffer());index.html
<p>
<label>Open a .postext file: <input id="file" type="file" accept=".postext,application/zip"></label>
<button id="pdf" disabled>Make the PDF</button>
<span id="links" hidden>
<a id="open" target="_blank" rel="noopener">open it</a> ·
<a id="download" download="book.pdf">download it</a>
</span>
</p>
<p id="status">Loading the sample book…</p>
<div id="pages"></div>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
#pages {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-start;
}
#pages canvas {
display: block;
width: 240px;
height: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}Loads an interactive editor from codepen.io. The example imports the latest postext release from a CDN.
#Creating a bundle
createBundle writes a .postext file from a document: its chapters, configuration, resources and the payloads they reference.
import { createBundle } from 'postext';
const { bytes, manifest, warnings } = await createBundle({
name: 'The Lantern',
locale: 'en',
chapters: [
{ markdown: '# Dusk\n\nIt is drawn in :ref{id="lantern"}.' },
{ title: 'Night', markdown: '# Night\n\n…' },
],
config,
resources: [{
id: 'lantern', typeId: 'figure', kind: 'svg', caption: 'The lantern.',
svg: { fileId: 'lantern.svg', width: 240, height: 150 },
createdAt: 0, updatedAt: 0,
}],
files: { 'lantern.svg': svgMarkup, 'garamond-regular': fontBytes },
});| Input | Meaning |
|---|---|
name, id, description, locale | The manifest's metadata. id defaults to a slug of name. |
chapters or markdown | The book, one { title?, markdown } per chapter, or a single document. |
config | The PostextConfig. Values equal to the defaults are left out of the manifest. |
resources | The resources. A picture names its payload by bitmap.fileId / svg.fileId (and svg.pdfFileId for a print master). |
files | The payloads by fileId (an object or a Map): the pictures the resources reference and the font files the config.customFonts variants reference. Values may be a Uint8Array, an ArrayBuffer, a Blob or a string (SVG markup). |
thumbnail | { data, mime }: a cover picture (PNG, JPEG, WebP, GIF or SVG). |
canvasScope | 'book' asks viewers to lay the whole book out as one canvas. |
start | Where the book starts, for a bundle that holds part of a longer one: the continuation a single document would be built with. Written as the manifest's start. |
mtime | The modification date written on every file of the archive (a Date, a timestamp or a date string). Left out, it is the time of the call, so two calls with the same input give different bytes. Pass a fixed date and the same input gives the same bytes, which you can hash or compare. A zip keeps a date and time with no time zone, in two-second steps, from 1980 to 2099, and the date is written in the machine's local time. For bytes that match on every machine, build the date from local fields, such as new Date(1980, 0, 1): a timestamp or a string ending in Z names an instant, which falls on a different local time in each time zone ('1980-01-01T00:00:00Z' is still 1979 west of UTC). A date outside those years, in local time, throws. |
localized | More languages of the same book, by locale tag: { es: { chapters?, config?, resources? } }. The inputs above are then the content of locale, which is required. See Bilingual bundles. |
It returns the archive's bytes, the manifest written as preset.json, every file as files (path → bytes) and a list of warnings. Files are named after their resource id (resources/lantern.svg) or font file name (fonts/…), and chapters after their order and title (chapters/01-dusk.md). Fonts are declared in the manifest's fonts, never inside config.customFonts. Some things are left out, each with a warning:
- a resource or font face whose payload is not in
files - a
.woffface (the PDF backend cannot embed one) - a family marked
redistributable: false
In the browser, hand bytes to a download link: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })). In Node, write them with fs.writeFile. createBundle and openBundle do not need a DOM. The dists use extensionless module paths, so under plain Node, without a bundler, they need a resolve hook. The repository's docs/examples/open-bundle/build-sample.mjs shows one in a few lines.
#Bilingual bundles
A .postext file can carry a book in several languages, and openBundle(bytes, { locale }) reads it in any of them. createBundle writes one from localized: one entry per extra locale, each with what differs from the primary content (the locale input):
const { bytes, manifest } = await createBundle({
name: 'The Lantern',
locale: 'en',
chapters: [{ markdown: '# Dusk\n\n…' }, { markdown: '# Night\n\n…' }],
config,
resources: [lanternFigure, hoursTable],
files: { 'lantern.svg': svgEn, 'lantern-es.svg': svgEs },
localized: {
es: {
chapters: [{ markdown: '# Anochecer\n\n…' }, { markdown: '# Noche\n\n…' }],
config: { headings: { levels: [{ level: 1, numberingTemplate: 'Capítulo {1}' }] } },
resources: [
{ id: 'lantern', caption: 'El farol.', svg: { fileId: 'lantern-es.svg', width: 240, height: 150 } },
{ id: 'hours', caption: 'Horas de luz.' },
],
},
},
});
const es = await openBundle(bytes, { locale: 'es' }); // Spanish chapters, config and captionschapters: the book in that language. The chapter files go into one folder per locale (chapters/en/01-dusk.md,chapters/es/01-anochecer.md) and the manifest'schaptersbecomes a locale → chapters map. A locale withoutchaptersreads the primary ones; when no locale has chapters of its own, they stay a single list.config: the configuration for that language. Each top-level key replaces the shared key wholesale when the bundle is read in the locale, soheadingsabove replaces the wholeheadingsobject. Keys left out, or equal to the shared ones, are shared and not written, so passing the locale's full configuration works as well as passing the few keys that change. A key set to its defaults while the shared one is not (layout: {}) is written as given, so it resets the shared value. Fonts are shared: the families of a locale'scustomFontsjoin the bundle'sfonts.resources: the wording of the shared resources, matched byid:caption,note,altTextand a table'stable. A picture with words in it can have its own artwork:bitmap.fileIdorsvg.fileId(andsvg.pdfFileId) name another payload infiles, written asresources/es/lantern.svg. Other fields, such as the type or the placement, are shared. An id that is not amongresourcesis left out with a warning, and a missing locale picture leaves the locale on the shared one, also with a warning.
The manifest lists every language in locales (['en', 'es']), keeps the primary one as locale and stores the rest under localized. openBundle without a locale reads the primary language.
Which language a reader gets. openBundle(bytes, { locale }) serves the exact locale, else its base language (es-MX reads es), else the primary one, and bundle.locale says which it served. Chapters and wording always come from the same language. The primary language keeps the shared wording even when localized carries a regional variant of it: a pt-PT bundle with a pt-BR entry reads the Brazilian captions for pt-BR only, and the shared ones for pt-PT and pt.
#Live example: create a bundle
The pen builds a two-chapter book with an SVG figure and lists the files createBundle wrote along with the manifest. It offers the archive as a download, then opens it again with openBundle and paints its first page: the full round trip in a few lines. Import the downloaded file in the Sandbox to keep working on it there.
import { createBundle, openBundle, registerBundleImages, buildBundle, renderPage } from 'https://esm.sh/postext';
// A picture resource names its payload by fileId; the bytes (here, SVG
// markup) go in `files` under that same id.
const lanternSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 150">
<rect width="240" height="150" fill="#f3efe6"/>
<path d="M100 36 h40 l8 14 h-56 z" fill="#2f3e46"/>
<rect x="98" y="50" width="44" height="58" rx="4" fill="#f6c453" stroke="#2f3e46" stroke-width="4"/>
<circle cx="120" cy="79" r="11" fill="#fff4c2"/>
<path d="M94 108 h52 l-6 12 h-40 z" fill="#2f3e46"/>
</svg>`;
const resources = [{
id: 'lantern',
typeId: 'figure',
kind: 'svg',
caption: 'The lantern by the door.',
svg: { fileId: 'lantern.svg', width: 240, height: 150 },
createdAt: 0,
updatedAt: 0,
}];
const text = 'The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.';
// One entry per chapter; a chapter without a title takes its first # heading.
const chapters = [
{ markdown: `# Dusk\n\n${text} It is drawn in :ref{id="lantern"}.\n\n${text}\n\n${text}` },
{ markdown: `# Night\n\n${text}\n\n${text}` },
];
const config = {
layout: { layoutType: 'double' },
// Two short chapters that run on, with no blank verso between them (an
// H1 otherwise opens on a fresh recto), as in the open-bundle sample.
headings: { levels: [{ level: 1, numberingTemplate: 'Chapter {1}', breakBefore: { enabled: false } }] },
};
// Everything a .postext file holds: manifest, chapters, resources, fonts.
const { bytes, manifest, files, warnings } = await createBundle({
name: 'The Lantern',
locale: 'en',
chapters,
config,
resources,
files: { 'lantern.svg': lanternSvg },
});
if (warnings.length) console.warn(warnings);
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/zip' }));
document.getElementById('download').href = url;
document.getElementById('actions').hidden = false;
document.getElementById('files').replaceChildren(...Object.entries(files).map(([path, data]) => {
const li = document.createElement('li');
li.textContent = `${path} (${data.length} B)`;
return li;
}));
document.getElementById('manifest').textContent = JSON.stringify(manifest, null, 2);
// Round trip: open the file just written, the way any program would.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const bundle = await openBundle(bytes);
await registerBundleImages(bundle);
const [firstChapter] = buildBundle(bundle);
document.getElementById('page').replaceChildren(renderPage(firstChapter.pages[0], firstChapter));
document.getElementById('status').textContent =
`${bundle.name}: ${bundle.chapters.length} chapters, ${(bytes.length / 1024).toFixed(1)} KB`;index.html
<p id="status">Building the bundle…</p>
<p id="actions" hidden>
<a id="download" download="lantern.postext">Download lantern.postext</a> ·
<a href="https://postext.dev/en/sandbox" target="_blank" rel="noopener">open the Sandbox</a> and import it (Projects → New → Import .postext…)
</p>
<div id="output">
<section>
<h3>Files in the bundle</h3>
<ul id="files"></ul>
<h3>preset.json</h3>
<pre id="manifest"></pre>
</section>
<section>
<h3>Opened again: page 1</h3>
<div id="page"></div>
</section>
</div>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
#output {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
#output section {
flex: 1 1 280px;
min-width: 0;
}
h3 {
margin: 8px 0;
font-size: 14px;
}
ul {
margin: 0;
padding-left: 20px;
font-family: ui-monospace, monospace;
font-size: 13px;
}
pre {
max-height: 320px;
overflow: auto;
padding: 8px;
background: #fff;
font-size: 12px;
}
#page canvas {
display: block;
max-width: 100%;
height: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}Loads an interactive editor from codepen.io. The example imports the latest postext release from a CDN.
#Working with bundles
Because the Sandbox, the agent skill and the postext package all read and write the same file, a .postext file is a convenient way to hand a book from one tool to another:
- Start from a bundle. Port an existing publication with the agent skill, or design a book in the Sandbox and export it (Download (.postext) in its row's ⋯ menu in the Books panel). Load the file from your program with
openBundleto render it to canvas, HTML or PDF. Keep the file as the book's source: edit the chapters, the configuration or the resources in code and write it back withcreateBundle, or simply reload it whenever it changes. - Debug and fine-tune in the Sandbox. When something in your program's output needs work (a figure that lands on the wrong page, a heading style, the column balance), export what your program lays out with
createBundle. Import that file in the Sandbox (Books → New → Open a .postext file…), fix the text, design or figures with the live preview, the Checks panel and the PDF view, then export it again. Your program then loads the corrected file withopenBundle. Or copy what changed back into your code: the manifest'sconfigholds only the values that differ from the defaults, so it reads as a short diff.
#Low-level API
postext/bundle also exports the building blocks behind openBundle and createBundle, for hosts that store or serve bundles their own way (an unzipped directory over HTTP, records in a database):
openBundleZip(bytes)/zipBundle(files, { mtime }): the archive layer. Opening tolerates a top-level folder and ignores__MACOSXentries and dotfiles. Paths that escape the bundle are refused.mtimedates the files ascreateBundle's input does.readBundle(manifest, readFile, options)reads a manifest plus areadFile(path)callback into chapters, config, resources, pictures and fonts.optionssets the locale, how file ids are named (ids), the base configuration (baseConfig, under the manifest's; by default the palette and the resource types ofbundleBaseConfigin the bundle's language, whichresolveBundleConfigLocale(manifest, locale)returns, and a host that passes its ownbaseConfigshould localise it to that language; under a manifest older thanconfigVersion: 4itsmathis pinned with the bundle's, under one older than 5 itslayout's inline gap, under one older than 6 itslayout's gap in boxes, itsbodyText's colon-line room, itsheadings' inline marks and its drop-cap sizes, under one older than 7 itsbodyText's dash breaks and ragged breaking, and under one older than 8 itsheadings' split under a heading and itsbodyText's compound breaks and space under:::paragraphscontainers, see Bundles written by postext 1.4 or earlier) and how intrinsic sizes are measured.readResolutionreads each bitmap's resolution from its file intobitmap.fileResolution; it defaults to true when the bundle setslayout.bitmapResolution: 'file'.planBundle(meta, content)/resolveBundleFiles(plan, sources): the writing side, split into a pure plan (file names and manifest) and resolving the bytes throughreadBlob/readFontcallbacks.isBundleManifest(value), the locale pickers (pickChapterSpecs,pickLocaleOverrides,pickBundleView,resolveBundleLocale,resolveBundleConfigLocale),svgSize/bitmapSize/bitmapInfo(a bitmap's pixels and the resolution its file states), and the format types (BundleManifest,BundleResourceSpec,BundleFontFamilySpec, …).CONFIG_VERSION,migrateConfig(config, configVersion, { content }),pinLegacyHeadingBreaks(config),pinLegacyMathSize(config),pinLegacyInlineGap(config),pinLegacyBoxResourceGap(config),pinLegacyHeadingMarks(config),pinLegacyDropCapSize(config),pinLegacyColonListRoom(config),pinLegacyBoxChildCut(config),pinLegacyDashBreaks(config),pinLegacyRaggedBreaking(config),pinLegacyHeadingSplit(config),pinLegacyParagraphContainerSpacing(config),pinLegacyHyphenBreaks(config)andLEGACY_MATH_SIZE(0.5 ÷ 0.442): a stored configuration in today's terms (see Bundles written by postext 1.4 or earlier).readBundleapplies it; a host that stores configurations its own way can too, once per stored copy.
The Sandbox is built on these. It adds its own storage ids and the layouts.json page records.