En bref
Un petit recueil de poèmes avec l’espagnol d’un côté et la traduction anglaise à côté. Chaque groupe de vers de la traduction commence à la hauteur de l’original.
Ce que vous allez composer
Une plaquette bilingue de cinq poèmes sur un marais salant du sud de l’Espagne, en 156 × 234 mm, avec l’espagnol à gauche et la version anglaise du poète lui-même à droite, vers pour vers. Une page de titre ornée d’un flamant et la note de l’auteur viennent d’abord ; les poèmes s’ouvrent sous une aquarelle de bassins roses, de tas de sel et de flamants au lever du soleil. Chaque poème tient dans un encadré sans cadre, sous un numéro garance en Castoro Titling, l’espagnol dans une colonne et l’anglais dans l’autre. L’anglais commence sur la même ligne que l’espagnol, et chaque strophe commence à la hauteur de son original. Quand un vers anglais est trop long pour sa colonne, la fin est rejetée à la ligne avec un retrait de 2 em, et la strophe espagnole d’en face se termine par une ligne blanche de plus.
Cette recette répond à
- Comment mettre deux colonnes dans un encadré (une colonne de texte à côté d’une colonne de figure) ?
- Comment composer de la poésie : un vers par ligne, blancs entre les strophes, retraits suspendus pour les vers renvoyés, sans césure ?
- Comment ajouter de l’espace vertical entre deux blocs, quand les lignes vides n’y font rien ?
- Comment obtenir une bonne justification et une bonne césure pour un texte en espagnol, en français ou en allemand ?
La réponse courte
// Each poem is a box titled with its numeral, holding one two-column group. breaks="5"
// opens the second column at the group's 5th block, since the Spanish title and its three
// stanzas come before it. A :::space is not a block, so the gaps leave the count alone:
// :::callout{type="poem" title="I"}
// :::columns{count=2 breaks="5"}
// Spanish title, :::space{lines=0.5}, its stanzas in :::verse blocks
// English title, :::space{lines=0.5}, its stanzas in :::verse blocks
// :::
// :::
// Both columns open on the same line, so matching :::space gaps keep the stanzas level.
// keepTogether: false lets a poem that does not fit go on overleaf. A group with breaks is
// cut stream by stream (flow="parallel"): each language goes on in its own column, level.
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, under the picture as under
// a poem (12.3 mm there: its 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),
keepTogether: false, // a long poem goes on overleaf instead of moving whole
};
Ingrédients
- Fonctionnalités
- Vers ligne à ligneColonnes dans un encadré ou dans le texteEspace vertical expliciteStyles de paragrapheEncadrésCésure et langue du documentStyles de titreOuvertures composéesAttributs de titreImages dans les maquettes de pageCouvertures, pages de titre et colophonsMétadonnées du documentÉquilibrage des colonnesTitres courants et foliosTitres courants selon le rôle de la pageMarges en miroirPalette de couleurs sémantiqueFigures et tableaux comme ressourcesTitres courants par sectionPages sur un canvas
- Typographie
- Castoro, Castoro Titling, Tenor Sans (SIL OFL 1.1)
- Ressources
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)
Préparation
#1 · Un poème est un encadré avec un saut de colonne fixe
Le code est dans la réponse courte plus haut. Chaque poème est un encadré autour d’un groupe :::columns ; son style supprime le fond et la marge intérieure et imprime le title de la clôture, le numéro (:::columns). Sans breaks, le groupe se coupe là où ses deux colonnes sortent de même hauteur. Les deux moitiés de ces poèmes ont la même hauteur : la coupure tombe donc de toute façon sur le titre anglais. Allongez deux vers de The Rake jusqu’à ce qu’ils passent à la ligne, et la coupure descend d’un bloc et laisse THE RAKE au pied de la colonne espagnole. breaks="5" désigne le bloc qui ouvre la seconde colonne : le titre anglais la coiffe ainsi, quelle que soit la hauteur des deux moitiés.
#2 · Un bloc :::verse par colonne, une ligne blanche par changement de strophe
// Each half of a poem is a :::verse block, a line of verse a line of Markdown and a blank line
// between stanzas. A line too long for its 55 mm column turns over 2 em in, where it cannot
// pass for the next line; the Spanish half then ends that stanza's block and a
// :::space{lines=2} before the next keeps the stanzas level across the gutter.
const verse = { id: 'verse', hangingIndent: em(2) };
// Castoro Titling draws capitals only. A :::space{lines=0.5} in the Markdown sets each title
// off its poem.
const poemTitle = { id: 'poem-title', fontFamily: 'Castoro Titling', fontSize: pt(9.5),
color: col('madder'), firstLineIndent: pt(0) };
Chaque moitié d’un poème est un bloc :::verse : un vers par ligne du Markdown, en drapeau et jamais coupé, et une ligne blanche entre les strophes, qui laisse une ligne blanche dans la colonne (:::verse). Chaque strophe est un bloc : breaks compte donc les strophes, pas les vers. Le vers anglais each one standing on its own reflection. est trop long pour sa colonne de 55 mm et se rejette à la ligne avec un retrait de 2 em ; la moitié espagnole ferme son bloc après la strophe d’en face et place un :::space{lines=2} avant la suivante, si bien que les strophes suivantes commencent sur la même ligne. Un :::space n’est pas un bloc, et breaks ne le compte pas (:::space).

#3 · La note est coupée dans sa propre langue
const LOCALE = t({ en: 'en-us', es: 'es' }); // the patterns of the edition's language
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 choisit les motifs de césure. Avec 'es', la note espagnole coupe ori-ginal et Duna-liella ; avec les motifs anglais, les deux mots passent entiers à la ligne suivante, et seul arras-traba se coupe dans les deux cas (langues prises en charge). Les espaces-mots peuvent se réduire à 0,8 et s’étirer jusqu’à 1,35 fois leur largeur normale, et aucune ligne justifiée d’aucune des deux notes ne dépasse cet étirement. Le titre de la note est une maquette dont l’unique élément, le titre, se place 36 mm sous le haut du bloc de texte : la note commence donc à 73 mm du bord supérieur de sa page.
#4 · La page de titre et l’aquarelle sont des titres
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') }),
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') }),
] } },
};
// The picture of the salt pans runs 84 mm down from the top edge of the page, and the heading
// reserves down to its foot. No margin under it: the poem's own marginTop sets the gap.
const poemsOpener = {
id: 'poems', marginBottom: pt(0),
advancedDesign: { enabled: true, 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') }),
] } },
};
Chacune est un titre de niveau 1 avec son propre style (styles de titre) : la maquette de la page de titre prend {author} dans le frontmatter, et les deux maquettes prennent {attr.other} dans les attributs du titre. L’aquarelle est ancrée au bord supérieur de la page, au-dessus du bloc de texte, et le titre réserve l’espace jusqu’à son pied, à 84 mm dans la page. Le style règle marginBottom: pt(0) : le blanc sous l’image est donc la marge supérieure de deux lignes du poème lui-même, et le poème I commence sur la première ligne de la grille située au moins deux lignes sous l’image.
#5 · Les titres courants nomment les deux langues
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' })] };
Le verso porte le titre espagnol et le recto le titre anglais, dans l’ordre des colonnes. pages: 'body' les tient à l’écart des trois pages qui s’ouvrent sur un titre. La note et l’aquarelle reçoivent à la place un folio en pied, et le style de la page de titre vide son en-tête et son pied de page (éléments de texte).
La recette complète
// ═══ 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.25.0 import { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'bilingual-facing-verse'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // every colour in the config links to one of these ink: '#1f2430', // the text: a blue-black madder: '#9a3c52', // the one accent: numerals, poem titles, the other language's title muted: '#6a6770', // running heads, folios, the author's name, the colophon paper: '#ffffff', }; 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="5" // opens the second column at the group's 5th block, since the Spanish title and its three // stanzas come before it. A :::space is not a block, so the gaps leave the count alone: // :::callout{type="poem" title="I"} // :::columns{count=2 breaks="5"} // Spanish title, :::space{lines=0.5}, its stanzas in :::verse blocks // English title, :::space{lines=0.5}, its stanzas in :::verse blocks // ::: // ::: // Both columns open on the same line, so matching :::space gaps keep the stanzas level. // keepTogether: false lets a poem that does not fit go on overleaf. A group with breaks is // cut stream by stream (flow="parallel"): each language goes on in its own column, level. 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, under the picture as under // a poem (12.3 mm there: its 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), keepTogether: false, // a long poem goes on overleaf instead of moving whole }; // #endregion // #region verse: a :::verse block per column, turnovers that hang, a title over each column // Each half of a poem is a :::verse block, a line of verse a line of Markdown and a blank line // between stanzas. A line too long for its 55 mm column turns over 2 em in, where it cannot // pass for the next line; the Spanish half then ends that stanza's block and a // :::space{lines=2} before the next keeps the stanzas level across the gutter. const verse = { id: 'verse', hangingIndent: em(2) }; // Castoro Titling draws capitals only. A :::space{lines=0.5} in the Markdown 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' }); // the patterns of the edition's language 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') }), 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') }), ] } }, }; // The picture of the salt pans runs 84 mm down from the top edge of the page, and the heading // reserves down to its foot. No margin under it: the poem's own marginTop sets the gap. const poemsOpener = { id: 'poems', marginBottom: pt(0), advancedDesign: { enabled: true, 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 = () => ({ 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: { closingBox: 'off' }, // 'first', the default, drops poem III to the page's foot levels: [ // parity 'any': the note and the poems open the next page, on either side { 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`---Exemple Markdown · 253 lignes · content.en.md
title: "August Salt" author: "Adela Membrives" --- # August Salt {style="title-page" other="Sal de agosto" edition="Bilingual edition" version="English version by the author" press="Cuadernos de Salmuera · 7"} # Author’s Note {style="note"} I wrote these five poems at the salt pans where my father worked, in the south of Spain, during the August harvest of 2024. He spent thirty-one years there; I spent my summers sitting on the wall of a pond, watching him drag the salt to the edge with his rake. The English version is my own and follows the Spanish line by line: each line on the right translates the one across from it, and each stanza starts level with its original. When an English line is too long for the column it carries on below, indented, and the Spanish stanza leaves a blank line at its end, so that the next pair starts level again. Some words do not cross whole. *Rastro* is both the wooden rake that drags the salt and the trace it leaves behind; I chose *rake*, the tool, and left the trace for the reader. *Levante* is the east wind, which blows dry on that coast and sets the salt in three days. English has the word *levanter*, but few readers know it, so the poem says *east wind*. The pink of the ponds comes mostly from *Dunaliella salina*, a microscopic alga, and from archaea that live only in brine. Brine shrimp feed on the alga, and the flamingos take their pink from the shrimp they eat. :::paragraphs{style="signature"} *A. M., March 2025* ::: # August Salt {style="poems" other="Sal de agosto"} :::callout{type="poem" title="I"} :::columns{count=2 breaks="5"} :::paragraphs{style="poem-title"} La salina al alba ::: :::space{lines=0.5} :::verse{style="verse" align=start} Antes del sol, la salina es una lámina de cobre. Los flamencos duermen de pie, cada uno sobre su reflejo. ::: :::space{lines=2} :::verse{style="verse" align=start} El agua no se mueve. Tiene el color de la encía, de la gamba cocida, de una uña que aprieta. 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} :::verse{style="verse" align=start} *Before the sun, the salt pan* *is a sheet of copper.* *The flamingos sleep upright,* *each one standing on its own reflection.* *The water does not move.* *It is the colour of gums,* *of a boiled prawn,* *of a fingernail pressed down.* *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="5"} :::paragraphs{style="poem-title"} El rastro ::: :::space{lines=0.5} :::verse{style="verse" align=start} A las seis ya está en la balsa con el agua a los tobillos y un rastro de madera más viejo que yo. Tira de la sal hacia sí como quien recoge una red sin peces, nada más que luz que cruje. 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} :::verse{style="verse" align=start} *By six he is in the pond* *with water to his ankles* *and a wooden rake* *older than I am.* *He draws the salt towards him* *the way you haul in a net* *with no fish in it, only light* *that crunches.* *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="4"} :::paragraphs{style="poem-title"} Los montones ::: :::space{lines=0.5} :::verse{style="verse" align=start} En agosto crecen montes blancos junto a la carretera. Los turistas paran y hacen fotos: creen que es nieve, o yeso. 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} :::verse{style="verse" align=start} *In August white hills grow* *beside the coast road.* *Tourists stop and take pictures:* *they think it is snow, or plaster.* *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="4"} :::paragraphs{style="poem-title"} Levante ::: :::space{lines=0.5} :::verse{style="verse" align=start} Cuando entra el levante la sal cuaja en tres días. Mi padre lo nota antes: se le pone ronca la voz. 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} :::verse{style="verse" align=start} *When the east wind comes in* *the salt sets in three days.* *My father feels it first:* *his voice goes hoarse.* *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="4"} :::paragraphs{style="poem-title"} Septiembre ::: :::space{lines=0.5} :::verse{style="verse" align=start} En septiembre se van todos. Se queda sola la iglesia entre las balsas vacías y un flamenco que no se decide. 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} :::verse{style="verse" align=start} *In September everyone leaves.* *The church is left alone* *among the empty ponds* *and one flamingo, undecided.* *What stays in my mouth* *is the taste of his thumb* *when he wiped my face* *before we went in to Mass.* ::: ::: ::: :::paragraphs{style="colophon"} Poems and note written for the Postext Cookbook (CC BY 4.0); the poet is fictional. Set in Castoro, Castoro Titling and Tenor Sans (SIL OFL). Watercolours made with diffusion models. :::`; // 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. None of the three ships a bold. const FONTS = { Castoro: ['400', '400i'], 'Castoro Titling': ['400'], 'Tenor Sans': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); 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 : identique dans toutes les recettes · 270 lignes
// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { 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', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes 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; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 ───────────────────────────────────────────────────────────────────────
Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)
Variantes
#Élargir la gouttière
À 12 mm, les colonnes se réduisent à 53 mm, et Los turistas paran y hacen fotos: passe à la ligne dans le poème III : sa deuxième strophe commence alors une ligne sous l’anglais. Fermez le bloc anglais après cette strophe et placez un :::space{lines=2} avant la suivante pour les remettre à niveau.
-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#Rejeter les vers plus loin
À 3 em, reflection. commence un em plus à l’intérieur, page 3.
-const verse = { id: 'verse', hangingIndent: em(2) };
+const verse = { id: 'verse', hangingIndent: em(3) };Erreurs fréquentes
- Un
:::spaceplacé avant le titre anglais disparaît : la seconde colonne s’ouvre sur le bloc désigné parbreaks, et l’espace tombe à la coupure. Placez-le après le titre, comme le:::space{lines=0.5}sous chaque titre de colonne ici. - Comptez
breaksà partir du titre espagnol : avec un titre et trois strophes, le titre anglais est le bloc 5. Chaque strophe est un bloc : une strophe espagnole de plus fait passer le titre anglais au bloc 6, etbreaksdoit augmenter d’autant ; une ligne de plus à l’intérieur d’une strophe ne change rien.
Crédits
- Recette
- Ignacio Ferro
- Texte
- The poem cycle Sal de agosto and its English version, August Salt, with the author’s note, written for this recipe; the poet Adela Membrives is fictional · Postext Cookbook · CC BY 4.0
- Images
- The salt pans at sunrise over the poems, a watercolour · Generated With Diffusion Models · original
- The flamingo on the title page, a watercolour · Generated With Diffusion Models · original
- Polices
- Castoro (SIL OFL 1.1) · Castoro Titling (SIL OFL 1.1) · Tenor Sans (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


