成品一览
一本收录五首诗的双语小诗集,写的是西班牙南部的一片盐田,开本156 × 234 mm,左边是西班牙语原文,右边是诗人自己的英文译本,逐行对应。书前是一张带火烈鸟的扉页和作者说明;诗作从一幅插图下开始,画的是日出时粉色的盐池、盐堆和火烈鸟。每首诗放在一个无边框的框里,上方是Castoro Titling字体的茜红色编号,西班牙语在一栏,英文在另一栏。英文与西班牙语从同一行开始,每一节都与原文齐平。英文诗行太长、一栏放不下时,余下部分转行并缩进2 em,对面的西班牙语诗节则在结尾多留一个空行。
这道食谱解答
- 怎样在框里分两栏(文字栏旁边是图栏)?
- 怎样排诗歌:每句一行,诗节间留空,折行悬挂缩进,不断词?
- 空行不起作用时,怎样在两个块之间加额外的垂直间距?
- 怎样让西班牙文、法文或德文获得良好的两端对齐和断词?
简短回答
// 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),
};
用料
- 功能
- 框内分栏显式竖向间距段落样式标注框断词与文档语言标题样式设计过的章首页标题属性页面设计中的图片封面、扉页和版权页文档元数据各栏齐底书眉与页码按页面角色显示书眉对称页边距语义调色板作为资源的图和表分节书眉在Canvas上绘制页面
- 还用到
- 参考文献与术语表
- 类型
- Castoro, Castoro Titling, Tenor Sans(SIL OFL 1.1)
- 素材
- 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)
做法
#1 · 一首诗就是一个带固定分栏点的框
代码见上文的简短回答。:::columns只能用在框内,所以每首诗都是一个标注框,它的样式去掉了填色和内边距,并印出围栏的title,也就是编号(:::columns)。不设breaks时,这一组在两栏高度相等的地方切开。这些诗的两半高度相同,所以切点本来就落在英文标题上;但如果把The Rake的两行加长到需要转行,切点就会下移一个块,让THE RAKE落在西班牙语栏的栏尾。breaks="14"指定开启第二栏的那个块,所以无论两半高度如何,英文标题都位于第二栏栏首。
#2 · 一行一个段落,诗节之间一个:::space
// 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会把一个段落里的各行连起来,所以每行诗都单独成段,使用齐左的verse样式,1.4.1对它从不断词。诗节之间的:::space在每栏里留出一个空行。它不是块,所以breaks不计算它(:::space)。英文*each one standing on its own reflection.*对55 mm的栏来说太长,转行并缩进2 em;对面的西班牙语诗节以:::space{lines=2}结尾,所以下一节从同一行开始。

#3 · 说明按它自己的语言断词
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按精确的语言代码选择断词模式。用'es'时,西班牙语说明断成ori-ginal和Duna-liella;用英文模式时,这两个词都会整个移到下一行,只有arras-traba在两种模式下都会断开(支持的语言)。词间距可以压缩到正常宽度的0.8倍、拉伸到1.35倍,两篇说明中没有一行两端对齐的文字超过这个限度。说明的标题是一个版面设计,其中唯一的元素即标题,位于版心顶端下方36 mm处,所以说明从页面上边缘往下73 mm处开始。
#4 · 扉页和插图都是标题
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') }),
] } },
};
它们都是一级标题,各有自己的样式(标题样式):扉页的版面设计从frontmatter取{author},两个版面设计都从标题属性取{attr.other}。插图从页面上边缘开始,在版心之上;留在栏内的版面设计会在那里被裁掉,所以它的样式设了span: 'page'。图像不占预留高度,所以如果没有minHeight,标题只有25.6 mm深,诗I会从页面往下58 mm处开始,压在一幅延伸到84 mm的插图上。把minHeight设为十一行,再加上该级别一行的下外边距,标题就结束在83.4 mm处。
#5 · 书眉写出两种语言
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' })] };
左页书眉是西班牙语书名,右页是英文书名,顺序与栏的顺序一致。pages: 'body'让三个以标题开篇的页面不印书眉。说明和插图页改为在页脚印页码,扉页的样式则清空了页眉和页脚(文本元素)。
完整食谱
// ═══ 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 = 'es'; // @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样例 · 349行 · content.es.md
title: "Sal de agosto" author: "Adela Membrives" --- # Sal de agosto {style="title-page" other="August Salt" edition="Edición bilingüe" version="Versión inglesa de la autora" press="Cuadernos de Salmuera · 7"} # Nota de la autora {style="note"} Escribí estos cinco poemas en la salina donde trabajó mi padre, en el sur, durante la cosecha de agosto de 2024. Él pasó allí treinta y un años; yo pasaba los veranos sentada en el muro de una balsa, viendo cómo arrastraba la sal hasta la orilla. La versión inglesa es mía y va verso a verso: cada línea de la derecha traduce la que tiene enfrente, y cada estrofa empieza a la altura de su original. Cuando un verso inglés no cabe en la columna, sigue en la línea de abajo, sangrado, y la estrofa española deja al final una línea en blanco para que la siguiente vuelva a empezar a la par. Hay palabras que no pasan enteras. *Rastro* nombra a la vez el rastrillo de madera que arrastra la sal y la huella que deja; en inglés elegí *rake*, la herramienta, y dejé la huella al lector. *Levante* es el viento del este, que en esa costa sopla seco y cuaja la sal en tres días; el inglés tiene *levanter*, pero casi nadie conoce esa palabra, y el poema dice *east wind*. El color de las balsas lo dan sobre todo un alga microscópica, *Dunaliella salina*, y unas arqueas que solo viven en la salmuera. La artemia, un camarón diminuto, se alimenta del alga, y los flamencos toman el rosa de la artemia que comen. :::paragraphs{style="signature"} *A. M., marzo de 2025* ::: # Sal de agosto {style="poems" other="August Salt"} :::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"} Poemas y nota escritos para el Recetario de Postext (CC BY 4.0); la autora es ficticia. Compuesto en Castoro, Castoro Titling y Tenor Sans (SIL OFL). Dibujos hechos en código. :::`; // 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' }) });工具包 · core, fonts, viewer, images:每道食谱都相同 · 270行
// ─── 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 ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗
变化
#加宽栏间距
栏间距为12 mm时,每栏缩窄到53 mm,诗III中的*Los turistas paran y hacen fotos:*会转行,于是第二节比英文晚一行开始;在英文诗节末尾加:::space{lines=2}即可让两边齐平。
-const GAP = 8; // 原文与译文之间的毫米数:每栏宽55 mm
+const GAP = 12; // 原文与译文之间的毫米数:每栏宽53 mm#转行悬挂得更深
设为3 em时,第3页的*reflection.*会再往里缩进一个em。
-const verse = { id: 'verse', textAlign: 'left', hangingIndent: em(2) };
+const verse = { id: 'verse', textAlign: 'left', hangingIndent: em(3) };常见问题
易错点
段落样式的外边距在框内不起作用
在postext 1.4.1中,嵌套在:::callout里的:::paragraphs容器会忽略其样式的marginTop和marginBottom,所以排在注释正文下面的一行小字会紧贴着正文。给这个样式更大的lineHeight,让首行上方留出空隙,或者把这一行放到框外。 段落样式 →
易错点
章首页的图片从不计入它预留的高度
在postext 1.4.1中,高级设计标题计算预留高度时不算其中的图片:文字、线条和框都计入,即使它们锚定在页面上;但图片(例如出血铺满页面顶部的图)不预留任何空间,所以正文可能排到它上面。把minHeight设为正文应当开始的位置。 设计过的章首页 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
设计文本的lineHeight是倍数,不是尺寸
在设计槽位中,文本元素的lineHeight是其字号的倍数(lineHeight: 1.05)。在postext 1.4.1中,写成pt(15)这样的尺寸值不会被拒绝:章首页的高度会算成NaN,它预留的空间(连同minHeight)被丢弃,也不给出警告,正文就排到了标题底下。 页面设计中的文字、线条和框 →
易错点
各栏齐底会把结束一页的框推到页底
当框是某页的最后一个块、正文还要延续到下一页时,各栏齐底(默认开启)会把框下面剩余的空间移到框上面,于是框与前一个块分开,结束在该页最后一行。在postext 1.4.1中,没有单独的齐底选项能关掉这一点:headings.balancing.enabled: false会关闭所有齐底,适合本来就要短页结束的页面,例如诗歌页。 各栏齐底 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
- 放在英文标题之前的
:::space会消失:第二栏从breaks指定的块开始,这个空行正好落在切点上。应当把它放在标题之后,就像这里每个栏标题下的:::space{lines=0.5}。 breaks要从西班牙语标题开始数:一个标题加12行,英文标题就是第14块。每行诗都是一个块,所以西班牙语多一行,英文标题就变成第15块,breaks也要跟着加一。
致谢
- 文本
- 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
- 图片
- 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
- 字体
- Castoro (SIL OFL 1.1) · Castoro Titling (SIL OFL 1.1) · Tenor Sans (SIL OFL 1.1)


