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

Receitas · Capítulo 6 · Boxes e notas

Clássico anotado com glosas na margem

O chá maluco de Alice em edição anotada: glosas verdes e vermelhas na margem externa, junto às linhas que explicam, cada uma chamada por uma letra da sua cor.

Nesta página
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

p. 82–83 · 2–3 de 3

  • Amostra em inglês: ainda sem edição em português
  • Refile 156 × 234 mm
  • Coluna e meia, medianiz de 4,5 mm
  • Unna 9,5/14
  • Cormorant SC
  • Rozha One
  • 3 páginas
  • Nível
  • Postext 1.19.1
  • Diagramado em 43 ms
  • 219 linhas de código

Em poucas palavras

Algumas páginas de Alice no País das Maravilhas com notas do editor na margem lateral. Cada nota fica ao lado das linhas que explica, marcada por uma letra colorida.

O que você vai compor

O começo do capítulo VII de Alice’s Adventures in Wonderland, o chá maluco, composto em três páginas de uma edição anotada num formato de 156 × 234 mm. O texto de Carroll corre numa coluna justificada de Unna, com uns 66 caracteres por linha. Um canal de 33 mm junto à borda externa recebe as oito glosas do editor, cada uma ao lado do parágrafo que traz a sua letra, de modo que elas descem pela direita numa página ímpar e pela esquerda numa página par. As glosas sobre palavras e piadas são verdes, as de história são vermelhas, e cada letra no texto leva a cor da sua glosa. O capítulo abre sob uma aquarela da mesa do chá vista de cima, seguida do numeral, do título em Rozha One e de um olho em itálico com capitular verde; a nota sobre esta edição fica na margem, ao lado do olho.

Esta receita responde a

  • Como coloco notas de margem ou glosas ao lado do parágrafo que elas explicam?
  • 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 a uma abertura uma linha de autor, uma linha fina ou um primeiro parágrafo com capitular?

A resposta curta

script.js · linhas 37–62no código completo
const layout = {
  layoutType: 'oneAndHalf', // one text column and a narrower side column
  sideColumnPercent: 26, // of the 127 mm between the margins: a 33 mm channel
  sideColumnRole: 'floats', // no text runs in it: it holds side boxes and figures
  sideColumnSide: 'outer', // at the fore-edge, which mirrored margins move page by page
  gutterWidth: mm(4.5), // the text column keeps 127 − 33 − 4.5 = 89.5 mm
};
// A gloss is :::callout{type="note" span="side" title="a · …"} in the Markdown.
// span="side" moves it out of the flow into the channel, level with the block after
// the fence, so the fence goes just before the paragraph that carries its letter.
// Glosses never float. One that meets the gloss above stacks under it. One that would
// run past the column's foot slides up, as far as the gloss above allows, until its
// foot sits on the foot; if it still does not fit, it waits for the next page
// (gotcha: side-box-starts-at-fence).
const gloss = (id, hue) => ({ id,
  backgroundEnabled: false, // one device: a hairline over the gloss, in its colour
  stripe: { enabled: true, side: 'top', width: pt(0.5), color: col(hue) },
  padding: { top: mm(1.5), right: pt(0), bottom: pt(0), left: pt(0) },
  // Cormorant SC draws lower case as small capitals: the title needs no textTransform.
  titleStyle: { fontFamily: LABEL, fontWeight: 600, fontSize: pt(8.5),
    letterSpacing: pt(0.3), color: col(hue), gap: mm(0.8) },
  // Face and colours come from bodyText. A box body also inherits its 4 mm first-line
  // indent, which a gloss sets back to 0.
  body: { fontSize: pt(7.8), lineHeight: pt(10), textAlign: 'left',
    firstLineIndent: pt(0) } });
const calloutStyles = [gloss('note', 'lawn'), gloss('context', 'jam')]; // words, history

Ingredientes

Tipografia
Unna, Rozha One, Cormorant SC (SIL OFL 1.1)
Materiais
  • tea-table-1560.jpg
  • The tea-table seen from above, a watercolour (Generated With Diffusion Models, original)

Preparo

#1 · Dê a borda externa às glosas

O código é a resposta curta logo acima. Num layout de coluna e meia, sideColumnPercent: 26 dá à coluna lateral 33 dos 127 mm entre as margens, e o texto fica com 89,5 mm depois da medianiz de 4,5 mm. sideColumnRole: 'floats' mantém Carroll fora da coluna lateral, que só recebe o que é colocado ali com span: 'side'. Uma nota de rodapé espera no pé da coluna, sob um fio; na margem, cada glosa fica na altura da linha que explica, e as glosas não tiram linhas da coluna de texto. Os dois estilos de glosa só diferem na cor do fio fino e do título, então o leitor distingue uma glosa sobre uma palavra de uma glosa sobre história antes de lê-la.

#2 · Espelhe a página para o canal acompanhar a borda externa

script.js · linhas 76–87no código completo
const PT = 25.4 / 72; // mm in a point
const [TRIM_W, TRIM_H] = [156, 234]; // mm
const [TOP, BOTTOM, INNER, OUTER] = [22, 22, 15, 14]; // mm: 38 lines of text
const page = { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
  backgroundColor: col('paper'),
  // left is a recto's inner margin; mirror swaps the sides on a verso, and 'outer' moves
  // the channel with them: right on a recto, left on a verso.
  margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
    mirror: true } };
const CONTENT_W = TRIM_W - INNER - OUTER; // 127 mm
const SIDE_W = CONTENT_W * layout.sideColumnPercent / 100; // 33 mm: the glosses' measure
const TEXT_W = CONTENT_W - SIDE_W - layout.gutterWidth.value;

Com mirror: true as margens se invertem em cada página par, e sideColumnSide: 'outer' leva o canal junto, de modo que as glosas descem pela direita da página 81 e da página 83 e pela esquerda da página 82. Margens superior e inferior de 22 mm deixam 190 mm, espaço para 38 linhas de 14 pt, e cada glosa começa numa linha da mesma grade.

#3 · Ponha cada glosa antes do parágrafo que ela explica

script.js · linhas 91–106no código completo
const bodyText = { // justified and hyphenated (en-us) by default
  // Copy-fitted to the 89.5 mm column: at 9.5 pt '“Have some wine,” the March Hare said in
  // an encouraging tone.' fits one line. At 10.4 pt every measure from 77 to 93 mm left
  // it a short last line: 'couraging tone.', 'aging tone.', 'ing tone.' or 'tone.'.
  fontFamily: TEXT, fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4),
  // Unna's space is narrow, 0.22 em. At the default 0.6 the line breaker set 'Alice felt
  // dreadfully puzzled. The Hatter’s remark seemed to have' with its spaces at 0.69.
  minWordSpacing: 0.75 };
const paragraphStyles = [
  // The chapter's first paragraph, set flush. indentAfterHeading: false would do it
  // without glosses, but the gloss fences between the heading and this paragraph
  // count as the block after the heading, so the default stays and the paragraph takes
  // this style (gotcha: side-box-after-heading).
  { id: 'opening', firstLineIndent: pt(0) },
];

Um boxe com span="side" no seu delimitador :::callout sai do fluxo e fica no canal na altura do bloco que vem depois do delimitador, então cada glosa é delimitada logo antes do parágrafo que traz a sua letra, como no exemplo abaixo. As glosas a e b pertencem ao lado do primeiro parágrafo, o que põe os seus delimitadores entre o título e esse parágrafo. Na 1.4.1, os dois boxes passam então a contar como o bloco depois do título, e indentAfterHeading: false não chegaria ao parágrafo; por isso a configuração deixa essa opção no valor padrão e o estilo opening compõe o parágrafo sem recuo. O texto corrido foi ajustado à coluna: em 9,5 pt, o parágrafo que começa com “Have some wine,” cabe numa linha de 89,5 mm, enquanto em 10,4 pt todas as medidas de 77 a 93 mm lhe deixavam uma última linha curta, como “ing tone.” ou “tone.”. O espaço entre palavras da Unna é estreito, 0,22 em, e com o minWordSpacing padrão de 0,6 uma linha da página 83 tinha os espaços apertados a 0,69 dessa largura; minWordSpacing: 0.75 descarta essa linha.

:::callout{type="context" span="side" title="c · Hair"}
Tenniel drew Alice with long, loose hair. …
:::
 
“Your hair wants cutting,”:chip[^c^]{style="context"} said the Hatter. …

#4 · Imprima cada letra na cor da sua glosa

script.js · linhas 66–72no código completo
// A letter in the text is a chip named after its gloss's type, :chip[^a^]{style="note"}:
// the Markdown has no mark for coloured text, and a chip style sets a colour. With no
// fill, outline, padding or gap the chip adds no width, so the lines break as they would
// with a plain ^a^.
const letter = (id, hue) => ({ id, backgroundEnabled: false, borderWidth: pt(0),
  paddingX: pt(0), gap: pt(0), color: col(hue), bold: true });
const chipStyles = [letter('note', 'lawn'), letter('context', 'jam')];

O Markdown não tem marcação para texto colorido, mas um estilo de chip define uma cor de texto, então cada letra é um chip cujo estilo tem o mesmo nome do tipo da sua glosa. :chip[^c^]{style="context"} imprime um c vermelho, o mesmo vermelho do fio e do título da glosa c. Sem preenchimento, contorno, espaçamento interno nem separação, o chip não acrescenta largura, e cada linha quebra onde quebrava com um ^c^ simples.

#5 · Abra o capítulo sob a mesa do chá

script.js · linhas 110–166no código completo
const HEADPIECE = 72; // mm: the depth of the painting at the head of the page
const HEADNOTE = { size: 9.8, lead: LEAD }; // pt: the headnote keeps the body's leading
const CAPS = { text: 0.597, initial: 0.56 }; // cap heights, em: Unna and Rozha One
// The initial's size: its capital runs from the first line's cap height down to the
// last baseline it spans. The default size is as tall as both line boxes, so its top
// rises above the first line's capitals.
const dropSize = (lines) => pt(((lines - 1) * HEADNOTE.lead + CAPS.text * HEADNOTE.size)
  / CAPS.initial);
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, size: { width } });
// The opener reserves the height of its lowest text, rounded up to the grid, and the
// heading's default bottom margin adds one blank line: with a four-line headnote the
// text starts on line 23. The painting reserves nothing
// (gotcha: opener-image-no-reserve), so the kicker, the title and the headnote hang
// below it and carry the reserve past it.
const opener = { enabled: true, slot: { elements: [
  // An image element draws a resource without number or caption. It hangs from the
  // trim's corner over the margin, which 1.4.1 paints only when level 1 spans the page.
  { kind: 'image', id: 'headpiece', resourceId: 'tea-table',
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: mm(TRIM_W) } } },
  // The numeral comes from the heading line,
  // # A Mad Tea-Party {num="VII" headnote="…" …}: the excerpt stands alone, so
  // {chapterNumber} would print 1 (gotcha: heading-number-placeholders).
  { kind: 'text', id: 'kicker', content: 'Chapter {attr.num}', fontFamily: LABEL,
    fontWeight: 600, fontSize: pt(9), letterSpacing: pt(2), textTransform: 'uppercase',
    color: col('jam'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: mm(HEADPIECE - TOP + 8) } } }, // 8 mm under the painting's foot
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY,
    fontSize: pt(40), lineHeight: 1.05, color: col('ink'), align: 'left',
    // A longer title wraps instead of ending in '…' (gotcha: overflow-ellipsis-default).
    overflow: 'wrap',
    placement: below('kicker', 2, 'fill') },
  // Drop caps exist only in design text, which is set ragged
  // (gotcha: design-text-ragged): the headnote is the editor's voice, italic and ragged;
  // Carroll's text opens in the flow.
  { kind: 'text', id: 'headnote', content: '{attr.headnote}', fontFamily: TEXT,
    italic: true, fontSize: pt(HEADNOTE.size),
    // A design text's lineHeight multiplies its size
    // (gotcha: design-lineheight-multiple).
    lineHeight: HEADNOTE.lead / HEADNOTE.size, color: col('ink'),
    align: 'left', overflow: 'wrap', // a drop cap needs wrapping text
    dropCap: { lines: 2, fontFamily: DISPLAY, fontSize: dropSize(2), color: col('lawn'),
      gap: mm(1.5) },
    placement: below('title', 5, mm(TEXT_W)) },
  // The note on this edition stands in the channel, level with the headnote's top.
  { kind: 'text', id: 'edition-label', content: 'This edition', fontFamily: LABEL,
    fontWeight: 600, fontSize: pt(8.5), letterSpacing: pt(0.3), color: col('muted'),
    align: 'left', placement: { anchor: { to: '#headnote', edge: 'right-of' },
      offset: { x: layout.gutterWidth }, size: { width: mm(SIDE_W) } } },
  { kind: 'text', id: 'edition', content: '{attr.source}', fontFamily: TEXT,
    fontSize: pt(7.5), lineHeight: 10 / 7.5, color: col('muted'), align: 'left',
    overflow: 'wrap',
    placement: { anchor: { to: '#edition-label', edge: 'below' }, offset: { y: mm(0.8) },
      size: { width: mm(SIDE_W) } } },
] } };

O encaixe de design do título ocupa a página inteira para que a pintura possa chegar ao refile. Um elemento de imagem não reserva altura, então o antetítulo, o título e o olho ficam pendurados abaixo da pintura e definem a reserva, que termina no mais baixo deles, arredondada para a grade; a margem inferior padrão do título acrescenta uma linha em branco. Com um olho de quatro linhas, o texto começa na linha 23, e uma quinta linha o levaria para a linha 24. O numeral, o olho e a nota sobre esta edição vêm de atributos de título, {num="VII" headnote="…" source="…"}. O numeral vem dali porque {chapterNumber} imprimiria 1 num trecho que é um documento à parte. O texto de design nunca é justificado, então a capitular abre o olho do editor, composto em bandeira e em itálico, e o texto de Carroll começa justificado no fluxo, logo abaixo. dropSize() dimensiona a inicial para que o seu topo se alinhe com as maiúsculas da primeira linha do olho.

#6 · Dê a cada glosa a cor do que ela explica

script.js · linhas 16–31no código completo
const palette = {
  ink: '#1f1b1a', // text: a warm near-black
  lawn: '#1e6f6b', // glosses on words and jokes, the drop cap
  jam: '#b23a48', // glosses on history, the kicker
  muted: '#6e645b', // running heads, the note on this edition
  paper: '#f8f3e6', // the page, and the tablecloth
};
// col(id): a colour linked to its entry. It carries the hex too, because 1.4.1 paints
// design elements from the hex alone (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const entry = (id, hex, name = id) => ({ id, name, value: { hex, model: 'hex' } });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => entry(id, hex)),
  // The engine's defaults link to 'main-color': aimed at the green, nothing prints blue.
  entry('main-color', palette.lawn, 'lawn (defaults)'),
];

O verde-grama marca as glosas sobre palavras, ditos e piadas, e o vermelho-geleia marca pessoas, datas e a história do texto. Um novo valor para jam, no mesmo objeto palette, recolore numa só edição as glosas de história, as suas letras e o antetítulo; a aquarela acima delas foi escolhida pelo mesmo verde-grama e pelo mesmo vermelho-geleia. Cada cor ligada à paleta também leva o seu hex, porque a 1.4.1 pinta a abertura e os cabeços a partir do hex (veja Erros comuns).

A receita completa

Sandbox
// ═══ Postext Cookbook · Nº 032 · Annotated classic with margin glosses ════════════
// https://postext.dev/en/cookbook/annotated-classic-glosses
// Code: MIT · Text: Lewis Carroll (PD), glosses (CC BY 4.0) · Headpiece: diffusion models
// Fonts: Unna, Rozha One, Cormorant SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// The mad tea-party as an annotated edition: the text keeps to one column, and its glosses stand
// in the outer margin beside the lines they explain, changing sides with the spread.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en')
const RECIPE = 'annotated-classic-glosses';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a lawn green and a jam red on cream paper
const palette = {
  ink: '#1f1b1a', // text: a warm near-black
  lawn: '#1e6f6b', // glosses on words and jokes, the drop cap
  jam: '#b23a48', // glosses on history, the kicker
  muted: '#6e645b', // running heads, the note on this edition
  paper: '#f8f3e6', // the page, and the tablecloth
};
// col(id): a colour linked to its entry. It carries the hex too, because 1.4.1 paints
// design elements from the hex alone (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const entry = (id, hex, name = id) => ({ id, name, value: { hex, model: 'hex' } });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => entry(id, hex)),
  // The engine's defaults link to 'main-color': aimed at the green, nothing prints blue.
  entry('main-color', palette.lawn, 'lawn (defaults)'),
];
// #endregion
const [TEXT, DISPLAY, LABEL] = ['Unna', 'Rozha One', 'Cormorant SC'];
const LEAD = 14; // body leading in pt: the grid of every page

// #region answer: a float-only channel at the fore-edge, and two gloss styles for it
const layout = {
  layoutType: 'oneAndHalf', // one text column and a narrower side column
  sideColumnPercent: 26, // of the 127 mm between the margins: a 33 mm channel
  sideColumnRole: 'floats', // no text runs in it: it holds side boxes and figures
  sideColumnSide: 'outer', // at the fore-edge, which mirrored margins move page by page
  gutterWidth: mm(4.5), // the text column keeps 127 − 33 − 4.5 = 89.5 mm
};
// A gloss is :::callout{type="note" span="side" title="a · …"} in the Markdown.
// span="side" moves it out of the flow into the channel, level with the block after
// the fence, so the fence goes just before the paragraph that carries its letter.
// Glosses never float. One that meets the gloss above stacks under it. One that would
// run past the column's foot slides up, as far as the gloss above allows, until its
// foot sits on the foot; if it still does not fit, it waits for the next page
// (gotcha: side-box-starts-at-fence).
const gloss = (id, hue) => ({ id,
  backgroundEnabled: false, // one device: a hairline over the gloss, in its colour
  stripe: { enabled: true, side: 'top', width: pt(0.5), color: col(hue) },
  padding: { top: mm(1.5), right: pt(0), bottom: pt(0), left: pt(0) },
  // Cormorant SC draws lower case as small capitals: the title needs no textTransform.
  titleStyle: { fontFamily: LABEL, fontWeight: 600, fontSize: pt(8.5),
    letterSpacing: pt(0.3), color: col(hue), gap: mm(0.8) },
  // Face and colours come from bodyText. A box body also inherits its 4 mm first-line
  // indent, which a gloss sets back to 0.
  body: { fontSize: pt(7.8), lineHeight: pt(10), textAlign: 'left',
    firstLineIndent: pt(0) } });
const calloutStyles = [gloss('note', 'lawn'), gloss('context', 'jam')]; // words, history
// #endregion

// #region letters: the letter that calls each gloss, in the gloss's colour
// A letter in the text is a chip named after its gloss's type, :chip[^a^]{style="note"}:
// the Markdown has no mark for coloured text, and a chip style sets a colour. With no
// fill, outline, padding or gap the chip adds no width, so the lines break as they would
// with a plain ^a^.
const letter = (id, hue) => ({ id, backgroundEnabled: false, borderWidth: pt(0),
  paddingX: pt(0), gap: pt(0), color: col(hue), bold: true });
const chipStyles = [letter('note', 'lawn'), letter('context', 'jam')];
// #endregion

// #region page: a trade page with mirrored margins, so the channel is always at the fore-edge
const PT = 25.4 / 72; // mm in a point
const [TRIM_W, TRIM_H] = [156, 234]; // mm
const [TOP, BOTTOM, INNER, OUTER] = [22, 22, 15, 14]; // mm: 38 lines of text
const page = { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
  backgroundColor: col('paper'),
  // left is a recto's inner margin; mirror swaps the sides on a verso, and 'outer' moves
  // the channel with them: right on a recto, left on a verso.
  margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
    mirror: true } };
const CONTENT_W = TRIM_W - INNER - OUTER; // 127 mm
const SIDE_W = CONTENT_W * layout.sideColumnPercent / 100; // 33 mm: the glosses' measure
const TEXT_W = CONTENT_W - SIDE_W - layout.gutterWidth.value;
// #endregion

// #region text: book texture and a flush opening paragraph
const bodyText = { // justified and hyphenated (en-us) by default
  // Copy-fitted to the 89.5 mm column: at 9.5 pt '“Have some wine,” the March Hare said in
  // an encouraging tone.' fits one line. At 10.4 pt every measure from 77 to 93 mm left
  // it a short last line: 'couraging tone.', 'aging tone.', 'ing tone.' or 'tone.'.
  fontFamily: TEXT, fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4),
  // Unna's space is narrow, 0.22 em. At the default 0.6 the line breaker set 'Alice felt
  // dreadfully puzzled. The Hatter’s remark seemed to have' with its spaces at 0.69.
  minWordSpacing: 0.75 };
const paragraphStyles = [
  // The chapter's first paragraph, set flush. indentAfterHeading: false would do it
  // without glosses, but the gloss fences between the heading and this paragraph
  // count as the block after the heading, so the default stays and the paragraph takes
  // this style (gotcha: side-box-after-heading).
  { id: 'opening', firstLineIndent: pt(0) },
];
// #endregion

// #region opener: a headpiece, the chapter's numeral and title, a headnote with a drop cap
const HEADPIECE = 72; // mm: the depth of the painting at the head of the page
const HEADNOTE = { size: 9.8, lead: LEAD }; // pt: the headnote keeps the body's leading
const CAPS = { text: 0.597, initial: 0.56 }; // cap heights, em: Unna and Rozha One
// The initial's size: its capital runs from the first line's cap height down to the
// last baseline it spans. The default size is as tall as both line boxes, so its top
// rises above the first line's capitals.
const dropSize = (lines) => pt(((lines - 1) * HEADNOTE.lead + CAPS.text * HEADNOTE.size)
  / CAPS.initial);
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, size: { width } });
// The opener reserves the height of its lowest text, rounded up to the grid, and the
// heading's default bottom margin adds one blank line: with a four-line headnote the
// text starts on line 23. The painting reserves nothing
// (gotcha: opener-image-no-reserve), so the kicker, the title and the headnote hang
// below it and carry the reserve past it.
const opener = { enabled: true, slot: { elements: [
  // An image element draws a resource without number or caption. It hangs from the
  // trim's corner over the margin, which 1.4.1 paints only when level 1 spans the page.
  { kind: 'image', id: 'headpiece', resourceId: 'tea-table',
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: mm(TRIM_W) } } },
  // The numeral comes from the heading line,
  // # A Mad Tea-Party {num="VII" headnote="…" …}: the excerpt stands alone, so
  // {chapterNumber} would print 1 (gotcha: heading-number-placeholders).
  { kind: 'text', id: 'kicker', content: 'Chapter {attr.num}', fontFamily: LABEL,
    fontWeight: 600, fontSize: pt(9), letterSpacing: pt(2), textTransform: 'uppercase',
    color: col('jam'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: mm(HEADPIECE - TOP + 8) } } }, // 8 mm under the painting's foot
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY,
    fontSize: pt(40), lineHeight: 1.05, color: col('ink'), align: 'left',
    // A longer title wraps instead of ending in '…' (gotcha: overflow-ellipsis-default).
    overflow: 'wrap',
    placement: below('kicker', 2, 'fill') },
  // Drop caps exist only in design text, which is set ragged
  // (gotcha: design-text-ragged): the headnote is the editor's voice, italic and ragged;
  // Carroll's text opens in the flow.
  { kind: 'text', id: 'headnote', content: '{attr.headnote}', fontFamily: TEXT,
    italic: true, fontSize: pt(HEADNOTE.size),
    // A design text's lineHeight multiplies its size
    // (gotcha: design-lineheight-multiple).
    lineHeight: HEADNOTE.lead / HEADNOTE.size, color: col('ink'),
    align: 'left', overflow: 'wrap', // a drop cap needs wrapping text
    dropCap: { lines: 2, fontFamily: DISPLAY, fontSize: dropSize(2), color: col('lawn'),
      gap: mm(1.5) },
    placement: below('title', 5, mm(TEXT_W)) },
  // The note on this edition stands in the channel, level with the headnote's top.
  { kind: 'text', id: 'edition-label', content: 'This edition', fontFamily: LABEL,
    fontWeight: 600, fontSize: pt(8.5), letterSpacing: pt(0.3), color: col('muted'),
    align: 'left', placement: { anchor: { to: '#headnote', edge: 'right-of' },
      offset: { x: layout.gutterWidth }, size: { width: mm(SIDE_W) } } },
  { kind: 'text', id: 'edition', content: '{attr.source}', fontFamily: TEXT,
    fontSize: pt(7.5), lineHeight: 10 / 7.5, color: col('muted'), align: 'left',
    overflow: 'wrap',
    placement: { anchor: { to: '#edition-label', edge: 'below' }, offset: { y: mm(0.8) },
      size: { width: mm(SIDE_W) } } },
] } };
// #endregion

// The running heads: the book on the verso, the chapter on the recto, folios at the fore-edge.
const HEAD = 14; // mm from the top trim to the heads' baseline
const INSET = 8; // mm from the folio's outer edge to the running head's
const DROP = 13; // mm from the bottom trim up to the foot of the drop folio's box
// In 1.4.1 a design text's first baseline sits 0.96 em below the top of its box: the default
// lineHeight, 1.2, times 0.8, where the baseline falls in the line box.
const BASELINE = 1.2 * 0.8;
const baseline = (size) => mm(HEAD - BASELINE * size * PT);
const head = (id, parity, content, x, extra = {}) => ({ kind: 'text', id, parity, content,
  pages: 'body', fontFamily: LABEL, fontWeight: 600, fontSize: pt(8.5), letterSpacing: pt(1.2),
  textTransform: 'uppercase', color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge: parity === 'even' ? 'top-left' : 'top-right' },
    offset: { x: mm(x), y: baseline(extra.fontSize?.value ?? 8.5) } } });
const folio = { fontFamily: TEXT, fontWeight: 700, fontSize: pt(9), letterSpacing: pt(0),
  color: col('ink') };
const header = { elements: [
  head('verso-folio', 'even', '{pageNumber}', OUTER, folio),
  head('verso-title', 'even', '{title}', OUTER + INSET),
  head('recto-title', 'odd', '{chapterTitle}', -(OUTER + INSET)),
  head('recto-folio', 'odd', '{pageNumber}', -OUTER, folio),
] };
// The opener, a recto, carries a drop folio at the foot of its channel instead.
const footer = { elements: [{ ...head('drop-folio', 'odd', '{pageNumber}', -OUTER, folio),
  pages: 'opener', placement: { anchor: { to: 'page', edge: 'bottom-right' },
    offset: { x: mm(-OUTER), y: mm(-DROP) } } }] };

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  colorPalette, page, layout, bodyText, paragraphStyles, calloutStyles, chipStyles, header,
  footer,
  headings: {
    fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), // Rozha One has one weight
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // 'odd' puts the opener on a recto; span 'page' lets its design cross the channel.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener },
    ],
  },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Amostra em Markdown · 121 linhas · content.en.mdtitle: "Alice’s Adventures in Wonderland" author: "Lewis Carroll" --- # A Mad Tea-Party {num="VII" headnote="The tea-party is not in the book Carroll wrote out by hand for Alice Liddell in 1864; he added it, with the Cheshire Cat, for the book of 1865. Letters in the text point to the glosses beside it: green for words and jokes, red for history." source="Text after Project Gutenberg eBook 11; notes CC BY 4.0. Set in Unna, Rozha One and Cormorant SC (SIL OFL)."} :::callout{type="note" span="side" title="a · The March Hare"} ‘Mad as a March hare’ is an old saying. Hares are shy, but in early spring they race about the fields and rear up on their hind legs to box. ::: :::callout{type="context" span="side" title="b · The Hatter"} ‘Mad as a hatter’ was a saying before Carroll used it. Hatters made felt from fur treated with mercury, and the fumes gave many of them tremors and fits of shyness and temper. Carroll never calls him the Mad Hatter. ::: :::paragraphs{style="opening"} There was a table set out under a tree in front of the house, and the March Hare:chip[^a^]{style="note"} and the Hatter:chip[^b^]{style="context"} were having tea at it: a Dormouse was sitting between them, fast asleep, and the other two were using it as a cushion, resting their elbows on it, and talking over its head. “Very uncomfortable for the Dormouse,” thought Alice; “only, as it’s asleep, I suppose it doesn’t mind.” ::: The table was a large one, but the three were all crowded together at one corner of it: “No room! No room!” they cried out when they saw Alice coming. “There’s *plenty* of room!” said Alice indignantly, and she sat down in a large arm-chair at one end of the table. “Have some wine,” the March Hare said in an encouraging tone. Alice looked all round the table, but there was nothing on it but tea. “I don’t see any wine,” she remarked. “There isn’t any,” said the March Hare. “Then it wasn’t very civil of you to offer it,” said Alice angrily. “It wasn’t very civil of you to sit down without being invited,” said the March Hare. “I didn’t know it was *your* table,” said Alice; “it’s laid for a great many more than three.” :::callout{type="context" span="side" title="c · Hair"} Tenniel drew Alice with long, loose hair. Alice Liddell, in Carroll’s photographs of her, wears hers short and dark, with a fringe. ::: “Your hair wants cutting,”:chip[^c^]{style="context"} said the Hatter. He had been looking at Alice for some time with great curiosity, and this was his first speech. “You should learn not to make personal remarks,” Alice said with some severity; “it’s very rude.” :::callout{type="context" span="side" title="d · The riddle"} Carroll made up the riddle without an answer. So many readers asked for one that in a preface of 1896 he offered this: ‘Because it can produce a few notes, tho they are very flat; and it is nevar put with the wrong end in front!’ Later printings changed *nevar*, raven spelt backwards, to *never*, and the joke was lost. ::: The Hatter opened his eyes very wide on hearing this; but all he *said* was, “Why is a raven like a writing-desk?”:chip[^d^]{style="context"} “Come, we shall have some fun now!” thought Alice. “I’m glad they’ve begun asking riddles.—I believe I can guess that,” she added aloud. “Do you mean that you think you can find out the answer to it?” said the March Hare. “Exactly so,” said Alice. “Then you should say what you mean,” the March Hare went on. “I do,” Alice hastily replied; “at least—at least I mean what I say—that’s the same thing, you know.” :::callout{type="note" span="side" title="e · I see what I eat"} The Hatter has the logic right. Turn a statement round and you get its converse, which can be false when the statement is true. Carroll, as Charles Dodgson, taught mathematics at Christ Church, Oxford, and wrote two books on logic. ::: “Not the same thing a bit!” said the Hatter. “You might just as well say that ‘I see what I eat’ is the same thing as ‘I eat what I see’!”:chip[^e^]{style="note"} “You might just as well say,” added the March Hare, “that ‘I like what I get’ is the same thing as ‘I get what I like’!” “You might just as well say,” added the Dormouse, who seemed to be talking in his sleep, “that ‘I breathe when I sleep’ is the same thing as ‘I sleep when I breathe’!” “It *is* the same thing with you,” said the Hatter, and here the conversation dropped, and the party sat silent for a minute, while Alice thought over all she could remember about ravens and writing-desks, which wasn’t much. The Hatter was the first to break the silence. “What day of the month is it?” he said, turning to Alice: he had taken his watch out of his pocket, and was looking at it uneasily, shaking it every now and then, and holding it to his ear. :::callout{type="context" span="side" title="f · The fourth"} Of May: Alice Liddell was born on 4 May 1852. ::: Alice considered a little, and then said “The fourth.”:chip[^f^]{style="context"} “Two days wrong!” sighed the Hatter. “I told you butter wouldn’t suit the works!” he added looking angrily at the March Hare. :::callout{type="note" span="side" title="g · The best butter"} Grocers sold butter by grade, and ‘best’ fetched the highest price. The March Hare defends its quality, which was never the trouble. ::: “It was the *best* butter,”:chip[^g^]{style="note"} the March Hare meekly replied. “Yes, but some crumbs must have got in as well,” the Hatter grumbled: “you shouldn’t have put it in with the bread-knife.” The March Hare took the watch and looked at it gloomily: then he dipped it into his cup of tea, and looked at it again: but he could think of nothing better to say than his first remark, “It was the *best* butter, you know.” Alice had been looking over his shoulder with some curiosity. “What a funny watch!” she remarked. “It tells the day of the month, and doesn’t tell what o’clock it is!” “Why should it?” muttered the Hatter. “Does *your* watch tell you what year it is?” “Of course not,” Alice replied very readily: “but that’s because it stays the same year for such a long time together.” “Which is just the case with *mine*,” said the Hatter. Alice felt dreadfully puzzled. The Hatter’s remark seemed to have no sort of meaning in it, and yet it was certainly English. “I don’t quite understand you,” she said, as politely as she could. “The Dormouse is asleep again,” said the Hatter, and he poured a little hot tea upon its nose. :::callout{type="note" span="side" title="h · The Dormouse"} Hazel dormice sleep through the day and hibernate for half the year, from autumn to spring. The name is often traced to the French *dormir*, to sleep. ::: The Dormouse:chip[^h^]{style="note"} shook its head impatiently, and said, without opening its eyes, “Of course, of course; just what I was going to remark myself.” “Have you guessed the riddle yet?” the Hatter said, turning to Alice again. “No, I give it up,” Alice replied: “what’s the answer?” “I haven’t the slightest idea,” said the Hatter. “Nor I,” said the March Hare. Alice sighed wearily. “I think you might do something better with the time,” she said, “than waste it in asking riddles that have no answers.” “If you knew Time as well as I do,” said the Hatter, “you wouldn’t talk about wasting *it*. It’s *him*.” “I don’t know what you mean,” said Alice. “Of course you don’t!” the Hatter said, tossing his head contemptuously. “I dare say you never even spoke to Time!”
`; // content.<lang>.md, inlined by the Cookbook // The headpiece is a resource that only the opener's design draws: never cited, never placed. // It is a watercolour, a JPEG in assets/ cut to the trim's 156 × 72 mm, declared at its pixels. const resources = [{ id: 'tea-table', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, bitmap: { fileId: 'tea-table-1560.jpg', format: 'jpeg', width: 1560, height: 720 }, altText: 'The tea-table under a tree, seen from above: a long white cloth laid with cups, a red ' + 'teapot, bread and butter and a watch on its chain; the Dormouse asleep in a teapot at one ' + 'end and a red arm-chair at the other.' }]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) Unna: ['400', '400i', '700'], // 700: the folios and the gloss letters 'Rozha One': ['400'], 'Cormorant SC': ['600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadImage('tea-table-1560.jpg', asset('tea-table-1560.jpg')); const continuation = { pageNumbering: { startAt: 81 } }; // chapter VII of a book: an odd folio const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: 'Annotated classic with margin glosses' });
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

#Mantenha as glosas à direita em todas as páginas

Para uma edição lida uma página por vez na tela, desligue o espelhamento: 'outer' passa a significar a borda direita de todas as páginas, e os cabeços continuam no lugar, já que são ancorados ao refile.

-    mirror: true } };
+    mirror: false } };

#Mande as notas longas para o pé da página

Escreva uma nota longa demais para a margem como nota de rodapé, [^1] no texto e a definição [^1]: abaixo do parágrafo: o Postext a compõe no pé da coluna de texto, com 89,5 mm de largura sob um fio curto, e as glosas continuam com o canal. Notas de rodapé no pé da coluna que as cita mostra como estilizar essas notas.

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

Um boxe lateral depois de um título recua o parágrafo seguinte

No postext 1.4.1, um boxe com span: 'side' delimitado entre um título e o primeiro parágrafo dá a esse parágrafo recuo de primeira linha, mesmo com indentAfterHeading: false: o boxe sai do fluxo, mas seus blocos continuam contando como o bloco que vem depois do título. Coloque o boxe depois do primeiro parágrafo. Notas na margem →

Erro comum

{number}/{chapterNumber} imprimem o número do H1; {numberRoman} só nas partes

{number} e {chapterNumber} imprimem o número já formatado do título, mas {numberRoman}, {numberDecimal} e as outras variantes numéricas só são preenchidas nas páginas de parte. Formate o número do capítulo no seu numberingTemplate ({1:I}) ou passe-o como atributo. Títulos numerados →

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

Texto de design nunca é justificado, então o texto ao lado da capitular fica em bandeira

No postext 1.4.1, um elemento de texto de design se alinha à esquerda, ao centro ou à direita e quebra palavra por palavra: não há alinhamento justificado, e hyphenate: true só divide uma palavra longa demais para uma linha inteira. As linhas de um texto inicial composto ao lado de uma dropCap terminam, portanto, em bandeira junto ao texto corrido justificado. Limite o texto inicial às linhas ao lado da capitular e ajuste-as à mão: com lines: 1 (uma capitular elevada) o texto inicial é uma linha só, que o gap da dropCap consegue deixar rente; o parágrafo continua no Markdown. Capitulares nas aberturas →

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

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

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 →

  • As glosas se empilham na ordem do Markdown, uma embaixo da outra, então uma glosa longa empurra a seguinte para baixo. Na página 81, as duas letras caem na segunda linha do primeiro parágrafo: a glosa a começa na altura do parágrafo e a glosa b abaixo dela, cinco linhas abaixo da sua letra. Onde as letras ficam próximas, escreva glosas curtas.
  • O erro comum “Um boxe lateral depois de um título recua o parágrafo seguinte”, acima, recomenda delimitar o boxe depois do primeiro parágrafo. Uma glosa delimitada ali começa ao lado do segundo parágrafo (veja “Um boxe lateral começa na altura do bloco que vem depois do seu delimitador”), por isso esta edição deixa os dois delimitadores antes do primeiro parágrafo e compõe esse parágrafo sem recuo com o estilo opening.

Créditos

Texto
  • Alice’s Adventures in Wonderland (1865), the opening of chapter VII, “A Mad Tea-Party” · Lewis Carroll · domínio público
  • The headnote, the eight glosses and the note on this edition · Ignacio Ferro · CC BY 4.0
Imagens
  • The tea-table seen from above, a watercolour · Generated With Diffusion Models · original
Fontes
Unna (SIL OFL 1.1) · Rozha One (SIL OFL 1.1) · Cormorant SC (SIL OFL 1.1)
Sandbox