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

Receitas · Capítulo 1 · Página e grade

Uma página de livro sobre a grade de linhas de base

Página em duas colunas espelhadas com mancha de exatamente 44 entrelinhas: o texto fica na mesma grade dos dois lados da medianiz e da lombada.

Nesta página

p. 9 · 1 de 3

  • Refile 210 × 280 mm
  • 2 colunas, medianiz de 6 mm
  • Vollkorn 9,8/13,4
  • Playfair Display
  • Vollkorn SC
  • 3 páginas
  • Nível
  • Postext 1.20.3
  • Diagramado em 82 ms
  • 190 linhas de código

Em poucas palavras

Três páginas de um ensaio sobre como os livros antigos eram diagramados. Mostra como deixar cada linha de texto na mesma altura nas duas colunas e nas duas páginas que ficam frente a frente.

O que você vai compor

As páginas 9 a 11 de A página pautada, uma coletânea sobre o ofício do livro, trazem “O cânone secreto”, um ensaio sobre as proporções que os copistas medievais davam às suas páginas. As margens seguem o cânone que o ensaio descreve, 2:3:4:6 e espelhadas, num formato de 210 × 280 mm com duas colunas de Vollkorn. Uma grade de linhas de base é desenhada sobre as páginas em vermelho-claro, e o texto corrido das duas colunas e das duas páginas que se olham assenta nela. O título em Playfair Display ocupa a primeira coluna da abertura, e o desenho do cânone encabeça a segunda. Os títulos de seção ficam meia linha fora da grade, e o texto sob eles volta para ela. Uma nota de oficina em corpo menor usa uma entrelinha própria de 10 pt. Na página 11, o ensaio termina sob uma figura na largura da página em duas colunas da mesma altura, e o colofão fecha a segunda.

Esta receita responde a

  • Como configuro uma página de livro: formato refilado, margens interna e externa espelhadas, duas colunas e medianiz?
  • Como prendo o texto a uma grade de linhas de base para que as linhas se alinhem entre as colunas e entre páginas opostas?
  • Como deixo as colunas alinhadas no pé e a última página equilibrada (justificação vertical)?
  • Como evito viúvas, órfãs e últimas linhas de uma só palavra, e mantenho cada título junto do seu texto?

A resposta curta

script.js · linhas 33–54no código completo
const LEAD = 13.4; // pt: the body leading is the pitch of the baseline grid
const UNIT = 8; // mm: the margins step 2:3:4:6 in units of 8 mm, like the canon's
const LINES = 44; // lines per column: the text block is a whole number of leads tall
const TRIM = { width: 210, height: 280 }; // mm
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { mirror: true, // left is the inner margin; mirror swaps it on every verso
    left: mm(2 * UNIT), top: mm(3 * UNIT), right: mm(4 * UNIT), // 16, 24 and 32 mm
    bottom: mm(TRIM.height - 3 * UNIT - LINES * LEAD * MM_PER_PT) }, // 48 mm: six units
  // The layout keeps to the grid whether or not the overlay draws it.
  baselineGrid: { enabled: true, color: col('grid') },
};
const GUTTER = 6; // mm: 162 mm of text block make two columns of 78 mm
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } };
const body = { fontFamily: 'Vollkorn', fontSize: pt(9.8), lineHeight: pt(LEAD) };
// Lists add no space of their own (any whole number of leads would do). The grid never
// absorbs a list's top margin: the default 1.5 em (14.7 pt here) would set the items
// 1.3 pt off the lines of the column beside them, until the list reaches a new column.
const onGrid = { marginTop: pt(0), marginBottom: pt(0) };

Ingredientes

Tipografia
Vollkorn, Playfair Display, Vollkorn SC (SIL OFL 1.1)
Materiais
  • Os dois desenhos, feitos em código com a paleta da página (Postext Cookbook, CC BY 4.0)

Preparo

#1 · Os títulos podem sair da grade, as listas nunca

script.js · linhas 104–117no código completo
  headings: { fontFamily: 'Playfair Display', color: col('ink'), balancing,
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // Two lines of 46 pt, each four leads tall, then one lead: the text starts on line 10.
      { level: 1, breakBefore: { enabled: true, parity: 'odd' },
        fontSize: pt(46), lineHeight: pt(4 * LEAD), italic: true, color: col('rubric'),
        marginBottom: pt(LEAD) },
      // A lead and a half above puts the head between two grid lines. The margin below
      // needs no fitting: the engine snaps the text under a head to the next grid line.
      { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(1.5 * LEAD) },
    ] },
  // Lists add no space of their own (onGrid, in the short answer): items stay on the grid.
  unorderedLists: { ...onGrid, bulletChar: '–', color: col('rubric'), fontWeight: 400 },
  orderedLists: { ...onGrid, color: col('rubric') }, // numbers in the rubric, text in ink

O espaço acima de um título é mantido exatamente como foi definido, então 1,5 entrelinha põe o título entre duas linhas da grade, e o texto sob ele é ajustado à linha seguinte (grade de linhas de base). Uma lista não é ajustada antes do primeiro item, por isso a resposta curta zera as margens das listas (qualquer número inteiro de entrelinhas também serviria); com o 1,5 em padrão, os três itens da página 10 ficariam 1,3 pt abaixo das linhas da coluna ao lado. As duas linhas do título, em 46 pt, ocupam quatro entrelinhas cada uma, e a margem inferior dele mais uma, de modo que o ensaio começa na linha 10.

Página 10: a lista da segunda coluna segue a grade dos parágrafos ao lado; a nota de oficina cai entre as linhas da grade, e o título sob ela fica meia linha fora, com o texto de volta à grade.

#2 · Sair da grade de propósito, e voltar

script.js · linhas 120–125no código completo
  paragraphStyles: [
    { id: 'note', fontSize: pt(7.8), lineHeight: pt(10), color: col('muted'),
      boldColor: col('rubric'), firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
    { id: 'colophon', fontFamily: 'Vollkorn SC', fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],

A nota é um contêiner :::paragraphs em 7,8 sobre 10 pt, fora da grade. Depois do último parágrafo, a posição é arredondada para a linha seguinte da grade, e o título que vem a seguir fica onde ficaria depois do texto corrido (o contêiner :::paragraphs). O colofão usa o mesmo contêiner em 7,5 sobre 10 pt: as quatro linhas somam 40 pt, 0,2 pt a menos que três entrelinhas, e assim a última coluna do ensaio termina na mesma altura da vizinha.

#3 · Pôr as figuras onde a grade consiga absorvê-las

script.js · linhas 294–307no código completo
// mm: a column wide and just short enough for a 17-lead band with its caption; a block wide
const CANON = [COLUMN_W, 61.8], PAGES = [BLOCK_W, 64];
const figure = (id, [width, height], placement, [caption, altText]) => ({
  id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height },
  placement, caption, altText, createdAt: 0, updatedAt: 0,
});
const resources = [
  // A float never lands above the paragraph that cites it. A column 'top' float cited in
  // the first column can still take the head of the second, on the same page.
  figure('canon', CANON, { position: 'top' }, t(CAPTIONS.canon)),
  // A page-wide 'top' float cannot, so it waits for the next page (gotcha: top-float-next-page).
  // Each band is rounded up to whole leads, so the text under it stays on the grid.
  figure('pages', PAGES, { position: 'top', span: 'page' }, t(CAPTIONS.pages)),
];

Um flutuante nunca cai acima do parágrafo que o cita (posicionamento). O cânone, citado na primeira coluna, ainda pode ocupar a cabeça da segunda na página 9; a comparação na largura da página, citada na página 10, espera pela 11. Cada faixa é arredondada para entrelinhas inteiras, de modo que o texto sob um desenho começa numa linha da grade, na altura da coluna ao lado e da página em frente. Dimensione o desenho pensando nesse arredondamento: com 61,8 mm, a faixa do cânone, legenda incluída, ocupa 17 entrelinhas; com 62,5 mm ocuparia 18 e deixaria quase uma linha em branco sob a legenda.

Página 11: o texto sob a figura na largura da página começa numa linha da grade, e o ensaio termina em duas colunas niveladas, com o colofão fechando a segunda.

#4 · Deixar o balanceamento acertar as colunas

script.js · linhas 88–91no código completo
// On by default: it fills a column a keep rule leaves short, and cuts the last page level.
// Off: the lever that starts a short closing column a line low under a page-wide figure;
// this copy never trips it, other copy can (gotcha: float-stretch-closing-page).
const balancing = { stretchAfterFloats: false };

Toda coluna cheia das páginas 9 e 10 termina na linha 44, porque a mancha tem exatamente 44 entrelinhas e o texto foi ajustado a ela. Manter o título com o texto seguinte e as regras de viúvas, órfãs e linhas curtas vêm ativados por padrão. Quando uma dessas regras faz uma coluna terminar com uma linha a menos, o balanceamento põe o espaço que falta acima de um título anterior (balanceamento de colunas), onde ele aparece como um vão; ajuste o texto até o balanceamento não ter nada para preencher. Na última página, ele divide as linhas restantes para que as duas colunas terminem na mesma linha.

#5 · Espelhar as margens e numerar a partir da página 9

script.js · linhas 58–84no código completo
// Design slots skip the palette: col() writes each hex too (gotcha: palette-skips-designs).
const OUTER = 4 * UNIT; // mm: the heads end where the text block does
const RISE = 1.5 * UNIT; // mm: heads 12 mm into the head margin; the drop folio 12 mm below
const TAB = 7; // mm from a folio to the title beside it
const onPage = (edge, x) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(RISE) } });
const head = (id, content, parity, placement, extra = {}) => ({
  kind: 'text', id, content, parity, placement, pages: 'body', // never on the opener
  fontFamily: 'Vollkorn SC', fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(0.8),
  color: col('muted'), ...extra,
});
const folio = { fontFamily: 'Vollkorn', fontWeight: 700, letterSpacing: pt(0),
  color: col('rubric') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', onPage('top-left', OUTER), folio),
  head('verso-title', '{title}', 'even', onPage('top-left', OUTER + TAB)),
  head('recto-title', '{chapterTitle}', 'odd', onPage('top-right', -(OUTER + TAB))),
  head('recto-folio', '{pageNumber}', 'odd', onPage('top-right', -OUTER), folio),
] };
// The opener drops its folio into the foot margin, centred under the text block.
const underBlock = { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(RISE) } };
const footer = { elements: [
  head('drop-folio', '{pageNumber}', 'all', underBlock, { ...folio, pages: 'opener' }),
] };
// startAt prints the folios from 9; pageIndexOffset counts the 8 pages before them, so
// mirroring, parity and breakBefore follow the book (7 would add a blank page).
const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } };

A resposta curta calcula a margem inferior: os 280 mm do formato, menos os 24 mm da margem superior e uma mancha de exatamente LINES entrelinhas (44 × 13,4 pt = 208 mm), deixam 48 mm, seis unidades, como pede a progressão 2:3:4:6. Com mirror: true, os 16 mm definidos em left são a margem interna, à esquerda numa página ímpar e à direita numa par (margens espelhadas); assim os cabeços, ancorados a OUTER milímetros da borda da página, terminam onde termina a mancha, do lado do corte. pages: 'body' tira os cabeços da abertura. startAt: 9 imprime os fólios 9 a 11, e pageIndexOffset: 8 conta as oito páginas anteriores, de modo que o espelhamento, a paridade dos cabeços e breakBefore: 'odd' acompanham o livro encadernado. Com um deslocamento de 7, a primeira página contaria como par, e a quebra para página ímpar poria uma página em branco antes do ensaio.

A receita completa

Sandbox
// ═══ Postext Cookbook · Nº 013 · A book page on a baseline grid ═════════════════════
// https://postext.dev/en/cookbook/baseline-grid-book-page
// Code: MIT · Text: original (CC BY 4.0) · Pictures: drawn in code (CC BY 4.0)
// Fonts: Vollkorn, Playfair Display, Vollkorn SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// An essay on the canon of page proportions, on a page that shows its geometry and grid.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes,
  registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'pt'; // @lang: the language of the sample document ('en' | 'es' | 'pt')
const RECIPE = 'baseline-grid-book-page';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// A scribe's colours: ink, one rubric red and the pale red of a ruled page.
const palette = {
  ink: '#211d1a', // text: a warm near-black
  rubric: '#b3261e', // the one accent: title, numbers, folios, the drawings' lines
  grid: '#efc6bd', // the baseline grid, as pale as a manuscript's ruling
  rule: '#d5cbbb', // the column rule and the drawings' hairlines
  muted: '#716860', // running heads, the workshop note, the colophon (5.1:1 on paper)
  tint: '#ede4d3', // the ground of the drawings
  paper: '#fbf8f1',
};
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' } })),
  // Defaults this config does not restate link to main-color: point it at the rubric.
  { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } },
];

// #region answer: one grid for the whole spread: trim, mirrored margins, two columns, leading
const LEAD = 13.4; // pt: the body leading is the pitch of the baseline grid
const UNIT = 8; // mm: the margins step 2:3:4:6 in units of 8 mm, like the canon's
const LINES = 44; // lines per column: the text block is a whole number of leads tall
const TRIM = { width: 210, height: 280 }; // mm
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { mirror: true, // left is the inner margin; mirror swaps it on every verso
    left: mm(2 * UNIT), top: mm(3 * UNIT), right: mm(4 * UNIT), // 16, 24 and 32 mm
    bottom: mm(TRIM.height - 3 * UNIT - LINES * LEAD * MM_PER_PT) }, // 48 mm: six units
  // The layout keeps to the grid whether or not the overlay draws it.
  baselineGrid: { enabled: true, color: col('grid') },
};
const GUTTER = 6; // mm: 162 mm of text block make two columns of 78 mm
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } };
const body = { fontFamily: 'Vollkorn', fontSize: pt(9.8), lineHeight: pt(LEAD) };
// Lists add no space of their own (any whole number of leads would do). The grid never
// absorbs a list's top margin: the default 1.5 em (14.7 pt here) would set the items
// 1.3 pt off the lines of the column beside them, until the list reaches a new column.
const onGrid = { marginTop: pt(0), marginBottom: pt(0) };
// #endregion

// #region heads: the book's title on versos, the essay's on rectos, folios from page 9
// Design slots skip the palette: col() writes each hex too (gotcha: palette-skips-designs).
const OUTER = 4 * UNIT; // mm: the heads end where the text block does
const RISE = 1.5 * UNIT; // mm: heads 12 mm into the head margin; the drop folio 12 mm below
const TAB = 7; // mm from a folio to the title beside it
const onPage = (edge, x) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(RISE) } });
const head = (id, content, parity, placement, extra = {}) => ({
  kind: 'text', id, content, parity, placement, pages: 'body', // never on the opener
  fontFamily: 'Vollkorn SC', fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(0.8),
  color: col('muted'), ...extra,
});
const folio = { fontFamily: 'Vollkorn', fontWeight: 700, letterSpacing: pt(0),
  color: col('rubric') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', onPage('top-left', OUTER), folio),
  head('verso-title', '{title}', 'even', onPage('top-left', OUTER + TAB)),
  head('recto-title', '{chapterTitle}', 'odd', onPage('top-right', -(OUTER + TAB))),
  head('recto-folio', '{pageNumber}', 'odd', onPage('top-right', -OUTER), folio),
] };
// The opener drops its folio into the foot margin, centred under the text block.
const underBlock = { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(RISE) } };
const footer = { elements: [
  head('drop-folio', '{pageNumber}', 'all', underBlock, { ...folio, pages: 'opener' }),
] };
// startAt prints the folios from 9; pageIndexOffset counts the 8 pages before them, so
// mirroring, parity and breakBefore follow the book (7 would add a blank page).
const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } };
// #endregion

// #region balance: full columns end flush, and the last page ends level
// On by default: it fills a column a keep rule leaves short, and cuts the last page level.
// Off: the lever that starts a short closing column a line low under a page-wide figure;
// this copy never trips it, other copy can (gotcha: float-stretch-closing-page).
const balancing = { stretchAfterFloats: false };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es', pt: 'pt' }), // exact codes (gotcha: hyphenation-locales)
  resourceTypes: defaultResourceTypes(LANG), // localized captions (gotcha: resource-types-locale)
  colorPalette,
  page,
  layout,
  bodyText: { ...body, color: col('ink'), boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('rubric'), referenceBold: false,
    firstLineIndent: mm(4), indentAfterHeading: false }, // hyphenation and widow rules are on
  // #region flow: heads that leave the grid and come back to it; lists that never leave it
  headings: { fontFamily: 'Playfair Display', color: col('ink'), balancing,
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // Two lines of 46 pt, each four leads tall, then one lead: the text starts on line 10.
      { level: 1, breakBefore: { enabled: true, parity: 'odd' },
        fontSize: pt(46), lineHeight: pt(4 * LEAD), italic: true, color: col('rubric'),
        marginBottom: pt(LEAD) },
      // A lead and a half above puts the head between two grid lines. The margin below
      // needs no fitting: the engine snaps the text under a head to the next grid line.
      { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(1.5 * LEAD) },
    ] },
  // Lists add no space of their own (onGrid, in the short answer): items stay on the grid.
  unorderedLists: { ...onGrid, bulletChar: '–', color: col('rubric'), fontWeight: 400 },
  orderedLists: { ...onGrid, color: col('rubric') }, // numbers in the rubric, text in ink
  // #endregion
  // #region note: small type leaves the grid on purpose, and the flow snaps back after it
  paragraphStyles: [
    { id: 'note', fontSize: pt(7.8), lineHeight: pt(10), color: col('muted'),
      boldColor: col('rubric'), firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
    { id: 'colophon', fontFamily: 'Vollkorn SC', fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  // #endregion
  captionStyle: { fontSize: pt(8), labelColor: col('rubric'), descriptionItalic: true },
  header,
  footer,
});

// #region art: the two drawings, in the page's palette (lines only: no text, no filters)
const BLOCK_W = TRIM.width - 6 * UNIT; // mm: the text block, 162 mm
const COLUMN_W = (BLOCK_W - GUTTER) / 2; // mm: one column, 78 mm
const svgFile = (w, h, markup) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"`
  + ` width="${w * 10}" height="${h * 10}">`
  + `<rect width="${w}" height="${h}" fill="${palette.tint}"/>${markup}</svg>`;
const ln = (x1, y1, x2, y2, stroke, w = 0.4) => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"`
  + ` stroke="${stroke}" stroke-width="${w}" stroke-linecap="round"/>`;
const leaf = (x, y, w, h) => `<rect x="${x}" y="${y}" width="${w}" height="${h}"`
  + ` fill="${palette.paper}" stroke="${palette.rule}" stroke-width="0.3"/>`;
const ruling = (x, y, w, h, n) => Array.from({ length: n }, (_, i) => // a ruled text block
  ln(x, y + (h * (i + 0.8)) / n, x + w, y + (h * (i + 0.8)) / n, palette.rule, 0.25)).join('');
const dot = (x, y) => `<circle cx="${x}" cy="${y}" r="0.8" fill="${palette.rubric}"/>`;

// Figure 1: the canon constructed on an open spread of 2:3 pages (sizes in mm).
function canonSvg(w, h) {
  const W = (w - 8) / 2, H = 1.5 * W, X = 4, Y = 3.5; // two 2:3 pages, the ninths under them
  const at = (fx, fy) => [X + fx * W, Y + fy * H]; // a point in page widths and heights
  const line = (a, b) => ln(...at(...a), ...at(...b), palette.rubric);
  let s = leaf(X, Y, W, H) + leaf(X + W, Y, W, H);
  s += ruling(...at(2 / 9, 1 / 9), (6 * W) / 9, (6 * H) / 9, 24); // verso text block
  s += ruling(...at(10 / 9, 1 / 9), (6 * W) / 9, (6 * H) / 9, 24); // recto text block
  s += `<circle cx="${X + W / 2}" cy="${Y + (4 * H) / 9}" r="${W / 2}" fill="none"`
    + ` stroke="${palette.muted}" stroke-width="0.35"/>`; // a page wide, a block tall
  s += ln(X + W, Y, X + W, Y + H, palette.muted, 0.5); // the spine
  s += line([0, 1], [2, 0]) + line([0, 0], [2, 1]); // 1 · the spread's diagonals
  s += line([1, 0], [0, 1]) + line([1, 0], [2, 1]); // 2 · each page's diagonal
  s += line([2 / 3, 1 / 3], [2 / 3, 0]) + line([4 / 3, 1 / 3], [4 / 3, 0]); // 3 · the verticals
  s += line([2 / 3, 0], [4 / 3, 1 / 3]) + line([4 / 3, 0], [2 / 3, 1 / 3]); // 4 · across the spine
  for (let i = 0; i <= 18; i++) { // the ninths, ticked under the spread
    const [x, y] = at(i / 9, 1);
    s += ln(x, y + 1.2, x, y + (i % 9 ? 2.8 : 4), palette.muted, 0.3);
  }
  for (const c of [[2 / 9, 1 / 9], [8 / 9, 1 / 9], [2 / 9, 7 / 9], [10 / 9, 1 / 9], [16 / 9, 1 / 9],
    [16 / 9, 7 / 9]]) s += dot(...at(...c)); // 5 · the corners the lines fix
  return svgFile(w, h, s);
}

// Figure 2: this book's page twice, at a quarter of its size.
function pagesSvg(w, h) {
  const k = (h - 8) / TRIM.height, W = TRIM.width * k, H = TRIM.height * k, Y = 4, GAP = 20;
  const X1 = (w - 2 * W - GAP) / 2, X2 = X1 + W + GAP;
  let s = leaf(X1, Y, W, H) + leaf(X2, Y, W, H);
  for (let i = 1; i < 9; i++) { // the canon's ninths
    s += ln(X1 + (i * W) / 9, Y, X1 + (i * W) / 9, Y + H, palette.grid, 0.3);
    s += ln(X1, Y + (i * H) / 9, X1 + W, Y + (i * H) / 9, palette.grid, 0.3);
  }
  s += ruling(X1 + W / 9, Y + H / 9, (6 * W) / 9, (6 * H) / 9, 32);
  s += `<rect x="${X1 + W / 9}" y="${Y + H / 9}" width="${(6 * W) / 9}" height="${(6 * H) / 9}"`
    + ` fill="none" stroke="${palette.rubric}" stroke-width="0.45"/>`;
  const colW = COLUMN_W * k, top = Y + 3 * UNIT * k, tall = LINES * LEAD * MM_PER_PT * k;
  const left = X2 + 2 * UNIT * k;
  for (const x of [left, left + colW + GUTTER * k]) s += ruling(x, top, colW, tall, LINES);
  s += `<rect x="${left}" y="${top}" width="${BLOCK_W * k}" height="${tall}"`
    + ` fill="none" stroke="${palette.rubric}" stroke-width="0.45"/>`;
  s += ln(X1, Y, X1, Y + H, palette.muted, 0.6) + ln(X2, Y, X2, Y + H, palette.muted, 0.6);
  return svgFile(w, h, s);
}
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Amostra em Markdown · 63 linhas · content.pt.mdtitle: "A página pautada" subtitle: "Ensaios sobre a feitura dos livros" --- # O cânone secreto Antes que um copista medieval escrevesse uma palavra, a página já tinha sido medida. As folhas de cada caderno eram furadas nas bordas com uma sovela e pautadas de furo a furo com uma ponta-seca ou de chumbo, para que cada linha de escrita tivesse onde se apoiar e cada folha do livro a mesma moldura. Em muitos manuscritos a pautação ainda se vê, tênue como uma marca-d’água. Ela guarda duas decisões que os copistas nunca explicaram: o tamanho da área escrita e o lugar dela na folha. O desenho da :ref{id="canon" style="full" case="lower"} dá a resposta que três estudiosos, cada um por conta própria, mediram no século XX nos próprios livros antigos. Em 1946, J. A. van de Graaf publicou numa revista holandesa uma construção que encontra a mancha de uma página só com uma régua, sem medir nada. Um ano depois, o tipógrafo argentino Raúl Rosarivo sustentou em *Divina proporción tipográfica* que Gutenberg, Peter Schöffer e Nicolas Jenson tinham dividido suas páginas em nonos. E Jan Tschichold, que na juventude defendera a composição assimétrica, passou os últimos anos mostrando por que as proporções antigas eram tão difíceis de melhorar. Os resultados dos três quase coincidem. O método ficou conhecido como cânone secreto porque ninguém o pôs por escrito, embora possa ser medido em manuscrito após manuscrito e nos primeiros livros impressos. ## Como se traça o cânone Para a construção bastam uma página dupla aberta e uma régua sem marcações: 1. Trace as duas diagonais da página dupla. 2. Em cada página, trace a diagonal que vai do alto da lombada ao pé do corte dianteiro. 3. Onde ela cruza uma diagonal da página dupla, levante uma vertical até a cabeça da página. 4. Ligue a ponta dela ao cruzamento da página ao lado. 5. A nova linha corta a diagonal da página no canto superior interno da mancha. O canto superior externo fica sobre a diagonal da página dupla, o inferior externo sobre a da página, e o retângulo se fecha sozinho. Esse retângulo é a mancha dos copistas. A margem interna mede um nono da largura da página, e a externa, dois; a superior, um nono da altura, e a inferior, dois. Numa página de dois por três, as quatro margens seguem a proporção 2:3:4:6: interna, superior, externa e inferior. A mancha tem as proporções da página e é tão alta quanto a página é larga, como mostra o círculo. ## A figura de Villard Na Idade Média também se sabia dividir uma linha sem precisar medi-la. O álbum de Villard de Honnecourt, trinta e três folhas de desenhos do século XIII que hoje pertencem à Biblioteca Nacional da França, traz uma figura que divide uma linha em partes iguais só com uma régua: algumas diagonais sobre um retângulo, e cada cruzamento marca um terço, um quarto ou um quinto do lado, até onde o desenhista quiser chegar. Tschichold mostrou que a mesma figura encontra os nonos de uma página. As folhas do álbum, tal como se conservam, medem cerca de 235 por 155 milímetros, perto de dois por três. Rosarivo chegou aos mesmos nonos pelo caminho inverso. Com compasso e régua, estudou livros impressos no século XV e concluiu que suas manchas saíam da divisão da página em nove partes iguais em cada sentido, seis delas reservadas ao texto, e chamou de “número secreto” a proporção 2:3 daquelas páginas. Especialistas do Museu Gutenberg, em Mainz, examinaram com cuidado as conclusões dele, que o *Gutenberg-Jahrbuch*, o anuário dedicado à história da impressão, mais tarde voltou a publicar. :::paragraphs{style="note"} **Nota de oficina.** Numa página de 160 por 240 milímetros, o cânone dá margens de 17,8 milímetros na lombada, 26,7 na cabeça, 35,6 no corte e 53,3 no pé, e uma mancha de 106,7 por 160 milímetros, tão alta quanto a página é larga. Divida a largura e a altura em nove partes e conte: uma dentro, uma em cima, duas fora e duas embaixo. ::: ## Linhas onde se apoiar A pautação fixava as linhas de escrita, além da moldura da página. Até o início do século XIII, quase todos os copistas escreviam a primeira linha em cima da primeira pauta; numa geração ela passou a pender abaixo da pauta, uma mudança tão regular que os paleógrafos a usam para datar manuscritos. N. R. Ker, que a descreveu em 1960, situou-a por volta de 1230. A pautação traçava também as colunas: pares de linhas verticais paralelas limitavam cada coluna de um livro em duas colunas, e o fio entre as colunas desta página é o último vestígio desse traçado. Os impressores mantiveram a mesma disciplina dos copistas. A Bíblia de Gutenberg é composta em duas colunas de quarenta linhas nas primeiras páginas, quarenta e uma na página dez e quarenta e duas em quase todas as seguintes; por isso os bibliógrafos costumam chamá-la de Bíblia de 42 linhas. As linhas foram aproximadas umas das outras dentro da mesma mancha, de modo que a moldura comportasse mais texto e a Bíblia inteira gastasse menos papel. ## O que fica do cânone O cânone foi pensado para livros grandes, feitos para durar, com espaço nas margens para as anotações do leitor. Poucos livros podem se dar a esse luxo hoje. Nesta página de 210 por 280 milímetros, ele pediria uma mancha de 140 por 187, e mais da metade do papel ficaria em branco. A página que você está lendo conserva três coisas do cânone: - a progressão das margens, ainda 2:3:4:6, agora em passos de oito milímetros: 16 na lombada, 24 na cabeça, 32 no corte e 48 no pé; - a página dupla como unidade de projeto: as duas margens internas somadas têm a largura de uma externa, de modo que o branco em volta de cada mancha se distribui por igual no livro aberto; - a própria pautação, que virou uma grade de linhas de base de 13,4 pontos onde se apoia o texto das duas colunas, nesta página e na da frente. Abre mão dos nonos. Sua mancha, de 162 por 208 milímetros, é bem maior que a do cânone e se divide em duas colunas de 78 milímetros, com uma medianiz de seis. Na largura da mancha inteira, uma linha neste corpo passaria de cem caracteres, comprida demais para que o olho encontre o início da seguinte; uma coluna leva uns cinquenta, e exatamente 44 linhas. A :ref{id="pages" style="full" case="lower"} mostra as duas páginas lado a lado. Uma página que conta suas linhas também pode nivelar o pé das colunas: toda coluna cheia deste ensaio termina na quadragésima quarta linha. Quando as regras que mantêm um título junto do seu texto, ou que impedem um parágrafo de deixar uma linha sozinha, fariam uma coluna terminar com uma linha a menos, a linha que falta vira um pouco mais de espaço acima de um título anterior, e a coluna chega mesmo assim à última linha. Na última página de um ensaio, ao contrário, as colunas são cortadas na mesma altura, e o texto termina numa faixa nivelada. Uma grade de linhas de base fixa o lugar de cada linha antes que se componha uma palavra, como faziam a sovela e a ponta-seca no pergaminho. As listas deste ensaio não acrescentam espaço próprio, e seus itens caem nas linhas dos parágrafos da coluna ao lado. Um título pode ficar entre duas linhas da grade, com uma linha e meia acima e meia linha abaixo, mas o texto seguinte volta logo à grade. A nota de oficina, em corpo menor, tem entrelinha própria e volta à grade onde termina. Com as figuras é igual: a faixa de cada uma, legenda incluída, é arredondada para linhas inteiras, e o texto sob um desenho no alto da página se alinha com o da página em frente. Nestas páginas a grade é desenhada em vermelho pálido para que o leitor possa conferir o texto com ela. Abra o livro em qualquer página dupla e cada linha da página esquerda continua, através da lombada, numa linha da direita, como a pautação dos copistas corria de furo a furo pelas duas metades da folha aberta. :::paragraphs{style="colophon"} Composto em Vollkorn de 9,8 sobre 13,4 pontos, com Playfair Display e Vollkorn SC (SIL Open Font License 1.1) Texto e desenhos: Postext Cookbook, CC BY 4.0 :::
`; // content.<lang>.md, inlined by the Cookbook // Captions and alt text in every sample language: [caption, altText]. const CAPTIONS = { canon: { en: ['The canon on an open spread of 2:3 pages. The diagonals fix the corners of both text ' + 'blocks; the circle, as wide as a page, is exactly as tall as its block.', 'Two facing pages crossed by red construction lines, a ruled text block where they cross ' + 'on each page, and a circle as wide as the left page.'], es: ['El canon sobre un pliego abierto de páginas 2:3. Las diagonales fijan las esquinas de ' + 'las dos cajas; el círculo, tan ancho como la página, es tan alto como su caja.', 'Dos páginas enfrentadas cruzadas por líneas rojas de construcción, una caja pautada donde ' + 'se cortan en cada página y un círculo tan ancho como la página izquierda.'], pt: ['O cânone sobre uma página dupla aberta de páginas 2:3. As diagonais fixam os cantos ' + 'das duas manchas; o círculo, da largura da página, tem exatamente a altura da mancha.', 'Duas páginas lado a lado cruzadas por linhas vermelhas de construção, uma mancha pautada ' + 'onde elas se cruzam em cada página e um círculo da largura da página esquerda.'], }, pages: { en: ['This book’s 210 × 280 mm page twice. Left, divided by the canon into ninths, with a ' + 'text block of 140 × 187 mm. Right, as the book sets it: margins of 16, 24, 32 and 48 ' + 'mm and two columns of 44 lines.', 'Two identical pages: the left one divided into a nine-by-nine grid around its text block, ' + 'the right one holding two ruled columns.'], es: ['La página de 210 × 280 mm de este libro, dos veces. A la izquierda, dividida en ' + 'novenos por el canon, con una caja de 140 × 187 mm. A la derecha, tal como la compone ' + 'el libro: márgenes de 16, 24, 32 y 48 mm y dos columnas de 44 líneas.', 'Dos páginas iguales: la izquierda, dividida en una cuadrícula de nueve por nueve alrededor ' + 'de su caja; la derecha, con dos columnas pautadas.'], pt: ['A página de 210 × 280 mm deste livro, duas vezes. À esquerda, dividida em nonos pelo ' + 'cânone, com uma mancha de 140 × 187 mm. À direita, como o livro a compõe: margens de ' + '16, 24, 32 e 48 mm e duas colunas de 44 linhas.', 'Duas páginas iguais: a da esquerda dividida numa grade de nove por nove em volta da ' + 'mancha, a da direita com duas colunas pautadas.'], }, }; // #region figures: one figure heads the next column, the other the next page // mm: a column wide and just short enough for a 17-lead band with its caption; a block wide const CANON = [COLUMN_W, 61.8], PAGES = [BLOCK_W, 64]; const figure = (id, [width, height], placement, [caption, altText]) => ({ id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, placement, caption, altText, createdAt: 0, updatedAt: 0, }); const resources = [ // A float never lands above the paragraph that cites it. A column 'top' float cited in // the first column can still take the head of the second, on the same page. figure('canon', CANON, { position: 'top' }, t(CAPTIONS.canon)), // A page-wide 'top' float cannot, so it waits for the next page (gotcha: top-float-next-page). // Each band is rounded up to whole leads, so the text under it stays on the grid. figure('pages', PAGES, { position: 'top', span: 'page' }, t(CAPTIONS.pages)), ]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages paint, loaded before the first build (gotcha: fonts-first). const FONTS = { // text, display and label faces (Vollkorn SC ships no italic) Vollkorn: ['400', '400i', '700'], 'Playfair Display': ['700', '700i'], 'Vollkorn SC': ['400', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('canon.svg', canonSvg(...CANON)); await loadSvg('pages.svg', pagesSvg(...PAGES)); const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'A book page on a baseline grid', es: 'Una página de libro sobre una rejilla base', pt: 'Uma página de livro sobre a grade de linhas de base' }) });
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

#Esconder a grade para a gráfica

enabled só controla as linhas desenhadas sobre as páginas prontas; a diagramação segue a grade de 13,4 pt de qualquer jeito, então nenhuma linha se move.

-  baselineGrid: { enabled: true, color: col('grid') },
+  baselineGrid: { enabled: false },

#Ver o que o balanceamento faz

Sem balanceamento, a página 11 coloca as últimas catorze linhas e o colofão na primeira coluna e deixa a segunda vazia.

-const balancing = { stretchAfterFloats: false };
+const balancing = { enabled: false };

Erros comuns

Erro comum

Um flutuante 'top' nunca cai na página que o cita

Um flutuante nunca fica acima da própria referência, então um flutuante 'top' na largura da página citado na página N abre a página N+1. Cite-o antes, ou use a posição 'auto' ou 'bottom', que podem ocupar o pé da página que o cita. Posicionamento de figuras →

Erro comum

Um flutuante superior pode baixar a última coluna numa página de fechamento

No postext 1.4.1, quando um capítulo ou uma matéria termina numa página que abre com um flutuante superior na largura toda e as linhas se dividem de forma desigual entre as colunas, stretchAfterFloats acrescenta uma linha em branco sob o flutuante na coluna mais curta, em vez de deixá-la terminar antes, e as duas colunas deixam de começar na mesma linha. Defina headings.balancing.stretchAfterFloats como false ou ajuste o texto a um número par de linhas. Equilíbrio de colunas →

Erro comum

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

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

Erro comum

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

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

Erro comum

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

Traduza Figura e Tabela com defaultResourceTypes(locale)

O locale da configuração define a hifenização, não as legendas: sem resourceTypes, os tipos embutidos dizem Figure e Table, em inglês. Passe resourceTypes: defaultResourceTypes('es') para o espanhol; para qualquer outro idioma, escreva você mesmo os nomes em resourceTypes. Figura e Tabela no seu idioma →

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 →

Erro comum

Carregue todas as fontes antes do layout

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

  • Um nível de título com span: 'page' e sem advancedDesign deveria atravessar as duas colunas, mas o postext 1.4.1 o mede com a largura de uma só: “O cânone secreto” sai numa linha na largura da página, mas reserva a altura de duas, com o fio de coluna desenhado através da faixa. Aqui o título fica na primeira coluna.
  • A regra de que um flutuante top nunca cai na página que o cita vale para os flutuantes na largura da página. Um flutuante top de coluna pode cair na página que o cita, na cabeça de uma coluna posterior, como a Figura 1.1 na página 9.
  • O tamanho da página, as margens e a medianiz usam unidades absolutas (mm, pt, cm, in). Ali um em não tem corpo de letra com que se medir, e a composição para com “dimensionToPx: baseFontSizePx is required for unit "em"”.
  • O espaço entre palavras da Vollkorn é estreito, 0,2 em, então numa coluna de uns 50 caracteres algumas linhas justificadas ficam frouxas ou apertadas. O texto de exemplo foi ajustado linha a linha com o relatório de linhas frouxas da captura; confira de novo depois de qualquer mudança no texto ou na medida.

Créditos

Texto
  • O ensaio “The Secret Canon” e suas versões em espanhol e português, “El canon secreto” e “O cânone secreto”, escritos para esta receta · Postext Cookbook · CC BY 4.0
Imagens
  • Os dois desenhos, feitos em código com a paleta da página · Postext Cookbook · CC BY 4.0
Fontes
Vollkorn (SIL OFL 1.1) · Playfair Display (SIL OFL 1.1) · Vollkorn SC (SIL OFL 1.1)
Sandbox