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

Receitas · Capítulo 2 · Tipo e texto

Edição crítica: versos numerados e notas por verso

Lycidas, de Milton, no texto de 1645: um script conta os versos, põe um boxe lateral a cada cinco e cada nota abre com o número do verso, composto como chip.

Nesta página
Gênero
Poesia
Saída
Canvas
Nível
Avançado
Postext
Testada com o Postext 1.19.1
Requer ≥ 1.4.1
Licença
Atualizada em 28 de set. de 2026
Código MIT · Texto CC BY 4.0
  • Amostra em inglês: ainda sem edição em português
  • Refile 138 × 216 mm
  • Coluna e meia, medianiz de 4 mm
  • Linden Hill 10,5/14,5
  • Imbue
  • Libre Franklin
  • 9 páginas
  • Nível
  • Postext 1.19.1
  • Diagramado em 30 ms
  • 189 linhas de código

Em poucas palavras

Uma edição crítica de um poema de Milton. A cada cinco versos aparece um número na margem, e as notas depois do poema indicam pelo número os versos que explicam.

O que você vai compor

Lycidas, a elegia de Milton a Edward King, numa pequena edição crítica de 138 × 216 mm que segue o texto e a grafia dos Poems de 1645. A primeira página abre sob um ramo de louro, com o título em maiúsculas altas da Imbue sobre a nota preliminar que Milton acrescentou em 1645. Os 193 versos vêm a seguir, um por linha, com uma linha em branco entre as estrofes e os versos curtos recuados. A cada cinco versos, o número aparece em verde-louro, numa coluna de 6 mm na margem externa: à direita do poema nas páginas ímpares, à esquerda nas pares. O poema não leva chamadas de nota. As notas ocupam as duas páginas depois do poema, e cada uma abre com o número do seu verso, na mesma Libre Franklin verde da numeração da margem.

Esta receita responde a

  • Como numero na margem cada quinto verso de um poema e remeto minhas notas a esses números de verso?
  • Como componho poesia: um verso por linha, espaço entre estrofes, recuo deslocado nos versos que quebram e sem hifenização?
  • Como faço notas de rodapé?
  • Como monto uma diagramação em coluna e meia, com uma coluna de texto larga e uma coluna lateral estreita?
  • Como acrescento espaço vertical entre dois blocos, se as linhas em branco não fazem nada?

A resposta curta

script.js · linhas 31–61no código completo
// The poem is written one line of verse to a line of Markdown, with a blank line between
// verse paragraphs and two spaces before a short line. numberVerse() gives each line a
// paragraph of its own and, after every fifth, a side box that holds its number. A side box
// stands where the text has reached at its fence, under the line it follows; a top padding
// of minus one line lifts the number back onto that line. Fenced before its line instead,
// the number of a line that opens a page slides up beside the last line of the page before
// (gotcha: side-box-starts-at-fence).
const EVERY = 5;
const rows = (...lines) => lines.join('\n');
function numberVerse(markdown) {
  return markdown.replace(/^:::paragraphs\{style="verse"\}\n([\s\S]*?)\n:::$/gm, (_, poem) => {
    let n = 0;
    return poem.split('\n').map((line) => {
      if (!line.trim()) return ':::space{lines=1}'; // one blank line of the grid
      n += 1;
      const style = line.startsWith('  ') ? 'short' : 'verse';
      const verse = rows(`:::paragraphs{style="${style}"}`, line.trim(), ':::');
      if (n % EVERY) return verse;
      return rows(verse, '', ':::callout{type="lineno" span="side"}',
        ':::paragraphs{style="number"}', n, ':::', ':::');
    }).join('\n\n');
  });
}
const lineno = { id: 'lineno', backgroundEnabled: false, // no box: only the number shows
  padding: { top: pt(-LEAD), right: pt(0), bottom: pt(0), left: pt(0) } };
// The number: right-aligned, so the numbers share a right edge, and at the verse's leading,
// so it sits on the baseline of its line.
const number = { id: 'number', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(LEAD),
  color: col('laurel'), textAlign: 'right' };
// Hook-up: calloutStyles: [lineno], paragraphStyles: [number, …] and
// buildDocument({ markdown: numberVerse(markdown) }, config()).

Ingredientes

Tipografia
Linden Hill, Imbue, Libre Franklin (SIL OFL 1.1)
Materiais
  • laurel-1040.jpg
  • The sprig of bay laurel on the first page, a watercolour (Generated With Diffusion Models, original)

Preparo

#1 · Conte os versos antes da composição

O código deste passo é a resposta curta lá em cima. O Postext não numera linhas, então numberVerse() conta os versos no Markdown antes da composição. Ele dá a cada verso um parágrafo próprio e, a cada cinco versos, acrescenta um :::callout{type="lineno" span="side"} com o número. Um boxe lateral começa na coluna lateral na altura a que o texto chegou no ponto em que o bloco é aberto, então este começa embaixo do verso que ele segue. O espaçamento superior do estilo, de menos uma linha (−14,5 pt), sobe o número de volta para o lado desse verso e deixa o boxe sem altura (estilos de boxe). Aberto antes do verso, o boxe ficaria na altura dele na maioria das páginas, mas quando o verso numerado abre uma página, o boxe sobe para o lado da última linha da página anterior. Dentro do boxe, o estilo de parágrafo number alinha os algarismos à direita, para que os números compartilhem a borda direita, e dá a eles a entrelinha do poema, 14,5 pt, o que põe cada um na linha de base do seu verso.

#2 · Uma coluna de seis milímetros

script.js · linhas 65–81no código completo
const PT = 25.4 / 72; // mm in a point
const [TRIM_W, TRIM_H] = [138, 216]; // mm
const [TOP, INNER] = [21, 20]; // mm
const LINES = 34; // lines of verse to a page
const BOTTOM = TRIM_H - TOP - LINES * LEAD * PT; // 21.08 mm
const [MEASURE, GUTTER, CHANNEL] = [80, 4, 6]; // mm: the longest line of Lycidas is 78.1 mm
const OUTER = TRIM_W - INNER - MEASURE - GUTTER - CHANNEL; // 28 mm beyond the numbers
const layout = {
  layoutType: 'oneAndHalf',
  sideColumnRole: 'floats', // the side column takes side boxes, never text
  sideColumnSide: 'outer', // right of the verse on a recto, left of it on a verso
  // 6 of 90 mm. The zero is Libre Franklin's widest figure, so '100' (4.9 mm) is the widest number.
  sideColumnPercent: (CHANNEL / (MEASURE + GUTTER + CHANNEL)) * 100,
  gutterWidth: mm(GUTTER),
};
const page = { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
  margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } };

Com sideColumnRole: 'floats', a coluna estreita de um layout oneAndHalf recebe boxes laterais e nunca texto (tipos de layout). sideColumnPercent é uma fração da mancha, então o código o calcula a partir de milímetros: 6 de 90 mm, o bastante para o número mais largo. Os algarismos da Libre Franklin são proporcionais, e em 7,5 pt o zero é o mais largo (1,8 mm, contra 1,2 mm do um), por isso o 100, com 4,9 mm, é o número mais largo do poema; o 180 e o 190 medem 4,8 mm. 'outer' acompanha as margens espelhadas, de modo que os números ficam à direita do poema nas páginas ímpares e à esquerda nas pares; nas ímpares, terminam na mesma borda que o fólio acima deles. A coluna do poema tem 80 mm, 1,9 mm a mais que o verso 31, o mais longo, então nenhum verso quebra para a linha seguinte.

#3 · Cada verso é um parágrafo

script.js · linhas 85–91no código completo
const verseStyles = [
  // Ragged, like all the text here (bodyText). A line too long for the measure would turn
  // over and hang 2 em in; none does at 80 mm.
  { id: 'verse', hangingIndent: em(2) },
  // Milton's short lines: a one-line paragraph, so the first-line indent moves all of it.
  { id: 'short', firstLineIndent: em(2) },
];

O Postext junta as linhas de um parágrafo de Markdown, então cada verso precisa de um parágrafo próprio, e numberVerse() põe cada um num contêiner :::paragraphs com o estilo do poema (estilos de parágrafo). Um mesmo estilo não consegue recuar um verso e ao mesmo tempo dar recuo deslocado à sua continuação, por isso os catorze versos curtos de Milton, o 4 entre eles, recebem um estilo próprio: num parágrafo de uma linha só, um recuo de primeira linha de 2 em desloca o verso inteiro. Cada linha em branco entre estrofes vira :::space{lines=1}, uma linha da grade. No alto de uma página ele é descartado (:::space), então a página 2 começa com o verso 15 no alto da mancha, e a troca de estrofe entre os versos 14 e 15 não aparece (veja “Erros comuns”).

#4 · O louro, o título e a nota preliminar

script.js · linhas 95–121no código completo
const OPENER_LINES = 20; // of the page's 34: the first verse paragraph, 14 lines, takes the rest
const at = (id, edge, y) => ({ anchor: { to: id, edge }, offset: { x: mm(0), y: mm(y) } });
const title = (size, tracking, placement) => ({ kind: 'text', id: 'title', content: '{titleText}',
  // lineHeight is a multiple of the size (gotcha: design-lineheight-multiple).
  fontFamily: DISPLAY, fontWeight: 300, fontSize: pt(size), lineHeight: 1,
  letterSpacing: pt(tracking), textTransform: 'uppercase', color: col('ink'), placement });
const opener = { enabled: true, minHeight: pt(OPENER_LINES * LEAD), slot: { elements: [
  // An image element reserves no height (gotcha: opener-image-no-reserve): the kicker, title
  // and headnote under it reach down 20 lines. minHeight is a floor at the same depth, so a
  // shorter headnote leaves the verse on line 21.
  { kind: 'image', id: 'laurel', resourceId: 'laurel',
    placement: { anchor: { to: 'page', edge: 'top-right' }, size: { width: mm(104) } } },
  { kind: 'text', id: 'kicker', content: '{author}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(8), letterSpacing: pt(1.6), textTransform: 'uppercase', color: col('laurel'),
    placement: at('container', 'top-left', 50) },
  title(66, 2, at('#kicker', 'below', 1)),
  // # Lycidas {headnote="In this Monody …"}. Design text wraps ragged and has no inline
  // italics (gotcha: design-text-no-inline-marks); at 64 mm no word stands alone.
  { kind: 'text', id: 'headnote', content: '{attr.headnote}', fontFamily: TEXT, italic: true,
    fontSize: pt(9.5), lineHeight: 13 / 9.5, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { ...at('#title', 'below', 3), size: { width: mm(64) } } },
] } };
// Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). span
// 'page' paints the laurel above the text block, where a column clips its design. With the
// default marginBottom the verse would start on line 22 and send line 14 to page 2.
const poem = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
  advancedDesign: opener, marginBottom: pt(0) };

O título de nível 1 imprime um design no lugar do próprio texto (span e design avançado). {author} vem do frontmatter, e a nota preliminar, de um atributo na linha do título, # Lycidas {headnote="In this Monody …"} (atributos de título). O louro é um elemento de imagem e não reserva altura, então a profundidade da abertura vem dos textos abaixo dele: o antetítulo, a 50 mm do alto da mancha, o título e as quatro linhas da nota preliminar chegam até a linha 20 das 34 da página, e a primeira estrofe, de 14 versos, ocupa o resto. minHeight é um piso na mesma profundidade: corte a nota preliminar para três linhas e o poema continua começando na linha 21, enquanto sem o piso ele sobe para a linha 20. marginBottom: pt(0) tira do vão a margem padrão do título; com ela, o poema começaria na linha 22 e mandaria o verso 14 para a página 2. O nível ocupa a largura da página porque um design mantido na coluna é cortado na borda superior da coluna, 21 mm abaixo do refile, e o louro começa no próprio refile.

#5 · Notas ligadas aos números dos versos

script.js · linhas 125–144no código completo
const NOTE = 8.6; // pt: the notes, and the note on the text
const noteStyles = [
  { id: 'textnote', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.33) },
  // The note on the text ends on the grid, 2.4 mm below its last line; half a line more
  // leaves one blank line before the first note.
  { id: 'note', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.33), hangingIndent: em(1.6),
    marginTop: pt(LEAD / 2) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
    marginTop: pt(LEAD) },
];
// :chip[8]{style="line"}: Linden Hill has no bold, so the number changes face and colour.
// The chip has no fill, outline or side padding, so nothing is drawn around the number.
const chipStyles = [{ id: 'line', backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0),
  fontFamily: LABEL, fontSize: em(0.9), color: col('laurel') }];
// # Notes {style="notes"} opens the next page under the title's capitals, smaller. A heading
// style keeps the level's break unless it sets its own (gotcha: style-inherits-break). Its
// design stays in the column, so the title lines up with the notes on either page.
const notesHead = { id: 'notes', span: 'column', breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true,
    slot: { elements: [title(30, 1, at('container', 'top-left', 0))] } } };

As notas remetem aos números dos versos, então o poema não leva chamadas, e elas vêm depois dele como um comentário, sem tirar linhas das suas páginas. Cada nota abre com o número do verso, escrito :chip[8]{style="line"}, depois as palavras que explica, em itálico, e um colchete de fechamento. O chip não tem preenchimento, contorno nem espaçamento lateral, então só mudam a fonte e a cor: Libre Franklin de 7,7 pt em verde-louro (estilos de chip). A Linden Hill não tem negrito, por isso os números se destacam pela troca de fonte. O estilo de nota recua 1,6 em todas as linhas depois da primeira, o que deixa os números livres à esquerda, e as notas são alinhadas à esquerda, como o poema. # Notes {style="notes"} salta para a página 8 e compõe o título nas maiúsculas da abertura, em 30 pt.

Página 8: cada nota abre com o número verde do seu verso; o poema, nas páginas 1 a 7, não leva chamadas.

#6 · Cabeços por paridade

script.js · linhas 148–169no código completo
const HEAD = 13; // mm from the trim to the running heads' baseline
// A design text's first baseline sits 0.8 of a line below the top of its box: 0.96 em at the
// default lineHeight of 1.2, which the running heads keep.
const BASE = 1.2 * 0.8;
const head = (id, parity, content, x, size = 7.5, extra = {}) => ({ kind: 'text', id, parity,
  content, pages: 'body', fontFamily: LABEL, fontWeight: 500, fontSize: pt(size),
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge: parity === 'even' ? 'top-left' : 'top-right' },
    offset: { x: mm(x), y: mm(HEAD - BASE * size * PT) } } });
const folio = { fontFamily: TEXT, fontWeight: 400, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  head('verso-folio', 'even', '{pageNumber}', OUTER, 10, folio),
  head('verso-head', 'even', '{author}', OUTER + 9),
  head('recto-head', 'odd', '{chapterTitle}', -(OUTER + 9)), // LYCIDAS, then NOTES
  head('recto-folio', 'odd', '{pageNumber}', -OUTER, 10, folio),
] };
// The two openers carry their folio at the foot instead, at the outer edge of the text block.
const drop = (parity, edge, x) => ({ ...head(`drop-${parity}`, parity, '{pageNumber}', 0, 10,
  folio), pages: 'opener', placement: { anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(-12) } } });
const footer = { elements: [drop('odd', 'bottom-right', -OUTER),
  drop('even', 'bottom-left', OUTER)] };

Cada elemento é ancorado à página e filtrado por parity e pages: 'body' (elementos de texto). A página par leva o autor, e a ímpar, {chapterTitle}, que diz LYCIDAS sobre o poema e NOTES sobre as notas, porque as notas abrem com um título próprio. O fólio fica na borda externa da mancha; as duas páginas de abertura, a 1 e a 8, o levam no pé.

A receita completa

Sandbox
// ═══ Postext Cookbook · Nº 044 · Critical edition: line numbers and line-keyed notes ═══
// https://postext.dev/en/cookbook/critical-edition-line-numbers
// Code: MIT · Text: Milton, Poems (1645) (PD) · Notes: CC BY 4.0 · Laurel: diffusion models
// Fonts: Linden Hill, Imbue, Libre Franklin (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// Lycidas in the spelling of 1645, with a number beside every fifth line and two pages of
// notes keyed to those numbers, so the verse carries no note markers.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en')
const RECIPE = 'critical-edition-line-numbers';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Black text on white, and one laurel green for the apparatus.
const palette = {
  ink: '#1b1b1b', // the text
  laurel: '#3c5a3e', // line numbers, note numbers, the kicker
  muted: '#6a706a', // running heads, the colophon
  paper: '#ffffff',
};
// col(id) carries the hex beside the id, because design slots paint the hex
// (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 [TEXT, DISPLAY, LABEL] = ['Linden Hill', 'Imbue', 'Libre Franklin'];
const LEAD = 14.5; // pt: the leading of the verse, and the grid every page keeps

// #region answer: count the lines, and after every fifth set its number in the margin
// The poem is written one line of verse to a line of Markdown, with a blank line between
// verse paragraphs and two spaces before a short line. numberVerse() gives each line a
// paragraph of its own and, after every fifth, a side box that holds its number. A side box
// stands where the text has reached at its fence, under the line it follows; a top padding
// of minus one line lifts the number back onto that line. Fenced before its line instead,
// the number of a line that opens a page slides up beside the last line of the page before
// (gotcha: side-box-starts-at-fence).
const EVERY = 5;
const rows = (...lines) => lines.join('\n');
function numberVerse(markdown) {
  return markdown.replace(/^:::paragraphs\{style="verse"\}\n([\s\S]*?)\n:::$/gm, (_, poem) => {
    let n = 0;
    return poem.split('\n').map((line) => {
      if (!line.trim()) return ':::space{lines=1}'; // one blank line of the grid
      n += 1;
      const style = line.startsWith('  ') ? 'short' : 'verse';
      const verse = rows(`:::paragraphs{style="${style}"}`, line.trim(), ':::');
      if (n % EVERY) return verse;
      return rows(verse, '', ':::callout{type="lineno" span="side"}',
        ':::paragraphs{style="number"}', n, ':::', ':::');
    }).join('\n\n');
  });
}
const lineno = { id: 'lineno', backgroundEnabled: false, // no box: only the number shows
  padding: { top: pt(-LEAD), right: pt(0), bottom: pt(0), left: pt(0) } };
// The number: right-aligned, so the numbers share a right edge, and at the verse's leading,
// so it sits on the baseline of its line.
const number = { id: 'number', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(LEAD),
  color: col('laurel'), textAlign: 'right' };
// Hook-up: calloutStyles: [lineno], paragraphStyles: [number, …] and
// buildDocument({ markdown: numberVerse(markdown) }, config()).
// #endregion

// #region page: a poetry trim, and a channel for the numbers at the fore-edge
const PT = 25.4 / 72; // mm in a point
const [TRIM_W, TRIM_H] = [138, 216]; // mm
const [TOP, INNER] = [21, 20]; // mm
const LINES = 34; // lines of verse to a page
const BOTTOM = TRIM_H - TOP - LINES * LEAD * PT; // 21.08 mm
const [MEASURE, GUTTER, CHANNEL] = [80, 4, 6]; // mm: the longest line of Lycidas is 78.1 mm
const OUTER = TRIM_W - INNER - MEASURE - GUTTER - CHANNEL; // 28 mm beyond the numbers
const layout = {
  layoutType: 'oneAndHalf',
  sideColumnRole: 'floats', // the side column takes side boxes, never text
  sideColumnSide: 'outer', // right of the verse on a recto, left of it on a verso
  // 6 of 90 mm. The zero is Libre Franklin's widest figure, so '100' (4.9 mm) is the widest number.
  sideColumnPercent: (CHANNEL / (MEASURE + GUTTER + CHANNEL)) * 100,
  gutterWidth: mm(GUTTER),
};
const page = { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
  margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } };
// #endregion

// #region verse: a paragraph per line, ragged, and the short lines set in
const verseStyles = [
  // Ragged, like all the text here (bodyText). A line too long for the measure would turn
  // over and hang 2 em in; none does at 80 mm.
  { id: 'verse', hangingIndent: em(2) },
  // Milton's short lines: a one-line paragraph, so the first-line indent moves all of it.
  { id: 'short', firstLineIndent: em(2) },
];
// #endregion

// #region opener: the laurel, the title in tall capitals, and the headnote of 1645
const OPENER_LINES = 20; // of the page's 34: the first verse paragraph, 14 lines, takes the rest
const at = (id, edge, y) => ({ anchor: { to: id, edge }, offset: { x: mm(0), y: mm(y) } });
const title = (size, tracking, placement) => ({ kind: 'text', id: 'title', content: '{titleText}',
  // lineHeight is a multiple of the size (gotcha: design-lineheight-multiple).
  fontFamily: DISPLAY, fontWeight: 300, fontSize: pt(size), lineHeight: 1,
  letterSpacing: pt(tracking), textTransform: 'uppercase', color: col('ink'), placement });
const opener = { enabled: true, minHeight: pt(OPENER_LINES * LEAD), slot: { elements: [
  // An image element reserves no height (gotcha: opener-image-no-reserve): the kicker, title
  // and headnote under it reach down 20 lines. minHeight is a floor at the same depth, so a
  // shorter headnote leaves the verse on line 21.
  { kind: 'image', id: 'laurel', resourceId: 'laurel',
    placement: { anchor: { to: 'page', edge: 'top-right' }, size: { width: mm(104) } } },
  { kind: 'text', id: 'kicker', content: '{author}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(8), letterSpacing: pt(1.6), textTransform: 'uppercase', color: col('laurel'),
    placement: at('container', 'top-left', 50) },
  title(66, 2, at('#kicker', 'below', 1)),
  // # Lycidas {headnote="In this Monody …"}. Design text wraps ragged and has no inline
  // italics (gotcha: design-text-no-inline-marks); at 64 mm no word stands alone.
  { kind: 'text', id: 'headnote', content: '{attr.headnote}', fontFamily: TEXT, italic: true,
    fontSize: pt(9.5), lineHeight: 13 / 9.5, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { ...at('#title', 'below', 3), size: { width: mm(64) } } },
] } };
// Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). span
// 'page' paints the laurel above the text block, where a column clips its design. With the
// default marginBottom the verse would start on line 22 and send line 14 to page 2.
const poem = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
  advancedDesign: opener, marginBottom: pt(0) };
// #endregion

// #region notes: each note opens on its line number, a chip in the label face
const NOTE = 8.6; // pt: the notes, and the note on the text
const noteStyles = [
  { id: 'textnote', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.33) },
  // The note on the text ends on the grid, 2.4 mm below its last line; half a line more
  // leaves one blank line before the first note.
  { id: 'note', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.33), hangingIndent: em(1.6),
    marginTop: pt(LEAD / 2) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
    marginTop: pt(LEAD) },
];
// :chip[8]{style="line"}: Linden Hill has no bold, so the number changes face and colour.
// The chip has no fill, outline or side padding, so nothing is drawn around the number.
const chipStyles = [{ id: 'line', backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0),
  fontFamily: LABEL, fontSize: em(0.9), color: col('laurel') }];
// # Notes {style="notes"} opens the next page under the title's capitals, smaller. A heading
// style keeps the level's break unless it sets its own (gotcha: style-inherits-break). Its
// design stays in the column, so the title lines up with the notes on either page.
const notesHead = { id: 'notes', span: 'column', breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true,
    slot: { elements: [title(30, 1, at('container', 'top-left', 0))] } } };
// #endregion

// #region heads: the author on the verso, the section on the recto, folios at the fore-edge
const HEAD = 13; // mm from the trim to the running heads' baseline
// A design text's first baseline sits 0.8 of a line below the top of its box: 0.96 em at the
// default lineHeight of 1.2, which the running heads keep.
const BASE = 1.2 * 0.8;
const head = (id, parity, content, x, size = 7.5, extra = {}) => ({ kind: 'text', id, parity,
  content, pages: 'body', fontFamily: LABEL, fontWeight: 500, fontSize: pt(size),
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge: parity === 'even' ? 'top-left' : 'top-right' },
    offset: { x: mm(x), y: mm(HEAD - BASE * size * PT) } } });
const folio = { fontFamily: TEXT, fontWeight: 400, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  head('verso-folio', 'even', '{pageNumber}', OUTER, 10, folio),
  head('verso-head', 'even', '{author}', OUTER + 9),
  head('recto-head', 'odd', '{chapterTitle}', -(OUTER + 9)), // LYCIDAS, then NOTES
  head('recto-folio', 'odd', '{pageNumber}', -OUTER, 10, folio),
] };
// The two openers carry their folio at the foot instead, at the outer edge of the text block.
const drop = (parity, edge, x) => ({ ...head(`drop-${parity}`, parity, '{pageNumber}', 0, 10,
  folio), pages: 'opener', placement: { anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(-12) } } });
const footer = { elements: [drop('odd', 'bottom-right', -OUTER),
  drop('even', 'bottom-left', OUTER)] };
// #endregion

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  colorPalette, page, layout, header, footer,
  bodyText: { // every paragraph sits in a styled container and takes these as defaults
    fontFamily: TEXT, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    // Ragged throughout, verse and notes alike, so nothing is hyphenated
    // (gotcha: ragged-no-hyphenation).
    textAlign: 'left', firstLineIndent: pt(0),
  },
  // The designs print the titles, but each heading's own text is still measured, in this face.
  // Left at the default, the page would fetch Open Sans 700 for text it never paints.
  headings: { fontFamily: DISPLAY, fontWeight: 300, levels: [poem] },
  headingStyles: [notesHead],
  paragraphStyles: [...verseStyles, number, ...noteStyles],
  calloutStyles: [lineno],
  chipStyles,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Amostra em Markdown · 211 linhas · content.en.mdtitle: "Lycidas" author: "John Milton" --- # Lycidas {headnote="In this Monody the Author bewails a learned Friend, unfortunatly drown’d in his Passage from Chester on the Irish Seas, 1637. And by occasion foretels the ruine of our corrupted Clergy then in their height."} :::paragraphs{style="verse"} Yet once more, O ye Laurels, and once more Ye Myrtles brown, with Ivy never-sear, I com to pluck your Berries harsh and crude, And with forc’d fingers rude, Shatter your leaves before the mellowing year. Bitter constraint, and sad occasion dear, Compels me to disturb your season due: For *Lycidas* is dead, dead ere his prime, Young *Lycidas*, and hath not left his peer: Who would not sing for *Lycidas?* he knew Himself to sing, and build the lofty rhyme. He must not flote upon his watry bear Unwept, and welter to the parching wind, Without the meed of som melodious tear. Begin then, Sisters of the sacred well, That from beneath the seat of *Jove* doth spring, Begin, and somwhat loudly sweep the string. Hence with denial vain, and coy excuse, So may som gentle Muse With lucky words favour my destin’d Urn, And as he passes turn, And bid fair peace be to my sable shrowd. For we were nurst upon the self-same hill, Fed the same flock, by fountain, shade, and rill. Together both, ere the high Lawns appear’d Under the opening eye-lids of the morn, We drove a field, and both together heard What time the Gray-fly winds her sultry horn, Batt’ning our flocks with the fresh dews of night, Oft till the Star that rose, at Ev’ning, bright Toward Heav’ns descent had slop’d his westering wheel. Mean while the Rural ditties were not mute, Temper’d to th’ Oaten Flute, Rough *Satyrs* danc’d, and *Fauns* with clov’n heel, From the glad sound would not be absent long, And old *Damœtas* lov’d to hear our song. But O the heavy change, now thou art gon, Now thou art gon, and never must return! Thee Shepherd, thee the Woods, and desert Caves, With wilde Thyme and the gadding Vine o’regrown, And all their echoes mourn. The Willows, and the Hazle Copses green, Shall now no more be seen, Fanning their joyous Leaves to thy soft layes. As killing as the Canker to the Rose, Or Taint-worm to the weanling Herds that graze, Or Frost to Flowers, that their gay wardrop wear, When first the White thorn blows; Such, *Lycidas*, thy loss to Shepherds ear. Where were ye Nymphs when the remorseless deep Clos’d o’re the head of your lov’d *Lycidas*? For neither were ye playing on the steep, Where your old *Bards*, the famous *Druids* ly, Nor on the shaggy top of *Mona* high, Nor yet where Deva spreads her wisard stream: Ay me, I fondly dream! Had ye bin there—for what could that have don? What could the Muse her self that *Orpheus* bore, The Muse her self, for her inchanting son Whom Universal nature did lament, When by the rout that made the hideous roar, His goary visage down the stream was sent, Down the swift *Hebrus* to the *Lesbian* shore. Alas! What boots it with uncessant care To tend the homely slighted Shepherds trade, And strictly meditate the thankles Muse, Were it not better don as others use, To sport with *Amaryllis* in the shade, Or with the tangles of *Neæra*’s hair? Fame is the spur that the clear spirit doth raise (That last infirmity of Noble mind) To scorn delights, and live laborious dayes; But the fair Guerdon when we hope to find, And think to burst out into sudden blaze, Comes the blind *Fury* with th’ abhorred shears, And slits the thin spun life. But not the praise, *Phœbus* repli’d, and touch’d my trembling ears; *Fame* is no plant that grows on mortal soil, Nor in the glistering foil Set off to th’ world, nor in broad rumour lies, But lives and spreds aloft by those pure eyes, And perfet witnes of all judging *Jove*; As he pronounces lastly on each deed, Of so much fame in Heav’n expect thy meed. O Fountain *Arethuse*, and thou honour’d flood, Smooth-sliding *Mincius*, crown’d with vocall reeds, That strain I heard was of a higher mood: But now my Oate proceeds, And listens to the Herald of the Sea That came in *Neptune*’s plea, He ask’d the Waves, and ask’d the Fellon winds, What hard mishap hath doom’d this gentle swain? And question’d every gust of rugged wings That blows from off each beaked Promontory, They knew not of his story, And sage *Hippotades* their answer brings, That not a blast was from his dungeon stray’d, The Ayr was calm, and on the level brine, Sleek *Panope* with all her sisters play’d. It was that fatall and perfidious Bark Built in th’ eclipse, and rigg’d with curses dark, That sunk so low that sacred head of thine. Next *Camus*, reverend Sire, went footing slow, His Mantle hairy, and his Bonnet sedge, Inwrought with figures dim, and on the edge Like to that sanguine flower inscrib’d with woe. Ah! Who hath reft (quoth he) my dearest pledge? Last came, and last did go, The Pilot of the *Galilean* lake, Two massy Keyes he bore of metals twain, (The Golden opes, the Iron shuts amain) He shook his Miter’d locks, and stern bespake, How well could I have spar’d for thee young swain. Anow of such as for their bellies sake, Creep and intrude, and climb into the fold? Of other care they little reck’ning make, Then how to scramble at the shearers feast, And shove away the worthy bidden guest. Blind mouthes! that scarce themselves know how to hold A Sheep-hook, or have learn’d ought els the least That to the faithfull Herdmans art belongs! What recks it them? What need they? They are sped; And when they list, their lean and flashy songs Grate on their scrannel Pipes of wretched straw, The hungry Sheep look up, and are not fed, But swoln with wind, and the rank mist they draw, Rot inwardly, and foul contagion spread: Besides what the grim Woolf with privy paw Daily devours apace, and nothing sed, But that two-handed engine at the door, Stands ready to smite once, and smite no more. Return *Alpheus*, the dread voice is past, That shrunk thy streams; Return *Sicilian* Muse, And call the Vales, and bid them hither cast Their Bels, and Flourets of a thousand hues. Ye valleys low where the milde whispers use, Of shades and wanton winds, and gushing brooks, On whose fresh lap the swart Star sparely looks, Throw hither all your quaint enameld eyes, That on the green terf suck the honied showres, And purple all the ground with vernal flowres. Bring the rathe Primrose that forsaken dies. The tufted Crow-toe, and pale Gessamine, The white Pink, and the Pansie freakt with jeat, The glowing Violet. The Musk-rose, and the well attir’d Woodbine, With Cowslips wan that hang the pensive hed, And every flower that sad embroidery wears: Bid *Amaranthus* all his beauty shed, And Daffadillies fill their cups with tears, To strew the Laureat Herse where *Lycid* lies. For so to interpose a little ease, Let our frail thoughts dally with false surmise. Ay me! Whilst thee the shores and sounding Seas Wash far away, where ere thy bones are hurld, Whether beyond the stormy *Hebrides*, Where thou perhaps under the whelming tide Visit’st the bottom of the monstrous world; Or whether thou to our moist vows deny’d, Sleep’st by the fable of *Bellerus* old, Where the great vision of the guarded Mount Looks toward *Namancos* and *Bayona*’s hold; Look homeward Angel now, and melt with ruth. And, O ye *Dolphins*, waft the haples youth. Weep no more, woful Shepherds weep no more, For *Lycidas* your sorrow is not dead, Sunk though he be beneath the watry floar, So sinks the day-star in the Ocean bed, And yet anon repairs his drooping head, And tricks his beams, and with new-spangled Ore, Flames in the forehead of the morning sky: So *Lycidas* sunk low, but mounted high, Through the dear might of him that walk’d the waves; Where other groves, and other streams along, With *Nectar* pure his oozy Lock’s he laves, And hears the unexpressive nuptiall Song, In the blest Kingdoms meek of joy and love. There entertain him all the Saints above, In solemn troops, and sweet Societies That sing, and singing in their glory move, And wipe the tears for ever from his eyes. Now *Lycidas* the Shepherds weep no more; Hence forth thou art the Genius of the shore, In thy large recompense, and shalt be good To all that wander in that perilous flood. Thus sang the uncouth Swain to th’ Okes and rills, While the still morn went out with Sandals gray, He touch’d the tender stops of various Quills, With eager thought warbling his Dorick lay: And now the Sun had stretch’d out all the hills, And now was dropt into the Western bay; At last he rose, and twitch’d his Mantle blew: To morrow to fresh Woods, and Pastures new. :::
`; // content.<lang>.md, inlined by the Cookbook: the poem const notes = String.raw`# Notes {style="notes"}
Amostra em Markdown · 72 linhas · content.notes.en.md :::paragraphs{style="textnote"} *The text* is that of *Poems of Mr. John Milton* (London, 1645), pages 57 to 65. Lycidas had first been printed, without the headnote, in *Justa Edovardo King naufrago* (Cambridge, 1638), the volume of elegies for King. Spelling and capitals follow 1645, and so does the italic of names in the poem. A verse paragraph that 1645 marks by indenting its first line is marked here by a blank line; the short lines are indented. The notes are keyed to the line numbers in the margin. ::: :::paragraphs{style="note"} :chip[1]{style="line"} *Yet once more*] Hebrews 12.26, ‘Yet once more I shake not the earth only, but also heaven.’ :chip[1–2]{style="line"} *Laurels … Myrtles … Ivy*] evergreens and poets’ crowns: the laurel is Apollo’s, the myrtle Venus’s, the ivy Bacchus’s. :chip[3]{style="line"} *crude*] unripe (Latin *crudus*). The berries are picked before their season, as King died before his. :chip[8]{style="line"} *Lycidas*] Edward King (1612–1637), fellow of Christ’s College, Cambridge, drowned on 10 August 1637 when his ship, bound from Chester for Dublin, struck a rock off the Welsh coast. Lycidas is a herdsman in Theocritus, *Idyll* 7, and in Virgil, *Eclogue* 9. :chip[12]{style="line"} *flote … bear*] float … bier. :chip[15]{style="line"} *Sisters of the sacred well*] the Muses, who dance round the spring and the altar of Zeus on Helicon in the first lines of Hesiod’s *Theogony*. :chip[23]{style="line"} *the self-same hill*] Christ’s College, Cambridge, where Milton studied from 1625 to 1632 and King from 1626. :chip[36]{style="line"} *Damœtas*] a herdsman in Theocritus and Virgil. Some commentators see in him a tutor of Christ’s, William Chappell or Joseph Mede; neither identification is certain. :chip[53–55]{style="line"} *Druids … Mona … Deva*] the Druids’ island is Anglesey, *Mona* in Tacitus, *Annals* 14.30; the Dee (*Deva*) reaches the sea below Chester, where King sailed. Its shifting course was read as an omen for England and Wales, hence *wisard*. :chip[58]{style="line"} *the Muse her self that Orpheus bore*] Calliope. The women of Thrace tore Orpheus apart, and his head, still singing, went down the Hebrus and over the sea to Lesbos (Ovid, *Metamorphoses* 11.1–55). :chip[64]{style="line"} *uncessant*] unceasing; so 1638, 1645 and 1673. :chip[70–71]{style="line"} *That last infirmity of Noble mind*] Tacitus, *Histories* 4.6: the desire for glory is the last thing even the wise put off. :chip[75]{style="line"} *the blind Fury*] Atropos, the Fate who cuts the thread of life; Milton makes her a Fury, and blind. :chip[77]{style="line"} *touch’d my trembling ears*] Apollo plucks the poet’s ear in Virgil, *Eclogue* 6.3–4, to call him back from kings and battles to pastoral. :chip[85–86]{style="line"} *Arethuse … Mincius*] the fountain of Syracuse and the river of Mantua: the country of Theocritus and the country of Virgil. :chip[96]{style="line"} *Hippotades*] Aeolus, son of Hippotes, keeper of the winds. :chip[103]{style="line"} *Camus*] the god of the Cam, who stands for Cambridge; he walks as slowly as his river flows. :chip[106]{style="line"} *that sanguine flower*] the hyacinth, sprung from the blood of Hyacinthus, whose petals were said to carry AI, a cry of grief (Ovid, *Metamorphoses* 10.215). :chip[109]{style="line"} *The Pilot of the Galilean lake*] St Peter, the fisherman given the keys of heaven (Matthew 16.19), mitred here as the first bishop. :chip[114–117]{style="line"} *Anow … Then*] enough … than. :chip[119]{style="line"} *Blind mouthes*] Ruskin, in *Sesame and Lilies* (1865): a bishop is one who sees and a pastor one who feeds, so a blind mouth is a clergyman who does neither. :chip[128]{style="line"} *the grim Woolf*] usually read as the Church of Rome, which was making converts at the court of Charles I. :chip[130]{style="line"} *that two-handed engine*] no reading has settled it. Readers have proposed the sword of the archangel Michael, the axe laid to the root of the trees in Matthew 3.10, the two Houses of Parliament and St Peter’s two keys. :chip[132]{style="line"} *Alpheus*] the river said to run under the sea from Greece and rise in Arethusa’s fountain (line 85). Called on here, it brings the poem back to pastoral after St Peter’s speech. :chip[138]{style="line"} *the swart Star*] Sirius, the Dog Star of the hottest weeks, which scorches what it looks on. :chip[156]{style="line"} *Hebrides*] King’s body was never found. :chip[160–162]{style="line"} *Bellerus … the guarded Mount … Namancos*] Bellerus is made from Bellerium, the Roman name of Land’s End. From St Michael’s Mount the archangel looks out over the sea to Galicia, where Mercator’s atlas marks Namancos, near the castle of Bayona. :chip[164]{style="line"} *Dolphins*] like the dolphin that carried the singer Arion to shore at Taenarum (Herodotus 1.24). :chip[176]{style="line"} *unexpressive nuptiall Song*] the song past expressing at the marriage of the Lamb (Revelation 19.7–9). :chip[183]{style="line"} *Genius of the shore*] the guardian spirit of a place: King will keep those who cross the sea he drowned in. :chip[193]{style="line"} *fresh Woods*] in the spring of 1638 Milton left England for Italy. ::: :::paragraphs{style="colophon"} Set in Linden Hill, Imbue and Libre Franklin (SIL Open Font License). Text of 1645, public domain. Notes, CC BY 4.0; laurel painted with diffusion models. :::
`; // content.notes.<lang>.md: the notes // #region art: a sprig of bay laurel with its unripe berries, a watercolour in assets/ // The JPEG is cut to the sprig's 104 × 74 mm frame on white paper and declared at its pixels. const resources = [{ id: 'laurel', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, bitmap: { fileId: 'laurel-1040.jpg', format: 'jpeg', width: 1040, height: 740 }, altText: 'A sprig of bay laurel with a cluster of unripe berries, entering from the corner.' }]; await loadImage(resources[0].bitmap.fileId, asset(resources[0].bitmap.fileId)); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Loaded before the first build (gotcha: fonts-first). Linden Hill has no bold, Imbue no italic. const FONTS = { 'Linden Hill': ['400', '400i'], Imbue: ['300'], 'Libre Franklin': ['400', '500'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown + notes); const source = `${numberVerse(markdown)}\n\n${notes}`; const doc = await buildWithFonts(() => buildDocument({ markdown: source, resources }, config()), source); showPages(doc, { title: 'Lycidas · with line numbers and notes' });
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

#Numere de dez em dez

Poemas longos costumam ser numerados de dez em dez; a coluna de 6 mm não precisa mudar.

-const EVERY = 5;
+const EVERY = 10;

#Deixe todos os números à direita do poema

Com a coluna lateral fixa à direita, os números ficam à direita do poema em todas as páginas, o que nas pares os coloca junto à lombada.

-  sideColumnSide: 'outer', // right of the verse on a recto, left of it on a verso
+  sideColumnSide: 'right', // right of the verse on every page

Erros comuns

Erro comum

Um boxe lateral começa na altura do bloco que vem depois do seu delimitador

No postext 1.4.1, um boxe com span: 'side' fica na coluna lateral na altura a que o texto chegou no seu delimitador, na linha seguinte da grade e abaixo dos boxes que já estiverem ali. Abra o delimitador de uma glosa logo antes do parágrafo que ela explica: se vier depois, a glosa começa ao lado do parágrafo seguinte. Um boxe que passaria do pé da coluna sobe até seu pé coincidir com o da coluna, se o boxe de cima permitir; se mesmo assim não couber, espera pela coluna lateral da página seguinte. Notas na margem →

Erro comum

Texto alinhado à esquerda nunca é hifenizado

A hifenização só se aplica ao texto justificado; o texto alinhado à esquerda quebra entre palavras, então uma coluna estreita em bandeira fica muito irregular. Justifique o trecho ou aumente a medida. Hifenização e idioma do documento →

Erro comum

Texto em bandeira nunca é verificado contra linhas curtas

optimalLineBreaking, avoidRunts, runtPenalty e runtMinCharacters agem sobre o algoritmo de quebra de linha de Knuth–Plass, que o postext 1.4.1 só aplica a texto justificado. Um parágrafo em bandeira é quebrado linha a linha e pode terminar numa única palavra curta, digam o que disserem essas configurações. Revise as últimas linhas do texto em bandeira e reescreva o parágrafo que terminar numa linha curta. Viúvas, órfãs e linhas curtas →

Erro comum

Valores de atributo: sem { ou }; aspas simples se tiver "

Um valor de atributo termina na chave de fechamento, por isso não pode conter { nem }. Um valor que contém aspas duplas vai entre aspas simples; o cifrão não causa problema. Atributos de título →

Erro comum

Texto de design não aceita ^sup^ nem **negrito** em linha

Os elementos de texto de design imprimem texto simples, então ^1^ ou **negrito** em um atributo aparecem literalmente. Use sobrescritos Unicode (¹ ² ³ estão no subconjunto latin) ou um segundo elemento com outro peso. Textos, fios e caixas nos designs de página →

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

Um estilo de título herda a quebra de página do seu nível

Uma entrada de headingStyles recebe do seu nível de título todos os campos que não define, inclusive breakBefore. Um sumário ou um colofão com estilo sobre um H1 depois de um :::pagebreak herda a paridade 'odd' e cai depois de uma página em branco. Dê a esse estilo breakBefore: { enabled: false }. Estilos de título →

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

A configuração fica em cache pela identidade: crie um objeto novo

O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config(). Páginas em um canvas →

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 verso numerado que quebra para a linha seguinte leva o número ao lado da continuação, porque o boxe lateral é aberto depois do parágrafo inteiro. Com 80 mm nenhum verso de Lycidas quebra; se você estreitar a medida, confira os versos numerados.
  • :::space é descartado no alto de uma página, então a troca de estrofe entre os versos 14 e 15, que cai na passagem da página 1 para a 2, não aparece. Se a sua edição precisa mostrar todas as trocas, dê um recuo ao primeiro verso de cada estrofe, como faz a edição de 1645.
  • O cartão sobre boxes laterais manda abrir uma glosa antes do seu parágrafo, para que ela comece na altura da primeira linha. Um número de verso, ao contrário, é aberto depois do seu verso e sobe com o espaçamento negativo: aberto antes, o número de um verso que abre uma página sobe para o lado da última linha da página anterior (passo 1).

Créditos

Texto
  • Lycidas, with its headnote, in the text of Poems of Mr. John Milton (1645), pages 57–65, from the proofread Wikisource transcription of the 1927 facsimile; checked against H. C. Beeching’s Oxford text (Project Gutenberg eBook 1745) · John Milton · domínio público
  • The note on the text, the notes and the colophon · Ignacio Ferro · CC BY 4.0
Imagens
  • The sprig of bay laurel on the first page, a watercolour · Generated With Diffusion Models · original
Fontes
Linden Hill (SIL OFL 1.1) · Imbue (SIL OFL 1.1) · Libre Franklin (SIL OFL 1.1)
Sandbox