# 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

- HTML version: https://postext.dev/en/docs/configuration-bundles
- Last updated: 2026-10-16
- Reading time: 27 min
- Other languages: [es](https://postext.dev/es/docs/configuration-bundles.md), [ca](https://postext.dev/ca/docs/configuration-bundles.md), [de](https://postext.dev/de/docs/configuration-bundles.md), [fr](https://postext.dev/fr/docs/configuration-bundles.md), [it](https://postext.dev/it/docs/configuration-bundles.md), [pt](https://postext.dev/pt/docs/configuration-bundles.md), [zh](https://postext.dev/zh/docs/configuration-bundles.md), [ja](https://postext.dev/ja/docs/configuration-bundles.md), [ar](https://postext.dev/ar/docs/configuration-bundles.md)

## 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](https://postext.dev/en/docs/sandbox-books-saving.md#export-and-import) exports and imports it and the [agent skill](https://postext.dev/en/docs/skill.md) 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](https://postext.dev/en/docs/sandbox-embedding.md#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:

```ts
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. `name` and `description` are the edition's: the served language's `localized[…].name` and `description` when it gives them, else the manifest's. |
| `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 `headings` object with no H1 break had none (see [Per-level overrides](https://postext.dev/en/docs/configuration-headings.md#per-level-overrides)).
- **The maths size** (rules 4): up to 1.4, formulas came out 1.131 times larger than `fontSizeScale` says (see [Formula size](https://postext.dev/en/docs/configuration-lists-math.md#math)).
- **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 (see `layout.inlineResourceGap` under [Layout](https://postext.dev/en/docs/configuration-page-layout.md#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.inlineResourceGapInBoxes` under [Layout](https://postext.dev/en/docs/configuration-page-layout.md#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 (see `headings.inlineMarks` under [Headings](https://postext.dev/en/docs/configuration-headings.md#headings)).
- **The size of a drop cap** (rules 6): up to 1.4, a design text's `dropCap` with no `fontSize` was as tall as all the line boxes it spans, its top above the first line (see `dropCap` under [Text elements](https://postext.dev/en/docs/configuration-headers-footers.md#text-elements)).
- **The room under a colon line** (rules 6): up to 1.4, `keepColonWithList` took 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 (see `bodyText.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 `splitMinLines` lines in all (see `layout.boxChildSplitMinLines` under [Layout](https://postext.dev/en/docs/configuration-page-layout.md#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 (see `bodyText.breakAfterDashes` under [Body text](https://postext.dev/en/docs/configuration-text.md#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 `optimalLineBreaking` said (see `bodyText.optimalRagged` under [Body text](https://postext.dev/en/docs/configuration-text.md#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.keepWithNextSplit` under [Headings](https://postext.dev/en/docs/configuration-headings.md#headings)).
- **The space under a `:::paragraphs` container** (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's `marginTop`) was added under it, and the text's paragraph spacing was left out (see `bodyText.paragraphContainerSpacing` under [Body text](https://postext.dev/en/docs/configuration-text.md#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 (see `bodyText.breakAfterHyphens` under [Body text](https://postext.dev/en/docs/configuration-text.md#body-text)).
- **Poems with no separator** (rules 9): up to 1.22, a `:::verse` poem whose lines carried no `||` was set as single hemistichs, each line centred (see `bodyText.verse.layout` under [Verse](https://postext.dev/en/docs/configuration-text.md#verse)).
- **A first-line indent beside a hanging indent** (rules 9): up to 1.22, a paragraph style's `hangingIndent` replaced its `firstLineIndent`, and the first line started at `indent` (see [Paragraph styles](https://postext.dev/en/docs/configuration-styles.md#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 (see `bodyText.hardLineBreaks` under [Body text](https://postext.dev/en/docs/configuration-text.md#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 (see `codeStyle.blocks` under [Code listings](https://postext.dev/en/docs/configuration-styles.md#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.tighten` under [Verse](https://postext.dev/en/docs/configuration-text.md#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](https://postext.dev/en/docs/configuration-lists-math.md#math) 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.

```ts
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` itself
```

`content` 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 with `document.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 the `resourceImageUrl` resolver for `renderToHtml`, and **`bundleVideoUrl(bundle)`** its `resourceVideoUrl` resolver for the video files a bundle carries. Both recolour SVG figures when `diagramStyle.singleInk` is 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](https://postext.dev/en/docs/configuration-resources.md#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](https://postext.dev/en/docs/configuration-resources.md#fonts-in-svg-text)); `registerBundleImages(bundle, { onWarning })` and `bundleImageUrl(bundle, { onWarning })` report a family with no face.
- **`buildBundle(bundle)`** lays the chapters out in order and returns one `VDTDocument` per 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 as `buildDocument`, plus `config` to override the bundle's configuration, `cache` to share a measurement cache and `metadata` (see below).
- **`bundleResourceBytes(bundle)`** and **`bundleFontProvider(bundle, { decodeWoff2, fallback })`** are the `resourceBytes` and `fontProvider` options of `postext-pdf`'s `renderToPdf`. The font provider picks the nearest weight of the requested style from the bundle. For a `.woff2` face it needs `decompressWoff2`, and for a family the bundle does not carry it calls `fallback` with the renderer's arguments, `request` included, and passes on whatever it returns. A fallback that fetches only a family's `latin` file prints a Chinese family the bundle does not embed as empty boxes; one that answers with slices, such as `sliceFontProvider` in [Chinese, Japanese and Korean fonts](https://postext.dev/en/docs/configuration-pdf-output.md#chinese-japanese-and-korean-fonts), prints it whole.

```ts
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](https://postext.dev/en/docs/configuration-headers-footers.md#book-page-count)).

```ts
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.

```ts
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.

> **Runnable example: Postext · open a .postext bundle** — Open a .postext file with postext, lay the book out and render it to canvas and PDF. ([source](https://github.com/drnachio/postext/tree/main/docs/examples/open-bundle))

### Creating a bundle

`createBundle` writes a `.postext` file from a document: its chapters, configuration, resources and the payloads they reference.

```ts
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: { name?, description?, chapters?, config?, resources? } }`. The inputs above are then the content of `locale`, which is required. See [Bilingual bundles](https://postext.dev/en/docs/configuration-bundles.md#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 `.woff` face (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):

```ts
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: {
      name: 'El farol',
      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 captions
```

- **`chapters`**: 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's `chapters` becomes a locale → chapters map. A locale without `chapters` reads 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, so `headings` above replaces the whole `headings` object. 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's `customFonts` join the bundle's `fonts`.
- **`resources`**: the wording of the shared resources, matched by `id`: `caption`, `note`, `altText` and a table's `table`. A picture with words in it can have its own artwork: `bitmap.fileId` or `svg.fileId` (and `svg.pdfFileId`) name another payload in `files`, written as `resources/es/lantern.svg`. Other fields, such as the type or the placement, are shared. An id that is not among `resources` is left out with a warning, and a missing locale picture leaves the locale on the shared one, also with a warning.
- **`name`**, **`description`**: the book's title and description in that language, written as `localized[…].name` and `description` when they differ from the shared ones. `openBundle` returns them as `bundle.name` and `bundle.description` for that locale, the Sandbox lists the edition under that title (on import and in its library), and `postext info` and `postext check` print them. An `index.json` entry of a preset source may copy them under `localized` (`localized.ca.name`) so the library names each edition before it is opened. A locale without them keeps the manifest's `name` and `description`.

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.

> **Runnable example: Postext · create a .postext bundle** — Write a .postext file with postext's createBundle, download it and open it again. ([source](https://github.com/drnachio/postext/tree/main/docs/examples/create-bundle))

### 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](https://postext.dev/en/docs/skill.md), or design a book in the [Sandbox](https://postext.dev/en/sandbox.md) and export it (**Download (.postext)** in its row's ⋯ menu in the Books panel). Load the file from your program with `openBundle` to 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 with `createBundle`, 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 with `openBundle`. Or copy what changed back into your code: the manifest's `config` holds 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 `__MACOSX` entries and dotfiles. Paths that escape the bundle are refused. `mtime` dates the files as `createBundle`'s input does.
- **`readBundle(manifest, readFile, options)`** reads a manifest plus a `readFile(path)` callback into chapters, config, resources, pictures and fonts. `options` sets the locale, how file ids are named (`ids`), the base configuration (`baseConfig`, under the manifest's; by default the palette and the resource types of `bundleBaseConfig` in the bundle's language, which `resolveBundleConfigLocale(manifest, locale)` returns, and a host that passes its own `baseConfig` should localise it to that language; under a manifest older than `configVersion: 4` its `math` is pinned with the bundle's, under one older than 5 its `layout`'s inline gap, under one older than 6 its `layout`'s gap in boxes, its `bodyText`'s colon-line room, its `headings`' inline marks and its drop-cap sizes, under one older than 7 its `bodyText`'s dash breaks and ragged breaking, and under one older than 8 its `headings`' split under a heading and its `bodyText`'s compound breaks and space under `:::paragraphs` containers, see [Bundles written by postext 1.4 or earlier](https://postext.dev/en/docs/configuration-bundles.md#bundles-written-by-postext-14-or-earlier)) and how intrinsic sizes are measured. `readResolution` reads each bitmap's resolution from its file into `bitmap.fileResolution`; it defaults to true when the bundle sets `layout.bitmapResolution: 'file'`.
- **`planBundle(meta, content)` / `resolveBundleFiles(plan, sources)`**: the writing side, split into a pure plan (file names and manifest) and resolving the bytes through `readBlob` / `readFont` callbacks.
- **`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)` and `LEGACY_MATH_SIZE`** (0.5 ÷ 0.442): a stored configuration in today's terms (see [Bundles written by postext 1.4 or earlier](https://postext.dev/en/docs/configuration-bundles.md#bundles-written-by-postext-14-or-earlier)). `readBundle` applies 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.
