En poques paraules
Un poemari amb l'original en castellà a un costat i la traducció anglesa a l'altre. Cada estrofa de la traducció comença a la mateixa altura que l'original.
Què compondràs
Un quadern bilingüe de cinc poemes sobre una salina del sud d'Espanya, de 156 × 234 mm, amb el castellà a l'esquerra i la versió anglesa de la mateixa autora a la dreta, vers a vers. Obren el llibre una portada amb un flamenc i una nota de l'autora; els poemes comencen sota una aquarel·la de basses roses, munts de sal i flamencs a trenc d'alba. Cada poema va en un requadre sense marc, sota un número romà granat en Castoro Titling, amb el castellà en una columna i l'anglès en l'altra. L'anglès arrenca sempre a la mateixa línia que el castellà, i cada estrofa, a l'altura del seu original. Quan un vers anglès no cap a la seva columna, continua a la línia de sota sagnat 2 em, i l'estrofa castellana del davant acaba amb una línia en blanc més.
Aquesta recepta respon a
- Com poso dues columnes dins d'un requadre (una de text al costat d'una altra amb una figura)?
- Com componc poesia: un vers per línia, espai entre estrofes, sagnat francès als versos que no hi caben i sense partició de mots?
- Com afegeixo espai vertical entre dos blocs, si les línies en blanc no fan res?
- Com aconsegueixo una bona justificació i partició de mots en textos en castellà, francès o alemany?
La resposta curta
// 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 picture,
// 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),
};
Ingredients
- Funcions
- Columnes dins d'un requadreEspai vertical explícitEstils de paràgrafRequadresPartició de mots i llengua del documentEstils de títolObertures dissenyadesAtributs de títolImatges als dissenys de pàginaCobertes, portades i colofonsMetadades del documentEquilibri de columnesCapçaleres i folisCapçaleres segons el tipus de pàginaMarges simètricsPaleta de color semànticaFigures i taules com a recursosCapçaleres per seccióPàgines en un canvas
- També fa servir
- Bibliografies i glossaris
- Tipografia
- Castoro, Castoro Titling, Tenor Sans (SIL OFL 1.1)
- Recursos
flamingo-680.jpgsalina-1560.jpg- The salt pans at sunrise over the poems, a watercolour (Generated With Diffusion Models, original)
- The flamingo on the title page, a watercolour (Generated With Diffusion Models, original)
Elaboració
#1 · Un poema és un requadre amb un tall de columna fix
El codi és la resposta curta de més amunt. :::columns només funciona dins d'un requadre, així que cada poema és un requadre l'estil del qual treu el fons i el farciment i imprimeix el title de la directiva, el número (:::columns). Sense breaks, el grup talla on les dues columnes queden igualades. En aquests poemes les dues meitats fan el mateix i el tall cau igualment al títol anglès, però si allargues dos versos de The Rake fins que no hi càpiguen, el tall baixa un bloc i THE RAKE es queda al peu de la columna castellana. breaks="14" fixa el bloc que obre la segona columna, així que el títol anglès l'encapçala facin el que facin les dues meitats.
#2 · Un paràgraf per vers, un :::space per salt d'estrofa
// 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 uneix les línies d'un paràgraf, així que cada vers és un paràgraf a part amb l'estil verse, en bandera, al qual la versió 1.4.1 no aplica mai la partició de mots. Un :::space entre estrofes deixa una línia en blanc a cada columna. No és un bloc, així que breaks no el compta (:::space). El vers anglès each one standing on its own reflection. no cap a la seva columna de 55 mm i continua a la línia de sota, sagnat 2 em; l'estrofa castellana del davant acaba amb :::space{lines=2}, així que les estrofes següents comencen a la mateixa línia.

#3 · La nota fa servir la partició de mots de la seva llengua
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 tria els patrons de partició de mots pel seu codi exacte. Amb 'es', la nota castellana talla ori-ginal i Duna-liella; amb els patrons anglesos les dues paraules passen senceres a la línia següent, i només arras-traba es talla amb tots dos (llengües admeses). Els espais entre paraules es poden encongir fins a 0,8 i estirar fins a 1,35 de la seva amplada normal, i cap línia justificada de les dues notes no passa d'aquí. El títol de la nota és un disseny d'un sol element, situat 36 mm per sota de l'inici de la caixa de text, així que la nota comença a 73 mm de la vora superior de la seva pàgina.
#4 · La portada i l'aquarel·la són títols
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 picture 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 picture'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') }),
] } },
};
Cadascun és un títol de nivell 1 amb un estil propi (estils de títol): el disseny de la portada pren {author} del frontmatter, i els dos dissenys prenen {attr.other} dels atributs del títol. L'aquarel·la comença a la vora superior de la pàgina, per sobre de la caixa de text, i un disseny que es queda a la columna es retalla per dalt, així que el seu estil porta span: 'page'. Una imatge no reserva alçada: sense minHeight, el títol fa 25,6 mm i el poema I comença a 58 mm de la vora, damunt d'una aquarel·la que arriba a 84 mm. Amb un minHeight d'onze línies i el marge inferior del nivell, d'una línia, el títol acaba a 83,4 mm.
#5 · Les capçaleres anomenen les dues llengües
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 picture 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' })] };
La pàgina parella porta el títol castellà i la senar l'anglès, en l'ordre de les columnes. pages: 'body' les treu de les tres pàgines que comencen amb un títol. La nota i l'aquarel·la porten en lloc seu un foli al peu, i l'estil de la portada en deixa buides la capçalera i el peu (elements de text).
La recepta completa
// ═══ 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: diffusion models // 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 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 picture, // 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 picture 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 picture 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 picture'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 picture 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`---Mostra en Markdown · 349 línies · 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). Acuarelas hechas con modelos de difusión. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the salt pans at sunrise and a flamingo, two watercolours in assets/ // Each JPEG is cut to its frame: the band 156 × 84 mm, the flamingo 34 × 44 mm on white paper. const picture = (id, fileId, w, h, altText) => ({ id, typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, altText, bitmap: { fileId, format: 'jpeg', width: w, height: h } }); // Nothing cites them: the heading designs show them. const resources = [ picture('salina', 'salina-1560.jpg', 1560, 840, t({ en: 'Salt pans at sunrise: pink ponds, white heaps of salt, a chapel on the horizon ' + 'and flamingos standing over their reflections.', es: 'Salinas al amanecer: balsas rosas, montones de sal, una ermita en el horizonte y ' + 'flamencos de pie sobre su reflejo.' })), picture('flamingo', 'flamingo-680.jpg', 680, 880, t({ en: 'A flamingo standing on one leg among ripples.', es: 'Un flamenco sobre una pata entre las ondas del agua.' })), ]; for (const { bitmap } of resources) await loadImage(bitmap.fileId, asset(bitmap.fileId)); // #endregion // ─── 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: igual a totes les receptes · 270 línies
// ─── 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 ───────────────────────────────────────────────────────────────────────
L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)
Variants
#Eixampla l'espai entre columnes
Amb 12 mm les columnes es queden en 53 mm, i Los turistas paran y hacen fotos: ja no cap al poema III, així que la seva segona estrofa comença una línia per sota de l'anglesa; acaba l'estrofa anglesa amb :::space{lines=2} per igualar-les.
-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#Sagna més els versos que no hi caben
Amb 3 em, reflection. comença a la pàgina 3 un em més endins.
-const verse = { id: 'verse', textAlign: 'left', hangingIndent: em(2) };
+const verse = { id: 'verse', textAlign: 'left', hangingIndent: em(3) };Errors freqüents
Error freqüent
:::columns només funciona dins d'un requadre i no es parteix
:::columns s'ignora fora d'un requadre, i un requadre que es parteix mai no talla dins d'un grup de columnes. L'atribut breaks compta blocs fills, i un requadre imbricat compta com un. Columnes dins d'un requadre →
Error freqüent
Els marges d'un estil de paràgraf no compten dins d'un requadre
A postext 1.4.1, un contenidor :::paragraphs dins d'un :::callout no aplica el marginTop ni el marginBottom del seu estil, de manera que una línia en lletra petita sota el text d'una nota hi queda enganxada. Dona a l'estil un lineHeight més gran, que deixa aire sobre la primera línia, o treu aquesta línia del requadre. Estils de paràgraf →
Error freqüent
Les imatges d'una obertura no compten per a l'alçada que reserva
A postext 1.4.1, un títol amb disseny avançat mesura l'alçada que reserva sense comptar-ne les imatges: els seus textos, filets i caixes compten, encara que estiguin ancorats a la pàgina, però una imatge, com un dibuix a sang al capdamunt de la pàgina, no reserva res, de manera que el text pot començar a sobre. Fixa amb minHeight on ha de començar el text. Obertures dissenyades →
Error freqüent
Només 8 llengües tenen partició de mots, amb el codi exacte
La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà. Partició de mots i llengua del document →
Error freqüent
Qualsevol objecte headings desactiva el salt de pàgina de l'H1
Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →
Error freqüent
Una paleta canviada no arriba als elements de disseny ni al color de les remissions
postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →
Error freqüent
El lineHeight d'un text de disseny és un múltiple, mai una mesura
En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
L'equilibrat de columnes baixa al peu el requadre que tanca una pàgina
Quan un requadre és l'últim bloc d'una pàgina després de la qual continua el text, l'equilibrat de columnes (actiu per defecte) passa l'espai que queda a sota per sobre del requadre, que se separa del bloc anterior i acaba a l'última línia de la pàgina. A postext 1.4.1 cap opció de l'equilibrat no el desactiva per separat: headings.balancing.enabled: false apaga tot l'equilibrat, cosa que convé a pàgines que han d'acabar curtes, com una pàgina de poemes. Equilibri de columnes →
Error freqüent
Carrega totes les fonts abans de compondre
La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →
- Un
:::spaceposat abans del títol anglès desapareix: la segona columna s'obre al bloc que indicabreaks, i l'espai cau al tall. Posa'l després del títol, com el:::space{lines=0.5}que segueix aquí el títol de cada columna. - Compta
breaksdes del títol castellà: amb un títol i 12 versos, el títol anglès és el bloc 14. Cada vers és un bloc, així que un vers castellà més porta el títol anglès al bloc 15, ibreaksha de pujar amb ell.
Crèdits
- Recepta
- 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
- Imatges
- The salt pans at sunrise over the poems, a watercolour · Generated With Diffusion Models · original
- The flamingo on the title page, a watercolour · Generated With Diffusion Models · original
- Fonts
- Castoro (SIL OFL 1.1) · Castoro Titling (SIL OFL 1.1) · Tenor Sans (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


