Pular para o conteúdo principal
Receita número 43

Receitas · Capítulo 2 · Tipo e texto

Tradução lado a lado, estrofe por estrofe

Cada poema é um boxe sem moldura com duas colunas: uma quebra fixa abre o inglês no título, e :::space deixa cada estrofe na altura do original.

Nesta página
Gênero
Poesia
Saída
Canvas
Postext
Testada com o Postext 1.19.1
Requer ≥ 1.4.1
Licença
Atualizada em 26 de set. de 2026
Código MIT · Texto CC BY 4.0
  • Amostra em inglês: ainda sem edição em português
  • Refile 156 × 234 mm
  • 1 coluna
  • Castoro 10,5/14,5
  • Castoro Titling
  • Tenor Sans
  • 5 páginas
  • Nível
  • Postext 1.19.1
  • Diagramado em 60 ms
  • 166 linhas de código

Em poucas palavras

Um livrinho de poemas com o espanhol de um lado e a tradução inglesa ao lado. Cada estrofe da tradução começa na mesma altura que a do original.

O que você vai compor

Uma plaquete bilíngue de cinco poemas sobre uma salina no sul da Espanha, em 156 × 234 mm, com o espanhol à esquerda e a versão inglesa da própria autora à direita, verso a verso. Abrem o livro uma folha de rosto com um flamingo e a nota da autora; os poemas começam sob uma aquarela de tanques cor-de-rosa, montes de sal e flamingos ao amanhecer. Cada poema fica num boxe sem moldura, sob um numeral cor de garança em Castoro Titling, com o espanhol numa coluna e o inglês na outra. O inglês começa na mesma linha que o espanhol, e cada estrofe começa na altura do original. Quando um verso inglês é longo demais para a coluna, o resto dele continua na linha de baixo com recuo de 2 em, e a estrofe espanhola em frente termina com uma linha em branco a mais.

Esta receita responde a

  • Como coloco duas colunas dentro de um boxe (uma coluna de texto ao lado de uma coluna com figura)?
  • Como componho poesia: um verso por linha, espaço entre estrofes, recuo deslocado nos versos que quebram e sem hifenização?
  • Como acrescento espaço vertical entre dois blocos, se as linhas em branco não fazem nada?
  • Como consigo boa justificação e hifenização em textos em espanhol, francês ou alemão?

A resposta curta

script.js · linhas 30–53no código completo
// Each poem is a box titled with its numeral, holding one two-column group. breaks="14"
// opens the second column at the group's 14th block, since the Spanish title and its 12
// lines come before it. A :::space is not a block, so stanza gaps leave the count alone:
//   :::callout{type="poem" title="I"}
//   :::columns{count=2 breaks="14"}
//   Spanish title, :::space{lines=0.5}, 12 lines with a :::space between stanzas
//   English title, :::space{lines=0.5}, 12 lines with a :::space between stanzas
//   :::
//   :::
// Both columns open on the same line, so matching :::space gaps keep the stanzas level.
// A box keeps together and a group never splits (gotcha: callout-columns): a poem that does
// not fit moves whole to the next page.
const poem = {
  id: 'poem',
  backgroundEnabled: false, // no fill, border or stripe
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  columnGap: mm(GAP),
  titleStyle: { fontFamily: 'Castoro Titling', fontSize: pt(22), fontWeight: 400,
    color: col('madder'), gap: pt(4) },
  // A box starts on the first grid line at least two lines down: 10.2 mm under the picture,
  // 12.3 mm under a poem, whose box ends off the grid. The default marginBottom (0.75 em)
  // would add to marginTop and open 17.4 mm between poems.
  marginTop: pt(LEAD * 2), marginBottom: pt(0),
};

Ingredientes

Tipografia
Castoro, Castoro Titling, Tenor Sans (SIL OFL 1.1)
Materiais
  • flamingo-680.jpg
  • salina-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)

Preparo

#1 · Um poema é um boxe com uma quebra de coluna fixa

O código está na resposta curta, mais acima. :::columns só funciona dentro de um boxe, por isso cada poema é um boxe cujo estilo tira o fundo e o preenchimento e imprime o title da cerca, que é o numeral (:::columns). Sem breaks, o grupo corta onde as duas colunas ficam niveladas. As duas metades destes poemas têm a mesma altura, então o corte cai no título inglês de qualquer maneira; mas alongue dois versos de The Rake até eles não caberem, e o corte desce um bloco e deixa THE RAKE no pé da coluna espanhola. breaks="14" indica o bloco que abre a segunda coluna, e o título inglês a encabeça seja qual for a altura das duas metades.

#2 · Um parágrafo por verso, um :::space por quebra de estrofe

script.js · linhas 57–64no código completo
// 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) };

O Markdown junta as linhas de um parágrafo, então cada verso é um parágrafo à parte, no estilo verse, em bandeira, que a versão 1.4.1 nunca hifeniza. Um :::space entre estrofes deixa uma linha em branco em cada coluna. Ele não é um bloco, e por isso breaks não o conta (:::space). O verso inglês each one standing on its own reflection. é longo demais para a coluna de 55 mm e continua na linha de baixo com recuo de 2 em; a estrofe espanhola em frente termina com :::space{lines=2}, e as estrofes seguintes começam na mesma linha.

O poema I na página 3: reflection. passa para a linha de baixo no inglês, e a estrofe espanhola termina com duas linhas em branco em vez de uma.

#3 · A nota é hifenizada na própria língua

script.js · linhas 68–82no código completo
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 escolhe os padrões de hifenização pelo código exato. Com 'es', a nota espanhola divide ori-ginal e Duna-liella; com os padrões ingleses as duas palavras vão inteiras para a linha seguinte, e só arras-traba se divide com ambos (idiomas suportados). Os espaços entre palavras podem encolher até 0,8 e esticar até 1,35 da largura normal, e nenhuma linha justificada das duas notas passa disso. O título da nota é um design de um único elemento, o próprio título, colocado 36 mm abaixo do topo da mancha, e assim a nota começa a 73 mm da borda superior da página.

#4 · A folha de rosto e a aquarela são títulos

script.js · linhas 86–117no código completo
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } });
const face = (id, content, font, size, y, extra = {}) => ({ kind: 'text', id, content,
  fontFamily: font, fontSize: pt(size), color: col('ink'), align: 'center', overflow: 'wrap',
  placement: onPage(y), ...extra });
const tracked = { letterSpacing: pt(2), textTransform: 'uppercase' };
const image = (id, placement) => ({ kind: 'image', id, resourceId: id, placement });
const titlePage = {
  id: 'title-page',
  header: { elements: [] }, footer: { elements: [] }, // no running head, no folio
  advancedDesign: { enabled: true, slot: { elements: [
    face('author', '{author}', 'Tenor Sans', 9, 44, { ...tracked, color: col('muted') }),
    // A multiple of the size, never pt() (gotcha: design-lineheight-multiple).
    face('title', '{titleText}', 'Castoro Titling', 34, 58, { lineHeight: 1 }),
    face('other', '{attr.other}', 'Castoro', 16, 76, { italic: true, color: col('madder') }),
    image('flamingo', { ...onPage(96), size: { width: mm(34) } }),
    face('edition', '{attr.edition}', 'Tenor Sans', 8.5, 170, tracked),
    face('version', '{attr.version}', 'Castoro', 10.5, 176, { italic: true }),
    face('press', '{attr.press}', 'Tenor Sans', 8, 206, { ...tracked, color: col('muted') }),
  ] } },
};
const BAND = 84; // mm: the picture of the salt pans, from the top edge of the page
const UNDER = Math.floor((BAND - TOP) / ((LEAD * 25.4) / 72)); // 12 grid lines to its foot
const poemsOpener = {
  id: 'poems', span: 'page', // span 'page': a column clips its design
  // An image reserves no height (gotcha: opener-image-no-reserve). minHeight, a whole number
  // of lines, plus the level's one-line bottom margin end the heading at the picture's foot.
  advancedDesign: { enabled: true, minHeight: pt(LEAD * (UNDER - 1)), slot: { elements: [
    image('salina', { anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill' } }),
    face('title', '{titleText}', 'Castoro Titling', 38, 17, { lineHeight: 1 }),
    face('other', '{attr.other}', 'Castoro', 15, 33, { italic: true, color: col('madder') }),
  ] } },
};

Cada uma é um título de nível 1 com estilo próprio (estilos de título): o design da folha de rosto pega {author} do frontmatter, e os dois designs pegam {attr.other} dos atributos do título. A aquarela começa na borda superior da página, acima da mancha, onde um design mantido na coluna seria cortado, por isso o estilo dela tem span: 'page'. Uma imagem não reserva altura: sem minHeight, o título tem 25,6 mm e o poema I começa a 58 mm da borda, em cima de uma imagem que vai até 84 mm. Um minHeight de onze linhas, mais a margem inferior de uma linha do nível, faz o título terminar a 83,4 mm.

#5 · Os cabeços nomeiam as duas línguas

script.js · linhas 121–134no código completo
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' })] };

A página par traz o título espanhol e a ímpar o inglês, na ordem das colunas. pages: 'body' tira os cabeços das três páginas que abrem com um título. A nota e a aquarela levam um fólio no pé no lugar deles, e o estilo da folha de rosto esvazia o cabeçalho e o rodapé (elementos de texto).

A receita completa

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.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, 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',
};
// col() writes the hex beside the id, since designs and running heads do not read the
// palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries(palette)
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const TRIM_W = 156, TRIM_H = 234; // mm
const TOP = 22, OUTER = 18, INNER = 20; // mm: the text block is 118 mm wide
const LEAD = 14.5; // pt: the leading of the note and of every line of verse
const GAP = 8; // mm between the original and the translation: each column is 55 mm wide

// #region answer: a poem and its translation: a box with two columns and a fixed break
// Each poem is a box titled with its numeral, holding one two-column group. breaks="14"
// opens the second column at the group's 14th block, since the Spanish title and its 12
// lines come before it. A :::space is not a block, so stanza gaps leave the count alone:
//   :::callout{type="poem" title="I"}
//   :::columns{count=2 breaks="14"}
//   Spanish title, :::space{lines=0.5}, 12 lines with a :::space between stanzas
//   English title, :::space{lines=0.5}, 12 lines with a :::space between stanzas
//   :::
//   :::
// Both columns open on the same line, so matching :::space gaps keep the stanzas level.
// A box keeps together and a group never splits (gotcha: callout-columns): a poem that does
// not fit moves whole to the next page.
const poem = {
  id: 'poem',
  backgroundEnabled: false, // no fill, border or stripe
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  columnGap: mm(GAP),
  titleStyle: { fontFamily: 'Castoro Titling', fontSize: pt(22), fontWeight: 400,
    color: col('madder'), gap: pt(4) },
  // A box starts on the first grid line at least two lines down: 10.2 mm under the picture,
  // 12.3 mm under a poem, whose box ends off the grid. The default marginBottom (0.75 em)
  // would add to marginTop and open 17.4 mm between poems.
  marginTop: pt(LEAD * 2), marginBottom: pt(0),
};
// #endregion

// #region verse: a paragraph per line, turnovers that hang, a title over each column
// Every line of verse is a paragraph of this style, set ragged so that no line is stretched.
// A line too long for its 55 mm column turns over 2 em in, where it cannot pass for the
// next line; the Spanish stanza opposite then ends with :::space{lines=2}, not :::space.
const verse = { id: 'verse', textAlign: 'left', hangingIndent: em(2) };
// Castoro Titling draws capitals only. A paragraph style's margins do not count inside a box
// (gotcha: box-paragraph-margins), so :::space{lines=0.5} sets each title off its poem.
const poemTitle = { id: 'poem-title', fontFamily: 'Castoro Titling', fontSize: pt(9.5),
  color: col('madder'), firstLineIndent: pt(0) };
// #endregion

// #region note: the author's note, justified and hyphenated in the edition's language
const LOCALE = t({ en: 'en-us', es: 'es' }); // exact codes (gotcha: hyphenation-locales)
const bodyText = {
  fontFamily: 'Castoro', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
  italicColor: col('ink'), firstLineIndent: mm(4.5), indentAfterHeading: false,
  minWordSpacing: 0.8, maxWordSpacing: 1.35, // word spaces from 0.8 to 1.35 of normal
  // No bold on these pages: ink keeps a **bold** or a :ref added later off the default blue
  // (#295AA3). References take boldColor while referenceColor is unset.
  boldColor: col('ink'),
};
// The heading prints its title 36 mm down the text block, and the note starts under it.
const notePage = { id: 'note', advancedDesign: { enabled: true, slot: {
  elements: [{ kind: 'text', id: 'title', content: '{titleText}', align: 'left',
    fontFamily: 'Castoro Titling', fontSize: pt(13), color: col('madder'), overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(36) } } }],
} } };
// #endregion

// #region front: the title page and the picture over the poems, each a heading's design
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } });
const face = (id, content, font, size, y, extra = {}) => ({ kind: 'text', id, content,
  fontFamily: font, fontSize: pt(size), color: col('ink'), align: 'center', overflow: 'wrap',
  placement: onPage(y), ...extra });
const tracked = { letterSpacing: pt(2), textTransform: 'uppercase' };
const image = (id, placement) => ({ kind: 'image', id, resourceId: id, placement });
const titlePage = {
  id: 'title-page',
  header: { elements: [] }, footer: { elements: [] }, // no running head, no folio
  advancedDesign: { enabled: true, slot: { elements: [
    face('author', '{author}', 'Tenor Sans', 9, 44, { ...tracked, color: col('muted') }),
    // A multiple of the size, never pt() (gotcha: design-lineheight-multiple).
    face('title', '{titleText}', 'Castoro Titling', 34, 58, { lineHeight: 1 }),
    face('other', '{attr.other}', 'Castoro', 16, 76, { italic: true, color: col('madder') }),
    image('flamingo', { ...onPage(96), size: { width: mm(34) } }),
    face('edition', '{attr.edition}', 'Tenor Sans', 8.5, 170, tracked),
    face('version', '{attr.version}', 'Castoro', 10.5, 176, { italic: true }),
    face('press', '{attr.press}', 'Tenor Sans', 8, 206, { ...tracked, color: col('muted') }),
  ] } },
};
const BAND = 84; // mm: the picture of the salt pans, from the top edge of the page
const UNDER = Math.floor((BAND - TOP) / ((LEAD * 25.4) / 72)); // 12 grid lines to its foot
const poemsOpener = {
  id: 'poems', span: 'page', // span 'page': a column clips its design
  // An image reserves no height (gotcha: opener-image-no-reserve). minHeight, a whole number
  // of lines, plus the level's one-line bottom margin end the heading at the picture's foot.
  advancedDesign: { enabled: true, minHeight: pt(LEAD * (UNDER - 1)), slot: { elements: [
    image('salina', { anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill' } }),
    face('title', '{titleText}', 'Castoro Titling', 38, 17, { lineHeight: 1 }),
    face('other', '{attr.other}', 'Castoro', 15, 33, { italic: true, color: col('madder') }),
  ] } },
};
// #endregion

// #region heads: the Spanish title over the verso, the English title over the recto
const edgeAt = (edge, x, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } });
const head = (id, content, parity, placement, extra = {}) => ({ kind: 'text', id, content,
  parity, pages: 'body', fontFamily: 'Tenor Sans', fontSize: pt(7.5), color: col('muted'),
  ...tracked, letterSpacing: pt(1.5), placement, ...extra });
const folio = { color: col('ink'), letterSpacing: pt(0) };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', edgeAt('top-left', OUTER, 12), folio),
  head('verso-title', 'Sal de agosto', 'even', edgeAt('top-left', OUTER + 8, 12)),
  head('recto-title', 'August Salt', 'odd', edgeAt('top-right', -(OUTER + 8), 12)),
  head('recto-folio', '{pageNumber}', 'odd', edgeAt('top-right', -OUTER, 12), folio),
] };
// The note and the picture open their pages ('opener'): a folio at the foot instead.
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', edgeAt('bottom', 0, -12),
  { ...folio, pages: 'opener' })] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: LOCALE,
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(22), left: mm(INNER), right: mm(OUTER), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText,
  headings: {
    // The headings print designs, but their blocks carry this face: left out, the build
    // would ask for Open Sans 700, and Castoro Titling ships a 400 only.
    fontFamily: 'Castoro Titling', fontWeight: 400,
    balancing: { enabled: false }, // on, poem III drops 22.5 mm (gotcha: balancing-drops-last-box)
    levels: [ // restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break)
      { level: 1, marginBottom: pt(LEAD), breakBefore: { enabled: true, parity: 'any' } },
    ],
  },
  headingStyles: [titlePage, notePage, poemsOpener],
  calloutStyles: [poem],
  paragraphStyles: [verse, poemTitle,
    { id: 'signature', textAlign: 'right', firstLineIndent: pt(0), marginTop: pt(LEAD) },
    { id: 'colophon', fontFamily: 'Tenor Sans', fontSize: pt(7.5), lineHeight: pt(11),
      color: col('muted'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD * 3) },
  ],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Amostra em Markdown · 349 linhas · 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="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"} 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 (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 em todas as receitas · 271 linhas// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** 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. * 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', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; 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 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 = `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` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds 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. */ 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' }; } /** 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, '-'); } /** 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 v1 ── 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. */ 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 ───────────────────────────────────────────────────────────────────────

O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)

Variações

#Alargar a medianiz

Com 12 mm, as colunas ficam com 53 mm, e Los turistas paran y hacen fotos: deixa de caber no poema III, então a segunda estrofe começa uma linha abaixo da inglesa; termine a estrofe inglesa com :::space{lines=2} para nivelá-las.

-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

#Recuar mais os versos que não cabem

Com 3 em, reflection. na página 3 começa um em mais para dentro.

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

Erros comuns

Erro comum

:::columns só funciona dentro de um boxe e nunca se divide

:::columns é ignorado fora de um boxe, e um boxe que se divide nunca corta dentro de um grupo de colunas. O atributo breaks conta blocos filhos, e um boxe aninhado conta como um. Colunas dentro de um boxe →

Erro comum

As margens de um estilo de parágrafo não contam dentro de um boxe

No postext 1.4.1, um contêiner :::paragraphs dentro de um :::callout ignora o marginTop e o marginBottom do seu estilo, então uma linha em letra miúda sob o texto de uma nota fica colada a ele. Dê ao estilo um lineHeight maior, que abre espaço acima da primeira linha, ou tire essa linha do boxe. Estilos de parágrafo →

Erro comum

As imagens de uma abertura nunca contam para a altura que ela reserva

No postext 1.4.1, um título com design avançado mede a altura que reserva sem as imagens: textos, fios e caixas contam, mesmo quando ancorados na página, mas uma imagem, como uma ilustração sangrada no alto da página, não reserva nada, então o texto pode começar por cima dela. Defina com minHeight onde o texto deve começar. Aberturas desenhadas →

Erro comum

Só 8 idiomas têm hifenização, com o código exato

A hifenização existe para en-us, es, fr, de, it, pt, ca e nl, com o código exato: 'es-ES' ou qualquer outro idioma passa sem aviso para o inglês americano. Hifenização e idioma do documento →

Erro comum

Qualquer objeto headings desativa a quebra de página do H1

Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →

Erro comum

Uma paleta trocada não chega aos elementos de design nem à cor das referências

postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor. Paleta de cores semântica →

Erro comum

O lineHeight de um texto de design é um múltiplo, nunca uma medida

Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título. Textos, fios e caixas nos designs de página →

Erro comum

O balanceamento de colunas desce até o pé o boxe que fecha uma página

Quando um boxe é o último bloco de uma página depois da qual o texto continua, o balanceamento de colunas (ativo por padrão) passa o espaço que sobra embaixo do boxe para cima dele, de modo que o boxe se afasta do bloco anterior e termina na última linha da página. No postext 1.4.1 nenhuma opção do balanceamento desativa só isso: headings.balancing.enabled: false desliga todo o balanceamento, o que serve para páginas que devem terminar curtas, como uma página de poemas. Equilíbrio de colunas →

Erro comum

Carregue todas as fontes antes do layout

O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada. Fontes antes da diagramação →

  • Um :::space posto antes do título inglês desaparece: a segunda coluna abre no bloco que breaks indica, e o espaço cai no corte. Ponha-o depois do título, como o :::space{lines=0.5} sob o título de cada coluna aqui.
  • Conte breaks a partir do título espanhol: com um título e 12 versos, o título inglês é o bloco 14. Cada verso é um bloco, então um verso espanhol a mais leva o título inglês para o bloco 15, e breaks precisa subir junto.

Créditos

Texto
  • 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
Imagens
  • 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
Fontes
Castoro (SIL OFL 1.1) · Castoro Titling (SIL OFL 1.1) · Tenor Sans (SIL OFL 1.1)
Sandbox