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

Receitas · Capítulo 4 · Cabeços e fólios

Dicionário com índice de dedo que desce

Um laço cria um estilo de título por letra, e o cabeçalho de cada estilo imprime a aba azul-marinho da letra um degrau abaixo no corte, sobre um índice claro.

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 29 de set. de 2026
Código MIT · Texto CC BY 4.0
  • Amostra em inglês: ainda sem edição em português
  • Refile 150 × 200 mm
  • 2 colunas, medianiz de 4 mm
  • Alegreya 8/10,4
  • Alegreya SC
  • Alegreya Sans SC
  • 4 páginas
  • Nível
  • Postext 1.19.1
  • Diagramado em 248 ms
  • 185 linhas de código

Em poucas palavras

Quatro páginas de um velho dicionário de marinheiros. Abas com letras descem pela borda externa de cada página, e a aba da letra atual é escura e fica mais baixa à medida que o alfabeto avança.

O que você vai compor

Quatro páginas de uma nova edição do Sailor’s Word-Book do almirante Smyth (1867), letras A a C, numa página de 150 × 200 mm. Os verbetes correm em duas colunas justificadas de Alegreya de 8 pt separadas por um fio fino, cada lema em negrito azul-marinho e cada linha de continuação com recuo deslocado de 1 em. Uma letra abre dentro da sua coluna, como uma inicial azul-marinho de quatro linhas sobre um fio cor de latão, e os verbetes seguem sem quebra de página. No corte da frente, toda página imprime o índice de dedo inteiro, 22 abas num tom claro. A aba da letra atual é azul-marinho, 3,2 mm mais larga que as outras, e desce um degrau a cada letra. A edição em espanhol mantém os lemas em inglês de Smyth e traduz os verbetes.

Esta receita responde a

  • Como acrescento um índice de dedo: uma aba no corte da frente que desce pela página de uma letra ou de um capítulo para o seguinte?
  • Como componho uma bibliografia ou um glossário (recuo deslocado, corpo menor)?
  • Como dou cor aos termos-chave (em negrito ou itálico) no texto ou dentro dos boxes?
  • Como configuro os cabeços: título do livro na página esquerda, título do capítulo na direita e fólio do lado de fora?
  • Como oculto os cabeços nas aberturas e nas páginas em branco, ou pinto uma página par em branco com a cor da parte?

A resposta curta

script.js · linhas 36–66no código completo
// 22 tabs share the 44 lines of the text block, two lines each; letters with few words
// share a tab, as in most thumb-indexed dictionaries.
const TABS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
  'QR', 'S', 'T', 'UV', 'W', 'XYZ'];
const STEP = 2 * LEAD * PT; // mm: 7.34
const EDGE = { odd: 'top-right', even: 'top-left' }; // the fore-edge: right on a recto
// A cell of the index, `width` mm inside the trim and 3 mm past it, so that only its
// inner corners show their rounding; its label is centred on the part inside the trim.
const cell = (id, label, i, parity, width, fill, ink, size) => {
  const y = mm(TOP + i * STEP + 0.3); // a 0.6 mm gap between neighbours
  const at = (x, w) => ({ anchor: { to: 'page', edge: EDGE[parity] }, offset: { x: mm(x), y },
    size: { width: mm(w), height: mm(STEP - 0.6) } });
  return [
    { kind: 'box', id, parity, style: { backgroundColor: col(fill), borderRadius: mm(1.2) },
      placement: at(parity === 'odd' ? 3 : -3, width + 3) },
    { kind: 'text', id: `${id}-label`, parity, content: label, fontFamily: LABEL, fontWeight: 700,
      fontSize: pt(size), lineHeight: 1, color: col(ink), align: 'center',
      verticalAlign: 'middle', placement: at(0, width) },
  ];
};
const sides = (make) => ['odd', 'even'].flatMap(make);
// Every page prints the whole index, pale; the section's own letter stands out of it.
const ladder = sides((p) => TABS.flatMap((label, i) =>
  cell(`index-${i}-${p}`, label, i, p, 5.8, 'tint', 'slate', 7.5)));
const letterStyles = () => TABS.flatMap((label, i) => [...label].map((letter) => ({
  id: letter,
  // '# B {style="B"}' opens the section of B: its pages take this header, and a page
  // where A ends and B begins takes B's (gotcha: section-last-wins).
  header: { elements: [...runningHeads, ...ladder,
    ...sides((p) => cell(`tab-${p}`, label, i, p, 9, 'navy', 'paper', 8.5))] },
})));

Ingredientes

Tipografia
Alegreya, Alegreya SC, Alegreya Sans SC (SIL OFL 1.1)
Materiais
Nenhum: todas as imagens são desenhadas em código

Preparo

#1 · Dê a cada letra um estilo de título com a sua aba

O código é a resposta curta lá em cima. TABS lista 22 abas, e as letras raras dividem uma (QR, UV, XYZ). Cada aba tem a altura de duas linhas do texto (7,34 mm), de modo que juntas preenchem as 44 linhas da mancha. O laço dá a cada letra um estilo de título cujo header acrescenta aos cabeços e ao índice claro a aba azul-marinho daquela letra, um degrau abaixo da anterior (estilos de título). Cada célula é escrita duas vezes, uma por parity, e ancorada à borda externa da página, não ao contêiner do próprio cabeçalho. Esse contêiner é a margem superior, da largura da mancha e indo do refile até o texto, enquanto as abas precisam chegar ao corte da frente e descer pelas 44 linhas. Uma página recebe o cabeçalho da última seção que começa nela, por isso a página 2 começa no A e mostra a aba do B.

#2 · Tire os cabeços da primeira página

script.js · linhas 70–86no código completo
const HEAD = 11; // mm from the trim's top to the heads' top
const head = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: LABEL, fontSize: pt(8), fontWeight: 500, letterSpacing: pt(1),
  color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } }, ...extra });
const folio = { fontWeight: 700, color: col('ink'), letterSpacing: pt(0) };
const runningHeads = [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-letter', t({ en: 'Letter {chapterTitle}', es: 'Letra {chapterTitle}' }), 'odd',
    'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
];
// The first page drops its folio to the foot, under the text block.
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, {
  ...folio, pages: 'opener',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(6) } } })] };

Os cabeços são elementos de texto ancorados à página e filtrados por parity e pages: 'body' (elementos de texto). O título da página 1 ocupa as duas colunas, o que faz dela uma página de abertura. Os cabeços pulam essa página, e o fólio dela vem do rodapé, um elemento com pages: 'opener' que imprime 6 mm abaixo da mancha. Numa página ímpar, {chapterTitle} imprime a letra do último título de nível 1 que está na página ou antes dela, Letter C. Desde o postext 1.5.0, {firstMark.<key>} e {lastMark.<key>} imprimem no lugar dela o primeiro e o último lema da página, as palavras-guia que a maioria dos dicionários traz; esta receita foi composta com a 1.4.1 e mantém a letra.

#3 · Dê cor aos lemas e a nada mais

script.js · linhas 90–101no código completo
// Bold and italic default to the engine's blue: the body sets them to ink, and only the
// entry style prints its bold, the headwords, in navy.
const BODY = 8; // pt: a reference size, about 50 characters to the 60 mm column
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(0),
  minWordSpacing: 0.7, maxWordSpacing: 1.7,
  maxRuntTracking: 0 }; // gotcha: runt-tracking-unpainted
const paragraphStyles = [
  { id: 'entry', hangingIndent: em(1), boldColor: col('navy') }, // :::paragraphs{style="entry"}
  { id: 'colophon', fontSize: pt(7), color: col('muted'), textAlign: 'center',
    marginTop: pt(LEAD) },
];

Cada verbete é um parágrafo de um bloco :::paragraphs{style="entry"}, com as linhas de continuação em recuo deslocado de 1 em (estilos de parágrafo). O boldColor do texto corrido deixa o Note. da página 1 na cor da tinta, e o boldColor do estilo de verbete imprime os lemas em azul-marinho. Um estilo de parágrafo não tem italicColor, então os subverbetes em itálico ficam na cor do texto corrido.

#4 · Ponha uma inicial de quatro linhas na coluna

script.js · linhas 105–118no código completo
// Its cap line meets the first body line's and it stands on the fourth baseline. Cap
// heights measured on the glyph H: 0.652 em in Alegreya SC 900, 0.646 em in Alegreya.
const DROP = 4; // body lines the letter spans
const LETTER = ((DROP - 1) * LEAD + 0.646 * BODY) / 0.652; // pt: 55.8
// A design line sets its baseline 0.8 of its height below its top; a body line does too.
const letterHead = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'letter', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 900,
    fontSize: pt(LETTER), lineHeight: 1, color: col('navy'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: pt((DROP - 0.2) * LEAD - 0.8 * LETTER) } } },
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6), color: col('brass'),
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: pt((DROP + 0.55) * LEAD) }, size: { width: 'fill' } } },
] } };

LETTER, 55,8 pt, é o corpo em que uma maiúscula da Alegreya SC 900 (0,652 em de altura) se apoia na quarta linha de base e alcança a altura das maiúsculas da primeira linha do texto. A letra e o fio cor de latão terminam dentro da quinta linha, e o bloco do título desce até a linha seguinte da grade, então cada inicial ocupa cinco linhas e os verbetes abaixo dela continuam na grade. Na página 3, o C no alto da segunda coluna fica alinhado com as quatro primeiras linhas da primeira coluna. O nível 1 define breakBefore: { enabled: false }, e os estilos das letras herdam esse valor. Na 1.4.1 qualquer objeto headings já tira a quebra de página do nível 1; com o valor escrito, as letras continuam seguidas se uma versão futura trouxer a quebra de volta.

#5 · Estenda o título pelas duas colunas

script.js · linhas 122–148no código completo
const centred = (id, content, family, size, lineHeight, y, width, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), lineHeight, color: col('navy'), align: 'center',
  overflow: 'wrap', ...extra, placement: { anchor: { to: 'container', edge: 'top' },
    offset: { y: mm(y) }, size: { width: width ? mm(width) : 'fill' } } });
const rule = (id, y, thickness) => ({ kind: 'rule', id, direction: 'horizontal',
  thickness: pt(thickness), color: col('navy'), placement: { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(y) }, size: { width: 'fill' } } });
const titleBand = { enabled: true, slot: { elements: [
  // The column rule starts at the top of the text block, under the band too: a field of
  // paper down to the thin rule covers it, so it hangs from the double rule.
  { kind: 'box', id: 'field', style: { backgroundColor: col('paper') }, placement: {
    anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill', height: mm(45) } } },
  { kind: 'image', id: 'anchor', resourceId: 'anchor', placement: {
    anchor: { to: 'container', edge: 'top' }, size: { width: 'auto', height: mm(18) } } },
  centred('name', '{titleText}', DISPLAY, 22, 1.1, 20, 0, { fontWeight: 900 }),
  centred('subtitle', '{attr.subtitle}', TEXT, 9, 1.25, 29.5, 92, { italic: true,
    color: col('ink') }),
  centred('byline', '{attr.byline}', LABEL, 7, 1.2, 38.8, 0, { fontWeight: 500,
    letterSpacing: pt(1.2), color: col('muted') }),
  rule('thick', 43.6, 1.2),
  rule('thin', 45, 0.4),
] } };
// '# The Sailor’s Word-Book {style="title" subtitle="…" byline="…"}' opens page 1. The band's
// {titleText} joins the hidden heading's wrapped lines with a space, and a page-span heading
// wraps at the column width: at the default H1 size the band printed 'Word- Book'. At body
// size the hidden title fits one line of the 60 mm column.
const titleStyle = { id: 'title', span: 'page', advancedDesign: titleBand, fontSize: pt(BODY) };

O estilo do título ocupa a largura da página com uma faixa que reúne a âncora, o nome, um subtítulo e uma linha de autoria lidos de atributos do título, e um fio duplo (span e design avançado). O fio fino termina 45,1 mm abaixo do alto da mancha, então a faixa se ajusta a 13 linhas da grade, ou 47,7 mm. Sob um título na largura da página, o fio entre colunas continua começando no alto da mancha e atravessaria o título. Uma caixa da cor do papel cobre a faixa até o fio fino, e o fio entre colunas só aparece abaixo dela. {titleText} junta com um espaço as linhas quebradas do título oculto, e a 1.4.1 quebra um título na largura da página pela largura da coluna. No corpo padrão do H1, a faixa imprimia Word- Book, por isso o estilo do título compõe o título oculto no corpo do texto, o que faz o nome caber numa linha da coluna de 60 mm.

A receita completa

Sandbox
// ═══ Postext Cookbook · Nº 046 · Dictionary with a moving thumb index ═══════════════
// https://postext.dev/en/cookbook/dictionary-thumb-index
// Code: MIT · Text: W. H. Smyth, 1867 (PD); Spanish translation CC BY 4.0 · Pictures: code
// Fonts: Alegreya, Alegreya SC, Alegreya Sans SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'dictionary-thumb-index';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink and navy on a warm paper, brass for the rules and the rope
const palette = {
  ink: '#1b1f24', // text
  navy: '#1f3a5f', // headwords, letters and tabs
  brass: '#a4834f', // hairlines and the rope of the vignette
  rule: '#cfccc3', // the column rule
  tint: '#e4e9f0', // the pale cells of the index
  slate: '#56657b', // their letters
  muted: '#62666b', // running heads
  paper: '#fbfaf6', // the page, and the letter reversed out of each tab
};
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' } }));
// #endregion
const TEXT = 'Alegreya', DISPLAY = 'Alegreya SC', LABEL = 'Alegreya Sans SC';
const PT = 25.4 / 72; // mm in a point
const LEAD = 10.4; // pt: the body's leading, the grid every column is set on
const LINES = 44; // lines in a full column
const TOP = 18, INNER = 12, OUTER = 14; // mm; the foot margin makes the block whole lines
const BOTTOM = 200 - TOP - LINES * LEAD * PT;

// #region answer: one heading style per letter, each with its tab one step down the fore-edge
// 22 tabs share the 44 lines of the text block, two lines each; letters with few words
// share a tab, as in most thumb-indexed dictionaries.
const TABS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
  'QR', 'S', 'T', 'UV', 'W', 'XYZ'];
const STEP = 2 * LEAD * PT; // mm: 7.34
const EDGE = { odd: 'top-right', even: 'top-left' }; // the fore-edge: right on a recto
// A cell of the index, `width` mm inside the trim and 3 mm past it, so that only its
// inner corners show their rounding; its label is centred on the part inside the trim.
const cell = (id, label, i, parity, width, fill, ink, size) => {
  const y = mm(TOP + i * STEP + 0.3); // a 0.6 mm gap between neighbours
  const at = (x, w) => ({ anchor: { to: 'page', edge: EDGE[parity] }, offset: { x: mm(x), y },
    size: { width: mm(w), height: mm(STEP - 0.6) } });
  return [
    { kind: 'box', id, parity, style: { backgroundColor: col(fill), borderRadius: mm(1.2) },
      placement: at(parity === 'odd' ? 3 : -3, width + 3) },
    { kind: 'text', id: `${id}-label`, parity, content: label, fontFamily: LABEL, fontWeight: 700,
      fontSize: pt(size), lineHeight: 1, color: col(ink), align: 'center',
      verticalAlign: 'middle', placement: at(0, width) },
  ];
};
const sides = (make) => ['odd', 'even'].flatMap(make);
// Every page prints the whole index, pale; the section's own letter stands out of it.
const ladder = sides((p) => TABS.flatMap((label, i) =>
  cell(`index-${i}-${p}`, label, i, p, 5.8, 'tint', 'slate', 7.5)));
const letterStyles = () => TABS.flatMap((label, i) => [...label].map((letter) => ({
  id: letter,
  // '# B {style="B"}' opens the section of B: its pages take this header, and a page
  // where A ends and B begins takes B's (gotcha: section-last-wins).
  header: { elements: [...runningHeads, ...ladder,
    ...sides((p) => cell(`tab-${p}`, label, i, p, 9, 'navy', 'paper', 8.5))] },
})));
// #endregion

// #region running-heads: the book on the verso, the letter on the recto, folios outside
const HEAD = 11; // mm from the trim's top to the heads' top
const head = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: LABEL, fontSize: pt(8), fontWeight: 500, letterSpacing: pt(1),
  color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } }, ...extra });
const folio = { fontWeight: 700, color: col('ink'), letterSpacing: pt(0) };
const runningHeads = [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-letter', t({ en: 'Letter {chapterTitle}', es: 'Letra {chapterTitle}' }), 'odd',
    'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
];
// The first page drops its folio to the foot, under the text block.
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, {
  ...folio, pages: 'opener',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(6) } } })] };
// #endregion

// #region entries: one paragraph per entry, the headword bold in navy, the turnovers hanging
// Bold and italic default to the engine's blue: the body sets them to ink, and only the
// entry style prints its bold, the headwords, in navy.
const BODY = 8; // pt: a reference size, about 50 characters to the 60 mm column
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(0),
  minWordSpacing: 0.7, maxWordSpacing: 1.7,
  maxRuntTracking: 0 }; // gotcha: runt-tracking-unpainted
const paragraphStyles = [
  { id: 'entry', hangingIndent: em(1), boldColor: col('navy') }, // :::paragraphs{style="entry"}
  { id: 'colophon', fontSize: pt(7), color: col('muted'), textAlign: 'center',
    marginTop: pt(LEAD) },
];
// #endregion

// #region letter-heads: a four-line initial over a brass hairline, in the column, running on
// Its cap line meets the first body line's and it stands on the fourth baseline. Cap
// heights measured on the glyph H: 0.652 em in Alegreya SC 900, 0.646 em in Alegreya.
const DROP = 4; // body lines the letter spans
const LETTER = ((DROP - 1) * LEAD + 0.646 * BODY) / 0.652; // pt: 55.8
// A design line sets its baseline 0.8 of its height below its top; a body line does too.
const letterHead = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'letter', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 900,
    fontSize: pt(LETTER), lineHeight: 1, color: col('navy'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: pt((DROP - 0.2) * LEAD - 0.8 * LETTER) } } },
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6), color: col('brass'),
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: pt((DROP + 0.55) * LEAD) }, size: { width: 'fill' } } },
] } };
// #endregion

// #region title: the book's name across both columns, under a fouled anchor
const centred = (id, content, family, size, lineHeight, y, width, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), lineHeight, color: col('navy'), align: 'center',
  overflow: 'wrap', ...extra, placement: { anchor: { to: 'container', edge: 'top' },
    offset: { y: mm(y) }, size: { width: width ? mm(width) : 'fill' } } });
const rule = (id, y, thickness) => ({ kind: 'rule', id, direction: 'horizontal',
  thickness: pt(thickness), color: col('navy'), placement: { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(y) }, size: { width: 'fill' } } });
const titleBand = { enabled: true, slot: { elements: [
  // The column rule starts at the top of the text block, under the band too: a field of
  // paper down to the thin rule covers it, so it hangs from the double rule.
  { kind: 'box', id: 'field', style: { backgroundColor: col('paper') }, placement: {
    anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill', height: mm(45) } } },
  { kind: 'image', id: 'anchor', resourceId: 'anchor', placement: {
    anchor: { to: 'container', edge: 'top' }, size: { width: 'auto', height: mm(18) } } },
  centred('name', '{titleText}', DISPLAY, 22, 1.1, 20, 0, { fontWeight: 900 }),
  centred('subtitle', '{attr.subtitle}', TEXT, 9, 1.25, 29.5, 92, { italic: true,
    color: col('ink') }),
  centred('byline', '{attr.byline}', LABEL, 7, 1.2, 38.8, 0, { fontWeight: 500,
    letterSpacing: pt(1.2), color: col('muted') }),
  rule('thick', 43.6, 1.2),
  rule('thin', 45, 0.4),
] } };
// '# The Sailor’s Word-Book {style="title" subtitle="…" byline="…"}' opens page 1. The band's
// {titleText} joins the hidden heading's wrapped lines with a space, and a page-span heading
// wraps at the column width: at the default H1 size the band printed 'Word- Book'. At body
// size the hidden title fits one line of the 60 mm column.
const titleStyle = { id: 'title', span: 'page', advancedDesign: titleBand, fontSize: pt(BODY) };
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette, footer,
  header: { elements: [] }, // every page takes the header of its letter's style
  page: { sizePreset: 'custom', width: mm(150), height: mm(200), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
      mirror: true } },
  layout: { gutterWidth: mm(4), // two columns is the default layout
    columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } },
  bodyText, paragraphStyles,
  // The designs paint every heading; the hidden ones are still measured, and in the face
  // FONTS loads, or the kit would fetch Open Sans 700 for text nobody sees.
  headings: { fontFamily: DISPLAY, fontWeight: 900, levels: [
    // Letters run on in the column. Any headings object already drops the H1 break
    // (gotcha: headings-drop-h1-break); stating it keeps them running on if that default
    // returns, and the letter styles inherit it (gotcha: style-inherits-break).
    { level: 1, breakBefore: { enabled: false }, marginTop: pt(LEAD), marginBottom: pt(0),
      advancedDesign: letterHead },
  ] },
  headingStyles: [
    titleStyle,
    ...letterStyles(),
  ],
});

// #region art: a fouled anchor, the Admiralty's badge, for the title band
// Drawn in the page's navy and brass; the rope's lay is a dashed navy stroke over it.
const anchorSvg = () => {
  const { navy, brass } = palette;
  const rope = 'M66.5 19 C88 26 86 44 60 52 C34 60 32 76 60 82 C88 88 86 104 60 110 '
    + 'C44 114 38 122 42 136';
  const fluke = (s) => `<path transform="translate(60 0) scale(${s} 1)" fill="${navy}"
    d="M-44 92 L-50 78 L-36 70 L-34 88 Z"/>`;
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 150" width="120" height="150">
  <circle cx="60" cy="13" r="8.5" fill="none" stroke="${navy}" stroke-width="4"/>
  <path d="M56.5 21 L63.5 21 L64.5 124 L55.5 124 Z" fill="${navy}"/>
  <rect x="20" y="27" width="80" height="6.5" rx="3.2" fill="${navy}"/>
  <circle cx="19" cy="30.2" r="5" fill="${navy}"/>
  <circle cx="101" cy="30.2" r="5" fill="${navy}"/>
  <path d="M16 84 Q20 132 60 134 Q100 132 104 84" fill="none" stroke="${navy}"
    stroke-width="7" stroke-linecap="round"/>
  ${fluke(1)}${fluke(-1)}
  <path d="M60 126 L66 134 L60 142 L54 134 Z" fill="${navy}"/>
  <path d="${rope}" fill="none" stroke="${brass}" stroke-width="4.2" stroke-linecap="round"/>
  <path d="${rope}" fill="none" stroke="${navy}" stroke-opacity="0.5" stroke-width="4.2"
    stroke-dasharray="1 1.8"/>
</svg>`;
};
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Amostra em Markdown · 118 linhas · content.en.mdtitle: "The Sailor’s Word-Book" author: "W. H. Smyth" --- # The Sailor’s Word-Book {style="title" subtitle="An alphabetical digest of nautical terms, including some more especially military and scientific, but useful to seamen" byline="Admiral W. H. Smyth · London, 1867"} **Note.** Admiral William Henry Smyth died on 8 September 1865 and left this word-book in manuscript. Sir Edward Belcher, a vice-admiral, revised it for the press, and Blackie and Son published it in London in 1867. This selection prints entries from the letters A, B and C in Smyth’s wording, some of them shortened, among them words now in everyday English: A1, *taken aback*, *aloof*, the *bitter end*, a wide *berth*, *clear the decks*, *cut and run*. Cross-references follow *see*; the entries they name are not in this selection. # A {style="A"} :::paragraphs{style="entry"} **A.** The highest class of the excellence of merchant ships on Lloyd’s books, subdivided into A1 and A2, after which they descend by the vowels: A1 being the very best of the first class. Formerly a river-built (Thames) ship took the first rate for 12 years, a Bristol one for 11, and those of the northern ports 10. Some of the out-port built ships keep their rating 6 to 8 years, and inferior ones only 4. But improvements in ship-building, and the large introduction of iron, are now claiming longer life. **Aback.** The situation of a ship’s sails when the wind bears against their front surfaces. They are *laid aback*, when this is purposely effected to deaden her way by rounding in the weather-braces; and *taken aback*, when brought to by an unexpected change of wind, or by inattention in the helmsman. – *All aback forward*, the notice given from the forecastle, when the head-sails are pressed aback by a sudden change in the wind. (*See* Work aback.) – *Taken aback*, a colloquialism for being suddenly surprised or found out. **Abeam.** In a line at right angles to the vessel’s length; opposite the centre of a ship’s side. **About.** Circularly; the situation of a ship after she has gone round, and trimmed sails on the opposite tack. – *Ready about!* and *About-ship!* are orders to the ship’s company to prepare for tacking by being at their stations. **Adrift.** Floating at random; the state of a boat or vessel broken from her moorings, and driven to and fro without control by the winds and waves. Cast loose; cut adrift. **Afloat.** Borne up and supported by the water; buoyed clear of the ground; also used for being on board ship. **Ahead.** A term especially referable to any object farther onward, or immediately before the ship, or in the course steered, and therefore opposed to *astern*. – *Ahead of the reckoning*, is sailing beyond the estimated position of the ship. – *Ahead* is also used for progress; as, *cannot get ahead*, and is generally applied to forward, in advance. **A-hull.** A ship under bare poles and her helm a-lee, driving from wind and sea, stern foremost. Also a ship deserted, and exposed to the tempestuous winds. **A-lee.** The contrary of *a-weather*: the position of the helm when its tiller is borne over to the lee-side of the ship, in order to go about or put her head to windward. – *Hard a-lee!* or *luff a-lee!* is said to the steersman to put the helm down. – *Helm’s a-lee!* the word of command given on putting the helm down, and causing the head-sails to shake in the wind. **Aloof.** The old word for “keep your luff,” in the act of sailing to the wind. (*See* Luff.) – *Keep aloof*, at a distance. **Anchor.** A large and heavy instrument in use from the earliest times for holding and retaining ships, which it executes with admirable force. With few exceptions it consists of a long iron shank, having at one end a ring, to which the cable is attached, and the other branching out into two arms, with flukes or palms at their bill or extremity. A stock of timber or iron is fixed at right angles to the arms, and serves to guide the flukes perpendicularly to the surface of the ground. According to their various form and size, anchors obtain the epithets of the *sheet*, *best bower*, *small bower*, *spare*, *stream*, *kedge*, and *grapling* (which see under their respective heads). **Apple-pie order.** A strange but not uncommon term for a ship in excellent condition and well looked to. Neat and orderly. Absurdly said to be a corruption of *du pol au pied*. **Ashore.** Aground, on land. – To *go ashore*, to disembark from a boat. Opposed to *aboard*. **Astern.** Any distance behind a vessel; in the after-part of the ship; in the direction of the stern, and therefore the opposite of *ahead*. – *To drop astern*, is to be left behind – when abaft a right angle to the keel at the main-mast, she drops astern. **Athwart.** The transverse direction; anything extending or across the line of a ship’s course. – *Athwart hawse*, a vessel, boat, or floating lumber accidentally drifted across the stem of a ship, the transverse position of the drift being understood. – *Athwart the fore-foot*, just before the stem; ships fire a shot in this direction to arrest a stranger, and make her bring-to. – *Athwart ships*, in the direction of the beam; from side to side: in opposition to *fore-and-aft*. **Avast.** The order to stop, hold, cease, or stay, in any operation: its derivation from the Italian *basta* is more plausible than *have fast*. **Awning.** A cover or canvas canopy suspended by a crow-foot and spread over a ship, boat, or other vessel, to protect the decks and crew from the sun and weather. (*See* Euphroe.) Also that part of the poop-deck which is continued forward beyond the bulk-head of the cabin. **Azure.** The deep blue colour of the sky, when perfectly cloudless. ::: # B {style="B"} :::paragraphs{style="entry"} **Ballast.** As a verb, signifies to steady; as a substantive, a comprehensive mind. A man is said to “lose his ballast” when his judgment fails him, or he becomes top-heavy from conceit. **Beam-ends.** A ship is said to be on her beam-ends when she has heeled over so much on one side that her beams approach to a vertical position; hence also a person lying down is metaphorically said to be on his beam-ends. **Berth.** The station in which a ship rides at anchor, either alone, or in a fleet; as, she lies in a good berth, *i.e.* in good anchoring ground, well sheltered from the wind and sea, and at a proper distance from the shore and other vessels. – *Snug berth*, a place, situation, or establishment. A sleeping berth. – *To berth a vessel*, is to fix upon, and put her into the place she is to occupy. – *To berth a ship’s company*, to allot to each man the space in which his hammock is to be hung, giving the customary 14 inches in width. – *To give a berth*, to keep clear of, as to give a point of land a wide berth, is to keep at a due distance from it. **Bitter-end.** That part of the cable which is abaft the bitts, and therefore within board when the ship rides at anchor. They say, “Bend to the bitter-end” when they would have that end bent to the anchor, and when a chain or rope is paid out to the bitter-end, no more remains to be let go. The bitter-end is the clinching end – sometimes that end is bent to the anchor, because it has never been used, and is more trustworthy. The first 40 fathoms of a cable of 115 fathoms is generally worn out when the inner end is comparatively new. **Booby.** A well-known tropical sea-bird, *Sula fusca*, of the family *Pelecanidae*. It is fond of resting out of the water at night, even preferring an unstable perch on the yard of a ship. The name is derived from the way in which it allows itself to be caught immediately after settling. The direction in which they fly as evening comes on often shows where land may be found. **Brace of shakes.** A moment: taken from the flapping of a sail. I will be with you before it shakes thrice. **Bring up with a round turn.** Suddenly arresting a running rope by taking a round turn round a bollard, bitt-head, or cleat. Said of doing a thing effectually though abruptly. It is used to bring one up to his senses by a severe rating. **Broach-to, to.** To fly up into the wind. It generally happens when a ship is carrying a press of canvas with the wind on the quarter, and a good deal of after-sail set. The masts are endangered by the course being so altered, as to bring it more in opposition to, and thereby increasing the pressure of the wind. In extreme cases the sails are caught flat aback, when the masts would be likely to give way, or the ship might go down stern foremost. **Broadside.** The whole array, or the simultaneous discharge of the artillery on one side of a ship of war above and below. It also implies the whole of that side of a ship above the water which is situate between the bow and quarter, and is in a position nearly perpendicular to the horizon. Also, a name given to the old folio sheets whereon ballads and proclamations were printed of old (broad-sheet). **By the board.** Over the ship’s side. When a mast is carried away near the deck it is said to go by the board. ::: # C {style="C"} :::paragraphs{style="entry"} **Cabin.** A room or compartment partitioned off in a ship, where the officers and passengers reside. In a man-of-war, the principal cabin, in which the captain or admiral lives, is the upper after-part of the vessel. **Cabin-boy.** A boy whose duty is to attend and serve the officers and passengers in the cabin. **Cable’s length.** A measure of about 100 fathoms, by which the distances of ships in a fleet are frequently estimated. This term is frequently misunderstood. In all marine charts a cable is deemed 607.56 feet, or one-tenth of a sea mile. In rope-making the cable varies from 100 to 115 fathoms; cablet, 120 fathoms; hawser-laid, 130 fathoms, as determined by the admiralty in 1830. **Caboose.** The cook-room or kitchen of merchantmen on deck; a diminutive substitute for the galley of a man-of-war. It is generally furnished with cast-iron apparatus for cooking. **Careen, to.** A ship is said to careen when she inclines to one side, or lies over when sailing on a wind; off her keel or carina. **Cat o’ nine tails.** An instrument of punishment used on board ships in the navy; it is commonly of nine pieces of line or cord, about half a yard long, fixed upon a piece of thick rope for a handle, and having three knots on each, at small intervals, nearest one end; with this the seamen who transgress are flogged upon the bare back. **Caulk, to.** (*See* Caulking.) To lie down on deck and sleep, with clothes on. **Chart.** A hydrographical map, or a projection of some part of the earth’s superficies *in plano*, for the use of navigators, further distinguished as plane-charts, Mercator’s charts, globular charts, and the bottle or current chart, to aid in the investigation of surface currents (all which see). A selenographic chart represents the moon, especially as seen by the aid of photography and Mr. De la Rue’s arrangement. **Cheer, to.** To salute a ship *en passant*, by the people all coming on deck and huzzahing three times; it also implies to encourage or animate. (*See also* Hearty and Man ship!) **Chock-a-block, or chock and block.** Is the same with *block-a-block* and *two-blocks* (which see). When the lower block of a tackle is run close up to the upper one, so that you can hoist no higher, the blocks being together. **Clear, to.** Has several significations, particularly to escape from, to unload, to empty, to prepare, &c., as: – *To clear for action.* To prepare for action. – *To clear away* for this or that, is to get obstructions out of the way. – *To clear the decks.* To remove lumber, put things in their places, and coil down the ropes. Also, to take the things off a table after a meal. – *To clear goods.* To pay the custom-house dues and duties. – *To clear the land.* To escape from the land. – *To clear a lighter, or the hold.* To empty either. **Coil.** A certain quantity of rope laid up in ring fashion. The manner in which all ropes are disposed of on board ship for convenience of stowage. They are laid up round, one fake over another, or by concentric turns, termed *Flemish coil*, forming but one tier, and lying flat on the deck, the end being in the middle of it, as a snake or worm coils itself. **Convoy.** A fleet of merchant ships similarly bound, protected by an armed force. Also, the ship or ships appointed to conduct and defend them on their passage. Also, a guard of troops to escort a supply of stores to a detached force. **Cross-trees.** Certain timbers supported by the cheeks and trestle-trees at the upper ends of the lower and top masts, athwart which they are laid to sustain the frame of the tops on the one, and to extend the top-gallant shrouds on the other. **Cuddy.** A sort of cabin or cook-room, generally in the fore-part, but sometimes near the stern of lighters and barges of burden. In the oceanic traders it is a cabin abaft, under the round-house or poop-deck, for the commander and his passengers. Also, the little cabin of a boat. **Cut and run, to.** To cut the cable for an escape. Also, to move off quickly; to quit occupation; to be gone. ::: :::paragraphs{style="colophon"} Type: Alegreya, Alegreya SC and Alegreya Sans SC (SIL OFL) Text: W. H. Smyth, 1867 (Project Gutenberg eBook 26000) Note and anchor: Postext Cookbook (CC BY 4.0) :::
`; // content.<lang>.md, inlined by the Cookbook // No paragraph cites the anchor, so it is never placed as a figure: only the title band's // image element draws it. const resources = [{ id: 'anchor', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, altText: t({ en: 'An anchor with a rope wound round its shank.', es: 'Un ancla con un cabo enrollado en la caña.' }), svg: { fileId: 'anchor.svg', width: 120, height: 150 } }]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Alegreya: ['400', '400i', '700'], 'Alegreya SC': ['900'], 'Alegreya Sans SC': ['500', '700'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('anchor.svg', anchorSvg()); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'The Sailor’s Word-Book', es: 'Vocabulario del marinero' }) });
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

#Imprima só a aba atual

Os índices de dedo impressos costumam mostrar só a aba da letra atual; tire ladder do cabeçalho e a aba azul-marinho continua descendo do A ao C.

-  header: { elements: [...runningHeads, ...ladder,
+  header: { elements: [...runningHeads,

Erros comuns

Erro comum

A página assume o cabeçalho da última seção que começa nela

Quando dois estilos de título começam na mesma página, ela assume o cabeçalho, o rodapé e a paleta do último. Uma página em branco de separação pertence ao capítulo anterior, e o preenchimento de paridade, ao seguinte. Cabeços por seção →

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

Os elementos de cabeçalho e rodapé são pintados sobre o texto

Os elementos de cabeçalho e rodapé são pintados por cima da página, e a área de texto não abre espaço para eles. Mantenha esses elementos dentro das margens, que são o que reserva o espaço deles. Cabeços e fólios →

Erro comum

Deslocamentos negativos relativos ao contêiner não pintam nada

O texto de design com largura automática fica limitado ao seu contêiner, então um deslocamento negativo em relação ao contêiner o empurra para fora e nada é pintado. Ancore esses elementos na página ou na sangria com deslocamentos explícitos em mm, ou dê a eles uma largura fixa. Ancoragem de elementos de design →

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

Um espaço não separável ainda quebra a linha

No postext 1.4.1, o algoritmo de quebra de linha trata U+00A0 como um espaço comum, então 0,08 %, 2,006 s ou seção 2 podem ficar em duas linhas. Junte os dois elementos (0,08%) ou reescreva a frase. Escapes e caracteres literais →

Erro comum

O ajuste de linhas curtas pode apertar um tracking que nunca é pintado

No postext 1.4.1, quando um parágrafo termina numa linha curta, a diagramação o compõe com uma linha a menos: primeiro aperta o espaçamento entre palavras, depois aplica até maxRuntTracking milésimos de em de tracking negativo. Os renderizadores de canvas e PDF só pintam tracking acima de zero, então o parágrafo sai impresso sem ele: as linhas justificadas perdem essa diferença nos espaços entre palavras, que ficam esmagados, e a última linha pode passar da medida e ser cortada na borda da coluna. Defina bodyText.maxRuntTracking: 0, que mantém o ajuste pelo espaçamento entre palavras, e reescreva os parágrafos que voltarem a terminar numa linha curta. Viúvas, órfãs e linhas curtas →

Erro comum

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

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

Erro comum

Coloque entre aspas cada valor do frontmatter

O YAML lê title: 1984 como número e uma data como objeto Date, e valores que não são strings saem vazios nos placeholders e deixam o PDF sem título. Coloque cada valor entre aspas: title: "1984". Metadados do documento →

  • No postext 1.4.1, o fio entre colunas sob um título na largura da página começa no alto da mancha mesmo quando a faixa tem um número inteiro de linhas da grade, como esta. Uma caixa da cor da página, pintada por baixo dos outros elementos do design, o cobre.
  • Uma coluna de 60 mm comporta uns 50 caracteres, e o inglês de Smyth não pode ser reescrito. Na captura, 18 das 218 linhas justificadas em inglês (8%) esticam os espaços além de 1,7 vez a largura normal, contra menos de 2% no espanhol, cuja tradução foi ajustada à coluna. O inglês estava em 17% antes de os cinco verbetes que compunham pior (Aground, Aloft, Belay, Cockpit, Crow’s nest) serem trocados por outros. Escolha verbetes que componham bem, ou alargue a coluna.

Créditos

Texto
Fontes
Alegreya (SIL OFL 1.1) · Alegreya SC (SIL OFL 1.1) · Alegreya Sans SC (SIL OFL 1.1)
Sandbox