Lo que vas a componer
Un cuaderno bilingüe de cinco poemas sobre una salina del sur de España, de 156 × 234 mm, con el español a la izquierda y la versión inglesa de la propia autora a la derecha, verso a verso. Abren el libro una portada con un flamenco y una nota de la autora; los poemas empiezan bajo un dibujo de balsas rosas, montones de sal y flamencos al amanecer. Cada poema va en un recuadro sin marco, bajo un número romano granate en Castoro Titling, con el español en una columna y el inglés en la otra. El inglés arranca siempre en la misma línea que el español, y cada estrofa, a la altura de su original. Cuando un verso inglés no cabe en su columna, sigue en la línea de abajo sangrado 2 em, y la estrofa española de enfrente termina con una línea en blanco más.
Esta receta responde a
- ¿Cómo pongo dos columnas dentro de un recuadro (una de texto junto a otra con una figura)?
- ¿Cómo compongo poesía: un verso por línea, espacio entre estrofas, sangría francesa en los versos que no caben y sin separación silábica?
- ¿Cómo añado espacio vertical entre dos bloques, si las líneas en blanco no hacen nada?
- ¿Cómo consigo una buena justificación y separación silábica en textos en español, francés o alemán?
La respuesta corta
// 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),
};
Ingredientes
- Funciones
- Columnas dentro de un recuadroEspacio vertical explícitoEstilos de párrafoRecuadrosSeparación silábica e idioma del documentoEstilos de títuloAperturas diseñadasAtributos de títuloImágenes en los diseños de páginaCubiertas, portadas y colofonesMetadatos del documentoEquilibrado de columnasCabeceras y foliosCabeceras según el tipo de páginaMárgenes simétricosPaleta de color semánticaFiguras y tablas como recursosCabeceras por secciónPáginas en un canvas
- También usa
- Bibliografías y glosarios
- Tipografía
- Castoro, Castoro Titling, Tenor Sans (SIL OFL 1.1)
- Recursos
- Las salinas al amanecer y el flamenco de la portada, dibujados en código con la paleta de la página (Postext Cookbook, CC BY 4.0)
Elaboración
#1 · Un poema es un recuadro con un corte de columna fijo
El código es la respuesta corta de arriba. :::columns solo funciona dentro de un recuadro, así que cada poema es un recuadro cuyo estilo quita el fondo y el relleno e imprime el title de la directiva, el número (:::columns). Sin breaks, el grupo corta donde sus dos columnas quedan igualadas. En estos poemas las dos mitades miden lo mismo y el corte cae de todos modos en el título inglés, pero si alargas dos versos de The Rake hasta que no quepan, el corte baja un bloque y THE RAKE se queda al pie de la columna española. breaks="14" fija el bloque que abre la segunda columna, así que el título inglés la encabeza midan lo que midan las dos mitades.
#2 · Un párrafo por verso, un :::space por salto de 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 une las líneas de un párrafo, así que cada verso es un párrafo aparte con el estilo verse, en bandera, al que la versión 1.4.1 nunca aplica la separación silábica. Un :::space entre estrofas deja una línea en blanco en cada columna. No es un bloque, así que breaks no lo cuenta (:::space). El verso inglés each one standing on its own reflection. no cabe en su columna de 55 mm y sigue en la línea de abajo, sangrado 2 em; la estrofa española de enfrente termina con :::space{lines=2}, así que las estrofas siguientes empiezan en la misma línea.

#3 · La nota usa la separación silábica de su idioma
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 elige los patrones de separación silábica por su código exacto. Con 'es', la nota española corta ori-ginal y Duna-liella; con los patrones ingleses las dos palabras pasan enteras a la línea siguiente, y solo arras-traba se corta con ambos (idiomas soportados). Los espacios entre palabras pueden encogerse hasta 0,8 y estirarse hasta 1,35 de su ancho normal, y ninguna línea justificada de las dos notas pasa de ahí. El título de la nota es un diseño de un solo elemento, situado 36 mm por debajo del inicio de la caja de texto, así que la nota empieza a 73 mm del borde superior de su página.
#4 · La portada y el dibujo son títulos
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') }),
] } },
};
Cada uno es un título de nivel 1 con un estilo propio (estilos de título): el diseño de la portada toma {author} del frontmatter, y los dos diseños toman {attr.other} de los atributos del título. El dibujo empieza en el borde superior de la página, por encima de la caja de texto, y un diseño que se queda en la columna se recorta por arriba, así que su estilo lleva span: 'page'. Una imagen no reserva altura: sin minHeight, el título mide 25,6 mm y el poema I empieza a 58 mm del borde, encima de un dibujo que llega a 84 mm. Con un minHeight de once líneas y el margen inferior del nivel, de una línea, el título termina a 83,4 mm.
#5 · Las cabeceras nombran los dos idiomas
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' })] };
La página par lleva el título español y la impar el inglés, en el orden de las columnas. pages: 'body' las quita de las tres páginas que empiezan con un título. La nota y el dibujo llevan en su lugar un folio al pie, y el estilo de la portada deja vacíos su cabecera y su pie (elementos de texto).
La receta 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: 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`---Muestra en Markdown · 349 líneas · 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' }) });Kit · core, fonts, viewer, images: igual en todas las recetas · 270 líneas
// ─── 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 ───────────────────────────────────────────────────────────────────────
El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗
Variantes
#Ensancha el medianil
Con 12 mm las columnas se quedan en 53 mm, y Los turistas paran y hacen fotos: ya no cabe en el poema III, así que su segunda estrofa empieza una línea por debajo de la inglesa; termina la estrofa inglesa con :::space{lines=2} para igualarlas.
-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#Sangra más los versos que no caben
Con 3 em, reflection. empieza en la página 3 un em más adentro.
-const verse = { id: 'verse', textAlign: 'left', hangingIndent: em(2) };
+const verse = { id: 'verse', textAlign: 'left', hangingIndent: em(3) };Errores frecuentes
Error frecuente
:::columns solo funciona dentro de un recuadro y no se parte
:::columns se ignora fuera de un recuadro, y un recuadro que se parte nunca corta dentro de un grupo de columnas. El atributo breaks cuenta bloques hijos, y un recuadro anidado cuenta como uno. Columnas dentro de un recuadro →
Error frecuente
Los márgenes de un estilo de párrafo no cuentan dentro de un recuadro
En postext 1.4.1, un contenedor :::paragraphs dentro de un :::callout no aplica el marginTop ni el marginBottom de su estilo, así que una línea en letra pequeña bajo el texto de una nota queda pegada a él. Da al estilo un lineHeight mayor, que deja aire sobre su primera línea, o saca esa línea del recuadro. Estilos de párrafo →
Error frecuente
Las imágenes de una apertura no cuentan para la altura que reserva
En postext 1.4.1, un título con diseño avanzado mide la altura que reserva sin contar sus imágenes: sus textos, filetes y cajas cuentan, aunque estén anclados a la página, pero una imagen, como un dibujo a sangre en la cabeza de la página, no reserva nada, así que el texto puede empezar encima de ella. Fija con minHeight dónde debe empezar el texto. Aperturas diseñadas →
Error frecuente
Solo 8 idiomas tienen separación silábica, con el código exacto
La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano. Separación silábica e idioma del documento →
Error frecuente
Cualquier objeto headings desactiva el salto de página del H1
Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →
Error frecuente
Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones
postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer. Paleta de color semántica →
Error frecuente
El lineHeight de un texto de diseño es un múltiplo, nunca una medida
En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título. Textos, filetes y cajas en los diseños de página →
Error frecuente
El equilibrado de columnas baja al pie el recuadro que cierra una página
Cuando un recuadro es el último bloque de una página tras la que sigue el texto, el equilibrado de columnas (activo por defecto) pasa el espacio que queda debajo por encima del recuadro, que se separa del bloque anterior y termina en la última línea de la página. En postext 1.4.1 ninguna opción del equilibrado lo desactiva por separado: headings.balancing.enabled: false apaga todo el equilibrado, lo que conviene a páginas que deben terminar cortas, como una página de poemas. Equilibrado de columnas →
Error frecuente
Carga todas las fuentes antes de componer
La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →
- Un
:::spacepuesto antes del título inglés desaparece: la segunda columna se abre en el bloque que nombrabreaks, y el espacio cae en el corte. Ponlo después del título, como el:::space{lines=0.5}que sigue aquí al título de cada columna. - Cuenta
breaksdesde el título español: con un título y 12 versos, el título inglés es el bloque 14. Cada verso es un bloque, así que un verso español más lleva el título inglés al bloque 15, ybreakstiene que subir con él.
Créditos
- Receta
- Ignacio Ferro
- Texto
- El ciclo de poemas Sal de agosto y su versión inglesa, August Salt, con la nota de la autora, escritos para esta receta; la poeta Adela Membrives es ficticia · Postext Cookbook · CC BY 4.0
- Imágenes
- Las salinas al amanecer y el flamenco de la portada, dibujados en código con la paleta de la página · Postext Cookbook · CC BY 4.0
- Fuentes
- Castoro (SIL OFL 1.1) · Castoro Titling (SIL OFL 1.1) · Tenor Sans (SIL OFL 1.1)
- Código
- MIT, como Postext


