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

Receitas · Capítulo 8 · Tabelas

Catálogo de venda por correio com imagens nas células

Catálogo de sementes com a lista de preços numa tabela lida de TSV, um envelope pintado na célula de cada variedade e a lista dividida numa página dupla.

Nesta página
  • Amostra em inglês: ainda sem edição em português
  • Refile 190 × 250 mm
  • 2 colunas, medianiz de 7 mm
  • Gelasio 9,5/13
  • Alfa Slab One
  • Cabin Condensed
  • 4 páginas
  • Nível
  • Postext 1.19.1
  • Diagramado em 19 ms
  • 231 linhas de código

Em poucas palavras

Um catálogo de sementes de quatro páginas com lista de preços. Cada variedade tem uma pequena pintura do seu envelope dentro da tabela, e a lista continua de uma página para a da frente.

O que você vai compor

A lista de preços de primavera da Brindlewood Seed Co., uma casa de sementes inventada, num livreto de quatro páginas em papel cor de camurça. A capa compõe Spring Seeds em Alfa Slab One sobre a pintura de um girassol e de um pé de tomate, com a carta aos produtores em duas colunas embaixo. As páginas 2 e 3 trazem a lista. Cada variedade tem a pintura do seu envelope de sementes no alto da célula e o nome embaixo, e os tipos de hortaliça descem por uma primeira coluna de células mescladas. Os preços ficam alinhados à direita numa letra condensada de rótulo, e chips vermelhos e verdes marcam as sementes novas e as orgânicas. A lista quebra depois do pimentão de fritar e continua na página da frente sob a mesma faixa vermelha, marcada com (continued). A última página é a folha de pedido, duas grades arredondadas com linhas vazias para preencher.

Esta receita responde a

  • Como coloco imagens ou ícones dentro das células de uma tabela?
  • Como divido uma tabela longa entre páginas com o cabeçalho repetido e um aviso de “continua”?
  • Como dou estilos diferentes a várias tabelas (preenchimentos, células zebradas, molduras arredondadas) em um mesmo documento?
  • Como faço uma tabela com linhas de cabeçalho, células mescladas, larguras de coluna e alinhamento por célula?

A resposta curta

script.js · linhas 43–78no código completo
const at = (row, c) => ({ row, col: c });
const slug = (name) => name.toLowerCase().replace(/\W+/g, '-'); // 'Gold Medal' → 'gold-medal'
function priceList(tsv) { // Kind · Variety · Description · Packet · Ounce, under one head row
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [20, 40, 63, 17, 17] }; // mm
  for (const c of [0, 1]) m = setAlignment(m, at(0, c), 'center'); // each head over its column
  for (const c of [3, 4]) m = setAlignment(m, at(0, c), 'right');
  for (let r = 1; r < m.rows.length; r++) {
    // The picture is a resource of its own; the cell sets it at the top, centred, with the
    // variety's name under it, and the row grows to hold both.
    const variety = m.rows[r][1].content;
    m = setCellImage(m, at(r, 1), { resourceId: slug(variety), width: PACKET });
    m = setCellContent(m, at(r, 1), `**${variety}**`);
    m = setAlignment(m, at(r, 1), 'center');
    for (const c of [3, 4]) { // prices in the label face: lining figures, flush right, and a
      // bare $, since a cell prints the backslash of \$ (gotcha: cell-dollar-backslash)
      m = setCellContent(m, at(r, c), `:chip[$${m.rows[r][c].content}]{style="price"}`);
      m = setAlignment(m, at(r, c), 'right');
    }
    if (!m.rows[r][0].content) continue; // an empty Kind goes on with the one above
    let end = r;
    while (m.rows[end + 1] && !m.rows[end + 1][0].content) end++;
    // One cell down the rows of its kind; mergeCells leaves the covered cells in the grid,
    // marked hiddenBy (gotcha: merged-cells-hiddenby). A split never cuts through it.
    m = mergeCells(m, { start: at(r, 0), end: at(end, 0) });
    m = setAlignment(m, at(r, 0), 'center', 'middle');
    m = setCellBackground(m, at(r, 0), col('cream'));
  }
  return m;
}
// The letter on page 1 cites the list, so it floats to the first free slot after the
// letter, page 2, and is cut between rows where the page ends; span 'page' gives it the
// full 157 mm, not a 75 mm column. Only a float splits (gotcha: here-table-no-split).
const vegetableList = (tsv) => ({ id: 'vegetables', typeId: 'list', kind: 'table',
  caption: '**Vegetable Seeds**', placement: { span: 'page' },
  note: 'A packet holds about 30 tomato or pepper seeds, 40 beans or 12 squash seeds.',
  table: { model: priceList(tsv) }, createdAt: 0, updatedAt: 0 });

Ingredientes

Tipografia
Gelasio, Alfa Slab One, Cabin Condensed (SIL OFL 1.1)
Materiais
  • amish-paste-360.jpg
  • black-beauty-360.jpg
  • brandywine-360.jpg
  • cherokee-purple-360.jpg
  • cover-1500.jpg
  • dragon-tongue-360.jpg
  • gold-medal-360.jpg
  • jimmy-nardello-360.jpg
  • kentucky-wonder-360.jpg
  • waltham-butternut-360.jpg
  • The cover's sunflower and tomato plant, a painting (Generated With Diffusion Models, original)
  • The Brandywine seed packet (Generated With Diffusion Models, original)
  • The Cherokee Purple seed packet (Generated With Diffusion Models, original)
  • The Gold Medal seed packet (Generated With Diffusion Models, original)
  • The Amish Paste seed packet (Generated With Diffusion Models, original)
  • The Jimmy Nardello seed packet (Generated With Diffusion Models, original)
  • The Kentucky Wonder seed packet (Generated With Diffusion Models, original)
  • The Dragon Tongue seed packet (Generated With Diffusion Models, original)
  • The Black Beauty seed packet (Generated With Diffusion Models, original)
  • The Waltham Butternut seed packet (Generated With Diffusion Models, original)

Preparo

#1 · Acrescente o que o TSV não traz

O código é a resposta curta lá em cima. O TSV guarda só palavras e preços. setCellImage põe o envelope de cada variedade, um recurso com o nome da variedade, no alto da sua célula a 55% da largura interna, de modo que o nome fica sob uma imagem de 20,3 × 27,1 mm e cada linha cresce até 36 mm. mergeCells estende cada tipo pela primeira coluna e marca as células que ele cobre com hiddenBy. As células do corpo de uma tabela compartilham uma única fonte, aqui a Gelasio, por isso cada preço é um chip sem preenchimento, sem borda e sem preenchimento lateral, composto em Cabin Condensed negrito a 1,12 em por causa dos algarismos alinhados.

#2 · Cite a lista e divida-a pela página dupla

script.js · linhas 82–98no código completo
const tableStyle = { bodyFontSize: pt(9), headerFontFamily: LABEL, // body cells in Gelasio
  headerFontSize: pt(7.5), headerColor: col('paper'), headerBackground: col('ink'),
  rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), cellPadding: mm(1.5),
  // 'split' is the default, written out as the key to change: 'clip' keeps the rows that fit
  // page 2 and drops the rest, 'hide' drops the whole list. Suffix and marker label the parts.
  overflow: 'split', continuedSuffix: '(continued)',
  continuesMarker: 'Continued on the facing page' };
const tableStyles = [{ id: 'form', rules: 'grid', borderRadius: mm(2.5), // a card to write on
  borderColor: col('muted'), borderWidth: pt(0.6), headerBackground: col('basil'),
  bodyFontFamily: LABEL, bodyFontSize: pt(8.5),
  bodyBackgroundEnabled: true, bodyBackground: col('cream'), cellPadding: mm(1.6) }];
// The caption sits above the table on a tomato band and is repeated on every part. Its note
// and the marker take the caption's face, so the caption keeps the body's Gelasio, which has
// the italic the suffix and the marker are set in; Cabin Condensed has none.
const captionStyle = { fontSize: pt(12), color: col('cream'),
  position: 'above', backgroundEnabled: true, background: col('tomato'), padding: mm(2),
  gap: mm(0), note: { fontSize: pt(7.8), color: col('muted') } };

A carta da página 1 cita a lista com :ref{id="vegetables" text="the price list"}, e text imprime essas palavras no lugar de um número. Uma tabela citada flutua para o primeiro espaço livre depois da citação, aqui o alto da página 2. A lista não cabe ali, então o motor a corta entre linhas depois de Jimmy Nardello, nunca dentro de um tipo mesclado. A página 3 repete a linha de cabeçalho e a faixa da legenda com o sufixo; o aviso de continuação vai sob a primeira parte, e a nota, sob a última (tabelas mais altas que a página). parseTSV não marca nenhuma célula como cabeçalho, então headerRowCount: 1 é definido à mão para pintar a primeira linha como cabeçalho e repeti-la na página 3. O sufixo e o aviso vão em itálico e usam a fonte da legenda, por isso a faixa fica em Gelasio; a Cabin Condensed não tem itálico.

#3 · Um recurso para cada envelope

script.js · linhas 276–288no código completo
// Nothing cites the pictures, so none is numbered or placed: the cover design and the cells
// draw them by id, and their typeId is never looked up.
const picture = (id, fileId, [w, h], altText) => ({ id, typeId: 'figure', kind: 'bitmap',
  altText, bitmap: { fileId, format: 'jpeg', width: w, height: h }, createdAt: 0, updatedAt: 0 });
const varieties = parseTSV(list).rows.slice(1).map((row) => row[1].content);
const resources = [vegetableList(list), shipTo, orderForm,
  picture('cover-art', 'cover-1500.jpg', [1500, 1040], // the cover's 150 × 104 mm
    'A sunflower and a staked tomato plant in a ploughed field.'),
  ...varieties.map((name) => picture(slug(name), `${slug(name)}-360.jpg`, [360, 480], // 3 : 4
    `Seed packet of ${name}.`))];
await loadFonts(FONTS, markdown + list);
await Promise.all(resources.flatMap(({ bitmap }) => bitmap
  ? [loadImage(bitmap.fileId, asset(bitmap.fileId))] : []));

A imagem de uma célula indica um recurso pelo id, então cada envelope é um recurso bitmap próprio, um JPEG de 360 × 480 px recortado na proporção 3 : 4 do envelope, e o loadImage do kit o registra sob o fileId com registerResourceImage. Nada cita os envelopes, então nenhum é numerado ou posicionado fora da sua célula. As pinturas não têm letreiro: o nome é composto como texto da célula, nas fontes da página.

#4 · Um segundo estilo de tabela para a folha de pedido

script.js · linhas 245–264no código completo
function form(id, widths, rows, merges, note) { // an empty cell still takes a line's height
  let m = { headerRowCount: 1, columnWidths: widths, rows: rows.map((row, r) =>
    widths.map((_, c) => ({ content: row[c] ?? '', isHeader: r === 0 }))) };
  for (const [r, c0, c1] of merges) m = mergeCells(m, { start: at(r, c0), end: at(r, c1) });
  m.rows.forEach((row, r) => row.forEach((cell, c) => { // a label takes the paper's buff
    if (r === 0 || !cell.content || cell.hiddenBy) return;
    m = setCellBackground(m, at(r, c), col('paper'));
    if (cell.colSpan > 1) m = setAlignment(m, at(r, c), 'right'); // the totals' labels
  }));
  return { id, typeId: 'list', kind: 'table', note, placement: { position: 'here' },
    table: { model: m, styleId: 'form' }, createdAt: 0, updatedAt: 0 };
}
const shipTo = form('ship-to', [24, 60, 24, 49], [['Ship to'], ['Name'], ['Street or box'],
  ['Town', '', 'State and ZIP']], [[0, 0, 3], [1, 1, 3], [2, 1, 3]]);
const lines = Array.from({ length: 11 }, () => []); // eleven varieties to a sheet
const orderForm = form('order', [16, 73, 20, 20, 28], [['No.', 'Variety', 'Packets', 'Ounces',
  'Amount'], ...lines, ['Seeds total'], ['Postage: free on orders of $40 or more, otherwise $4.50'],
['**Total enclosed**']], [1, 2, 3].map((k) => [lines.length + k, 0, 3]),
'We guarantee every packet to grow. If a variety fails to come up in your garden, write to '
  + 'us before August 1 and we will send a new packet or refund what you paid for it.');

As duas grades usam o estilo nomeado form, com fios em grade, raio de 2,5 mm na moldura externa e um preenchimento creme para escrever em cima. A lista de preços mantém o tableStyle do documento (estilos de tabela nomeados). Uma célula vazia não precisa de texto provisório, porque a sua linha ocupa uma linha mesmo assim, aqui de 7,3 mm. Cada total mescla quatro colunas num único rótulo alinhado à direita. O estilo de título da folha de pedido passa a página 4 para uma só coluna, de modo que as duas grades ocupam a medida inteira de 157 mm.

#5 · Mantenha a carta embaixo da pintura

script.js · linhas 115–141no código completo
const ART = { w: 150, h: 104, y: 60 }; // mm: the cover painting, and its top on the page
const FRAME = 8; // mm: the cover's frame, in from the trim
const AIR = 10; // mm: at least this much between the painting and the letter
const SINK = Math.ceil((ART.y + ART.h + AIR - MARGIN.top) / (LEAD * 25.4 / 72)); // lines
const cover = { id: 'cover', span: 'page', footer: { elements: [] },
  // A style's header replaces the document's on every page of its section, so it carries
  // the running heads too. The frame is drawn there: in the design it would count as
  // reserved height down to the page's foot, more than the column holds, and 1.4.1 then
  // drops the reservation and sets the letter over the painting (gotcha:
  // opener-reserves-anchored).
  header: { elements: [...header.elements, { kind: 'box', id: 'frame', pages: 'opener',
    style: { borderColor: col('basil'), borderWidth: pt(1.2) },
    placement: on('page', 'top-left', FRAME, FRAME,
      { width: mm(TRIM.w - 2 * FRAME), height: mm(TRIM.h - 2 * FRAME) }) }] },
  // The painting is an image, and images do not count towards the height an opener
  // reserves (gotcha: opener-image-no-reserve): minHeight holds the letter under it.
  advancedDesign: { enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [
    text('publisher', '{title}', on('page', 'top', 0, 17), { ...caps(9.5), color: col('basil') }),
    text('title', '{titleText}', on('page', 'top', 0, 23), { fontFamily: DISPLAY,
      fontSize: pt(58), lineHeight: 1, color: col('tomato') }),
    text('issue', '{subtitle}', on('page', 'top', 0, 49), { ...caps(8.5, 700), color: col('ink'),
      box: { backgroundColor: col('sun'), padding: { top: mm(1.3), bottom: mm(1.1),
        left: mm(4), right: mm(4) } } }),
    { kind: 'image', id: 'art', resourceId: 'cover-art',
      placement: on('page', 'top', 0, ART.y, { width: mm(ART.w) }) },
  ] } },
};

A pintura da capa é um elemento de imagem, e uma imagem não acrescenta nada à altura que uma abertura reserva. Sem minHeight, a carta começaria a 55 mm do alto da página, em cima do girassol. SINK acrescenta 10 mm de respiro sob a pintura e arredonda a profundidade abaixo da margem superior para cima, em linhas inteiras de 13 pt, 34 delas, de modo que as duas colunas da carta começam na grade, a 177,9 mm do alto da página.

A receita completa

Sandbox
// ═══ Postext Cookbook · Nº 036 · Mail-order catalogue with pictures in cells ═══════
// https://postext.dev/en/cookbook/seed-catalogue
// Code: MIT · Text: original (CC BY 4.0) · Pictures: diffusion models
// Fonts: Gelasio, Alfa Slab One, Cabin Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// A fictional seed farm's spring list: a table read from TSV, a packet pictured in each row.
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  parseTSV, mergeCells, setAlignment, setCellBackground, setCellContent, setCellImage,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // eight named colours; every colour in the config links to one of them
  ink: '#2d2620', paper: '#f0e2c4', // brown-black text on buff
  cream: '#fcf7ea', tomato: '#c23b22', // cells to write in; the accent: bands, titles, NEW
  basil: '#2f6b3b', sun: '#e8b53a', // rules, ORGANIC, the form's head; the cover's banner
  rule: '#c7ae86', muted: '#6d5f50', // hairlines; running heads and notes
};
// 1.4.1 designs read the hex, not paletteId: col() sets both (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries(palette)
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

const TRIM = { w: 190, h: 250 }; // mm: a stapled mail-order booklet
const MARGIN = { top: 22, bottom: 20, inner: 18, outer: 15 }; // mm, mirrored: 157 mm wide
const LEAD = 13; // pt: the body leading, the grid the headings keep to
const TEXT = 'Gelasio', DISPLAY = 'Alfa Slab One', LABEL = 'Cabin Condensed';
const PACKET = 0.55; // the packet picture's share of its cell's inner width
const caps = (size, fontWeight = 600) => ({ fontFamily: LABEL, fontSize: pt(size), fontWeight,
  letterSpacing: pt(size * 0.18), textTransform: 'uppercase' });
const on = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(size && { size }) });
const text = (id, content, placement, style) => ({ kind: 'text', id, content, placement,
  overflow: 'wrap', ...style }); // not '…' (gotcha: overflow-ellipsis-default)
const chip = (id, size, ink, box) => ({ id, fontFamily: LABEL, bold: true, fontSize: em(size),
  color: col(ink), borderRadius: pt(1.2), ...box });
const rule = (id, below, gap, thickness) => ({ kind: 'rule', id, direction: 'horizontal',
  thickness: pt(thickness), color: col('basil'),
  placement: { ...on(`#${below}`, 'below', 0, gap), size: { width: 'fill' } } });

// #region answer: a price list from TSV: a packet in each variety's cell, kinds merged down
const at = (row, c) => ({ row, col: c });
const slug = (name) => name.toLowerCase().replace(/\W+/g, '-'); // 'Gold Medal' → 'gold-medal'
function priceList(tsv) { // Kind · Variety · Description · Packet · Ounce, under one head row
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [20, 40, 63, 17, 17] }; // mm
  for (const c of [0, 1]) m = setAlignment(m, at(0, c), 'center'); // each head over its column
  for (const c of [3, 4]) m = setAlignment(m, at(0, c), 'right');
  for (let r = 1; r < m.rows.length; r++) {
    // The picture is a resource of its own; the cell sets it at the top, centred, with the
    // variety's name under it, and the row grows to hold both.
    const variety = m.rows[r][1].content;
    m = setCellImage(m, at(r, 1), { resourceId: slug(variety), width: PACKET });
    m = setCellContent(m, at(r, 1), `**${variety}**`);
    m = setAlignment(m, at(r, 1), 'center');
    for (const c of [3, 4]) { // prices in the label face: lining figures, flush right, and a
      // bare $, since a cell prints the backslash of \$ (gotcha: cell-dollar-backslash)
      m = setCellContent(m, at(r, c), `:chip[$${m.rows[r][c].content}]{style="price"}`);
      m = setAlignment(m, at(r, c), 'right');
    }
    if (!m.rows[r][0].content) continue; // an empty Kind goes on with the one above
    let end = r;
    while (m.rows[end + 1] && !m.rows[end + 1][0].content) end++;
    // One cell down the rows of its kind; mergeCells leaves the covered cells in the grid,
    // marked hiddenBy (gotcha: merged-cells-hiddenby). A split never cuts through it.
    m = mergeCells(m, { start: at(r, 0), end: at(end, 0) });
    m = setAlignment(m, at(r, 0), 'center', 'middle');
    m = setCellBackground(m, at(r, 0), col('cream'));
  }
  return m;
}
// The letter on page 1 cites the list, so it floats to the first free slot after the
// letter, page 2, and is cut between rows where the page ends; span 'page' gives it the
// full 157 mm, not a 75 mm column. Only a float splits (gotcha: here-table-no-split).
const vegetableList = (tsv) => ({ id: 'vegetables', typeId: 'list', kind: 'table',
  caption: '**Vegetable Seeds**', placement: { span: 'page' },
  note: 'A packet holds about 30 tomato or pepper seeds, 40 beans or 12 squash seeds.',
  table: { model: priceList(tsv) }, createdAt: 0, updatedAt: 0 });
// #endregion

// #region split: the list's style says what a continued part prints; the forms have their own
const tableStyle = { bodyFontSize: pt(9), headerFontFamily: LABEL, // body cells in Gelasio
  headerFontSize: pt(7.5), headerColor: col('paper'), headerBackground: col('ink'),
  rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), cellPadding: mm(1.5),
  // 'split' is the default, written out as the key to change: 'clip' keeps the rows that fit
  // page 2 and drops the rest, 'hide' drops the whole list. Suffix and marker label the parts.
  overflow: 'split', continuedSuffix: '(continued)',
  continuesMarker: 'Continued on the facing page' };
const tableStyles = [{ id: 'form', rules: 'grid', borderRadius: mm(2.5), // a card to write on
  borderColor: col('muted'), borderWidth: pt(0.6), headerBackground: col('basil'),
  bodyFontFamily: LABEL, bodyFontSize: pt(8.5),
  bodyBackgroundEnabled: true, bodyBackground: col('cream'), cellPadding: mm(1.6) }];
// The caption sits above the table on a tomato band and is repeated on every part. Its note
// and the marker take the caption's face, so the caption keeps the body's Gelasio, which has
// the italic the suffix and the marker are set in; Cabin Condensed has none.
const captionStyle = { fontSize: pt(12), color: col('cream'),
  position: 'above', backgroundEnabled: true, background: col('tomato'), padding: mm(2),
  gap: mm(0), note: { fontSize: pt(7.8), color: col('muted') } };
// #endregion

const run = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  pages: 'body', ...caps(7.5), color: col('muted'), placement: on('page', edge, x, 12.5),
  ...extra });
const folio = { fontWeight: 700, color: col('ink') };
const header = { elements: [ // folios outside, the booklet's name on the verso, its issue opposite
  run('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer, folio),
  run('verso-title', '{title}', 'even', 'top-left', MARGIN.outer + 6),
  run('recto-title', '{subtitle}', 'odd', 'top-right', -(MARGIN.outer + 6)),
  run('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer, folio),
] };
const footer = { elements: [{ ...run('drop-folio', '{pageNumber}', 'all', 'bottom', 0, folio),
  pages: 'opener', placement: on('page', 'bottom', 0, -11) }] };

// #region cover: the frontmatter and the heading set the type; the painting fills the middle
const ART = { w: 150, h: 104, y: 60 }; // mm: the cover painting, and its top on the page
const FRAME = 8; // mm: the cover's frame, in from the trim
const AIR = 10; // mm: at least this much between the painting and the letter
const SINK = Math.ceil((ART.y + ART.h + AIR - MARGIN.top) / (LEAD * 25.4 / 72)); // lines
const cover = { id: 'cover', span: 'page', footer: { elements: [] },
  // A style's header replaces the document's on every page of its section, so it carries
  // the running heads too. The frame is drawn there: in the design it would count as
  // reserved height down to the page's foot, more than the column holds, and 1.4.1 then
  // drops the reservation and sets the letter over the painting (gotcha:
  // opener-reserves-anchored).
  header: { elements: [...header.elements, { kind: 'box', id: 'frame', pages: 'opener',
    style: { borderColor: col('basil'), borderWidth: pt(1.2) },
    placement: on('page', 'top-left', FRAME, FRAME,
      { width: mm(TRIM.w - 2 * FRAME), height: mm(TRIM.h - 2 * FRAME) }) }] },
  // The painting is an image, and images do not count towards the height an opener
  // reserves (gotcha: opener-image-no-reserve): minHeight holds the letter under it.
  advancedDesign: { enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [
    text('publisher', '{title}', on('page', 'top', 0, 17), { ...caps(9.5), color: col('basil') }),
    text('title', '{titleText}', on('page', 'top', 0, 23), { fontFamily: DISPLAY,
      fontSize: pt(58), lineHeight: 1, color: col('tomato') }),
    text('issue', '{subtitle}', on('page', 'top', 0, 49), { ...caps(8.5, 700), color: col('ink'),
      box: { backgroundColor: col('sun'), padding: { top: mm(1.3), bottom: mm(1.1),
        left: mm(4), right: mm(4) } } }),
    { kind: 'image', id: 'art', resourceId: 'cover-art',
      placement: on('page', 'top', 0, ART.y, { width: mm(ART.w) }) },
  ] } },
};
// #endregion

// #region heads: slab-serif section heads over a basil double rule, and the order sheet
const h2 = { level: 2, marginTop: pt(0), marginBottom: pt(0), advancedDesign: { // both open a
  enabled: true, minHeight: pt(2 * LEAD), slot: { elements: [ // column: no space above
    text('title', '{titleText}', on('container', 'top-left', 0, 0), { fontFamily: DISPLAY,
      fontSize: pt(14), lineHeight: 1.1, color: col('tomato') }),
    rule('thick', 'title', 1.4, 1.2), rule('thin', 'thick', 0.7, 0.4),
  ] } } };
const order = { id: 'order', span: 'page', layout: { layoutType: 'single' }, // one wide column
  advancedDesign: { enabled: true, slot: { elements: [
    text('address', '{attr.address}', on('container', 'top-left', 0, 0),
      { ...caps(8), color: col('basil') }),
    text('title', '{titleText}', on('#address', 'below', 0, 1.5), { fontFamily: DISPLAY,
      fontSize: pt(34), lineHeight: 1, color: col('tomato') }),
    rule('thick', 'title', 2.2, 1.6), rule('thin', 'thick', 0.8, 0.5),
    text('how', '{attr.how}', { ...on('#thin', 'below', 0, 3), size: { width: mm(150) } },
      { fontFamily: TEXT, italic: true, fontSize: pt(10.5), lineHeight: 1.35, align: 'left',
        color: col('ink') }),
  ] } } };
// #endregion

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  colorPalette,
  page: { width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(7) }, // two 75 mm columns of letter text
  bodyText: { fontFamily: TEXT, fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceBold: false, referenceItalic: true,
    firstLineIndent: mm(4), indentAfterHeading: false, // ~48 characters to a 75 mm column, so
    maxWordSpacing: 1.6 }, // a cap under the default 2; Knuth–Plass can exceed it, so reword to fit
  headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0) }, h2] },
  headingStyles: [cover, order],
  paragraphStyles: [
    { id: 'signature', textAlign: 'right', firstLineIndent: pt(0) },
    { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10), color: col('muted'),
      textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  chipStyles: [ // a price is a bare chip: the label face, bold, a little larger than the text
    chip('price', 1.12, 'ink', { backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0) }),
    chip('new', 0.8, 'cream', { background: col('tomato'), borderWidth: pt(0) }),
    chip('organic', 0.8, 'basil', { backgroundEnabled: false, borderColor: col('basil'),
      borderWidth: pt(0.6) })],
  resourceTypes: [{ id: 'list', name: 'Price list', shortLabel: 'List', captionPrefix: '',
    numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }], // no prefix: the
  // caption is the list's name alone, with no "Table 1".
  tableStyle, tableStyles, captionStyle, header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Amostra em Markdown · 35 linhas · content.en.mdtitle: "Brindlewood Seed Co." subtitle: "Catalog No. 12 · Spring 2027" --- # Spring Seeds {style="cover"} ## To Our Growers This is our twelfth spring catalog, and it lists only the vegetables we grew out ourselves last summer at Brindlewood Farm, nine of them, each tested for germination in the packing shed this January. You will find each of them, with a picture of its packet, in :ref{id="vegetables" text="the price list"} on the next two pages. Tomatoes open the list, as they have done since our first catalog in 2016. Two varieties are new to it this spring and carry a red :chip[NEW]{style="new"} tag, while four were grown on the certified organic field behind the barn and are marked :chip[ORGANIC]{style="organic"}. The rest came from farms we have bought seed from for years, and each one was grown out beside our own before we listed it. Order early. Last spring the Brandywine and the Jimmy Nardello were sold out by March. We mail seed three days a week from February 1 to May 31. The order sheet is on the back page; tear it out, or copy it by hand and keep the catalog whole. :::paragraphs{style="signature"} *Ada Brindle and Tom Okafor* Brindlewood Farm, Marrow Creek ::: ## From Our Trial Garden Every variety on these pages spent last summer in our trial rows. We set out twenty plants of each and score them for how quickly they come up, how long they bear and whether the fruit comes true to type. A tomato that throws odd fruit on two of its twenty plants is dropped from the list. Each lot was tested in January: a hundred seeds on damp paper in a warm room, counted after one week and again after two, and the figure is printed on the packet. Stored cool and dry in a sealed jar, tomato and squash seed stays good for four years, bean seed for three and pepper seed for two. Onion and parsnip seed keeps for only one year, which is why this list carries neither. # Order Sheet {style="order" address="Mail to Brindlewood Seed Co. · Box 12 · Marrow Creek" how="Write one line for each variety: its number, its name and how many packets or ounces you want. Add the postage, and mail the sheet with a check or money order."} ::resource{id="ship-to"} ::resource{id="order"} :::paragraphs{style="colophon"} Set in Gelasio, Alfa Slab One and Cabin Condensed (SIL OFL) · Text: CC BY 4.0 · Pictures: generated · A fictional seed house :::
`; // the letter, the trial notes and the order sheet const list = String.raw`KIND VARIETY DESCRIPTION PACKET OUNCE
Amostra em Markdown · 9 linhas · content.list.en.md**Heirloom tomatoes** Brandywine *No. 101 · 90 days.* The pink beefsteak a Philadelphia seed house first listed in 1889. Fruit of a pound or more on tall, potato-leaved vines, sweet but with enough bite to taste of tomato. Stake the vines well. :chip[ORGANIC]{style="organic"} 3.95 42.00 Cherokee Purple *No. 102 · 80 days.* Dusky rose fruit of 10 to 12 ounces with green shoulders and brick-red flesh, kept by a Tennessee family long before it reached seed lists in the 1990s. Rich and smoky; slice it thick. 3.95 42.00 Gold Medal *No. 103 · 85 days.* A yellow beefsteak streaked red inside and out, so every slice comes out marbled. Mild and low in acid. Give it the longest season you have. :chip[NEW]{style="new"} 4.25 46.00 Amish Paste *No. 104 · 80 days.* Heart-shaped fruit of 8 to 12 ounces, meaty enough for sauce and juicy enough to eat off the vine. A paste tomato holds less water and fewer seeds, so it cooks down faster. 3.95 38.00 **Frying pepper** Jimmy Nardello *No. 201 · 80 days.* A long, thin-walled Italian frying pepper, sweet and red when ripe; fry it whole in olive oil. Brought from Basilicata to Connecticut in 1887 by the Nardello family. :chip[ORGANIC]{style="organic"} 4.25 48.00 **Beans** Kentucky Wonder *No. 301 · 65 days.* Pole bean. Meaty green pods 7 to 9 inches long, stringless if you pick them young, and in American gardens since the 1870s. Give it a pole or a fence 6 feet tall. :chip[ORGANIC]{style="organic"} 3.50 4.75 Dragon Tongue *No. 302 · 60 days.* A bush bean from the Netherlands. Flat, pale yellow pods streaked violet, tender up to 7 inches long. Cooking turns them plain yellow. :chip[NEW]{style="new"} 3.75 5.25 **Squash** Black Beauty *No. 401 · 50 days.* Zucchini. Glossy green-black fruit on an open bush that is easy to pick over. Cut them at 6 to 8 inches, every other day in July, and the plants keep bearing. On seed lists since the 1920s. 3.50 9.50 Waltham Butternut *No. 402 · 100 days.* Winter squash of 4 to 5 pounds with sweet orange flesh. Its hard, solid stems resist the vine borer that kills other squash, and it keeps until March in a cool room. :chip[ORGANIC]{style="organic"} 3.75 11.00
`; // the price list: TSV, as a spreadsheet exports it // #region forms: the order sheet: empty rows to write in, labels on buff, totals merged function form(id, widths, rows, merges, note) { // an empty cell still takes a line's height let m = { headerRowCount: 1, columnWidths: widths, rows: rows.map((row, r) => widths.map((_, c) => ({ content: row[c] ?? '', isHeader: r === 0 }))) }; for (const [r, c0, c1] of merges) m = mergeCells(m, { start: at(r, c0), end: at(r, c1) }); m.rows.forEach((row, r) => row.forEach((cell, c) => { // a label takes the paper's buff if (r === 0 || !cell.content || cell.hiddenBy) return; m = setCellBackground(m, at(r, c), col('paper')); if (cell.colSpan > 1) m = setAlignment(m, at(r, c), 'right'); // the totals' labels })); return { id, typeId: 'list', kind: 'table', note, placement: { position: 'here' }, table: { model: m, styleId: 'form' }, createdAt: 0, updatedAt: 0 }; } const shipTo = form('ship-to', [24, 60, 24, 49], [['Ship to'], ['Name'], ['Street or box'], ['Town', '', 'State and ZIP']], [[0, 0, 3], [1, 1, 3], [2, 1, 3]]); const lines = Array.from({ length: 11 }, () => []); // eleven varieties to a sheet const orderForm = form('order', [16, 73, 20, 20, 28], [['No.', 'Variety', 'Packets', 'Ounces', 'Amount'], ...lines, ['Seeds total'], ['Postage: free on orders of $40 or more, otherwise $4.50'], ['**Total enclosed**']], [1, 2, 3].map((k) => [lines.length + k, 0, 3]), 'We guarantee every packet to grow. If a variety fails to come up in your garden, write to ' + 'us before August 1 and we will send a new packet or refund what you paid for it.'); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) Gelasio: ['400', '400i', '700'], // text, captions, notes and the list 'Alfa Slab One': ['400'], // display: the cover, section heads, the order sheet 'Cabin Condensed': ['400', '600', '700'], // labels: heads, prices, chips, the forms }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region pictures: every picture is a resource of its own, a JPEG in assets/ at its pixels // Nothing cites the pictures, so none is numbered or placed: the cover design and the cells // draw them by id, and their typeId is never looked up. const picture = (id, fileId, [w, h], altText) => ({ id, typeId: 'figure', kind: 'bitmap', altText, bitmap: { fileId, format: 'jpeg', width: w, height: h }, createdAt: 0, updatedAt: 0 }); const varieties = parseTSV(list).rows.slice(1).map((row) => row[1].content); const resources = [vegetableList(list), shipTo, orderForm, picture('cover-art', 'cover-1500.jpg', [1500, 1040], // the cover's 150 × 104 mm 'A sunflower and a staked tomato plant in a ploughed field.'), ...varieties.map((name) => picture(slug(name), `${slug(name)}-360.jpg`, [360, 480], // 3 : 4 `Seed packet of ${name}.`))]; await loadFonts(FONTS, markdown + list); await Promise.all(resources.flatMap(({ bitmap }) => bitmap ? [loadImage(bitmap.fileId, asset(bitmap.fileId))] : [])); // #endregion const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown + list); showPages(doc, { title: 'Brindlewood Seed Co. · Spring 2027' });
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

#Fique só com o que cabe

Com overflow: 'clip', a lista para depois das cinco variedades que cabem na página 2, com a nota embaixo delas, e a página 3 traz só as notas do campo de testes, na primeira coluna.

-  overflow: 'split', continuedSuffix: '(continued)',
+  overflow: 'clip', continuedSuffix: '(continued)',

#Diminua os envelopes

A 40%, as linhas caem para 29 mm, mas os dois feijões dividem uma célula mesclada e precisam juntos de 57 mm, mais que os 44 mm que sobram na página 2. As notas do campo de testes sobem para esse espaço, e a página 3 traz só o resto da lista, com a página em branco abaixo de 157 mm.

-const PACKET = 0.55; // the packet picture's share of its cell's inner width
+const PACKET = 0.4; // the packet picture's share of its cell's inner width

Erros comuns

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

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 abertura reserva altura até o seu elemento ancorado mais baixo

Uma abertura com design avançado reserva a altura do seu elemento mais baixo, e os elementos ancorados à página ou à sangria abaixo do título também contam, então um ornamento no pé da página empurra o texto para a página seguinte. Mantenha esses ornamentos acima do título, passe-os para uma posição do cabeçalho ou do rodapé, ou defina a reserva com minHeight. Aberturas desenhadas →

Erro comum

As imagens de uma abertura nunca contam para a altura que ela reserva

No postext 1.4.1, um título com design avançado mede a altura que reserva sem as imagens: textos, fios e caixas contam, mesmo quando ancorados na página, mas uma imagem, como uma ilustração sangrada no alto da página, não reserva nada, então o texto pode começar por cima dela. Defina com minHeight onde o texto deve começar. Aberturas desenhadas →

Erro comum

O texto dentro de um SVG <img> não pode usar fontes web

Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda. Figuras e tabelas como recursos →

Erro comum

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

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

Erro comum

O excesso de texto de design é 'ellipsis-end' por padrão

Um elemento de texto de design que não cabe na sua largura termina em reticências por padrão. Use overflow: 'wrap' nos títulos que devem passar para mais linhas. 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

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 →

Erro comum

Uma célula de tabela mantém a barra de \$

postext 1.4.1 não lê as células de tabela como matemática nem tira a barra de \$ nelas: uma célula escrita \$4.50 imprime \$4.50, com a barra, enquanto \* na mesma célula imprime só o asterisco. Num parágrafo o escape é necessário, porque ali um $ solto abre matemática. Escreva $ sem barra no conteúdo das células e \$ nos parágrafos; um texto usado nos dois lugares sai errado num deles. Tabelas a partir de dados →

  • Um recurso que ninguém cita nunca é posicionado. Tire o :ref da carta e o catálogo fica sem lista de preços.
  • Em colunas de 75 mm, o Knuth–Plass pode deixar uma linha justificada com espaços esticados além de maxWordSpacing. A carta e as notas do campo de testes foram reescritas até que a linha mais frouxa abrisse os espaços 1,33 vez a largura normal. As notas também estão ajustadas para 14 linhas: com 13, o equilíbrio de colunas da página final põe uma linha da grade em branco acima de From Our Trial Garden, e o título começa uma linha abaixo do texto da outra coluna.

Créditos

Texto
Texto original, CC BY 4.0
Imagens
  • The cover's sunflower and tomato plant, a painting · Generated With Diffusion Models · original
  • The Brandywine seed packet · Generated With Diffusion Models · original
  • The Cherokee Purple seed packet · Generated With Diffusion Models · original
  • The Gold Medal seed packet · Generated With Diffusion Models · original
  • The Amish Paste seed packet · Generated With Diffusion Models · original
  • The Jimmy Nardello seed packet · Generated With Diffusion Models · original
  • The Kentucky Wonder seed packet · Generated With Diffusion Models · original
  • The Dragon Tongue seed packet · Generated With Diffusion Models · original
  • The Black Beauty seed packet · Generated With Diffusion Models · original
  • The Waltham Butternut seed packet · Generated With Diffusion Models · original
Fontes
Gelasio (SIL OFL 1.1) · Alfa Slab One (SIL OFL 1.1) · Cabin Condensed (SIL OFL 1.1)
Sandbox