Zum Hauptinhalt springen
Rezept Nummer 43

Rezepte · Kapitel 2 · Schrift und Text

Übersetzung gegenüber, Strophe für Strophe

Jedes Gedicht ist ein randloser Kasten mit zwei Spalten: Ein fester Spaltenumbruch beginnt das Englische beim Titel, :::space hält die Strophen auf einer Höhe.

Auf dieser Seite
Gattung
Lyrik
Ausgabe
Canvas
Stufe
Mittel
Postext
Getestet mit Postext 1.31.0
Erfordert ≥ 1.25.0
Lizenz
Aktualisiert am 10. Okt. 2026
Code MIT · Text CC BY 4.0

S. 2–3 von 5

  • Englisches Beispiel: noch keine deutsche Ausgabe
  • Endformat 156 × 234 mm
  • 1 Spalte
  • Castoro 10,5/14,5
  • Castoro Titling
  • Tenor Sans
  • 5 Seiten
  • Stufe
  • Postext 1.31.0
  • In 55 ms gesetzt
  • 161 Zeilen Code

Kurz gesagt

Ein kleiner Gedichtband mit dem spanischen Text auf der einen Seite und der englischen Übersetzung daneben. Jede Gruppe von Versen in der Übersetzung beginnt auf gleicher Höhe wie das Original.

Was Sie setzen

Ein zweisprachiger Gedichtband mit fünf Gedichten über eine Saline im Süden Spaniens, 156 × 234 mm, links das Spanische, rechts die englische Fassung des Dichters selbst, Zeile für Zeile. Zuerst kommen eine Titelseite mit einem Flamingo und die Vorbemerkung des Autors; die Gedichte beginnen unter einem Aquarell mit rosa Becken, Salzhaufen und Flamingos bei Sonnenaufgang. Jedes Gedicht steht in einem rahmenlosen Kasten unter einer krapproten Ziffer in der Castoro Titling, das Spanische in der einen Spalte, das Englische in der anderen. Das Englische beginnt auf derselben Zeile wie das Spanische, und jede Strophe beginnt auf gleicher Höhe wie ihr Original. Ist eine englische Zeile zu lang für ihre Spalte, läuft ihr Rest um 2 em eingerückt in die nächste Zeile, und die spanische Strophe gegenüber endet mit einer zusätzlichen Leerzeile.

Dieses Rezept beantwortet

  • Wie setze ich zwei Spalten in einen Kasten (eine Textspalte neben einer Abbildungsspalte)?
  • Wie setze ich Lyrik: eine Zeile pro Vers, Abstand zwischen Strophen, hängende Einzüge für umbrochene Zeilen, keine Silbentrennung?
  • Wie füge ich zusätzlichen vertikalen Abstand zwischen zwei Blöcken ein, wenn Leerzeilen nichts bewirken?
  • Wie erreiche ich guten Blocksatz und gute Silbentrennung für spanischen, französischen oder deutschen Text?

Die kurze Antwort

script.js · Zeilen 28–52im vollständigen Code
// 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
};

Zutaten

Schrift
Castoro, Castoro Titling, Tenor Sans (SIL OFL 1.1)
Dateien
  • flamingo-680.jpg
  • salina-1560.jpg
  • The salt pans at sunrise over the poems, a watercolour (Generated With Diffusion Models, eigener Text)
  • The flamingo on the title page, a watercolour (Generated With Diffusion Models, eigener Text)

Zubereitung

#1 · Ein Gedicht ist ein Kasten mit festem Spaltenumbruch

Der Code steht oben in der kurzen Antwort. Jedes Gedicht ist ein Kasten um eine Gruppe :::columns; sein Stil verzichtet auf Fläche und Innenabstand und druckt den title des Direktivenblocks, die Ziffer (:::columns). Ohne breaks bricht die Gruppe dort um, wo ihre beiden Spalten gleich hoch ausgehen. Beide Hälften dieser Gedichte sind gleich hoch, also fällt der Umbruch ohnehin auf den englischen Titel; verlängern Sie zwei Zeilen von The Rake, bis sie umbrechen, und der Umbruch rückt einen Block nach unten und lässt THE RAKE am Fuß der spanischen Spalte stehen. breaks="5" nennt den Block, der die zweite Spalte eröffnet, sodass der englische Titel sie anführt, wie hoch die beiden Hälften auch sind.

#2 · Ein Block :::verse pro Spalte, eine Leerzeile pro Strophenwechsel

script.js · Zeilen 56–64im vollständigen Code
// 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) };

Jede Hälfte eines Gedichts ist ein Block :::verse: ein Vers pro Markdown-Zeile, im Flattersatz und nie getrennt, und zwischen den Strophen eine Leerzeile, die in der Spalte eine Leerzeile lässt (:::verse). Jede Strophe ist ein Block, also zählt breaks Strophen, nicht Zeilen. Das englische each one standing on its own reflection. ist zu lang für seine 55 mm breite Spalte und bricht um 2 em eingerückt um; die spanische Hälfte schließt ihren Block nach der gegenüberliegenden Strophe und setzt vor die nächste ein :::space{lines=2}, sodass die folgenden Strophen auf derselben Zeile beginnen. Ein :::space ist kein Block, und breaks zählt es nicht mit (:::space).

Gedicht I auf Seite 3: „reflection.“ bricht im Englischen um, und die spanische Strophe endet mit zwei Leerzeilen statt einer.

#3 · Die Vorbemerkung wird in ihrer eigenen Sprache getrennt

script.js · Zeilen 68–82im vollständigen Code
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 wählt die Trennmuster. Mit 'es' trennt die spanische Vorbemerkung ori-ginal und Duna-liella; mit den englischen Mustern gehen beide Wörter ganz in die nächste Zeile, und nur arras-traba wird in beiden Fällen getrennt (unterstützte Locales). Wortabstände dürfen auf das 0,8-Fache ihrer normalen Breite schrumpfen und sich auf das 1,35-Fache dehnen, und keine Blocksatzzeile in einer der beiden Vorbemerkungen dehnt sich darüber hinaus. Die Überschrift der Vorbemerkung ist eine Gestaltung, deren einziges Element, der Titel, 36 mm unter der Oberkante des Satzspiegels steht, sodass die Vorbemerkung 73 mm unter der Oberkante ihrer Seite beginnt.

#4 · Titelseite und Aquarell sind Überschriften

script.js · Zeilen 86–114im vollständigen Code
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') }),
  ] } },
};

Beide sind Überschriften der Ebene 1 mit eigenem Stil (Überschriftenstile): Die Gestaltung der Titelseite nimmt {author} aus dem Frontmatter, und beide Gestaltungen nehmen {attr.other} aus den Attributen der Überschrift. Das Aquarell ist an der Oberkante der Seite verankert, über dem Satzspiegel, und die Überschrift reserviert Platz bis zu seinem Fuß, 84 mm weit hinunter. Der Stil setzt marginBottom: pt(0), sodass der Abstand unter dem Bild der eigene obere Rand des Gedichts von zwei Zeilen ist und Gedicht I auf der ersten Rasterlinie mindestens zwei Zeilen unter dem Bild beginnt.

#5 · Die Kolumnentitel nennen beide Sprachen

script.js · Zeilen 118–131im vollständigen Code
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' })] };

Die linke Seite trägt den spanischen Titel, die rechte den englischen, in der Reihenfolge der Spalten. pages: 'body' hält sie von den drei Seiten fern, die mit einer Überschrift beginnen. Vorbemerkung und Aquarell bekommen stattdessen eine Seitenzahl am Fuß, und der Stil der Titelseite leert Kopf- und Fußzeile (Textelemente).

Das ganze Rezept

Sandbox
// ═══ 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`---
Markdown-Beispiel · 253 Zeilen · content.en.mdtitle: "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: in jedem Rezept gleich · 270 Zeilen// ─── 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 ───────────────────────────────────────────────────────────────────────

Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)

Varianten

#Den Spaltenabstand vergrößern

Bei 12 mm schrumpfen die Spalten auf 53 mm, und Los turistas paran y hacen fotos: bricht in Gedicht III um, sodass seine zweite Strophe eine Zeile unter der englischen beginnt; schließen Sie den englischen Block nach dieser Strophe und setzen Sie vor die nächste ein :::space{lines=2}, um beide anzugleichen.

-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

#Umbruchzeilen tiefer einziehen

Bei 3 em beginnt reflection. auf Seite 3 ein Geviert weiter innen.

-const verse = { id: 'verse', hangingIndent: em(2) };
+const verse = { id: 'verse', hangingIndent: em(3) };

Häufige Fehler

  • Ein :::space vor dem englischen Titel verschwindet: Die zweite Spalte beginnt bei dem Block, den breaks nennt, und der Abstand fällt genau auf den Umbruch. Setzen Sie ihn nach den Titel, wie das :::space{lines=0.5} unter jedem Spaltentitel hier.
  • Zählen Sie breaks ab dem spanischen Titel: Bei einem Titel und drei Strophen ist der englische Titel Block 5. Jede Strophe ist ein Block, also schiebt eine spanische Strophe mehr den englischen Titel auf Block 6, und breaks muss mitwachsen; eine Zeile mehr innerhalb einer Strophe verschiebt nichts.

Nachweise

Text
  • The poem cycle Sal de agosto and its English version, August Salt, with the author’s note, written for this recipe; the poet Adela Membrives is fictional · Postext Cookbook · CC BY 4.0
Bilder
  • The salt pans at sunrise over the poems, a watercolour · Generated With Diffusion Models · eigener Text
  • The flamingo on the title page, a watercolour · Generated With Diffusion Models · eigener Text
Schriften
Castoro (SIL OFL 1.1) · Castoro Titling (SIL OFL 1.1) · Tenor Sans (SIL OFL 1.1)
Sandbox