What you'll build
A bilingual chapbook of five poems about a salt pan in the south of Spain, 156 × 234 mm, with the Spanish on the left and the poet’s own English version on the right, line for line. A title page with a flamingo and the author’s note come first; the poems open under a drawing of pink ponds, heaps of salt and flamingos at sunrise. Each poem sits in a frameless box under a madder numeral in Castoro Titling, the Spanish in one column and the English in the other. The English starts on the same line as the Spanish, and every stanza starts level with its original. When an English line is too long for its column, the rest of it turns over 2 em in, and the Spanish stanza opposite ends with an extra blank line.
This recipe answers
- How do I put two columns inside a box (a text column beside a figure column)?
- How do I set poetry: one line per verse, stanza gaps, hanging indents for wrapped lines, no hyphenation?
- How do I add extra vertical space between two blocks, when blank lines do nothing?
- How do I get good justification and hyphenation for Spanish, French or German text?
The short answer
// Each poem is a box titled with its numeral, holding one two-column group. breaks="14"
// opens the second column at the group's 14th block, since the Spanish title and its 12
// lines come before it. A :::space is not a block, so stanza gaps leave the count alone:
// :::callout{type="poem" title="I"}
// :::columns{count=2 breaks="14"}
// Spanish title, :::space{lines=0.5}, 12 lines with a :::space between stanzas
// English title, :::space{lines=0.5}, 12 lines with a :::space between stanzas
// :::
// :::
// Both columns open on the same line, so matching :::space gaps keep the stanzas level.
// A box keeps together and a group never splits (gotcha: callout-columns): a poem that does
// not fit moves whole to the next page.
const poem = {
id: 'poem',
backgroundEnabled: false, // no fill, border or stripe
padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
columnGap: mm(GAP),
titleStyle: { fontFamily: 'Castoro Titling', fontSize: pt(22), fontWeight: 400,
color: col('madder'), gap: pt(4) },
// A box starts on the first grid line at least two lines down: 10.2 mm under the drawing,
// 12.3 mm under a poem, whose box ends off the grid. The default marginBottom (0.75 em)
// would add to marginTop and open 17.4 mm between poems.
marginTop: pt(LEAD * 2), marginBottom: pt(0),
};
A poem and its translation: a box with two columns and a fixed break
Ingredients
- Features
- Columns inside a boxExplicit vertical spaceParagraph stylesCallout boxesHyphenation and document languageHeading stylesDesigned openersHeading attributesPictures in page designsCovers, title pages and colophonsDocument metadataColumn balancingRunning heads and foliosHeads by page roleMirrored marginsSemantic colour paletteFigures and tables as resourcesRunning heads per sectionPages on a canvas
- Also uses
- Bibliographies and glossaries
- Type
- Castoro, Castoro Titling, Tenor Sans (SIL OFL 1.1)
- Assets
- The salt pans at sunrise and the flamingo on the title page, drawn in code in the page’s palette (Postext Cookbook, CC BY 4.0)
Method
#1 · A poem is a box with a fixed column break
The code is the short answer above. :::columns works only inside a box, so each poem is a callout whose style drops the fill and the padding and prints the fence’s title, the numeral (:::columns). Without breaks, the group cuts where its two columns come out level. Both halves of these poems are the same height, so the cut falls on the English title anyway; lengthen two lines of The Rake until they turn over, and the cut moves one block down and leaves THE RAKE at the foot of the Spanish column. breaks="14" names the block that opens the second column, so the English title heads it whatever the heights of the two halves.
#2 · A paragraph per line, a :::space per stanza break
// Every line of verse is a paragraph of this style, set ragged so that no line is stretched.
// A line too long for its 55 mm column turns over 2 em in, where it cannot pass for the
// next line; the Spanish stanza opposite then ends with :::space{lines=2}, not :::space.
const verse = { id: 'verse', textAlign: 'left', hangingIndent: em(2) };
// Castoro Titling draws capitals only. A paragraph style's margins do not count inside a box
// (gotcha: box-paragraph-margins), so :::space{lines=0.5} sets each title off its poem.
const poemTitle = { id: 'poem-title', fontFamily: 'Castoro Titling', fontSize: pt(9.5),
color: col('madder'), firstLineIndent: pt(0) };
Markdown joins the lines of a paragraph, so each line of verse is a paragraph of its own, in the ragged verse style, which 1.4.1 never hyphenates. A :::space between stanzas leaves a blank line in each column. It is not a block, so breaks does not count it (:::space). The English each one standing on its own reflection. is too long for its 55 mm column and turns over 2 em in; the Spanish stanza opposite ends with :::space{lines=2}, so the next stanzas start on the same line.

#3 · The note is hyphenated in its own language
const LOCALE = t({ en: 'en-us', es: 'es' }); // exact codes (gotcha: hyphenation-locales)
const bodyText = {
fontFamily: 'Castoro', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
italicColor: col('ink'), firstLineIndent: mm(4.5), indentAfterHeading: false,
minWordSpacing: 0.8, maxWordSpacing: 1.35, // word spaces from 0.8 to 1.35 of normal
// No bold on these pages: ink keeps a **bold** or a :ref added later off the default blue
// (#295AA3). References take boldColor while referenceColor is unset.
boldColor: col('ink'),
};
// The heading prints its title 36 mm down the text block, and the note starts under it.
const notePage = { id: 'note', advancedDesign: { enabled: true, slot: {
elements: [{ kind: 'text', id: 'title', content: '{titleText}', align: 'left',
fontFamily: 'Castoro Titling', fontSize: pt(13), color: col('madder'), overflow: 'wrap',
placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(36) } } }],
} } };
locale picks the hyphenation patterns by exact code. With 'es' the Spanish note breaks ori-ginal and Duna-liella; with the English patterns both words go whole to the next line, and only arras-traba breaks under either (supported locales). Word spaces may shrink to 0.8 and stretch to 1.35 of their normal width, and no justified line in either note stretches past that. The note’s heading is a design whose one element, the title, sits 36 mm below the top of the text block, so the note starts 73 mm from the top edge of its page.
#4 · The title page and the drawing are headings
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } });
const face = (id, content, font, size, y, extra = {}) => ({ kind: 'text', id, content,
fontFamily: font, fontSize: pt(size), color: col('ink'), align: 'center', overflow: 'wrap',
placement: onPage(y), ...extra });
const tracked = { letterSpacing: pt(2), textTransform: 'uppercase' };
const image = (id, placement) => ({ kind: 'image', id, resourceId: id, placement });
const titlePage = {
id: 'title-page',
header: { elements: [] }, footer: { elements: [] }, // no running head, no folio
advancedDesign: { enabled: true, slot: { elements: [
face('author', '{author}', 'Tenor Sans', 9, 44, { ...tracked, color: col('muted') }),
// A multiple of the size, never pt() (gotcha: design-lineheight-multiple).
face('title', '{titleText}', 'Castoro Titling', 34, 58, { lineHeight: 1 }),
face('other', '{attr.other}', 'Castoro', 16, 76, { italic: true, color: col('madder') }),
image('flamingo', { ...onPage(96), size: { width: mm(34) } }),
face('edition', '{attr.edition}', 'Tenor Sans', 8.5, 170, tracked),
face('version', '{attr.version}', 'Castoro', 10.5, 176, { italic: true }),
face('press', '{attr.press}', 'Tenor Sans', 8, 206, { ...tracked, color: col('muted') }),
] } },
};
const BAND = 84; // mm: the drawing of the salt pans, from the top edge of the page
const UNDER = Math.floor((BAND - TOP) / ((LEAD * 25.4) / 72)); // 12 grid lines to its foot
const poemsOpener = {
id: 'poems', span: 'page', // span 'page': a column clips its design
// An image reserves no height (gotcha: opener-image-no-reserve). minHeight, a whole number
// of lines, plus the level's one-line bottom margin end the heading at the drawing's foot.
advancedDesign: { enabled: true, minHeight: pt(LEAD * (UNDER - 1)), slot: { elements: [
image('salina', { anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill' } }),
face('title', '{titleText}', 'Castoro Titling', 38, 17, { lineHeight: 1 }),
face('other', '{attr.other}', 'Castoro', 15, 33, { italic: true, color: col('madder') }),
] } },
};
Each is a level-1 heading with a style of its own (heading styles): the title page’s design takes {author} from the frontmatter, and both designs take {attr.other} from the heading’s attributes. The drawing starts at the top edge of the page, above the text block, where a design kept in the column would be clipped, so its style has span: 'page'. An image reserves no height, so without minHeight the heading is 25.6 mm deep and poem I starts 58 mm down the page, on a drawing that reaches 84 mm. A minHeight of eleven lines plus the level’s one-line bottom margin ends the heading at 83.4 mm.
#5 · The running heads name both languages
const edgeAt = (edge, x, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } });
const head = (id, content, parity, placement, extra = {}) => ({ kind: 'text', id, content,
parity, pages: 'body', fontFamily: 'Tenor Sans', fontSize: pt(7.5), color: col('muted'),
...tracked, letterSpacing: pt(1.5), placement, ...extra });
const folio = { color: col('ink'), letterSpacing: pt(0) };
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', edgeAt('top-left', OUTER, 12), folio),
head('verso-title', 'Sal de agosto', 'even', edgeAt('top-left', OUTER + 8, 12)),
head('recto-title', 'August Salt', 'odd', edgeAt('top-right', -(OUTER + 8), 12)),
head('recto-folio', '{pageNumber}', 'odd', edgeAt('top-right', -OUTER, 12), folio),
] };
// The note and the drawing open their pages ('opener'): a folio at the foot instead.
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', edgeAt('bottom', 0, -12),
{ ...folio, pages: 'opener' })] };
The verso carries the Spanish title and the recto the English one, in the order of the columns. pages: 'body' keeps them off the three pages that open with a heading. The note and the drawing get a folio at the foot instead, and the title page’s style empties its header and its footer (text elements).
The whole recipe
// ═══ Postext Cookbook · Nº 043 · Facing translation, stanza by stanza ═══════════════════ // https://postext.dev/en/cookbook/bilingual-facing-verse // Code: MIT · Text: original (CC BY 4.0) · Salt pans and flamingo: drawn in code // Fonts: Castoro, Castoro Titling, Tenor Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'bilingual-facing-verse'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // every colour in the config links to one of these ink: '#1f2430', // the text: a blue-black madder: '#9a3c52', // the one accent: numerals, poem titles, the other language's title brine: '#e7aaa2', // the pink of the crystallising ponds sky: '#dfe7ec', // the sky before sunrise dawn: '#f6dccb', // the sky at the horizon muted: '#6a6770', // running heads, folios, the author's name, the colophon paper: '#ffffff', }; // col() writes the hex beside the id, since designs and running heads do not read the // palette (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries(palette) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const TRIM_W = 156, TRIM_H = 234; // mm const TOP = 22, OUTER = 18, INNER = 20; // mm: the text block is 118 mm wide const LEAD = 14.5; // pt: the leading of the note and of every line of verse const GAP = 8; // mm between the original and the translation: each column is 55 mm wide // #region answer: a poem and its translation: a box with two columns and a fixed break // Each poem is a box titled with its numeral, holding one two-column group. breaks="14" // opens the second column at the group's 14th block, since the Spanish title and its 12 // lines come before it. A :::space is not a block, so stanza gaps leave the count alone: // :::callout{type="poem" title="I"} // :::columns{count=2 breaks="14"} // Spanish title, :::space{lines=0.5}, 12 lines with a :::space between stanzas // English title, :::space{lines=0.5}, 12 lines with a :::space between stanzas // ::: // ::: // Both columns open on the same line, so matching :::space gaps keep the stanzas level. // A box keeps together and a group never splits (gotcha: callout-columns): a poem that does // not fit moves whole to the next page. const poem = { id: 'poem', backgroundEnabled: false, // no fill, border or stripe padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) }, columnGap: mm(GAP), titleStyle: { fontFamily: 'Castoro Titling', fontSize: pt(22), fontWeight: 400, color: col('madder'), gap: pt(4) }, // A box starts on the first grid line at least two lines down: 10.2 mm under the drawing, // 12.3 mm under a poem, whose box ends off the grid. The default marginBottom (0.75 em) // would add to marginTop and open 17.4 mm between poems. marginTop: pt(LEAD * 2), marginBottom: pt(0), }; // #endregion // #region verse: a paragraph per line, turnovers that hang, a title over each column // Every line of verse is a paragraph of this style, set ragged so that no line is stretched. // A line too long for its 55 mm column turns over 2 em in, where it cannot pass for the // next line; the Spanish stanza opposite then ends with :::space{lines=2}, not :::space. const verse = { id: 'verse', textAlign: 'left', hangingIndent: em(2) }; // Castoro Titling draws capitals only. A paragraph style's margins do not count inside a box // (gotcha: box-paragraph-margins), so :::space{lines=0.5} sets each title off its poem. const poemTitle = { id: 'poem-title', fontFamily: 'Castoro Titling', fontSize: pt(9.5), color: col('madder'), firstLineIndent: pt(0) }; // #endregion // #region note: the author's note, justified and hyphenated in the edition's language const LOCALE = t({ en: 'en-us', es: 'es' }); // exact codes (gotcha: hyphenation-locales) const bodyText = { fontFamily: 'Castoro', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.35, // word spaces from 0.8 to 1.35 of normal // No bold on these pages: ink keeps a **bold** or a :ref added later off the default blue // (#295AA3). References take boldColor while referenceColor is unset. boldColor: col('ink'), }; // The heading prints its title 36 mm down the text block, and the note starts under it. const notePage = { id: 'note', advancedDesign: { enabled: true, slot: { elements: [{ kind: 'text', id: 'title', content: '{titleText}', align: 'left', fontFamily: 'Castoro Titling', fontSize: pt(13), color: col('madder'), overflow: 'wrap', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(36) } } }], } } }; // #endregion // #region front: the title page and the drawing over the poems, each a heading's design const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); const face = (id, content, font, size, y, extra = {}) => ({ kind: 'text', id, content, fontFamily: font, fontSize: pt(size), color: col('ink'), align: 'center', overflow: 'wrap', placement: onPage(y), ...extra }); const tracked = { letterSpacing: pt(2), textTransform: 'uppercase' }; const image = (id, placement) => ({ kind: 'image', id, resourceId: id, placement }); const titlePage = { id: 'title-page', header: { elements: [] }, footer: { elements: [] }, // no running head, no folio advancedDesign: { enabled: true, slot: { elements: [ face('author', '{author}', 'Tenor Sans', 9, 44, { ...tracked, color: col('muted') }), // A multiple of the size, never pt() (gotcha: design-lineheight-multiple). face('title', '{titleText}', 'Castoro Titling', 34, 58, { lineHeight: 1 }), face('other', '{attr.other}', 'Castoro', 16, 76, { italic: true, color: col('madder') }), image('flamingo', { ...onPage(96), size: { width: mm(34) } }), face('edition', '{attr.edition}', 'Tenor Sans', 8.5, 170, tracked), face('version', '{attr.version}', 'Castoro', 10.5, 176, { italic: true }), face('press', '{attr.press}', 'Tenor Sans', 8, 206, { ...tracked, color: col('muted') }), ] } }, }; const BAND = 84; // mm: the drawing of the salt pans, from the top edge of the page const UNDER = Math.floor((BAND - TOP) / ((LEAD * 25.4) / 72)); // 12 grid lines to its foot const poemsOpener = { id: 'poems', span: 'page', // span 'page': a column clips its design // An image reserves no height (gotcha: opener-image-no-reserve). minHeight, a whole number // of lines, plus the level's one-line bottom margin end the heading at the drawing's foot. advancedDesign: { enabled: true, minHeight: pt(LEAD * (UNDER - 1)), slot: { elements: [ image('salina', { anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill' } }), face('title', '{titleText}', 'Castoro Titling', 38, 17, { lineHeight: 1 }), face('other', '{attr.other}', 'Castoro', 15, 33, { italic: true, color: col('madder') }), ] } }, }; // #endregion // #region heads: the Spanish title over the verso, the English title over the recto const edgeAt = (edge, x, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } }); const head = (id, content, parity, placement, extra = {}) => ({ kind: 'text', id, content, parity, pages: 'body', fontFamily: 'Tenor Sans', fontSize: pt(7.5), color: col('muted'), ...tracked, letterSpacing: pt(1.5), placement, ...extra }); const folio = { color: col('ink'), letterSpacing: pt(0) }; const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', edgeAt('top-left', OUTER, 12), folio), head('verso-title', 'Sal de agosto', 'even', edgeAt('top-left', OUTER + 8, 12)), head('recto-title', 'August Salt', 'odd', edgeAt('top-right', -(OUTER + 8), 12)), head('recto-folio', '{pageNumber}', 'odd', edgeAt('top-right', -OUTER, 12), folio), ] }; // The note and the drawing open their pages ('opener'): a folio at the foot instead. const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', edgeAt('bottom', 0, -12), { ...folio, pages: 'opener' })] }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: LOCALE, colorPalette, page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, margins: { top: mm(TOP), bottom: mm(22), left: mm(INNER), right: mm(OUTER), mirror: true }, }, layout: { layoutType: 'single' }, bodyText, headings: { // The headings print designs, but their blocks carry this face: left out, the build // would ask for Open Sans 700, and Castoro Titling ships a 400 only. fontFamily: 'Castoro Titling', fontWeight: 400, balancing: { enabled: false }, // on, poem III drops 22.5 mm (gotcha: balancing-drops-last-box) levels: [ // restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break) { level: 1, marginBottom: pt(LEAD), breakBefore: { enabled: true, parity: 'any' } }, ], }, headingStyles: [titlePage, notePage, poemsOpener], calloutStyles: [poem], paragraphStyles: [verse, poemTitle, { id: 'signature', textAlign: 'right', firstLineIndent: pt(0), marginTop: pt(LEAD) }, { id: 'colophon', fontFamily: 'Tenor Sans', fontSize: pt(7.5), lineHeight: pt(11), color: col('muted'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD * 3) }, ], header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown sample · 349 lines · content.en.md
title: "August Salt" author: "Adela Membrives" --- # August Salt {style="title-page" other="Sal de agosto" edition="Bilingual edition" version="English version by the author" press="Cuadernos de Salmuera · 7"} # Author’s Note {style="note"} I wrote these five poems at the salt pans where my father worked, in the south of Spain, during the August harvest of 2024. He spent thirty-one years there; I spent my summers sitting on the wall of a pond, watching him drag the salt to the edge with his rake. The English version is my own and follows the Spanish line by line: each line on the right translates the one across from it, and each stanza starts level with its original. When an English line is too long for the column it carries on below, indented, and the Spanish stanza leaves a blank line at its end, so that the next pair starts level again. Some words do not cross whole. *Rastro* is both the wooden rake that drags the salt and the trace it leaves behind; I chose *rake*, the tool, and left the trace for the reader. *Levante* is the east wind, which blows dry on that coast and sets the salt in three days. English has the word *levanter*, but few readers know it, so the poem says *east wind*. The pink of the ponds comes mostly from *Dunaliella salina*, a microscopic alga, and from archaea that live only in brine. Brine shrimp feed on the alga, and the flamingos take their pink from the shrimp they eat. :::paragraphs{style="signature"} *A. M., March 2025* ::: # August Salt {style="poems" other="Sal de agosto"} :::callout{type="poem" title="I"} :::columns{count=2 breaks="14"} :::paragraphs{style="poem-title"} La salina al alba ::: :::space{lines=0.5} :::paragraphs{style="verse"} Antes del sol, la salina es una lámina de cobre. Los flamencos duermen de pie, cada uno sobre su reflejo. :::space{lines=2} El agua no se mueve. Tiene el color de la encía, de la gamba cocida, de una uña que aprieta. :::space Mi padre dice que es un alga y un camarón diminuto, que el flamenco se vuelve rosa de tanto comérselos. ::: :::paragraphs{style="poem-title"} The Salt Pans at Dawn ::: :::space{lines=0.5} :::paragraphs{style="verse"} *Before the sun, the salt pan* *is a sheet of copper.* *The flamingos sleep upright,* *each one standing on its own reflection.* :::space *The water does not move.* *It is the colour of gums,* *of a boiled prawn,* *of a fingernail pressed down.* :::space *My father says it is an alga* *and a tiny shrimp,* *that the flamingos turn pink* *from eating so many.* ::: ::: ::: :::callout{type="poem" title="II"} :::columns{count=2 breaks="14"} :::paragraphs{style="poem-title"} El rastro ::: :::space{lines=0.5} :::paragraphs{style="verse"} A las seis ya está en la balsa con el agua a los tobillos y un rastro de madera más viejo que yo. :::space Tira de la sal hacia sí como quien recoge una red sin peces, nada más que luz que cruje. :::space Por la tarde tiene grietas en los nudillos, finas como las de la costra que deja el agua al irse. ::: :::paragraphs{style="poem-title"} The Rake ::: :::space{lines=0.5} :::paragraphs{style="verse"} *By six he is in the pond* *with water to his ankles* *and a wooden rake* *older than I am.* :::space *He draws the salt towards him* *the way you haul in a net* *with no fish in it, only light* *that crunches.* :::space *By evening he has cracks* *across his knuckles, as fine* *as the ones in the crust* *the water leaves as it goes.* ::: ::: ::: :::callout{type="poem" title="III"} :::columns{count=2 breaks="10"} :::paragraphs{style="poem-title"} Los montones ::: :::space{lines=0.5} :::paragraphs{style="verse"} En agosto crecen montes blancos junto a la carretera. Los turistas paran y hacen fotos: creen que es nieve, o yeso. :::space Un camión se lleva uno cada mañana. A la vuelta la balsa ya tiene otra vez el cielo dentro. ::: :::paragraphs{style="poem-title"} The Heaps ::: :::space{lines=0.5} :::paragraphs{style="verse"} *In August white hills grow* *beside the coast road.* *Tourists stop and take pictures:* *they think it is snow, or plaster.* :::space *A lorry takes one away* *every morning. On its return* *the pond already holds* *the sky again.* ::: ::: ::: :::callout{type="poem" title="IV"} :::columns{count=2 breaks="10"} :::paragraphs{style="poem-title"} Levante ::: :::space{lines=0.5} :::paragraphs{style="verse"} Cuando entra el levante la sal cuaja en tres días. Mi padre lo nota antes: se le pone ronca la voz. :::space Se anuda el pañuelo y mira el cielo del cabo como se mira a un perro que puede morder. ::: :::paragraphs{style="poem-title"} East Wind ::: :::space{lines=0.5} :::paragraphs{style="verse"} *When the east wind comes in* *the salt sets in three days.* *My father feels it first:* *his voice goes hoarse.* :::space *He knots his neckerchief* *and looks at the sky over the cape* *the way you look at a dog* *that might bite.* ::: ::: ::: :::callout{type="poem" title="V"} :::columns{count=2 breaks="10"} :::paragraphs{style="poem-title"} Septiembre ::: :::space{lines=0.5} :::paragraphs{style="verse"} En septiembre se van todos. Se queda sola la iglesia entre las balsas vacías y un flamenco que no se decide. :::space Me queda en la boca el sabor de su pulgar cuando me limpiaba la cara antes de entrar a misa. ::: :::paragraphs{style="poem-title"} September ::: :::space{lines=0.5} :::paragraphs{style="verse"} *In September everyone leaves.* *The church is left alone* *among the empty ponds* *and one flamingo, undecided.* :::space *What stays in my mouth* *is the taste of his thumb* *when he wiped my face* *before we went in to Mass.* ::: ::: ::: :::paragraphs{style="colophon"} Poems and note written for the Postext Cookbook (CC BY 4.0); the poet is fictional. Set in Castoro, Castoro Titling and Tenor Sans (SIL OFL). Drawings made in code. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the salt pans at sunrise, and a flamingo for the title page let seed = 2408; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe const rand = () => { let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)); const mix = (a, b, k) => `#${hex(palette[a]).map((v, i) => Math.round(v * (1 - k) + hex(palette[b])[i] * k).toString(16).padStart(2, '0')).join('')}`; const f = (n) => n.toFixed(1); const PX = 10; // a drawing w × h mm has a viewBox in tenths of a millimetre const svgOf = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" ` + `height="${h * PX}" viewBox="0 0 ${w * PX} ${h * PX}">${body}</svg>`; const poly = (pts, fill) => `<path d="M${pts.map(([x, y]) => `${f(x)} ${f(y)}`).join('L')}Z" ` + `fill="${fill}"/>`; const line = (d, stroke, w) => `<path d="${d}" fill="none" stroke="${stroke}" ` + `stroke-width="${f(w)}" stroke-linecap="round" stroke-linejoin="round"/>`; const disk = (x, y, r, fill) => `<circle cx="${f(x)}" cy="${f(y)}" r="${f(r)}" fill="${fill}"/>`; const mirror = (y, a, body) => `<g transform="translate(0 ${f(2 * y)}) scale(1 -1)" ` + `opacity="${a}">${body}</g>`; // a reflection in still water // A flamingo standing, asleep with its head on its back, or feeding with its head in the // water; the foot at (x, y), h tall, facing right (dir 1) or left (dir -1). function flamingo(x, y, h, dir, pose) { const s = h / 100; const P = (u, v) => `${f(x + dir * u * s)} ${f(y - v * s)}`; const [pink, wing, bill] = [mix('brine', 'madder', 0.3), mix('brine', 'madder', 0.62), mix('brine', 'paper', 0.55)]; const out = [line(`M${P(0, 0)}L${P(1, 27)}L${P(0, 53)}`, pink, 1.4 * s)]; out.push(pose === 'feed' ? line(`M${P(9, 0)}L${P(7, 27)}L${P(5, 53)}`, pink, 1.4 * s) : line(`M${P(3, 53)}L${P(12, 42)}L${P(3, 36)}`, pink, 1.3 * s)); // the other leg, tucked out.push(`<path d="M${P(-12, 62)}C${P(-10, 72)} ${P(16, 73)} ${P(28, 58)}C${P(16, 52)} ` + `${P(-4, 52)} ${P(-12, 62)}Z" fill="${pink}"/>`, `<path d="M${P(2, 66)}C${P(12, 69)} ${P(21, 64)} ${P(28, 58)}C${P(18, 58)} ${P(9, 60)} ` + `${P(2, 66)}Z" fill="${wing}"/>`); const [neck, hx, hy, turn] = { // the neck, the head, and the bill's bend in degrees stand: [`M${P(-9, 64)}C${P(-20, 74)} ${P(3, 81)} ${P(-2, 90)}C${P(-5, 96)} ${P(-1, 100)} ` + `${P(4, 99)}`, 4, 98, 0], sleep: [`M${P(-9, 64)}C${P(-12, 76)} ${P(4, 78)} ${P(9, 71)}`, 10, 70, 20], feed: [`M${P(-10, 61)}C${P(-22, 60)} ${P(-23, 30)} ${P(-17, 8)}`, -17, 7, 150], }[pose]; out.push(line(neck, pink, 3.6 * s), disk(x + dir * hx * s, y - hy * s, 3.8 * s, pink)); // The bill: pale at the base, bent down halfway, black at the tip. const a = (turn * Math.PI) / 180; const B = (u, v) => P(hx + u * Math.cos(a) + v * Math.sin(a), hy - u * Math.sin(a) + v * Math.cos(a)); out.push(`<path d="M${B(0, 3)}L${B(7, 2.4)}L${B(11, -1)}L${B(8, -2.6)}L${B(0, -2.4)}Z" ` + `fill="${bill}"/>`, `<path d="M${B(7, 2.4)}L${B(11, -1)}L${B(11, -7)}L${B(8, -2.6)}Z" ` + `fill="${palette.ink}"/>`); return out.join(''); } // Sunrise over the salt pans: the sierra, the church, heaps of salt and flamingos in the // nearest pond. function saltPans(w, h) { const [W, H] = [w * PX, h * PX]; const HZ = H * 0.6; // the horizon const out = [`<defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">` + `<stop offset="0" stop-color="${palette.sky}"/><stop offset="0.6" ` + `stop-color="${mix('sky', 'dawn', 0.55)}"/><stop offset="1" stop-color="${palette.dawn}"/>` + `</linearGradient></defs>`, `<rect width="${W}" height="${f(HZ + 2)}" fill="url(#sky)"/>`, disk(W * 0.22, HZ - 52, 44, mix('dawn', 'brine', 0.55))]; // the sun, still behind the sierra for (const [lift, amp, k, from] of [[16, 46, 0.24, 0], [4, 20, 0.36, 0.45]]) { const pts = [[W * from, HZ + 2]]; const ph = rand() * 6; for (let x = W * from; x <= W + 20; x += 20) { const n = 0.5 + 0.3 * Math.sin(x / 210 + ph) + 0.2 * Math.sin(x / 67 + ph * 2); pts.push([x, HZ - lift - amp * n * Math.min(1, (x - W * from + 60) / 300)]); } pts.push([W + 20, HZ + 2]); out.push(poly(pts, mix('sky', 'ink', k))); } const cx = W * 0.8; // the church of the salt pans, alone on the horizon out.push(poly([[cx, HZ], [cx, HZ - 30], [cx + 36, HZ - 44], [cx + 72, HZ - 30], [cx + 72, HZ]], palette.paper), poly([[cx + 66, HZ], [cx + 66, HZ - 70], [cx + 76, HZ - 82], [cx + 86, HZ - 70], [cx + 86, HZ]], palette.paper), disk(cx + 76, HZ - 62, 4, palette.ink), poly([[cx + 14, HZ], [cx + 14, HZ - 14], [cx + 22, HZ - 14], [cx + 22, HZ]], mix('sky', 'ink', 0.36))); out.push(`<rect y="${f(HZ)}" width="${W}" height="${f(H - HZ)}" ` + `fill="${mix('dawn', 'ink', 0.1)}"/>`); // the dikes: bare earth between the ponds // Rows of ponds, deeper towards the viewer; the dikes between them run to one point. const rows = [HZ + 5, HZ + 16, HZ + 34, HZ + 64, HZ + 118, H + 10]; const vx = W * 0.47; const at = (y, u) => vx + (u * W * 1.8 - W * 0.4 - vx) * ((y - HZ + 30) / (H - HZ + 30)); for (let r = 0; r < rows.length - 1; r++) { const [y0, y1] = [rows[r] + 1 + r, rows[r + 1] - 1 - r]; const n = [9, 7, 5, 4, 2][r]; for (let i = 0; i < n; i++) { const [u0, u1] = [i / n + 0.003 * (r + 1), (i + 1) / n - 0.003 * (r + 1)]; const pond = rand() < 0.25 && r < 4 ? mix('sky', 'brine', 0.3) : mix('brine', 'sky', Math.max(0, 0.5 - r * 0.12 - rand() * 0.15)); out.push(poly([[at(y0, u0), y0], [at(y0, u1), y0], [at(y1, u1), y1], [at(y1, u0), y1]], pond)); } } for (let i = 0; i < 26; i++) { // ripples on the nearest pond const [x, y] = [rand() * W, rows[4] + 20 + rand() * (H - rows[4] - 20)]; const l = 20 + rand() * 50; out.push(line(`M${f(x)} ${f(y)}h${f(l)}`, mix('brine', 'paper', 0.45), 2)); } for (const [u, sz] of [[0.09, 1], [0.16, 0.8], [0.57, 1.15], [0.66, 0.9]]) { // salt heaps const [x, y, hw, hh] = [W * u, rows[2] - 1, 70 * sz, 44 * sz]; out.push(`<path d="M${f(x - hw)} ${f(y)}L${f(x - hw * 0.14)} ${f(y - hh)}Q${f(x)} ` + `${f(y - hh * 1.08)} ${f(x + hw * 0.14)} ${f(y - hh)}L${f(x + hw)} ${f(y)}Z" ` + `fill="${palette.paper}"/>`, poly([[x + hw * 0.1, y - hh * 0.98], [x + hw, y], [x + hw * 0.25, y]], mix('brine', 'paper', 0.62))); } const flock = [[0.07, 190, 1, 'stand'], [0.19, 170, 1, 'sleep'], [0.32, 180, -1, 'feed'], [0.64, 200, 1, 'feed'], [0.77, 175, -1, 'sleep'], [0.9, 205, -1, 'stand']]; for (const [u, fh, dir, pose] of flock) { const [x, y] = [W * u + (rand() - 0.5) * 30, H - 70 - rand() * 50]; out.push(mirror(y, 0.3, flamingo(x, y, fh, dir, pose)), flamingo(x, y, fh, dir, pose)); } return svgOf(w, h, out.join('')); } // The title page's vignette: one flamingo among a few ripples. function vignette(w, h) { const [W, H] = [w * PX, h * PX]; const y = H * 0.62; const ripples = [[0.18, 0.08, 0.5], [0.52, 0.1, 0.34], [0.3, 0.2, 0.44], [0.08, 0.26, 0.3], [0.58, 0.24, 0.3]].map(([u, v, l]) => line(`M${f(W * u)} ${f(y + H * v * 0.9)}h${f(W * l)}`, mix('brine', 'paper', 0.25), 5)); return svgOf(w, h, [...ripples, flamingo(W * 0.46, y, H * 0.58, 1, 'stand')].join('')); } const alt = { salina: t({ en: 'Salt pans at sunrise: pink ponds, white heaps of salt, a church on the ' + 'horizon and flamingos standing over their reflections.', es: 'Salinas al amanecer: balsas rosas, montones de sal, una iglesia en el horizonte y ' + 'flamencos de pie sobre su reflejo.' }), flamingo: t({ en: 'A flamingo standing on one leg among ripples.', es: 'Un flamenco sobre una pata entre las ondas del agua.' }), }; const BAND_ART = { salina: [TRIM_W, BAND], flamingo: [34, 44] }; const art = { salina: saltPans(...BAND_ART.salina), flamingo: vignette(...BAND_ART.flamingo) }; for (const [id, svg] of Object.entries(art)) await loadSvg(`${id}.svg`, svg); // #endregion // Nothing cites them: the heading designs draw them. const resources = Object.entries(BAND_ART).map(([id, [w, h]]) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, altText: alt[id], svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX } })); // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Loaded before the first build (gotcha: fonts-first). None of the three ships a bold. const FONTS = { Castoro: ['400', '400i'], 'Castoro Titling': ['400'], 'Tenor Sans': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'August Salt · five poems with a facing translation', es: 'Sal de agosto · cinco poemas con traducción enfrentada' }) });Kit · core, fonts, viewer, images: the same in every recipe · 270 lines
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗
Variations
#Widen the gutter
At 12 mm the columns narrow to 53 mm, and Los turistas paran y hacen fotos: turns over in poem III, so its second stanza starts a line below the English; end the English stanza with :::space{lines=2} to level them.
-const GAP = 8; // mm between the original and the translation: each column is 55 mm wide
+const GAP = 12; // mm between the original and the translation: each column is 53 mm wide#Hang turnovers deeper
At 3 em, reflection. on page 3 starts one em further in.
-const verse = { id: 'verse', textAlign: 'left', hangingIndent: em(2) };
+const verse = { id: 'verse', textAlign: 'left', hangingIndent: em(3) };Pitfalls
Pitfall
:::columns works only inside a box and never splits
:::columns is ignored outside a callout, and a box that splits never cuts inside a columns group. A breaks attribute counts child blocks, with a nested box as one. Columns inside a box →
Pitfall
A paragraph style's margins do not count inside a box
In postext 1.4.1 a :::paragraphs container nested in a :::callout ignores its style's marginTop and marginBottom, so a small-print line set under a note's text sits right against it. Give the style a taller lineHeight, which puts air above its first line, or keep the line out of the box. Paragraph styles →
Pitfall
An opener's images never count towards the height it reserves
In postext 1.4.1 an advanced-design heading measures the height it reserves without its images: its texts, rules and boxes count, even when anchored to the page, but an image, such as a picture bled across the head of the page, reserves nothing, so the text can start on top of it. Set minHeight to where the text should begin. Designed openers →
Pitfall
Only 8 locales hyphenate, by exact code
Hyphenation ships for en-us, es, fr, de, it, pt, ca and nl, matched exactly: 'es-ES' or any other language silently falls back to American English. Hyphenation and document language →
Pitfall
Any headings object switches off the H1 page break
By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →
Pitfall
A swapped palette misses design elements and the reference colour
postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build. Semantic colour palette →
Pitfall
A design text's lineHeight is a multiple, never a dimension
In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title. Text, rules and boxes in page designs →
Pitfall
Column balancing drops the box that ends a page to its foot
When a box is the last block on a page that the text goes on from, column balancing (on by default) moves the room left under the box above it, so the box leaves the block before it and ends on the page's last line. In postext 1.4.1 no single balancing option turns this off: headings.balancing.enabled: false turns off all balancing, which suits pages meant to end short, such as a page of poems. Column balancing →
Pitfall
Load every face before layout
Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →
- A
:::spaceput before the English title vanishes: the second column opens at the blockbreaksnames, and the space falls at the cut. Put it after the title, like the:::space{lines=0.5}under each column title here. - Count
breaksfrom the Spanish title: with a title and 12 lines, the English title is block 14. Every line of verse is a block, so one more Spanish line moves the English title to block 15, andbreakshas to go up with it.
Credits
- Recipe
- Ignacio Ferro
- Text
- The poem cycle Sal de agosto and its English version, August Salt, with the author’s note, written for this recipe; the poet Adela Membrives is fictional · Postext Cookbook · CC BY 4.0
- Images
- The salt pans at sunrise and the flamingo on the title page, drawn in code in the page’s palette · Postext Cookbook · CC BY 4.0
- Fonts
- Castoro (SIL OFL 1.1) · Castoro Titling (SIL OFL 1.1) · Tenor Sans (SIL OFL 1.1)


