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

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

Carta comercial segundo a DIN 5008

Um orçamento alemão de duas folhas A4. O assunto é o H1, e o design dele fixa o timbre, o endereço e o bloco de informações nas posições do formulário B.

Nesta página
Saída
Canvas · PDF
Postext
Testada com o Postext 1.19.1
Requer ≥ 1.4.1 · postext-pdf ≥ 1.4.1
Licença
Atualizada em 27 de set. de 2026
Código MIT · Texto CC BY 4.0
  • Amostra em inglês: ainda sem edição em português
  • Refile 210 × 297 mm
  • 1 coluna
  • Nunito Sans 10,5/15
  • Familjen Grotesk
  • Reddit Mono
  • 2 páginas
  • Nível
  • Postext 1.19.1
  • Diagramado em 103 ms
  • 180 linhas de código

Em poucas palavras

Um orçamento de duas páginas de uma oficina de janelas alemã. Mostra como pôr o endereço e as outras partes de uma carta comercial onde a norma alemã manda, para caber num envelope de janela.

O que você vai compor

A Fensterwerkstatt Tessin, uma oficina inventada de Berlim que restaura janelas duplas, manda um orçamento à administradora de um prédio de apartamentos. São duas folhas A4 em alemão, diagramadas segundo o formulário B da DIN 5008. Uma faixa azul-da-prússia no alto traz o logotipo e o nome da oficina. Embaixo dela, cada parte fica onde a norma manda: a linha do remetente e o endereço dentro do campo de 85 × 45 mm que aparece pela janela de um envelope DL, o bloco de informações a 125 mm da borda esquerda, o assunto em negrito duas linhas em branco abaixo do campo, e as marcas de dobra e de furação a 105, 148,5 e 210 mm. Os dados da empresa correm em três colunas no pé das duas folhas. A segunda folha repete a referência ao lado de “Seite 2 von 2” e termina com uma assinatura desenhada em código.

Esta receita responde a

  • Como fixo um selo ou um adesivo em uma posição exata da página?
  • Como acrescento uma marca-d'água, um fundo de cor ou uma imagem decorativa em todas as páginas?
  • Como oculto os cabeços nas aberturas e nas páginas em branco, ou pinto uma página par em branco com a cor da parte?
  • Como componho uma epígrafe, uma dedicatória, uma assinatura ou um olho com aspas grandes?

A resposta curta

script.js · linhas 58–80no código completo
const at = (x, y, size) => ({ anchor: { to: 'page', edge: 'top-left' }, // mm from the corner
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const ZONE = DIN.field.y + DIN.notes; // 62.7 mm: where the six address lines start
const SUBJECT = DIN.field.y + DIN.field.h + 2 * LEAD * PT; // two blank lines under the field
const firstPage = () => ({ enabled: true, slot: { elements: [ // in paint order
  ...letterhead(), // band, logo and name, and the corners of the window
  text('from', FROM, FACE.from, at(DIN.left, ZONE - 3.6)), // the return line, underlined
  rule('from-rule', 'muted', { anchor: { to: '#from', edge: 'below' }, offset: { y: mm(0.6) },
    size: { width: mm(WINDOW) } }),
  // One attribute holds the whole address. Its '\n' starts a new line only when
  // paragraphIndent is above zero (gotcha: design-text-newline).
  text('to', '{attr.to}', { ...FACE.address, paragraphIndent: pt(0.01) },
    at(DIN.left, ZONE, { width: mm(WINDOW) })),
  ...infoBlock(), // labels, and values from {attr.*}
  text('subject', '{titleText}', FACE.subject, at(DIN.left, SUBJECT, { width: mm(TEXT) })),
] } });
// The H1 reserves the sheet down to its lowest element (gotcha: opener-reserves-anchored).
// fontFamily: the design paints the H1, but the build measures it (default: Open Sans).
const headings = () => ({ fontFamily: 'Familjen Grotesk', levels: [{ level: 1,
  span: 'page', // a design kept in the column would be cut off at the top margin
  breakBefore: { enabled: true, parity: 'any' }, // gotcha: headings-drop-h1-break
  marginBottom: pt(2 * LEAD), // two blank lines between the subject and the salutation
  advancedDesign: firstPage() }] });

Ingredientes

Tipografia
Nunito Sans, Familjen Grotesk, Reddit Mono (SIL OFL 1.1)
Materiais
Nenhum: todas as imagens são desenhadas em código

Preparo

#1 · Fixe a primeira página na folha em milímetros

script.js · linhas 58–80no código completo
const at = (x, y, size) => ({ anchor: { to: 'page', edge: 'top-left' }, // mm from the corner
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const ZONE = DIN.field.y + DIN.notes; // 62.7 mm: where the six address lines start
const SUBJECT = DIN.field.y + DIN.field.h + 2 * LEAD * PT; // two blank lines under the field
const firstPage = () => ({ enabled: true, slot: { elements: [ // in paint order
  ...letterhead(), // band, logo and name, and the corners of the window
  text('from', FROM, FACE.from, at(DIN.left, ZONE - 3.6)), // the return line, underlined
  rule('from-rule', 'muted', { anchor: { to: '#from', edge: 'below' }, offset: { y: mm(0.6) },
    size: { width: mm(WINDOW) } }),
  // One attribute holds the whole address. Its '\n' starts a new line only when
  // paragraphIndent is above zero (gotcha: design-text-newline).
  text('to', '{attr.to}', { ...FACE.address, paragraphIndent: pt(0.01) },
    at(DIN.left, ZONE, { width: mm(WINDOW) })),
  ...infoBlock(), // labels, and values from {attr.*}
  text('subject', '{titleText}', FACE.subject, at(DIN.left, SUBJECT, { width: mm(TEXT) })),
] } });
// The H1 reserves the sheet down to its lowest element (gotcha: opener-reserves-anchored).
// fontFamily: the design paints the H1, but the build measures it (default: Open Sans).
const headings = () => ({ fontFamily: 'Familjen Grotesk', levels: [{ level: 1,
  span: 'page', // a design kept in the column would be cut off at the top margin
  breakBefore: { enabled: true, parity: 'any' }, // gotcha: headings-drop-h1-break
  marginBottom: pt(2 * LEAD), // two blank lines between the subject and the salutation
  advancedDesign: firstPage() }] });

at() ancora um elemento pelo canto superior esquerdo a 'page', a caixa de refile (posicionamento de elementos), então cada x e cada y é uma distância a partir do canto da folha, e os números da norma entram sem mudança: o endereço a 25 mm da esquerda e 62,7 mm do alto, o primeiro rótulo do bloco de informações a 125 e 50. Esses elementos formam o design do H1. O texto do título é o assunto ({titleText}) e os atributos dele guardam o endereço e as referências, de modo que a configuração guarda o papel timbrado e o Markdown guarda a carta. O assunto, a 100,6 mm, é o elemento mais baixo, então o título reserva a folha até o pé do assunto. marginBottom acrescenta as duas linhas em branco, e a saudação começa na linha seguinte da grade, a 117 mm.

O endereço é um único atributo com \n entre as linhas. Na 1.4.1, um texto de design só transforma o \n de um atributo em quebra de linha quando o seu paragraphIndent é maior que zero. O endereço usa 0,01 pt, um recuo pequeno demais para se ver; com 0, a barra e o n saem impressos no endereço. Sem span: 'page', o design fica na coluna de texto e é cortado na margem superior de 27 mm, o que tira o logotipo e o nome e deixa só os 5 mm de baixo da faixa.

#2 · Encadeie o bloco de informações a partir de um rótulo fixo

script.js · linhas 84–95no código completo
const INFO = [['Ihr Zeichen', 'ihr-zeichen'], ['Ihre Nachricht vom', 'ihre-nachricht'],
  ['Unser Zeichen', 'unser-zeichen'], ['Name', 'name'], ['Telefon', 'telefon'],
  ['E-Mail', 'email'], ['Datum', 'datum']]; // [label, heading attribute]
const VALUE = 29; // mm from the labels to the values
const infoBlock = () => INFO.flatMap(([label, key], i) => [
  text(`label-${i}`, label, FACE.label, i === 0 ? at(DIN.info.x, DIN.info.y) : {
    anchor: { to: `#label-${i - 1}`, edge: 'below' }, // each label under the last
    offset: { y: mm(key === 'datum' ? ROW * PT : 0) } }), // a blank row before the date
  text(`value-${i}`, `{attr.${key}}`, FACE.value, { // a 12 pt row as well, on the label's baseline
    anchor: { to: `#label-${i}`, edge: 'align-top' }, offset: { x: mm(VALUE) },
    size: { width: mm(DIN.info.w - VALUE) } }),
]);

Só o primeiro rótulo é fixado na folha. Cada um dos outros é ancorado 'below' do anterior, e cada valor ao seu rótulo com 'align-top' e um deslocamento de 29 mm, então uma linha acrescentada a INFO não precisa de coordenadas. O texto de design põe a linha de base a 80% da caixa da linha. O rótulo de 7 pt com lineHeight: 12 / 7 e o valor de 9 pt com 12 / 9 ganham os dois uma linha de 12 pt, e por isso dividem a linha de base em cada fileira de 4,23 mm.

#3 · Desenhe o timbre no mesmo design

script.js · linhas 99–120no código completo
const [BAND, TICK] = [32, 3]; // mm: the band's depth, each arm of the window's corner angles
const LOGO = { y: 7, h: 18 }; // mm: the mark, centred in the band
const CRAFTS = 3 * 7.5 * 1.45 * PT; // mm: three 7.5 pt lines at 1.45
const letterhead = () => [
  { kind: 'box', id: 'band', style: { backgroundColor: col('brand') },
    placement: at(0, 0, { width: 'fill', height: mm(BAND) }) },
  { kind: 'image', id: 'logo', resourceId: 'logo',
    placement: at(DIN.left, LOGO.y, { height: mm(LOGO.h) }) },
  text('name', 'Tessin', face('Familjen Grotesk', 30, 700, 'paper', { lineHeight: 1 }),
    { anchor: { to: '#logo', edge: 'right-of' }, offset: { x: mm(5), y: mm(1.2) } }),
  text('trade', 'FENSTERWERKSTATT · BERLIN', face('Reddit Mono', 7.5, 500, 'sky',
    { letterSpacing: pt(1.2) }), { anchor: { to: '#name', edge: 'below' },
    offset: { x: mm(0.4), y: mm(1.5) } }),
  text('crafts', 'Kastenfenster\nHolzfenster\nDenkmalpflege', face('Reddit Mono', 7.5,
    400, 'sky', { lineHeight: 1.45, align: 'right' }),
    at(210 - DIN.right - 45, LOGO.y + (LOGO.h - CRAFTS) / 2, { width: mm(45) })),
  ...[[0, 0], [1, 0], [0, 1], [1, 1]].flatMap(([right, low], i) => { // turned inwards
    const [x, y] = [DIN.field.x + right * DIN.field.w, DIN.field.y + low * DIN.field.h];
    return [rule(`across-${i}`, 'marks', at(x - right * TICK, y, { width: mm(TICK) })),
      rule(`down-${i}`, 'marks', at(x, y - low * TICK, { height: mm(TICK) }), 'vertical')];
  }),
];

A faixa é uma caixa fixada no canto da folha. width: 'fill' a estende de borda a borda, e os seus 32 mm de altura a levam 5 mm além da margem superior de 27 mm. O logotipo é um elemento de imagem que desenha um recurso de 18 mm de altura e tira a largura do próprio desenho (elementos de imagem). O nome fica pendurado 'right-of' do logotipo, e a linha do ofício, 'below' do nome. Oito fios de 3 mm desenham uma cantoneira em cada canto do campo de endereço de 85 × 45 mm, a parte da folha que a janela do envelope mostra.

#4 · Ponha no cabeçalho e no rodapé o que todas as folhas compartilham

script.js · linhas 124–146no código completo
const header = { elements: [
  // In the H1's design they would stretch its reserve (gotcha: opener-reserves-anchored).
  ...[[DIN.folds[0], 5], [DIN.punch, 8], [DIN.folds[1], 5]].map(([y, length], i) =>
    rule(`mark-${i}`, 'marks', at(0, y, { width: mm(length) }))), // fold, punch, fold
  // Page 1 opens with the H1, which makes it an 'opener'; later sheets are 'body' pages.
  { kind: 'box', id: 'strip', pages: 'body', style: { backgroundColor: col('brand') },
    placement: at(0, 0, { width: 'fill', height: mm(4) }) },
  text('ref', 'Fensterwerkstatt Tessin · Unser Zeichen {attr.unser-zeichen} · {attr.datum}',
    FACE.small, at(DIN.left, 13.2), { pages: 'body' }),
  text('page', 'Seite {pageNumber} von {totalPages}', FACE.small,
    at(210 - DIN.right - 40, 13.2, { width: mm(40) }), { pages: 'body', align: 'right' }),
] };
const FOOT = 297 - DIN.bottom + 5; // mm: the hairline over the company data
const COMPANY = [ // three columns, 57 mm apart, inside the bottom margin
  'Fensterwerkstatt Tessin GmbH\nRennbahnstraße 48\n13086 Berlin\nTelefon 030 23125-400',
  'Geschäftsführer Martin Tessin\nAmtsgericht Charlottenburg\nHRB 000000 B\nUSt-IdNr. DE000000000',
  'Musterbank Berlin\nIBAN DE00 0000 0000 0000 0000 00\nBIC MUSTDEBBXXX',
];
const footer = { elements: [
  rule('foot-rule', 'rule', at(DIN.left, FOOT, { width: mm(TEXT) })),
  ...COMPANY.map((lines, i) => text(`company-${i}`, lines, FACE.small,
    at(DIN.left + 57 * i, FOOT + 2.5, { width: mm(52) }))),
] };

Um elemento do design do H1 fixado abaixo do assunto aumenta a altura que o título reserva. Com a marca de dobra de 210 mm no design, a saudação desce para 222,8 mm e a carta passa a ter três páginas. O espaço do cabeçalho é pintado em todas as páginas e não reserva espaço, por isso as três marcas vão nele (cabeçalhos e rodapés). A página 1 abre com o H1, o que faz dela uma página 'opener', então pages: 'body' limita a tira azul, a linha de referência e Seite {pageNumber} von {totalPages} às folhas seguintes. A linha de referência lê os atributos do H1 com {attr.unser-zeichen} e {attr.datum}.

#5 · Ponha a assinatura no fluxo do texto

script.js · linhas 150–159no código completo
const resourceTypes = [{ id: 'drawing', name: 'Zeichnung', shortLabel: 'Zeichnung',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }];
const drawing = (id, width, height, altText, placement) => ({ id, typeId: 'drawing',
  kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, altText, placement,
  createdAt: 0, updatedAt: 0 });
const resources = [ // an image element draws the logo; nothing cites it
  drawing('logo', 200, 280, 'Zeichen der Fensterwerkstatt Tessin, ein Kastenfenster'),
  drawing('signature', 760, 328, 'Unterschrift von Martin Tessin',
    { position: 'here', width: 0.297 }), // 49 × 21.1 mm: under 4 lines, no grid gap below
];

::resource{id="signature"} põe o desenho embaixo de “Mit freundlichen Grüßen”, e width: 0.297 o deixa com 49 mm de largura e 21,1 mm de altura, um pouco menos que quatro linhas de 15 pt. O texto depois de um recurso no fluxo volta para a grade do texto corrido. Com 0.25, o desenho tinha 17,8 mm de altura, e os 3,4 mm que a grade acrescentava embaixo afastavam o nome da assinatura. O tipo de recurso tem captionPrefix vazio, e a 1.4.1 não compõe legenda quando o prefixo e a legenda estão vazios, então nenhum rótulo ou número aparece embaixo da assinatura (tipos de recurso). O logotipo é um recurso do mesmo tipo, desenhado só pelo elemento de imagem. Nenhum :ref ou ::resource o cita, então ele nunca entra no fluxo.

A receita completa

Sandbox
// ═══ Postext Cookbook · Nº 058 · Business letter to DIN 5008 ═════════════════════
// https://postext.dev/en/cookbook/din-business-letter
// Code: MIT · Text: original, in German (CC BY 4.0) · Logo, signature: drawn in code (CC BY 4.0)
// Fonts: Nunito Sans, Familjen Grotesk, Reddit Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// A German quotation on A4 whose H1 design pins the letterhead and the address to DIN 5008.
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1f2429', brand: '#1b4a73', // text, a blue-grey near-black; Berlin blue, for the band
  sky: '#b4cbe0', paper: '#ffffff', // the trade and the crafts on the band; the logo and name
  muted: '#5b6670', pen: '#26408f', // labels, return line, company data; the signature's ink
  marks: '#98a3ad', rule: '#c9d1d8', // fold marks, window corners; the hairline in the footer
};
// col(id) links a palette entry and keeps its hex for designs (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// main-color is the engine's default accent: the dashes of the list take it.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.brand })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

// DIN 5008, form B: every position in mm from the sheet's top-left corner.
const DIN = {
  left: 25, right: 20, top: 27, bottom: 30, // the margins of the text
  field: { x: 20, y: 45, w: 85, h: 45 }, // the address field, behind the envelope window
  notes: 17.7, // the field's top zone, for the return line; the address zone takes the rest
  info: { x: 125, y: 50, w: 75 }, // the information block
  folds: [105, 210], punch: 148.5, // fold marks for a DL envelope, and the punch mark
};
const TEXT = 210 - DIN.left - DIN.right; // 165 mm of text width
const WINDOW = DIN.field.w - 2 * (DIN.left - DIN.field.x); // 75 mm: text inset 5 mm each side
const PT = 25.4 / 72; // mm per point
const [LEAD, ROW] = [15, 12]; // pt: the body's leading, a row of the information block

// Design text is centred and cut with '…' by default (gotcha: overflow-ellipsis-default).
const text = (id, content, face, placement, more) => ({ kind: 'text', id, content,
  align: 'left', overflow: 'wrap', ...face, placement, ...more }); // more: pages, align
const rule = (id, colour, placement, direction = 'horizontal') => ({ kind: 'rule', id,
  direction, thickness: pt(0.5), color: col(colour), placement });
const face = (fontFamily, size, fontWeight, colour, more) => ({ fontFamily, fontSize: pt(size),
  fontWeight, color: col(colour), ...more });
const FACE = { // lineHeight is a multiple, never pt() (gotcha: design-lineheight-multiple)
  label: face('Reddit Mono', 7, 400, 'muted', { lineHeight: ROW / 7 }),
  value: face('Nunito Sans', 9, 400, 'ink', { lineHeight: ROW / 9 }),
  from: face('Reddit Mono', 6.5, 400, 'muted', { lineHeight: 1.2 }),
  address: face('Nunito Sans', 10, 400, 'ink', // six lines fill the address zone
    { lineHeight: (DIN.field.h - DIN.notes) / 6 / PT / 10 }),
  subject: face('Familjen Grotesk', 11, 700, 'ink', { lineHeight: LEAD / 11 }),
  small: face('Reddit Mono', 7, 400, 'muted', { lineHeight: 1.45 }),
};
const FROM = 'Fensterwerkstatt Tessin · Rennbahnstr. 48 · 13086 Berlin'; // 72 mm: fits

// #region answer: the subject line's design pins page 1 to the sheet at form B's positions
const at = (x, y, size) => ({ anchor: { to: 'page', edge: 'top-left' }, // mm from the corner
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const ZONE = DIN.field.y + DIN.notes; // 62.7 mm: where the six address lines start
const SUBJECT = DIN.field.y + DIN.field.h + 2 * LEAD * PT; // two blank lines under the field
const firstPage = () => ({ enabled: true, slot: { elements: [ // in paint order
  ...letterhead(), // band, logo and name, and the corners of the window
  text('from', FROM, FACE.from, at(DIN.left, ZONE - 3.6)), // the return line, underlined
  rule('from-rule', 'muted', { anchor: { to: '#from', edge: 'below' }, offset: { y: mm(0.6) },
    size: { width: mm(WINDOW) } }),
  // One attribute holds the whole address. Its '\n' starts a new line only when
  // paragraphIndent is above zero (gotcha: design-text-newline).
  text('to', '{attr.to}', { ...FACE.address, paragraphIndent: pt(0.01) },
    at(DIN.left, ZONE, { width: mm(WINDOW) })),
  ...infoBlock(), // labels, and values from {attr.*}
  text('subject', '{titleText}', FACE.subject, at(DIN.left, SUBJECT, { width: mm(TEXT) })),
] } });
// The H1 reserves the sheet down to its lowest element (gotcha: opener-reserves-anchored).
// fontFamily: the design paints the H1, but the build measures it (default: Open Sans).
const headings = () => ({ fontFamily: 'Familjen Grotesk', levels: [{ level: 1,
  span: 'page', // a design kept in the column would be cut off at the top margin
  breakBefore: { enabled: true, parity: 'any' }, // gotcha: headings-drop-h1-break
  marginBottom: pt(2 * LEAD), // two blank lines between the subject and the salutation
  advancedDesign: firstPage() }] });
// #endregion

// #region info: the information block, labels from the stationery, values from the letter
const INFO = [['Ihr Zeichen', 'ihr-zeichen'], ['Ihre Nachricht vom', 'ihre-nachricht'],
  ['Unser Zeichen', 'unser-zeichen'], ['Name', 'name'], ['Telefon', 'telefon'],
  ['E-Mail', 'email'], ['Datum', 'datum']]; // [label, heading attribute]
const VALUE = 29; // mm from the labels to the values
const infoBlock = () => INFO.flatMap(([label, key], i) => [
  text(`label-${i}`, label, FACE.label, i === 0 ? at(DIN.info.x, DIN.info.y) : {
    anchor: { to: `#label-${i - 1}`, edge: 'below' }, // each label under the last
    offset: { y: mm(key === 'datum' ? ROW * PT : 0) } }), // a blank row before the date
  text(`value-${i}`, `{attr.${key}}`, FACE.value, { // a 12 pt row as well, on the label's baseline
    anchor: { to: `#label-${i}`, edge: 'align-top' }, offset: { x: mm(VALUE) },
    size: { width: mm(DIN.info.w - VALUE) } }),
]);
// #endregion

// #region letterhead: a band of Berlin blue, the logo and the name, the window's corners
const [BAND, TICK] = [32, 3]; // mm: the band's depth, each arm of the window's corner angles
const LOGO = { y: 7, h: 18 }; // mm: the mark, centred in the band
const CRAFTS = 3 * 7.5 * 1.45 * PT; // mm: three 7.5 pt lines at 1.45
const letterhead = () => [
  { kind: 'box', id: 'band', style: { backgroundColor: col('brand') },
    placement: at(0, 0, { width: 'fill', height: mm(BAND) }) },
  { kind: 'image', id: 'logo', resourceId: 'logo',
    placement: at(DIN.left, LOGO.y, { height: mm(LOGO.h) }) },
  text('name', 'Tessin', face('Familjen Grotesk', 30, 700, 'paper', { lineHeight: 1 }),
    { anchor: { to: '#logo', edge: 'right-of' }, offset: { x: mm(5), y: mm(1.2) } }),
  text('trade', 'FENSTERWERKSTATT · BERLIN', face('Reddit Mono', 7.5, 500, 'sky',
    { letterSpacing: pt(1.2) }), { anchor: { to: '#name', edge: 'below' },
    offset: { x: mm(0.4), y: mm(1.5) } }),
  text('crafts', 'Kastenfenster\nHolzfenster\nDenkmalpflege', face('Reddit Mono', 7.5,
    400, 'sky', { lineHeight: 1.45, align: 'right' }),
    at(210 - DIN.right - 45, LOGO.y + (LOGO.h - CRAFTS) / 2, { width: mm(45) })),
  ...[[0, 0], [1, 0], [0, 1], [1, 1]].flatMap(([right, low], i) => { // turned inwards
    const [x, y] = [DIN.field.x + right * DIN.field.w, DIN.field.y + low * DIN.field.h];
    return [rule(`across-${i}`, 'marks', at(x - right * TICK, y, { width: mm(TICK) })),
      rule(`down-${i}`, 'marks', at(x, y - low * TICK, { height: mm(TICK) }), 'vertical')];
  }),
];
// #endregion

// #region furniture: marks on every sheet, 'Seite 2 von 2' on the next, the company data
const header = { elements: [
  // In the H1's design they would stretch its reserve (gotcha: opener-reserves-anchored).
  ...[[DIN.folds[0], 5], [DIN.punch, 8], [DIN.folds[1], 5]].map(([y, length], i) =>
    rule(`mark-${i}`, 'marks', at(0, y, { width: mm(length) }))), // fold, punch, fold
  // Page 1 opens with the H1, which makes it an 'opener'; later sheets are 'body' pages.
  { kind: 'box', id: 'strip', pages: 'body', style: { backgroundColor: col('brand') },
    placement: at(0, 0, { width: 'fill', height: mm(4) }) },
  text('ref', 'Fensterwerkstatt Tessin · Unser Zeichen {attr.unser-zeichen} · {attr.datum}',
    FACE.small, at(DIN.left, 13.2), { pages: 'body' }),
  text('page', 'Seite {pageNumber} von {totalPages}', FACE.small,
    at(210 - DIN.right - 40, 13.2, { width: mm(40) }), { pages: 'body', align: 'right' }),
] };
const FOOT = 297 - DIN.bottom + 5; // mm: the hairline over the company data
const COMPANY = [ // three columns, 57 mm apart, inside the bottom margin
  'Fensterwerkstatt Tessin GmbH\nRennbahnstraße 48\n13086 Berlin\nTelefon 030 23125-400',
  'Geschäftsführer Martin Tessin\nAmtsgericht Charlottenburg\nHRB 000000 B\nUSt-IdNr. DE000000000',
  'Musterbank Berlin\nIBAN DE00 0000 0000 0000 0000 00\nBIC MUSTDEBBXXX',
];
const footer = { elements: [
  rule('foot-rule', 'rule', at(DIN.left, FOOT, { width: mm(TEXT) })),
  ...COMPANY.map((lines, i) => text(`company-${i}`, lines, FACE.small,
    at(DIN.left + 57 * i, FOOT + 2.5, { width: mm(52) }))),
] };
// #endregion

// #region signature: a drawing set in the flow by ::resource, with no label or number
const resourceTypes = [{ id: 'drawing', name: 'Zeichnung', shortLabel: 'Zeichnung',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }];
const drawing = (id, width, height, altText, placement) => ({ id, typeId: 'drawing',
  kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, altText, placement,
  createdAt: 0, updatedAt: 0 });
const resources = [ // an image element draws the logo; nothing cites it
  drawing('logo', 200, 280, 'Zeichen der Fensterwerkstatt Tessin, ein Kastenfenster'),
  drawing('signature', 760, 328, 'Unterschrift von Martin Tessin',
    { position: 'here', width: 0.297 }), // 49 × 21.1 mm: under 4 lines, no grid gap below
];
// #endregion

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  locale: 'de', resourceTypes, colorPalette, // German hyphenation (gotcha: hyphenation-locales)
  page: { sizePreset: 'custom', width: mm(210), height: mm(297), dpi: 150, margins: {
    top: mm(DIN.top), bottom: mm(DIN.bottom), left: mm(DIN.left), right: mm(DIN.right) } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Nunito Sans', fontSize: pt(10.5), lineHeight: pt(LEAD), // justified
    color: col('ink'), boldColor: col('ink'), firstLineIndent: pt(0), // and hyphenated by default
    paragraphSpacing: true }, // DIN 5008: a blank line between paragraphs, no indent
  headings: headings(), header, footer,
  unorderedLists: { bulletChar: '–' }, // in main-color
  paragraphStyles: [
    { id: 'stack', marginBottom: pt(LEAD) }, // name and role, the enclosures: no blank lines
    { id: 'colophon', fontFamily: 'Reddit Mono', fontSize: pt(6.5), lineHeight: pt(9),
      color: col('muted') }, // a blank line under the enclosures, from the stack's margin
  ],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`# Angebot 2026-117: Instandsetzung der 24 Kastenfenster im Vorderhaus Christburger Straße 17 {to="Hausverwaltung Brenner & Kolb GmbH\nFrau Dr. Ines Kolb\nWinsstraße 11\n10405 Berlin" ihr-zeichen="Ko/CS17-F" ihre-nachricht="03.09.2026" unser-zeichen="MT 2026-117" name="Martin Tessin" telefon="030 23125-418" email="mt@tessin.example" datum="22.09.2026"}
Amostra em Markdown · 45 linhas · content.en.md Sehr geehrte Frau Dr. Kolb, vielen Dank für Ihre Anfrage und für den Ortstermin am 10. September. Wir haben dabei alle 24 Kastenfenster des Vorderhauses aufgenommen, je sechs im ersten bis vierten Obergeschoss, drei zur Straße und drei zum Hof. Die Fenster stammen aus der Bauzeit um 1896. Ihre Rahmen aus Kiefernkernholz sind bis auf wenige Stellen gesund, und in 19 Fenstern sitzt noch das mundgeblasene Zylinderglas. Eine Instandsetzung kostet deshalb deutlich weniger als ein Nachbau, und die Straßenfassade behält ihre Fenster. Die Schäden liegen vor allem an den Außenflügeln der Hofseite. Dort sind bei elf der zwölf Fenster die Wetterschenkel und die unteren Rahmenecken morsch. Der Kitt ist an fast allen Außenflügeln versprödet und stellenweise herausgebrochen, sodass bei Schlagregen Wasser in die Glasfalze läuft. Die Anstriche aus mehreren Jahrzehnten lösen sich großflächig; nach unserer Probe enthalten die beiden untersten Schichten Blei. Unser Angebot umfasst die folgenden Leistungen, die im beiliegenden Leistungsverzeichnis einzeln aufgeführt sind: - Ausbau in vier Etappen zu je sechs Fenstern, erst die Außen-, dann die Innenflügel, sodass jede Wohnung während der Arbeiten geschlossen bleibt - Entlackung in unserer Werkstatt mit Infrarotwärme statt Lösemitteln, Entsorgung der bleihaltigen Altanstriche als Sonderabfall - Holzreparatur mit Vierungen aus Altholz, neue Wetterschenkel an 22 Außenflügeln - Ersatz von 14 gesprungenen oder fehlenden Scheiben durch Restaurierungsglas - Neuverkittung aller Scheiben mit Leinölkitt, Anstrich mit Leinölfarbe in drei Schichten - Wiedereinbau, Gängigmachen der Beschläge, eine eingefräste Dichtung in jedem Innenflügel Für alle 24 Fenster berechnen wir 52.800,00 Euro netto, also 2.200,00 Euro je Fenster. Mit 19 % Umsatzsteuer von 10.032,00 Euro beträgt die Angebotssumme 62.832,00 Euro; das Angebot gilt bis zum 31. Oktober 2026. Die Flügel bauen wir von innen aus; ein Gerüst ist nicht nötig, und Arbeiten an der Fassade sind nicht enthalten. Die Arbeiten würden wir in der Zeit von Mitte Januar bis Mitte April 2027 ausführen. Die Außenflügel einer Etappe bleiben zwei Wochen in der Werkstatt, die Innenflügel eine. Die Mieterinnen und Mieter informieren wir zwei Wochen vor jeder Etappe mit einem Aushang und einem Brief, der den Tag des Ausbaus nennt. Nach jeder abgeschlossenen Etappe berechnen wir ein Viertel der Angebotssumme, zahlbar innerhalb von 14 Tagen ohne Abzug. Zur Eigentümerversammlung am 14. Oktober bringe ich gern einen instand gesetzten Außenflügel aus unserer Werkstatt mit, an dem die Eigentümer Glas, Kitt und Anstrich sehen können. Bei Fragen zum Leistungsverzeichnis erreichen Sie mich werktags von 7 bis 16 Uhr unter der Durchwahl 418. Mit freundlichen Grüßen ::resource{id="signature"} :::paragraphs{style="stack"} Martin Tessin Geschäftsführer ::: :::paragraphs{style="stack"} **Anlagen** Leistungsverzeichnis, 6 Seiten Fotodokumentation der 24 Fenster Farbbefund der Außenflügel ::: :::paragraphs{style="colophon"} Gesetzt in Nunito Sans, Familjen Grotesk und Reddit Mono (SIL OFL) · Firma, Personen und Bankdaten sind erfunden. :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the logo and the signature, drawn in code function mulberry32(seed) { // a seeded generator: the same signature on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function logoSvg(stroke) { // a Berlin box window: a cross frame, six panes and the sill const line = (d, width) => `<path d="${d}" fill="none" stroke="${stroke}" ` + `stroke-width="${width}"/>`; return '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="280" viewBox="0 0 40 56">' + line('M1.5 1.5H38.5V51.5H1.5ZM1.5 17H38.5M20 1.5V51.5', 3) + line('M1.5 34.5H38.5', 1.4) + line('M0 54.8H40', 2.2) + '</svg>'; } function catmull(points) { // a smooth path through the points, as cubic Béziers const f = (v) => v.toFixed(1); let d = `M${f(points[0][0])} ${f(points[0][1])}`; for (let i = 0; i < points.length - 1; i++) { const [p0, p1, p2, p3] = [points[i - 1] ?? points[i], points[i], points[i + 1], points[i + 2] ?? points[i + 1]]; const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6]; const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6]; d += `C${f(c1[0])} ${f(c1[1])} ${f(c2[0])} ${f(c2[1])} ${f(p2[0])} ${f(p2[1])}`; } return d; } function signatureSvg() { // 'M. Tessin' in a quick, forward-leaning hand const rand = mulberry32(5008); const SLANT = 0.32; // the lean: x moves right by a third of the height above the baseline const hand = (points) => points.map(([x, y]) => { // shake each point a little, then lean it const [jx, jy] = [x + (rand() - 0.5) * 1.4, y + (rand() - 0.5) * 1.4]; return [jx + SLANT * (60 - jy), jy]; }); const strokes = [ // x, y on a 190 × 82 sheet, baseline at 60 [[3, 63], [7, 44], [11, 18], [14, 9], [17, 22], [19, 44], [21, 60], [23, 44], [27, 22], [30, 17], [32, 30], [33, 48], [35, 61], [40, 63], [45, 57]], // M [[48.5, 61], [49.5, 60]], // the full stop [[50, 25], [55, 20], [78, 15], [102, 12], [128, 9]], // the bar of the T [[80, 13], [79, 30], [76, 48], [73, 61], [77, 66], [84, 61], [89, 52], [91, 46], [88, 43], [85, 48], [87, 57], [93, 62], [97, 56], [100, 47], [102, 44], [104, 51], [102, 58], [99, 61], [103, 62], [108, 58], [111, 48], [113, 44], [115, 51], [113, 58], [110, 61], [114, 62], [120, 58], [123, 52], [125, 46], [126, 58], [130, 63], [134, 57], [137, 47], [140, 48], [141, 62], [144, 52], [148, 46], [152, 48], [153, 61], [159, 64], [168, 57]], [[127, 37], [128.5, 36]], // the dot on the i [[168, 57], [174, 50], [172, 58], [156, 70], [120, 76], [80, 76], [50, 72], [36, 68]], ]; const pen = `fill="none" stroke="${palette.pen}" stroke-width="1.9" stroke-linecap="round" ` + 'stroke-linejoin="round"'; // The viewBox starts 3 units above the sheet, which lowers the ink towards the typed name. return '<svg xmlns="http://www.w3.org/2000/svg" width="760" height="328" viewBox="0 -3 190 82">' + strokes.map((points) => `<path d="${catmull(hand(points))}" ${pen}/>`).join('') + '</svg>'; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages paint, loaded before the first build (gotcha: fonts-first). const FONTS = { 'Nunito Sans': ['400', '700'], 'Familjen Grotesk': ['700'], 'Reddit Mono': ['400', '500'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadSvg('logo.svg', logoSvg(palette.paper)); await loadSvg('signature.svg', signatureSvg()); await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Business letter to DIN 5008', es: 'Carta comercial DIN 5008' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);
Kit · core, fonts, viewer, pdf, images: igual em todas as receitas · 316 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 · pdf v2 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType: the nearest weight the * family ships, upright if it has no italic; latin, then what the face's * letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const more = kitSubsetsFor(text, meta); const files = await Promise.all(['latin', ...more].map(async (subset) => { const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── 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)

Erros comuns

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

\n em um atributo só quebra linha com paragraphIndent > 0

Em um elemento de texto de design, um \n escrito no valor de um atributo só abre uma linha nova quando paragraphIndent é maior que zero ou há capitular; caso contrário, o texto fica em uma linha só. Dê a paragraphIndent um valor mínimo (0,01 pt) ou use um atributo por linha. Textos, fios e caixas nos designs de página →

Erro comum

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

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

Erro comum

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

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

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

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

Erro comum

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

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

Erro comum

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

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

Erro comum

O PDF pede todos os pesos e estilos de cada família

renderToPdf pede ao provedor de fontes o negrito, o itálico e o negrito itálico de cada família que um bloco poderia usar, mesmo os que nunca são impressos, e uma única recusa interrompe a exportação. O provedor deve se ajustar ao peso mais próximo que a família oferece e voltar ao romano quando não houver itálico. Fontes incorporadas ao PDF →

  • O cartão sobre \n em atributos diz que, com paragraphIndent em 0, o texto fica numa linha só. Na 1.4.1 o endereço quebra ao chegar aos 75 mm de largura, e os dois caracteres \n saem impressos onde a linha deveria quebrar.
  • {totalPages} conta as páginas do documento inteiro, então com duas cartas num mesmo arquivo Markdown a segunda folha da primeira carta diria “Seite 2 von 4”. Componha cada carta separadamente.

Créditos

Texto
Texto original, CC BY 4.0
Fontes
Nunito Sans (SIL OFL 1.1) · Familjen Grotesk (SIL OFL 1.1) · Reddit Mono (SIL OFL 1.1)
SandboxPDF