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

Receitas · Capítulo 8 · Tabelas

Cardápio de bistrô: preços alinhados sem tabulação

Um cardápio de bistrô impresso dos dois lados, com os preços em tabelas sem fios e um fio cor de latão ancorado de cada lado do título de cada seção.

Nesta página
  • Amostra em inglês: ainda sem edição em português
  • Refile 230 × 310 mm
  • 1 coluna
  • Noticia Text 10,5/14,5
  • Josefin Sans
  • Limelight
  • 2 páginas
  • Nível
  • Postext 1.19.1
  • Diagramado em 7 ms
  • 153 linhas de código

Em poucas palavras

Um cardápio de duas faces para um restaurante inventado de Paris. Mostra como alinhar os pratos e os preços em linhas certinhas usando tabelas sem linhas visíveis.

O que você vai compor

O cardápio de outono do Les Tanneurs, um bistrô parisiense imaginário: uma só folha de 230 × 310 mm impressa dos dois lados. Na frente, um toldo listrado pende sobre o nome, composto em Limelight. Cada título de seção fica entre dois fios de latão que vão até as margens, com quatro pratos embaixo. Um prato é o nome francês em negrito com a guarnição, uma linha em inglês em itálico logo abaixo e o preço em negrito, alinhado à direita. Um disco verde-escuro com V marca os pratos vegetarianos, e uma pílula contornada com SG os sem glúten. No verso, a carta de vinhos fica sob uma prateleira de garrafas, com os preços da taça e da garrafa em colunas encabeçadas por 12 cl e 75 cl. O Postext não tem tabulação, então cada coluna de preços pertence a uma tabela sem fios e sem legenda.

Esta receita responde a

  • Como faço uma tabela com linhas de cabeçalho, células mescladas, larguras de coluna e alinhamento por célula?
  • Como faço chips em linha: teclas, etiquetas, bancos de palavras para exercícios?
  • Como componho ilustrações sem numeração: ornamentos, vinhetas, logotipos?
  • Como adiciono imagens e tabelas a partir do código (recursos) em vez de usar ![]() do Markdown?

A resposta curta

script.js · linhas 26–43no código completo
// Postext has no tab stops, so each course is a table. The document's tableStyle turns off
// every rule and fill, so nothing on the page shows that a table is there. The wine list's head
// row and merged region rows are in #region wines.
const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD
  bodyFontFamily: TEXT, bodyFontSize: pt(BODY), bodyColor: col('ink'),
  headerFontFamily: LABEL, headerFontSize: pt(8.5), headerColor: col('wine'),
  headerBackgroundEnabled: false }; // header cells: the wine list's labels
// The kitchen keeps the menu as TSV: course · dish · the dish in the reader's language · price.
function course(id, tsv) {
  const rows = parseTSV(tsv).rows.filter(([c]) => c.content === id)
    .map(([, dish, translation, price]) => [
      { content: `${dish.content}\n*${translation.content}*` }, // one cell, two lines
      // Both cells start at the top of the row, so the price shares the dish's first baseline.
      { content: `**${price.content}**`, align: 'right' },
    ]);
  // columnWidths are weights: the price column takes 1/7 of the 158 mm measure, 22.6 mm.
  return piece(id, 'table', { table: { model: { rows, columnWidths: [6, 1] } } });
}

Ingredientes

Tipografia
Limelight, Noticia Text, Josefin Sans (SIL OFL 1.1)
Materiais
  • The awning and the shelf of bottles, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)

Preparo

#1 · Uma tabela onde ficariam as tabulações

O código está na resposta curta, mais acima. Cada seção é uma tabela de duas colunas. O prato, uma quebra de linha e a tradução em itálico dividem a primeira célula, e o preço fica alinhado à direita na segunda, na primeira linha de base do prato, porque as duas células começam no alto da linha da tabela. columnWidths recebe pesos, então [6, 1] dá aos preços 22,6 mm dos 158 mm da medida. rules: 'none' e headerBackgroundEnabled: false tiram a grade e a linha de cabeçalho cinza que o estilo de tabela padrão desenha. cellPadding: pt(LEAD / 4) faz cada prato ocupar duas linhas e meia da entrelinha de 14,5 pt, e uma seção de quatro pratos termina na décima linha da grade. Com o preenchimento padrão, 0,375 em, cada seção termina 0,9 mm abaixo dessa linha, cada título seguinte desce uma linha e o cardápio passa para quatro páginas.

#2 · Um só tipo de recurso, no lugar e sem número

script.js · linhas 47–54no código completo
// No caption prefix and no caption, so no 'Table 1' line prints under a course. Placement
// 'here' sets each piece at its ::resource line; such a table never splits, so a course that
// outgrows the page moves to the next one whole (gotcha: here-table-no-split).
const resourceTypes = [{ id: 'menu', name: 'Menu', shortLabel: 'Menu', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here' } }];
const piece = (id, kind, body) => ({ id, typeId: 'menu', kind, createdAt: 0, updatedAt: 0,
  ...body });

Os tipos de figura e de tabela que vêm no motor imprimem uma linha de legenda e fazem cada recurso flutuar até o primeiro espaço livre depois da primeira referência. O tipo menu não tem prefixo de legenda, e as peças dele não têm legenda, então nada se imprime sob uma seção. Dê a ele captionPrefix: 'Menu' e uma linha numerada aparece sob cada peça, começando por “Menu 1.” sob o toldo, e o cardápio passa para quatro páginas. defaultPlacement põe cada tabela e cada desenho na sua linha ::resource (inserção em bloco). Sem ele, os três títulos de seção ficam um atrás do outro no alto da frente, e as tabelas flutuam abaixo deles, as sobremesas primeiro. O toldo e as garrafas são recursos SVG do mesmo tipo, desenhados em código e registrados com o loadSvg do kit.

#3 · Mesclar as linhas de região

script.js · linhas 58–71no código completo
function wineList(tsv) {
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [5, 1, 1] };
  m.rows = m.rows.map((row, r) => row.map((cell, c) => (c === 0 ? cell : { align: 'right',
    content: r > 0 && cell.content ? `**${cell.content}**` : cell.content }))); // as the dishes'
  m.rows.forEach(([first, ...rest], r) => { // a line with one field names a region
    if (r === 0 || !first.content || rest.some((cell) => cell.content)) return;
    // One cell across the table, centred on the card like the course heads; a label centred
    // in the first column alone would sit 22.6 mm left of them. The label face is the header's.
    m.rows[r][0] = { ...first, isHeader: true, align: 'center' };
    // mergeCells marks the two cells it covers hiddenBy (gotcha: merged-cells-hiddenby).
    m = mergeCells(m, { start: { row: r, col: 0 }, end: { row: r, col: 2 } });
  });
  return piece('wines', 'table', { table: { model: m } });
}

Na 1.4.1, parseTSV não marca nenhuma linha como cabeçalho, por isso wineList define headerRowCount: 1 à mão. Appellation · cépage, 12 cl e 75 cl saem então sobre as colunas na fonte do cabeçalho, Josefin Sans. Uma linha com um só campo nomeia uma região. A célula dela vira uma célula de cabeçalho centralizada, e mergeCells a estende pelas três colunas e marca com hiddenBy as duas células que ela cobre (montar modelos de tabela). Centralizado só na primeira coluna, BLANCS ficaria 22,6 mm à esquerda do eixo em que La Cave e Le Comptoir estão centralizados. Uma linha em branco no TSV vira uma linha de células vazias, com uma linha e meia de altura, e é ela o respiro acima de Blancs e Rouges.

#4 · Pendurar um fio de cada lado do título de seção

script.js · linhas 75–89no código completo
// The title has no width, so it shrink-wraps its text, and 'top' centres it on the column. Each
// rule hangs off one edge of the title ('left-of', 'right-of'), 4 mm away, and 'fill' runs it
// to the column's edge: 66.7 mm beside PLATS, 56.2 mm beside the longer LE COMPTOIR.
const rule = (edge, x) => ({ kind: 'rule', id: `rule-${edge}`, color: col('brass'),
  thickness: pt(0.75), // required in 1.4.1: a rule without it paints nothing
  placement: { anchor: { to: '#title', edge },
    size: { width: 'fill' }, // to the column's edge
    offset: { x: mm(x), y: pt(6) } } }); // 6 pt down: the middle of Limelight's capitals
const courseHead = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(15),
    lineHeight: 0.96, // a multiple (gotcha: design-lineheight-multiple): 14.4 pt, one line
    textTransform: 'uppercase', color: col('wine'),
    placement: { anchor: { to: 'container', edge: 'top' } } },
  rule('left-of', -4), rule('right-of', 4),
] } };

Um título de seção é um design dentro da coluna. O elemento {titleText} não tem largura, então a caixa dele é tão larga quanto as maiúsculas, e a âncora 'top' o centraliza na coluna. Cada fio pende de uma borda do título com 'left-of' ou 'right-of' e se estende até a borda da coluna (posicionamento de elementos), de modo que os fios ao lado de PLATS têm 66,7 mm e os ao lado de LE COMPTOIR 56,2 mm, cada um a 4 mm das letras. O lineHeight do título é um múltiplo, 0,96, que deixa a caixa com 14,4 pt, dentro de uma linha de 14,5 pt da grade. Com 1, a caixa tem 15 pt, cada título ocupa duas linhas e o cardápio passa para quatro páginas.

#5 · Selos como chips, letra miúda como estilos de parágrafo

script.js · linhas 93–111no código completo
// A chip is a box around inline text: the V disc is filled, the SG pill only outlined. A chip
// takes the weight of the text around it, so without bold the V is set in 400, a weight not loaded.
const badge = { fontFamily: LABEL, fontSize: pt(7.5), bold: true, // Josefin Sans 700
  borderRadius: pt(8), paddingY: em(0.1), gap: em(0.6) }; // radius clamped to a half-height
const chipStyles = [
  // V: paddingX makes the box as wide as it is tall, a disc. borderWidth 0 removes the default
  // outline, 0.5 pt of main-color, which would ring the green in wine red.
  { id: 'veg', name: 'Vegetarian', ...badge, paddingX: em(0.3), borderWidth: pt(0),
    background: col('bottle'), color: col('paper') },
  { id: 'gf', name: 'Gluten-free', ...badge, paddingX: em(0.45), backgroundEnabled: false,
    borderColor: col('bottle'), borderWidth: pt(0.6), color: col('bottle') },
];
// The notes under the desserts and the colophon on the back: smaller, on the card's axis.
// marginTop gives the line of air that a table set 'here' does not leave below itself.
const paragraphStyles = [
  { id: 'notes', name: 'Notes', fontSize: pt(9), marginTop: pt(LEAD) },
  { id: 'colophon', name: 'Colophon', fontSize: pt(7.5), color: col('muted'),
    marginTop: pt(LEAD) },
];

Um chip desenha uma caixa em volta de um trecho de texto dentro da linha (estilos de chip), então :chip[V]{style="veg"} digitado depois da guarnição põe o disco na linha francesa do prato, tanto numa célula de tabela como num parágrafo. Os estilos de chip definem bold: true porque um chip assume o peso do texto em volta, 400 depois de uma guarnição, e FONTS carrega a Josefin Sans só em 700. As notas sob as sobremesas e o colofão do verso são blocos :::paragraphs em 9 e 7,5 pt, e os dois estilos têm um marginTop de uma linha (estilos de parágrafo), porque uma tabela posta com 'here' não deixa espaço embaixo de si. Sem essa margem, as notas começam na linha seguinte à linha em inglês da tábua de queijos, como se fizessem parte do prato.

#6 · Centralizar o cardápio num só eixo

script.js · linhas 115–126no código completo
// bodyText (in config) centres the lines under the name and the notes; only the tables keep a
// left edge. A centred line is never hyphenated (gotcha: ragged-no-hyphenation), so the config
// sets no locale: French patterns would change nothing on this card.
const headings = { fontFamily: DISPLAY, fontWeight: 400, color: col('wine'), textAlign: 'center',
  levels: [ // Limelight ships one weight, 400, and no italic
    // Any headings object drops the H1 break (gotcha: headings-drop-h1-break). Stated off, since
    // a break before an H1 would part the name from the awning and La Cave from its bottles.
    { level: 1, fontSize: pt(48), lineHeight: pt(3 * LEAD), breakBefore: { enabled: false },
      marginTop: pt(LEAD), marginBottom: pt(0) }, // three grid lines, one of air above
    { level: 2, lineHeight: pt(LEAD), advancedDesign: courseHead, // one line; #region heads
      marginTop: pt(LEAD), marginBottom: pt(0) }, // the table below adds a line of its own
  ] };

headings.textAlign e bodyText.textAlign põem na linha central do cardápio tudo o que não é tabela. lineHeight: pt(3 * LEAD) dá ao nome de 48 pt três linhas da grade. Sem isso, o nome usa a entrelinha padrão e ocupa quatro linhas, a segunda linha de notas vai para uma página só dela e o cardápio passa para quatro páginas. Na 1.4.1, qualquer objeto headings desliga a quebra do H1, e mesmo assim o nível 1 declara breakBefore: { enabled: false }, porque o nome e La Cave pertencem à página do desenho acima deles. Volte ao padrão, { enabled: true, parity: 'always-odd' }, e cada desenho fica sozinho numa página, sete páginas ao todo. A configuração não define locale, porque a 1.4.1 só hifeniza texto corrido justificado, e nada neste cardápio é justificado.

A receita completa

Sandbox
// ═══ Postext Cookbook · Nº 052 · Bistro menu: prices aligned without tab stops ═══════
// https://postext.dev/en/cookbook/bistro-menu
// Code: MIT · Text: original, in French (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Limelight, Noticia Text, Josefin Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// The autumn menu of an imaginary Paris bistro: two sides of one card, every price column a
// table with its rules switched off.
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  parseTSV, mergeCells } from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#1f2a24', paper: '#f6efdf', // green-black text on cream card
  wine: '#6d1f2c', brass: '#a9823a', // the name, heads and labels; rules and the drawings' metal
  straw: '#e8d4a8', bottle: '#2f4235', sage: '#8a9a78', // awning stripes and labels; glass
  muted: '#6b6457' }; // the colophon
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color' (#295aa3, a blue); this palette makes it the wine red.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.wine })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Noticia Text', 'Limelight', 'Josefin Sans'];
const [BODY, LEAD] = [10.5, 14.5]; // pt: the text, and the leading every table row keeps

// #region answer: dish on the left, price flush right: a two-column table with no rules
// Postext has no tab stops, so each course is a table. The document's tableStyle turns off
// every rule and fill, so nothing on the page shows that a table is there. The wine list's head
// row and merged region rows are in #region wines.
const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD
  bodyFontFamily: TEXT, bodyFontSize: pt(BODY), bodyColor: col('ink'),
  headerFontFamily: LABEL, headerFontSize: pt(8.5), headerColor: col('wine'),
  headerBackgroundEnabled: false }; // header cells: the wine list's labels
// The kitchen keeps the menu as TSV: course · dish · the dish in the reader's language · price.
function course(id, tsv) {
  const rows = parseTSV(tsv).rows.filter(([c]) => c.content === id)
    .map(([, dish, translation, price]) => [
      { content: `${dish.content}\n*${translation.content}*` }, // one cell, two lines
      // Both cells start at the top of the row, so the price shares the dish's first baseline.
      { content: `**${price.content}**`, align: 'right' },
    ]);
  // columnWidths are weights: the price column takes 1/7 of the 158 mm measure, 22.6 mm.
  return piece(id, 'table', { table: { model: { rows, columnWidths: [6, 1] } } });
}
// #endregion

// #region type: one resource type for the whole card: set where it stands, never numbered
// No caption prefix and no caption, so no 'Table 1' line prints under a course. Placement
// 'here' sets each piece at its ::resource line; such a table never splits, so a course that
// outgrows the page moves to the next one whole (gotcha: here-table-no-split).
const resourceTypes = [{ id: 'menu', name: 'Menu', shortLabel: 'Menu', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here' } }];
const piece = (id, kind, body) => ({ id, typeId: 'menu', kind, createdAt: 0, updatedAt: 0,
  ...body });
// #endregion

// #region wines: region rows merged across the three columns, prices under their labels
function wineList(tsv) {
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [5, 1, 1] };
  m.rows = m.rows.map((row, r) => row.map((cell, c) => (c === 0 ? cell : { align: 'right',
    content: r > 0 && cell.content ? `**${cell.content}**` : cell.content }))); // as the dishes'
  m.rows.forEach(([first, ...rest], r) => { // a line with one field names a region
    if (r === 0 || !first.content || rest.some((cell) => cell.content)) return;
    // One cell across the table, centred on the card like the course heads; a label centred
    // in the first column alone would sit 22.6 mm left of them. The label face is the header's.
    m.rows[r][0] = { ...first, isHeader: true, align: 'center' };
    // mergeCells marks the two cells it covers hiddenBy (gotcha: merged-cells-hiddenby).
    m = mergeCells(m, { start: { row: r, col: 0 }, end: { row: r, col: 2 } });
  });
  return piece('wines', 'table', { table: { model: m } });
}
// #endregion

// #region heads: a course head: its title centred, a brass rule anchored to each side of it
// The title has no width, so it shrink-wraps its text, and 'top' centres it on the column. Each
// rule hangs off one edge of the title ('left-of', 'right-of'), 4 mm away, and 'fill' runs it
// to the column's edge: 66.7 mm beside PLATS, 56.2 mm beside the longer LE COMPTOIR.
const rule = (edge, x) => ({ kind: 'rule', id: `rule-${edge}`, color: col('brass'),
  thickness: pt(0.75), // required in 1.4.1: a rule without it paints nothing
  placement: { anchor: { to: '#title', edge },
    size: { width: 'fill' }, // to the column's edge
    offset: { x: mm(x), y: pt(6) } } }); // 6 pt down: the middle of Limelight's capitals
const courseHead = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(15),
    lineHeight: 0.96, // a multiple (gotcha: design-lineheight-multiple): 14.4 pt, one line
    textTransform: 'uppercase', color: col('wine'),
    placement: { anchor: { to: 'container', edge: 'top' } } },
  rule('left-of', -4), rule('right-of', 4),
] } };
// #endregion

// #region badges: dietary badges as chips, explained in small type under the desserts
// A chip is a box around inline text: the V disc is filled, the SG pill only outlined. A chip
// takes the weight of the text around it, so without bold the V is set in 400, a weight not loaded.
const badge = { fontFamily: LABEL, fontSize: pt(7.5), bold: true, // Josefin Sans 700
  borderRadius: pt(8), paddingY: em(0.1), gap: em(0.6) }; // radius clamped to a half-height
const chipStyles = [
  // V: paddingX makes the box as wide as it is tall, a disc. borderWidth 0 removes the default
  // outline, 0.5 pt of main-color, which would ring the green in wine red.
  { id: 'veg', name: 'Vegetarian', ...badge, paddingX: em(0.3), borderWidth: pt(0),
    background: col('bottle'), color: col('paper') },
  { id: 'gf', name: 'Gluten-free', ...badge, paddingX: em(0.45), backgroundEnabled: false,
    borderColor: col('bottle'), borderWidth: pt(0.6), color: col('bottle') },
];
// The notes under the desserts and the colophon on the back: smaller, on the card's axis.
// marginTop gives the line of air that a table set 'here' does not leave below itself.
const paragraphStyles = [
  { id: 'notes', name: 'Notes', fontSize: pt(9), marginTop: pt(LEAD) },
  { id: 'colophon', name: 'Colophon', fontSize: pt(7.5), color: col('muted'),
    marginTop: pt(LEAD) },
];
// #endregion

// #region centred: one axis for the card: the name, the course heads and the notes centred
// bodyText (in config) centres the lines under the name and the notes; only the tables keep a
// left edge. A centred line is never hyphenated (gotcha: ragged-no-hyphenation), so the config
// sets no locale: French patterns would change nothing on this card.
const headings = { fontFamily: DISPLAY, fontWeight: 400, color: col('wine'), textAlign: 'center',
  levels: [ // Limelight ships one weight, 400, and no italic
    // Any headings object drops the H1 break (gotcha: headings-drop-h1-break). Stated off, since
    // a break before an H1 would part the name from the awning and La Cave from its bottles.
    { level: 1, fontSize: pt(48), lineHeight: pt(3 * LEAD), breakBefore: { enabled: false },
      marginTop: pt(LEAD), marginBottom: pt(0) }, // three grid lines, one of air above
    { level: 2, lineHeight: pt(LEAD), advancedDesign: courseHead, // one line; #region heads
      marginTop: pt(LEAD), marginBottom: pt(0) }, // the table below adds a line of its own
  ] };
// #endregion

// #region art: a striped awning over the name, a shelf of bottles over the wine list
function awning() { // 158 × 20 mm; the canopy narrows 6 % toward the wall
  const [W, N, ROD, DROP, HEM, INSET] = [1660, 19, 12, 104, 46, 50];
  const s = W / N, top = (i) => INSET + i * (W - 2 * INSET) / N, bottom = (i) => i * s;
  const shade = { [palette.wine]: mix(palette.wine, palette.ink, 0.25), // the valance: each
    [palette.straw]: mix(palette.straw, palette.brass, 0.4) }; // stripe a shade darker
  let shapes = '';
  for (let i = 0; i < N; i++) {
    const fill = i % 2 ? palette.straw : palette.wine, y = ROD + DROP;
    shapes += `<path d="M${top(i)} ${ROD}H${top(i + 1)}L${bottom(i + 1)} ${y}H${bottom(i)}Z" `
      + `fill="${fill}"/><path d="M${bottom(i)} ${y}h${s}v${HEM}a${s / 2} ${s / 2} 0 0 1 `
      + `${-s} 0Z" fill="${shade[fill]}"/>`;
  }
  shapes += `<rect x="${INSET - 16}" y="0" width="${W - 2 * INSET + 32}" height="${ROD}" rx="6" `
    + `fill="${palette.brass}"/><rect x="0" y="${ROD + DROP - 3}" width="${W}" height="6" `
    + `fill="${palette.brass}"/>`; // the rod on the wall, a brass bead along the front edge
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} 212" width="${W}" `
    + `height="212">${shapes}</svg>`;
}
function mix(a, b, t) { // a blend of two palette colours, t of the way from a to b
  const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
  const [x, y] = [rgb(a), rgb(b)];
  return `#${x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, '0'))
    .join('')}`;
}
function bottles() { // 158 × 29 mm: glasses, bottles and a carafe on a brass shelf
  const [W, H, BASE] = [1660, 300, 286];
  const glass = (x) => // a tulip glass, a third full
    `<path d="M${x - 34} ${BASE - 170}C${x - 36} ${BASE - 110} ${x - 20} ${BASE - 88} ${x} `
      + `${BASE - 86}C${x + 20} ${BASE - 88} ${x + 36} ${BASE - 110} ${x + 34} ${BASE - 170}Z" `
      + `fill="none" stroke="${palette.bottle}" stroke-width="4"/>`
      + `<path d="M${x - 33} ${BASE - 132}C${x - 30} ${BASE - 104} ${x - 16} ${BASE - 91} ${x} `
      + `${BASE - 90}C${x + 16} ${BASE - 91} ${x + 30} ${BASE - 104} ${x + 33} ${BASE - 132}Z" `
      + `fill="${palette.wine}"/>`
      + `<rect x="${x - 2.5}" y="${BASE - 88}" width="5" height="82" fill="${palette.bottle}"/>`
      + `<ellipse cx="${x}" cy="${BASE - 5}" rx="30" ry="5" fill="${palette.bottle}"/>`;
  const bottle = (x, h, w, shoulder, body, foil) => { // straight or sloping shoulders
    const neck = 13, top = BASE - h, sh = BASE - h * 0.62;
    return `<path d="M${x - w} ${BASE}V${sh}C${x - w} ${sh - shoulder} `
      + `${x - neck} ${sh - shoulder} ${x - neck} ${sh - shoulder * 1.6}`
      + `V${top + 6}Q${x - neck} ${top} ${x - neck + 6} ${top}`
      + `H${x + neck - 6}Q${x + neck} ${top} ${x + neck} ${top + 6}V${sh - shoulder * 1.6}`
      + `C${x + neck} ${sh - shoulder} ${x + w} ${sh - shoulder} ${x + w} ${sh}V${BASE}Z" `
      + `fill="${body}"/>`
      + `<rect x="${x - neck - 1}" y="${top}" width="${2 * neck + 2}" height="${h * 0.16}" `
      + `rx="4" fill="${foil}"/>`
      + `<rect x="${x - w + 8}" y="${BASE - h * 0.44}" width="${2 * w - 16}" height="${h * 0.26}" `
      + `fill="${palette.straw}"/>`
      + `<rect x="${x - w + 8}" y="${BASE - h * 0.3}" width="${2 * w - 16}" height="6" `
      + `fill="${foil}"/>`;
  };
  const carafe = (x) => `<path d="M${x - 16} ${BASE - 200}H${x + 16}V${BASE - 150}`
    + `C${x + 70} ${BASE - 120} ${x + 76} ${BASE - 20} ${x + 44} ${BASE}H${x - 44}`
    + `C${x - 76} ${BASE - 20} ${x - 70} ${BASE - 120} ${x - 16} ${BASE - 150}Z" fill="none" `
    + `stroke="${palette.bottle}" stroke-width="4"/>`
    + `<path d="M${x - 64} ${BASE - 74}C${x - 70} ${BASE - 30} ${x - 58} ${BASE - 8} ${x - 42} `
    + `${BASE - 4}H${x + 42}C${x + 58} ${BASE - 8} ${x + 70} ${BASE - 30} ${x + 64} ${BASE - 74}Z" `
    + `fill="${palette.wine}"/>`;
  const C = W / 2;
  const art = glass(C - 330) + bottle(C - 225, 250, 38, 12, palette.bottle, palette.wine)
    + bottle(C - 125, 262, 42, 34, palette.sage, palette.brass) + carafe(C)
    + bottle(C + 125, 256, 44, 36, palette.bottle, palette.brass)
    + bottle(C + 225, 250, 38, 12, palette.bottle, palette.wine) + glass(C + 330)
    + `<rect x="${C - 420}" y="${BASE}" width="840" height="6" fill="${palette.brass}"/>`;
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" `
    + `height="${H}">${art}</svg>`;
}
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  colorPalette, tableStyle, resourceTypes,
  page: { sizePreset: 'custom', width: mm(230), height: mm(310), dpi: 150,
    backgroundColor: col('paper'), // one card, printed both sides: margins are not mirrored
    margins: { top: mm(22), bottom: mm(20), left: mm(36), right: mm(36) } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), // both default to main-color, the wine red
    textAlign: 'center', firstLineIndent: mm(0) }, // the lines under the name and the notes
  headings, // the card's axis (#region centred)
  chipStyles, paragraphStyles, // the badges and the small print (#region badges)
  header: { elements: [] }, footer: { elements: [] }, // a menu has no running heads or folios
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Amostra em Markdown · 46 linhas · content.en.mdtitle: "Les Tanneurs" --- ::resource{id="awning"} # Les Tanneurs *Bistrot parisien depuis 1931* Carte d’automne 2026 · *autumn menu* ## Entrées ::resource{id="entrees"} ## Plats ::resource{id="plats"} ## Desserts ::resource{id="desserts"} :::paragraphs{style="notes"} :chip[V]{style="veg"} Plat végétarien · *vegetarian* · :chip[SG]{style="gf"} Sans gluten · *gluten-free* Bœuf d’origine France · *French beef* · Prix nets, service compris · *service included* ::: :::pagebreak ::resource{id="bottles"} # La Cave Vins au verre et à la bouteille · *wine by the glass and by the bottle* ::resource{id="wines"} ## Le Comptoir ::resource{id="counter"} :::paragraphs{style="colophon"} Set in Limelight, Noticia Text and Josefin Sans (SIL OFL) · Les Tanneurs is an imaginary bistro · Text and drawings CC BY 4.0 :::
`; // the two sides of the card, in French const dishes = String.raw`entrees **Œuf mayonnaise**, cornichons de la maison :chip[V]{style="veg"} :chip[SG]{style="gf"} Egg mayonnaise, house pickles 7
Amostra em Markdown · 15 linhas · content.carte.en.mdentrees **Velouté de potimarron**, crème crue, noisettes torréfiées :chip[V]{style="veg"} :chip[SG]{style="gf"} Red kuri squash soup, raw cream, toasted hazelnuts 9 entrees **Terrine de campagne** au poivre vert, pain grillé Country terrine with green peppercorns, toast 11 entrees **Poireaux vinaigrette**, œuf mimosa :chip[V]{style="veg"} :chip[SG]{style="gf"} Leeks in vinaigrette, egg mimosa 10 plats **Blanquette de veau** à l’ancienne, riz pilaf Veal blanquette the old way, pilaf rice 24 plats **Paleron de bœuf** braisé au vin rouge, carottes fondantes Beef chuck braised in red wine, slow-cooked carrots 26 plats **Filet de lieu jaune**, beurre blanc, poireaux fondus :chip[SG]{style="gf"} Pollack fillet, beurre blanc, softened leeks 27 plats **Risotto aux cèpes**, mascarpone et sauge :chip[V]{style="veg"} :chip[SG]{style="gf"} Cep risotto, mascarpone and sage 22 desserts **Tarte fine aux pommes**, crème fraîche Thin apple tart, crème fraîche 10 desserts **Mousse au chocolat noir** Dark chocolate mousse 9 desserts **Île flottante**, pralines roses Floating island, pink pralines 9 desserts **Trois fromages affinés**, confiture de cerises noires Three matured cheeses, black cherry jam 12 counter **Kir** au vin blanc et cassis White wine with blackcurrant liqueur 6 counter **Pastis**, carafe d’eau fraîche Pastis with a jug of cold water 5 counter **Bière à la pression**, 25 cl Draught beer, 25 cl 5 counter **Café**, noisette ou allongé Espresso, macchiato or lungo 3
`; // TSV: course · dish · translation · price const wines = String.raw`Appellation · cépage 12 cl 75 cl
Amostra em Markdown · 16 linhas · content.cave.en.mdBULLES **Crémant de Loire** brut · *chenin blanc* 9 44 BLANCS **Muscadet Sèvre-et-Maine** sur lie 2023 · *melon de Bourgogne* 7 32 **Mâcon-Villages** 2022 · *chardonnay* 9 40 **Sancerre** 2023 · *sauvignon blanc* 11 52 **Chablis** 2022 · *chardonnay* 12 58 ROUGES **Côtes-du-Rhône** 2022 · *grenache, syrah* 7 32 **Saumur-Champigny** 2022 · *cabernet franc* 9 42 **Morgon** 2022 · *gamay* 10 46 **Bordeaux supérieur** 2020 · *merlot, cabernet sauvignon* 8 38 **Saint-Joseph** 2021 · *syrah* 12 56 **Saint-Émilion grand cru** 2018 · *merlot, cabernet franc* — 78
`; // TSV: head row, regions, wines; one file for both editions const resources = [ piece('awning', 'svg', { svg: { fileId: 'awning.svg', width: 1660, height: 212 }, altText: 'A striped wine-red and straw awning on a brass rod.' }), piece('bottles', 'svg', { svg: { fileId: 'bottles.svg', width: 1660, height: 300 }, altText: 'Two glasses of red wine, four bottles and a carafe on a brass shelf.' }), course('entrees', dishes), course('plats', dishes), course('desserts', dishes), wineList(wines), course('counter', dishes), ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages paint, loaded before the first build (gotcha: fonts-first). const FONTS = { 'Noticia Text': ['400', '400i', '700'], Limelight: ['400'], 'Josefin Sans': ['700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const allText = markdown + dishes + wines; await loadFonts(FONTS, allText); await loadSvg('awning.svg', awning()); await loadSvg('bottles.svg', bottles()); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText); showPages(doc, { title: t({ en: 'Les Tanneurs: autumn menu', es: 'Les Tanneurs: carta de otoño' }) });
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

#Parar os fios num comprimento fixo

Uma largura fixa no lugar de 'fill' deixa cada fio com 20 mm, a 4 mm do título, e assim o par fica mais perto do centro ao lado de PLATS do que ao lado de LE COMPTOIR.

-    size: { width: 'fill' }, // to the column's edge
+    size: { width: mm(20) }, // 20 mm, wherever the title ends

#Riscar as linhas como um livro-caixa

rules: 'horizontal' desenha um fio fino de latão de 0,5 pt acima e abaixo de cada linha. Os fios não ocupam espaço, então os dois lados mantêm a diagramação, e as linhas em branco acima de Blancs e Rouges ganham também um par de fios.

-const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD
+const tableStyle = { rules: 'horizontal', borderColor: col('brass'),
+  borderWidth: pt(0.5), cellPadding: pt(LEAD / 4),

Erros comuns

Erro comum

Células mescladas precisam de hiddenBy: use mergeCells

As células são posicionadas pela posição no array da linha, então uma célula mesclada precisa de células de preenchimento marcadas com hiddenBy onde ela se estende; omiti-las, como no HTML, desloca todas as colunas seguintes. Crie as mesclagens com mergeCells. Tabelas a partir de dados →

Erro comum

Uma tabela 'here' nunca se divide

Só as tabelas flutuantes se dividem entre colunas e páginas; uma tabela posicionada como 'here' se move inteira. Deixe as tabelas longas flutuarem ou mantenha curtas as tabelas em linha. Tabelas que passam de página →

Erro comum

Uma figura no texto ganha espaço acima, mas não abaixo

No postext 1.4.1, uma figura que ::resource coloca na posição 'here' ganha uma linha da grade de espaço acima, mas abaixo só o que sobra quando a linha seguinte se ajusta à grade de linhas de base: de uma linha inteira a quase nada, então o parágrafo seguinte pode começar colado à legenda. Ponha :::space{lines=1} depois da linha ::resource; como todo :::space, ele é descartado no alto de uma coluna. Figuras exatamente aqui →

Erro comum

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

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

Erro comum

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

Texto alinhado à esquerda nunca é hifenizado

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

Erro comum

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

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

Erro comum

Carregue todas as fontes antes do layout

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

Dê a cada elemento de fio um thickness. Na 1.4.1, um fio sem espessura não pinta nada, embora a referência de configuração dê 0,5 pt como padrão. Apague-o de rule() e os dois fios de cada título de seção desaparecem.

Créditos

Texto
Texto original, CC BY 4.0
Imagens
  • The awning and the shelf of bottles, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
Fontes
Limelight (SIL OFL 1.1) · Noticia Text (SIL OFL 1.1) · Josefin Sans (SIL OFL 1.1)
Sandbox