Vai al contenuto principale
Ricetta numero 58

Ricettario · Capitolo 1 · Pagina e griglia

Lettera commerciale secondo la DIN 5008

Un preventivo tedesco di due pagine su A4. L’oggetto è l’H1, il cui progetto grafico fissa intestazione, indirizzo e blocco informativo dove vuole il modulo B.

In questa pagina
Output
Canvas · PDF
Livello
Intermedio
Postext
Provata con Postext 1.31.0
Richiede ≥ 1.25.0 · postext-pdf ≥ 1.25.0
Licenza
Aggiornata il 10 ott 2026
Codice MIT · Testo CC BY 4.0
  • Esempio in inglese: non c’è ancora un’edizione italiana
  • Formato 210 × 297 mm
  • 1 colonna
  • Nunito Sans 10,5/15
  • Familjen Grotesk
  • Reddit Mono
  • 2 pagine
  • Livello
  • Postext 1.31.0
  • Impaginato in 155 ms
  • 177 righe di codice

In breve

Un’offerta di prezzo di due pagine da un laboratorio tedesco di finestre. Mostra come collocare l’indirizzo e le altre parti di una lettera commerciale dove le mette la norma tedesca, perché entri in una busta a finestra.

Che cosa comporrai

La Fensterwerkstatt Tessin, un laboratorio berlinese inventato che restaura doppie finestre, manda un preventivo all’amministrazione di un condominio. Occupa due fogli A4 in tedesco, impaginati secondo il modulo B della DIN 5008. Una fascia blu di Berlino in testa porta il logo e il nome del laboratorio. Sotto, ogni parte sta dove la mette la norma: la riga del mittente e l’indirizzo dentro il campo di 85 × 45 mm che si vede attraverso la finestra di una busta DL, il blocco informativo a 125 mm dal bordo sinistro, l’oggetto in grassetto due righe vuote sotto il campo, e i segni di piega e di foratura a 105, 148,5 e 210 mm. I dati aziendali corrono su tre colonne al piede di entrambi i fogli. Il secondo foglio ripete il riferimento accanto a «Seite 2 von 2» e finisce con una firma disegnata in codice.

Questa ricetta risponde a

  • Come fisso un badge o un adesivo in una posizione fissa della pagina?
  • Come aggiungo una filigrana, una tinta di fondo o un’immagine decorativa su ogni pagina?
  • Come nascondo le testatine nelle aperture e nelle pagine bianche, o coloro un verso bianco con il colore della parte?
  • Come compongo un’epigrafe, una dedica, una firma o una citazione in evidenza con una grande virgoletta?

La risposta breve

script.js · righe 59–78nel codice 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: each '\n' in it starts a new line.
  text('to', '{attr.to}', FACE.address, 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,
  breakBefore: { enabled: true, parity: 'any' }, // a letter starts on the next sheet
  marginBottom: pt(2 * LEAD), // two blank lines between the subject and the salutation
  advancedDesign: firstPage() }] });

Ingredienti

Tipo
Nunito Sans, Familjen Grotesk, Reddit Mono (SIL OFL 1.1)
Risorse
Nessuna: ogni immagine è disegnata nel codice

Preparazione

#1 · Fissa la prima pagina al foglio in millimetri

script.js · righe 59–78nel codice 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: each '\n' in it starts a new line.
  text('to', '{attr.to}', FACE.address, 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,
  breakBefore: { enabled: true, parity: 'any' }, // a letter starts on the next sheet
  marginBottom: pt(2 * LEAD), // two blank lines between the subject and the salutation
  advancedDesign: firstPage() }] });

at() ancora un elemento per il suo angolo superiore sinistro a 'page', il formato rifilato (collocazione degli elementi), quindi ogni x e y è una distanza dall’angolo del foglio e le misure della norma entrano invariate: l’indirizzo a 25 mm in orizzontale e 62,7 mm in verticale, la prima etichetta del blocco informativo a 125 e 50. Questi elementi formano il progetto grafico dell’H1. Il testo del titolo è l’oggetto ({titleText}) e i suoi attributi contengono l’indirizzo e i riferimenti, così la configurazione contiene la carta da lettere e il Markdown contiene la lettera. L’oggetto, a 100,6 mm, è l’elemento più basso, quindi il titolo riserva il foglio fino al piede dell’oggetto. marginBottom aggiunge le due righe vuote, e il saluto iniziale comincia sulla linea di griglia successiva, a 117 mm.

L’indirizzo è un solo attributo con \n tra le righe, e ogni \n comincia una nuova riga nel testo del titolo grafico. Il titolo sta nella colonna di testo, eppure il suo progetto grafico dipinge la fascia, il logo e il nome sopra il margine superiore di 27 mm, perché ogni elemento è fissato alla pagina.

#2 · Concatena il blocco informativo da un’unica etichetta fissata

script.js · righe 82–93nel codice 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) } }),
]);

Solo la prima etichetta è fissata al foglio. Ogni altra etichetta è ancorata 'below' a quella precedente, e ogni valore alla sua etichetta con 'align-top' e uno scostamento di 29 mm, così una riga aggiunta a INFO non ha bisogno di coordinate. Il testo dei titoli grafici mette la linea di base all’80% del riquadro della riga. L’etichetta in corpo 7 e il valore in corpo 9 impostano entrambi lineHeight: pt(ROW), una riga di 12 pt, così condividono la linea di base su ogni riga di 4,23 mm.

#3 · Disegna la carta intestata nello stesso progetto grafico

script.js · righe 97–118nel codice 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')];
  }),
];

La fascia è un riquadro fissato all’angolo del foglio. width: 'fill' la stende da un bordo all’altro, e la sua altezza di 32 mm la porta 5 mm oltre il margine superiore di 27 mm. Il logo è un elemento immagine che disegna una risorsa alta 18 mm e prende la larghezza dal disegno (elementi immagine). Il nome sta 'right-of' il logo, e la riga delle attività 'below' il nome. Otto filetti di 3 mm disegnano un angolo a ogni vertice del campo indirizzo di 85 × 45 mm, la parte del foglio che mostra la finestra della busta.

#4 · Metti nell’intestazione e nel piè di pagina ciò che hanno in comune tutti i fogli

script.js · righe 122–144nel codice 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) }))),
] };

Un elemento del progetto grafico dell’H1 fissato sotto l’oggetto si aggiunge all’altezza che il titolo riserva. Con il segno di piega a 210 mm nel progetto grafico, il saluto scende a 222,8 mm e la lettera arriva a tre pagine. Lo slot dell’intestazione si dipinge su ogni pagina e non riserva spazio, quindi i tre segni vanno lì (intestazioni e piè di pagina). La pagina 1 si apre con l’H1, il che la rende una pagina 'opener', così pages: 'body' limita la striscia blu, la riga del riferimento e Seite {pageNumber} von {totalPages} ai fogli che seguono. La riga del riferimento legge gli attributi dell’H1 tramite {attr.unser-zeichen} e {attr.datum}.

#5 · Componi la firma nel flusso

script.js · righe 148–157nel codice 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"} compone il disegno sotto «Mit freundlichen Grüßen», e width: 0.297 lo rende largo 49 mm e alto 21,1 mm, poco meno di quattro righe da 15 pt. Il testo dopo una risorsa nel flusso torna sulla griglia del corpo. A 0.25 il disegno era alto 17,8 mm, e i 3,4 mm che la griglia aggiungeva sotto allontanavano il nome dalla firma. Il tipo di risorsa ha un captionPrefix vuoto, e Postext non compone una didascalia quando sia il prefisso sia la didascalia sono vuoti, quindi sotto la firma non compaiono etichetta né numero (tipi di risorsa). Il logo è una risorsa dello stesso tipo, disegnata solo dall’elemento immagine. Nessun :ref o ::resource lo nomina, quindi non entra mai nel flusso.

La ricetta 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.25.0
// A German quotation on A4 whose H1 design pins the letterhead and the address to DIN 5008.
import {
  buildDocumentWithFonts, renderPageToCanvas, 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): a colour linked to its palette entry, with the entry's hex beside the id.
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 by default, and a header or footer line ends in '…': left, wrapped.
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: a dimension is the line's depth, a number multiplies the size
  label: face('Reddit Mono', 7, 400, 'muted', { lineHeight: pt(ROW) }),
  value: face('Nunito Sans', 9, 400, 'ink', { lineHeight: pt(ROW) }),
  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: mm((DIN.field.h - DIN.notes) / 6) }),
  subject: face('Familjen Grotesk', 11, 700, 'ink', { lineHeight: pt(LEAD) }),
  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: each '\n' in it starts a new line.
  text('to', '{attr.to}', FACE.address, 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,
  breakBefore: { enabled: true, parity: 'any' }, // a letter starts on the next sheet
  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 = () => ({
  locale: 'de', resourceTypes, colorPalette, // German hyphenation
  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"}
Esempio Markdown · 45 righe · 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. 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()); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); 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: uguale in ogni ricetta · 315 righe// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { 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', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `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. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; 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 at = fontsourceAt(id); 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 = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** 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, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** 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 v2 ── 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. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its 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 files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 ───────────────────────────────────────────────────────────────────────

Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)

Errori frequenti

Errore frequente

Un’apertura riserva altezza fino al suo elemento ancorato alla pagina più basso

Un’apertura con progetto avanzato riserva l’altezza del suo elemento più basso, e contano anche gli elementi ancorati alla pagina o all’abbondanza sotto il titolo, quindi una decorazione al piede della pagina spinge il testo alla pagina successiva. Dai a un elemento del genere reserve: false (da postext 1.5): viene dipinto dove è ancorato e non riserva nulla. Gli elementi che seguono la fascia stessa (ancorati al suo piede, o alti 'fill') non ne determinano mai l’altezza. Aperture progettate →

Errore frequente

Gli elementi di intestazione e piè di pagina dipingono sopra il testo

Gli elementi di intestazione e piè di pagina vengono dipinti sopra la pagina e l’area del testo non fa loro spazio. Tienili entro i margini, che sono ciò che ne riserva lo spazio. Testatine e folio →

  • {totalPages} conta le pagine dell’intero documento, quindi con due lettere in un solo file Markdown il secondo foglio della prima lettera direbbe «Seite 2 von 4». Costruisci ogni lettera da sola.

Crediti

Testo
Testo originale, CC BY 4.0
Font
Nunito Sans (SIL OFL 1.1) · Familjen Grotesk (SIL OFL 1.1) · Reddit Mono (SIL OFL 1.1)
SandboxPDF