En poques paraules
Un pressupost de dues pàgines d'un taller de finestres alemany. Ensenya a col·locar l'adreça i les altres parts d'una carta comercial on mana la norma alemanya, perquè encaixi en un sobre amb finestreta.
Què compondràs
La Fensterwerkstatt Tessin, un taller berlinès inventat que restaura finestres dobles, envia un pressupost a l'administració d'una finca. Són dos fulls A4 en alemany, compostos segons la forma B de la DIN 5008. A dalt, una banda de blau de Berlín porta el logotip i el nom del taller. A sota, tot és on ho posa la norma: la línia de remitent i l'adreça, dins del camp de 85 × 45 mm que treu el cap per la finestreta d'un sobre DL; el bloc d'informació, a 125 mm de la vora esquerra; l'assumpte en negreta, dues línies en blanc sota el camp, i les marques de plec i perforació, a 105, 148,5 i 210 mm. Les dades de l'empresa ocupen tres columnes al peu de tots dos fulls. El segon repeteix la referència al costat de «Seite 2 von 2» i acaba amb una signatura dibuixada en codi.
Aquesta recepta respon a
- Com fixo una insígnia o un adhesiu en una posició concreta de la pàgina?
- Com afegeixo una marca d'aigua, un fons de color o una imatge decorativa a totes les pàgines?
- Com amago les capçaleres a les obertures i a les pàgines en blanc, o pinto una pàgina parella en blanc amb el color de la part?
- Com componc un epígraf, una dedicatòria, una signatura o una cita destacada amb una cometa gran?
La resposta curta
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() }] });
Ingredients
- Funcions
- Ancoratge d'elements de dissenyObertures dissenyadesAtributs de títolTextos, filets i caixes als dissenys de pàginaImatges als dissenys de pàginaBanda de capítol d'amplada completaCapçaleres segons el tipus de pàginaCapçaleres i folisFigures just aquíFigures i taules com a recursosTipus de recurs propisEstils de paràgrafPartició de mots i llengua del documentFormat de pàginaPaleta de color semànticaExportació a PDFFonts incrustades al PDFPàgines en un canvas
- Tipografia
- Nunito Sans, Familjen Grotesk, Reddit Mono (SIL OFL 1.1)
- Recursos
- Cap: totes les imatges es dibuixen en codi
Elaboració
#1 · Fixa la primera pàgina al full en mil·límetres
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 un element per la cantonada superior esquerra a 'page', la caixa de tall (posicionament d'elements). Cada x i cada y mesuren, per tant, la distància a la cantonada del full, i les xifres de la norma es copien tal qual: l'adreça, a 25 mm de la vora esquerra i a 62,7 mm de la superior; la primera etiqueta del bloc d'informació, a 125 i 50. Tots aquests elements formen el disseny de l'H1. El text del títol és l'assumpte ({titleText}), i l'adreça i les referències en són atributs, així que el paper de cartes queda a la configuració i la carta, al Markdown. L'assumpte, a 100,6 mm, és l'element més baix, de manera que el títol reserva el full fins al peu de l'assumpte. marginBottom hi afegeix les dues línies en blanc, i la salutació comença a la línia següent de la retícula de base, a 117 mm.
L'adreça és un únic atribut amb \n entre les seves línies. A la 1.4.1, un text de disseny només converteix en salt de línia el \n d'un atribut si el seu paragraphIndent és més gran que zero. L'adreça fa servir 0,01 pt, un sagnat que no es veu; amb 0, la barra i la n surten impreses a l'adreça. Sense span: 'page', el disseny es queda a la columna de text i es retalla al marge superior de 27 mm: el logotip i el nom desapareixen, i de la banda només en queden els 5 mm de baix.
#2 · Encadena el bloc d'informació des d'una etiqueta fixa
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) } }),
]);
Només la primera etiqueta està fixada al full. Les altres pengen 'below' de l'anterior, i cada valor penja de la seva etiqueta amb 'align-top' i 29 mm de desplaçament, així que afegir una fila a INFO no obliga a calcular coordenades. El text de disseny situa la línia de base al 80 % de l'alçada de línia. L'etiqueta de 7 pt (lineHeight: 12 / 7) i el valor de 9 pt (12 / 9) tenen tots dos una línia de 12 pt, i per això comparteixen línia de base a cada fila de 4,23 mm.
#3 · Dibuixa el membret al mateix disseny
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 banda és una caixa fixada a la cantonada del full. width: 'fill' l'estén de vora a vora, i els seus 32 mm d'alçada la porten 5 mm per sota del marge superior, que és a 27 mm. El logotip és un element d'imatge que dibuixa un recurs de 18 mm d'alçada i pren l'amplada del mateix dibuix (elements d'imatge). El nom penja 'right-of' del logotip, i la línia de l'ofici, 'below' del nom. Vuit filets de 3 mm formen un escaire a cada cantonada del camp d'adreça, els 85 × 45 mm del full que treuen el cap per la finestreta del sobre.
#4 · Posa a la capçalera i al peu el que comparteixen tots els fulls
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) }))),
] };
Qualsevol element del disseny de l'H1 fixat per sota de l'assumpte suma alçada a la reserva del títol. Amb la marca de plec de 210 mm al disseny, la salutació baixa a 222,8 mm i la carta passa a tres pàgines. La ranura de la capçalera es pinta a totes les pàgines sense reservar espai, i per això les tres marques hi van (capçaleres i peus). La pàgina 1 comença amb l'H1, cosa que la converteix en pàgina d'obertura ('opener'), així que pages: 'body' limita la franja blava, la línia de referència i Seite {pageNumber} von {totalPages} als fulls següents. La línia de referència pren els atributs de l'H1 amb {attr.unser-zeichen} i {attr.datum}.
#5 · Col·loca la signatura al flux del text
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"} posa el dibuix sota «Mit freundlichen Grüßen», i width: 0.297 li dona 49 mm d'amplada i 21,1 mm d'alçada, una mica menys de quatre línies de 15 pt. El text que segueix un recurs al flux torna a la retícula de base. Amb 0.25, el dibuix feia 17,8 mm d'alçada, i els 3,4 mm que la retícula hi afegia a sota allunyaven el nom de la signatura. El tipus de recurs té el captionPrefix buit, i la 1.4.1 no compon peu quan el prefix i el peu són buits, així que sota la signatura no apareix ni rètol ni número (tipus de recurs). El logotip és un recurs del mateix tipus, i només el dibuixa l'element d'imatge. Cap :ref ni ::resource l'anomena, així que mai no entra al flux.
La recepta completa
// ═══ 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 = 'es'; // @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"}Mostra en Markdown · 45 línies · content.es.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 a totes les receptes · 310 línies
// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. 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', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; 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` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs 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; its * bold, italic and bold-italic variants are listed whether or not used. */ 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' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true 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; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ 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 v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { 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 res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show 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 ───────────────────────────────────────────────────────────────────────
L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)
Errors freqüents
Error freqüent
Una obertura reserva alçada fins al seu element ancorat més baix
Una obertura de disseny avançat reserva l'alçada del seu element més baix, i també hi compten els ancorats a la pàgina o a la sang que queden per sota del títol, així que un ornament al peu de la pàgina empeny el text a la següent. Deixa aquests ornaments per sobre del títol, passa'ls a una ranura de capçalera o de peu, o fixa la reserva amb minHeight. Obertures dissenyades →
Error freqüent
\n en un atribut només talla línies amb paragraphIndent > 0
En un element de text de disseny, un \n escrit al valor d'un atribut només obre una línia nova si paragraphIndent és més gran que zero o hi ha caplletra; si no, el text continua en una línia. Posa a paragraphIndent un valor mínim (0,01 pt) o fes servir un atribut per línia. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
Els elements de capçalera i peu es pinten damunt del text
Els elements de capçalera i peu es pinten sobre la pàgina i l'àrea de text no els deixa lloc. Mantén-los dins dels marges, que són els que els reserven l'espai. Capçaleres i folis →
Error freqüent
Qualsevol objecte headings desactiva el salt de pàgina de l'H1
Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →
Error freqüent
El desbordament del text de disseny és 'ellipsis-end' per defecte
Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
El lineHeight d'un text de disseny és un múltiple, mai una mesura
En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
Una paleta canviada no arriba als elements de disseny ni al color de les remissions
postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →
Error freqüent
Només 8 llengües tenen partició de mots, amb el codi exacte
La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà. Partició de mots i llengua del document →
Error freqüent
El PDF demana tots els gruixos i estils de cada família
renderToPdf demana al proveïdor de fonts la negreta, la cursiva i la negreta cursiva de cada família que un bloc podria fer servir, encara que no s'imprimeixi mai, i un sol rebuig atura l'exportació. El proveïdor s'ha d'ajustar al gruix més proper que tingui la família i tornar a la rodona quan no hi hagi cursiva. Fonts incrustades al PDF →
- La targeta sobre
\nals atributs diu que, ambparagraphIndenta 0, el text es queda en una sola línia. A la 1.4.1 l'adreça es parteix en omplir els seus 75 mm d'amplada, i els dos caràcters\nsurten impresos on hi hauria d'haver un salt de línia. {totalPages}compta les pàgines de tot el document: amb dues cartes en un mateix Markdown, el segon full de la primera diria «Seite 2 von 4». Compon cada carta per separat.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Text original, CC BY 4.0
- Fonts
- Nunito Sans (SIL OFL 1.1) · Familjen Grotesk (SIL OFL 1.1) · Reddit Mono (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


