かんたんな説明
ドイツの窓工房が出す2ページの見積書です。窓付き封筒に収まるよう、宛名などビジネスレターの各部をドイツの規格が定める位置に置く方法を示します。
できあがり
ベルリンで二重窓を修復する架空の工房Fensterwerkstatt Tessinが、集合住宅の管理会社に見積書を送ります。ドイツ語でA4判2枚、DIN 5008の書式Bに従ってレイアウトします。上部を横切るベルリンブルーの帯に工房のロゴと名前が入ります。その下では、各部分が規格の定める位置に収まります。差出人行と宛名はDL封筒の窓から見える85 × 45 mmの欄の中に、情報ブロックは左端から125 mmの位置に、太字の件名は欄の2行下の空きのあとに置き、折り目マークとパンチマークは105 mm、148.5 mm、210 mmに付けます。会社情報は両方の用紙の地に3段で入ります。2枚目は「Seite 2 von 2」の横に整理番号をくり返し、コードで描いた署名で終わります。
このレシピが答える質問
- バッジやステッカーをページ上の決まった位置に固定するには?
- すべてのページに透かし、背景の色、装飾画像を加えるには?
- 章扉や白ページで柱を隠したり、空白の偶数ページを部の色で塗ったりするには?
- エピグラフ、献辞、署名、大きな引用符の付いたプルクオートを組むには?
手短な答え
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() }] });
材料
- 機能
- デザイン要素の基準位置デザインした章扉見出しの属性ページデザインのテキスト・罫・ボックスページデザインの画像段抜きの章見出し帯ページの役割ごとの柱柱とノンブルその場に置く図リソースとしての図と表独自のリソースの種類段落スタイルハイフネーションと文書の言語仕上がりサイズセマンティックカラーパレットPDFの書き出しPDFに埋め込むフォントキャンバス上のページ
- 種類
- Nunito Sans, Familjen Grotesk, Reddit Mono (SIL OFL 1.1)
- 素材
- なし:図はすべてコードで描画
作り方
#1 · 1ページ目をミリ単位で用紙に固定する
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()は要素の左上の角を'page'、つまり仕上がり枠に固定します(要素の配置)。そのためxとyはどれも用紙の角からの距離になり、規格の数値をそのまま書けます。宛名は横25 mm、縦62.7 mm、情報ブロックの最初のラベルは125と50です。これらの要素がH1のデザインになります。見出しのテキストは件名({titleText})で、その属性に宛名と整理番号を入れます。こうして設定が便箋を、Markdownが手紙の中身を受け持ちます。100.6 mmにある件名がいちばん下の要素なので、見出しは件名の下端までの用紙を確保します。marginBottomで2行の空きを加え、あいさつ文は次のグリッド行、117 mmから始まります。
宛名は1つの属性で、行のあいだに\nを入れます。1.4.1では、デザインのテキストが属性の\nを改行に変えるのは、paragraphIndentが0より大きいときだけです。宛名では見えないほど小さい0.01 ptの字下げを設定しています。0にすると、バックスラッシュとnが宛名に印刷されます。span: 'page'を付けないとデザインは本文の段の中にとどまり、27 mmの天の余白で切り取られます。するとロゴと名前が消え、帯は下の5 mmしか残りません。
#2 · 固定した1つのラベルから情報ブロックをつなげる
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) } }),
]);
用紙に固定するのは最初のラベルだけです。ほかのラベルはどれも直前のラベルの'below'に、各値は自分のラベルに'align-top'と29 mmのオフセットで固定するので、INFOに行を足しても座標は要りません。デザインのテキストはベースラインを行ボックスの80%の位置に置きます。lineHeight: 12 / 7の7 ptのラベルも12 / 9の9 ptの値も12 ptの行になるので、4.23 mmごとのどの行でも同じベースラインに並びます。
#3 · レターヘッドを同じデザインに描く
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')];
}),
];
帯は用紙の角に固定した囲みです。width: 'fill'で端から端まで伸ばし、32 mmの深さで27 mmの天の余白を5 mm越えます。ロゴは画像要素で、高さ18 mmでリソースを描き、幅は図から取ります(画像要素)。名前はロゴの'right-of'に、業種の行は名前の'below'に吊るします。3 mmの罫8本で、85 × 45 mmの宛名欄、つまり封筒の窓から見える部分の四隅に角マークを描きます。
#4 · どの用紙にも共通するものはヘッダーとフッターに置く
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) }))),
] };
H1のデザインのうち件名の下に固定した要素は、見出しが確保する高さを増やします。210 mmの折り目マークをデザインに入れると、あいさつ文は222.8 mmまで下がり、手紙は3ページになります。ヘッダー枠はすべてのページに描かれ、領域を確保しないので、3つのマークはそこに置きます(ヘッダーとフッター)。1ページ目はH1で始まるので'opener'ページになり、pages: 'body'で青い帯、整理番号の行、Seite {pageNumber} von {totalPages}を2枚目以降に限定します。整理番号の行は{attr.unser-zeichen}と{attr.datum}でH1の属性を読みます。
#5 · 署名を本文の流れに置く
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"}で「Mit freundlichen Grüßen」の下に図を置き、width: 0.297で幅49 mm、深さ21.1 mmにします。15 ptの行4本にわずかに足りない深さです。本文の流れの中でリソースに続くテキストは、本文のグリッドに戻ります。0.25では図の深さが17.8 mmで、その下にグリッドが加えた3.4 mmのために氏名が署名から離れていました。このリソース種別はcaptionPrefixが空で、1.4.1は接頭辞とキャプションがどちらも空ならキャプションを組まないため、署名の下にはラベルも番号も出ません(リソースの種類)。ロゴも同じ種別のリソースで、画像要素だけが描きます。:refも::resourceも名指ししないので、本文の流れには入りません。
レシピの全体
// ═══ Postext Cookbook · Nº 058 · Business letter to DIN 5008 ═════════════════════ // https://postext.dev/en/cookbook/din-business-letter // Code: MIT · Text: original, in German (CC BY 4.0) · Logo, signature: drawn in code (CC BY 4.0) // Fonts: Nunito Sans, Familjen Grotesk, Reddit Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // A German quotation on A4 whose H1 design pins the letterhead and the address to DIN 5008. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'din-business-letter'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#1f2429', brand: '#1b4a73', // text, a blue-grey near-black; Berlin blue, for the band sky: '#b4cbe0', paper: '#ffffff', // the trade and the crafts on the band; the logo and name muted: '#5b6670', pen: '#26408f', // labels, return line, company data; the signature's ink marks: '#98a3ad', rule: '#c9d1d8', // fold marks, window corners; the hairline in the footer }; // col(id) links a palette entry and keeps its hex for designs (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // main-color is the engine's default accent: the dashes of the list take it. const colorPalette = Object.entries({ ...palette, 'main-color': palette.brand }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // DIN 5008, form B: every position in mm from the sheet's top-left corner. const DIN = { left: 25, right: 20, top: 27, bottom: 30, // the margins of the text field: { x: 20, y: 45, w: 85, h: 45 }, // the address field, behind the envelope window notes: 17.7, // the field's top zone, for the return line; the address zone takes the rest info: { x: 125, y: 50, w: 75 }, // the information block folds: [105, 210], punch: 148.5, // fold marks for a DL envelope, and the punch mark }; const TEXT = 210 - DIN.left - DIN.right; // 165 mm of text width const WINDOW = DIN.field.w - 2 * (DIN.left - DIN.field.x); // 75 mm: text inset 5 mm each side const PT = 25.4 / 72; // mm per point const [LEAD, ROW] = [15, 12]; // pt: the body's leading, a row of the information block // Design text is centred and cut with '…' by default (gotcha: overflow-ellipsis-default). const text = (id, content, face, placement, more) => ({ kind: 'text', id, content, align: 'left', overflow: 'wrap', ...face, placement, ...more }); // more: pages, align const rule = (id, colour, placement, direction = 'horizontal') => ({ kind: 'rule', id, direction, thickness: pt(0.5), color: col(colour), placement }); const face = (fontFamily, size, fontWeight, colour, more) => ({ fontFamily, fontSize: pt(size), fontWeight, color: col(colour), ...more }); const FACE = { // lineHeight is a multiple, never pt() (gotcha: design-lineheight-multiple) label: face('Reddit Mono', 7, 400, 'muted', { lineHeight: ROW / 7 }), value: face('Nunito Sans', 9, 400, 'ink', { lineHeight: ROW / 9 }), from: face('Reddit Mono', 6.5, 400, 'muted', { lineHeight: 1.2 }), address: face('Nunito Sans', 10, 400, 'ink', // six lines fill the address zone { lineHeight: (DIN.field.h - DIN.notes) / 6 / PT / 10 }), subject: face('Familjen Grotesk', 11, 700, 'ink', { lineHeight: LEAD / 11 }), small: face('Reddit Mono', 7, 400, 'muted', { lineHeight: 1.45 }), }; const FROM = 'Fensterwerkstatt Tessin · Rennbahnstr. 48 · 13086 Berlin'; // 72 mm: fits // #region answer: the subject line's design pins page 1 to the sheet at form B's positions const at = (x, y, size) => ({ anchor: { to: 'page', edge: 'top-left' }, // mm from the corner offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); const ZONE = DIN.field.y + DIN.notes; // 62.7 mm: where the six address lines start const SUBJECT = DIN.field.y + DIN.field.h + 2 * LEAD * PT; // two blank lines under the field const firstPage = () => ({ enabled: true, slot: { elements: [ // in paint order ...letterhead(), // band, logo and name, and the corners of the window text('from', FROM, FACE.from, at(DIN.left, ZONE - 3.6)), // the return line, underlined rule('from-rule', 'muted', { anchor: { to: '#from', edge: 'below' }, offset: { y: mm(0.6) }, size: { width: mm(WINDOW) } }), // One attribute holds the whole address. Its '\n' starts a new line only when // paragraphIndent is above zero (gotcha: design-text-newline). text('to', '{attr.to}', { ...FACE.address, paragraphIndent: pt(0.01) }, at(DIN.left, ZONE, { width: mm(WINDOW) })), ...infoBlock(), // labels, and values from {attr.*} text('subject', '{titleText}', FACE.subject, at(DIN.left, SUBJECT, { width: mm(TEXT) })), ] } }); // The H1 reserves the sheet down to its lowest element (gotcha: opener-reserves-anchored). // fontFamily: the design paints the H1, but the build measures it (default: Open Sans). const headings = () => ({ fontFamily: 'Familjen Grotesk', levels: [{ level: 1, span: 'page', // a design kept in the column would be cut off at the top margin breakBefore: { enabled: true, parity: 'any' }, // gotcha: headings-drop-h1-break marginBottom: pt(2 * LEAD), // two blank lines between the subject and the salutation advancedDesign: firstPage() }] }); // #endregion // #region info: the information block, labels from the stationery, values from the letter const INFO = [['Ihr Zeichen', 'ihr-zeichen'], ['Ihre Nachricht vom', 'ihre-nachricht'], ['Unser Zeichen', 'unser-zeichen'], ['Name', 'name'], ['Telefon', 'telefon'], ['E-Mail', 'email'], ['Datum', 'datum']]; // [label, heading attribute] const VALUE = 29; // mm from the labels to the values const infoBlock = () => INFO.flatMap(([label, key], i) => [ text(`label-${i}`, label, FACE.label, i === 0 ? at(DIN.info.x, DIN.info.y) : { anchor: { to: `#label-${i - 1}`, edge: 'below' }, // each label under the last offset: { y: mm(key === 'datum' ? ROW * PT : 0) } }), // a blank row before the date text(`value-${i}`, `{attr.${key}}`, FACE.value, { // a 12 pt row as well, on the label's baseline anchor: { to: `#label-${i}`, edge: 'align-top' }, offset: { x: mm(VALUE) }, size: { width: mm(DIN.info.w - VALUE) } }), ]); // #endregion // #region letterhead: a band of Berlin blue, the logo and the name, the window's corners const [BAND, TICK] = [32, 3]; // mm: the band's depth, each arm of the window's corner angles const LOGO = { y: 7, h: 18 }; // mm: the mark, centred in the band const CRAFTS = 3 * 7.5 * 1.45 * PT; // mm: three 7.5 pt lines at 1.45 const letterhead = () => [ { kind: 'box', id: 'band', style: { backgroundColor: col('brand') }, placement: at(0, 0, { width: 'fill', height: mm(BAND) }) }, { kind: 'image', id: 'logo', resourceId: 'logo', placement: at(DIN.left, LOGO.y, { height: mm(LOGO.h) }) }, text('name', 'Tessin', face('Familjen Grotesk', 30, 700, 'paper', { lineHeight: 1 }), { anchor: { to: '#logo', edge: 'right-of' }, offset: { x: mm(5), y: mm(1.2) } }), text('trade', 'FENSTERWERKSTATT · BERLIN', face('Reddit Mono', 7.5, 500, 'sky', { letterSpacing: pt(1.2) }), { anchor: { to: '#name', edge: 'below' }, offset: { x: mm(0.4), y: mm(1.5) } }), text('crafts', 'Kastenfenster\nHolzfenster\nDenkmalpflege', face('Reddit Mono', 7.5, 400, 'sky', { lineHeight: 1.45, align: 'right' }), at(210 - DIN.right - 45, LOGO.y + (LOGO.h - CRAFTS) / 2, { width: mm(45) })), ...[[0, 0], [1, 0], [0, 1], [1, 1]].flatMap(([right, low], i) => { // turned inwards const [x, y] = [DIN.field.x + right * DIN.field.w, DIN.field.y + low * DIN.field.h]; return [rule(`across-${i}`, 'marks', at(x - right * TICK, y, { width: mm(TICK) })), rule(`down-${i}`, 'marks', at(x, y - low * TICK, { height: mm(TICK) }), 'vertical')]; }), ]; // #endregion // #region furniture: marks on every sheet, 'Seite 2 von 2' on the next, the company data const header = { elements: [ // In the H1's design they would stretch its reserve (gotcha: opener-reserves-anchored). ...[[DIN.folds[0], 5], [DIN.punch, 8], [DIN.folds[1], 5]].map(([y, length], i) => rule(`mark-${i}`, 'marks', at(0, y, { width: mm(length) }))), // fold, punch, fold // Page 1 opens with the H1, which makes it an 'opener'; later sheets are 'body' pages. { kind: 'box', id: 'strip', pages: 'body', style: { backgroundColor: col('brand') }, placement: at(0, 0, { width: 'fill', height: mm(4) }) }, text('ref', 'Fensterwerkstatt Tessin · Unser Zeichen {attr.unser-zeichen} · {attr.datum}', FACE.small, at(DIN.left, 13.2), { pages: 'body' }), text('page', 'Seite {pageNumber} von {totalPages}', FACE.small, at(210 - DIN.right - 40, 13.2, { width: mm(40) }), { pages: 'body', align: 'right' }), ] }; const FOOT = 297 - DIN.bottom + 5; // mm: the hairline over the company data const COMPANY = [ // three columns, 57 mm apart, inside the bottom margin 'Fensterwerkstatt Tessin GmbH\nRennbahnstraße 48\n13086 Berlin\nTelefon 030 23125-400', 'Geschäftsführer Martin Tessin\nAmtsgericht Charlottenburg\nHRB 000000 B\nUSt-IdNr. DE000000000', 'Musterbank Berlin\nIBAN DE00 0000 0000 0000 0000 00\nBIC MUSTDEBBXXX', ]; const footer = { elements: [ rule('foot-rule', 'rule', at(DIN.left, FOOT, { width: mm(TEXT) })), ...COMPANY.map((lines, i) => text(`company-${i}`, lines, FACE.small, at(DIN.left + 57 * i, FOOT + 2.5, { width: mm(52) }))), ] }; // #endregion // #region signature: a drawing set in the flow by ::resource, with no label or number const resourceTypes = [{ id: 'drawing', name: 'Zeichnung', shortLabel: 'Zeichnung', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; const drawing = (id, width, height, altText, placement) => ({ id, typeId: 'drawing', kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, altText, placement, createdAt: 0, updatedAt: 0 }); const resources = [ // an image element draws the logo; nothing cites it drawing('logo', 200, 280, 'Zeichen der Fensterwerkstatt Tessin, ein Kastenfenster'), drawing('signature', 760, 328, 'Unterschrift von Martin Tessin', { position: 'here', width: 0.297 }), // 49 × 21.1 mm: under 4 lines, no grid gap below ]; // #endregion const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity) locale: 'de', resourceTypes, colorPalette, // German hyphenation (gotcha: hyphenation-locales) page: { sizePreset: 'custom', width: mm(210), height: mm(297), dpi: 150, margins: { top: mm(DIN.top), bottom: mm(DIN.bottom), left: mm(DIN.left), right: mm(DIN.right) } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: 'Nunito Sans', fontSize: pt(10.5), lineHeight: pt(LEAD), // justified color: col('ink'), boldColor: col('ink'), firstLineIndent: pt(0), // and hyphenated by default paragraphSpacing: true }, // DIN 5008: a blank line between paragraphs, no indent headings: headings(), header, footer, unorderedLists: { bulletChar: '–' }, // in main-color paragraphStyles: [ { id: 'stack', marginBottom: pt(LEAD) }, // name and role, the enclosures: no blank lines { id: 'colophon', fontFamily: 'Reddit Mono', fontSize: pt(6.5), lineHeight: pt(9), color: col('muted') }, // a blank line under the enclosures, from the stack's margin ], }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`# Angebot 2026-117: Instandsetzung der 24 Kastenfenster im Vorderhaus Christburger Straße 17 {to="Hausverwaltung Brenner & Kolb GmbH\nFrau Dr. Ines Kolb\nWinsstraße 11\n10405 Berlin" ihr-zeichen="Ko/CS17-F" ihre-nachricht="03.09.2026" unser-zeichen="MT 2026-117" name="Martin Tessin" telefon="030 23125-418" email="mt@tessin.example" datum="22.09.2026"}Markdownの見本 · 45行 · content.en.md
Sehr geehrte Frau Dr. Kolb, vielen Dank für Ihre Anfrage und für den Ortstermin am 10. September. Wir haben dabei alle 24 Kastenfenster des Vorderhauses aufgenommen, je sechs im ersten bis vierten Obergeschoss, drei zur Straße und drei zum Hof. Die Fenster stammen aus der Bauzeit um 1896. Ihre Rahmen aus Kiefernkernholz sind bis auf wenige Stellen gesund, und in 19 Fenstern sitzt noch das mundgeblasene Zylinderglas. Eine Instandsetzung kostet deshalb deutlich weniger als ein Nachbau, und die Straßenfassade behält ihre Fenster. Die Schäden liegen vor allem an den Außenflügeln der Hofseite. Dort sind bei elf der zwölf Fenster die Wetterschenkel und die unteren Rahmenecken morsch. Der Kitt ist an fast allen Außenflügeln versprödet und stellenweise herausgebrochen, sodass bei Schlagregen Wasser in die Glasfalze läuft. Die Anstriche aus mehreren Jahrzehnten lösen sich großflächig; nach unserer Probe enthalten die beiden untersten Schichten Blei. Unser Angebot umfasst die folgenden Leistungen, die im beiliegenden Leistungsverzeichnis einzeln aufgeführt sind: - Ausbau in vier Etappen zu je sechs Fenstern, erst die Außen-, dann die Innenflügel, sodass jede Wohnung während der Arbeiten geschlossen bleibt - Entlackung in unserer Werkstatt mit Infrarotwärme statt Lösemitteln, Entsorgung der bleihaltigen Altanstriche als Sonderabfall - Holzreparatur mit Vierungen aus Altholz, neue Wetterschenkel an 22 Außenflügeln - Ersatz von 14 gesprungenen oder fehlenden Scheiben durch Restaurierungsglas - Neuverkittung aller Scheiben mit Leinölkitt, Anstrich mit Leinölfarbe in drei Schichten - Wiedereinbau, Gängigmachen der Beschläge, eine eingefräste Dichtung in jedem Innenflügel Für alle 24 Fenster berechnen wir 52.800,00 Euro netto, also 2.200,00 Euro je Fenster. Mit 19 % Umsatzsteuer von 10.032,00 Euro beträgt die Angebotssumme 62.832,00 Euro; das Angebot gilt bis zum 31. Oktober 2026. Die Flügel bauen wir von innen aus; ein Gerüst ist nicht nötig, und Arbeiten an der Fassade sind nicht enthalten. Die Arbeiten würden wir in der Zeit von Mitte Januar bis Mitte April 2027 ausführen. Die Außenflügel einer Etappe bleiben zwei Wochen in der Werkstatt, die Innenflügel eine. Die Mieterinnen und Mieter informieren wir zwei Wochen vor jeder Etappe mit einem Aushang und einem Brief, der den Tag des Ausbaus nennt. Nach jeder abgeschlossenen Etappe berechnen wir ein Viertel der Angebotssumme, zahlbar innerhalb von 14 Tagen ohne Abzug. Zur Eigentümerversammlung am 14. Oktober bringe ich gern einen instand gesetzten Außenflügel aus unserer Werkstatt mit, an dem die Eigentümer Glas, Kitt und Anstrich sehen können. Bei Fragen zum Leistungsverzeichnis erreichen Sie mich werktags von 7 bis 16 Uhr unter der Durchwahl 418. Mit freundlichen Grüßen ::resource{id="signature"} :::paragraphs{style="stack"} Martin Tessin Geschäftsführer ::: :::paragraphs{style="stack"} **Anlagen** Leistungsverzeichnis, 6 Seiten Fotodokumentation der 24 Fenster Farbbefund der Außenflügel ::: :::paragraphs{style="colophon"} Gesetzt in Nunito Sans, Familjen Grotesk und Reddit Mono (SIL OFL) · Firma, Personen und Bankdaten sind erfunden. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the logo and the signature, drawn in code function mulberry32(seed) { // a seeded generator: the same signature on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function logoSvg(stroke) { // a Berlin box window: a cross frame, six panes and the sill const line = (d, width) => `<path d="${d}" fill="none" stroke="${stroke}" ` + `stroke-width="${width}"/>`; return '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="280" viewBox="0 0 40 56">' + line('M1.5 1.5H38.5V51.5H1.5ZM1.5 17H38.5M20 1.5V51.5', 3) + line('M1.5 34.5H38.5', 1.4) + line('M0 54.8H40', 2.2) + '</svg>'; } function catmull(points) { // a smooth path through the points, as cubic Béziers const f = (v) => v.toFixed(1); let d = `M${f(points[0][0])} ${f(points[0][1])}`; for (let i = 0; i < points.length - 1; i++) { const [p0, p1, p2, p3] = [points[i - 1] ?? points[i], points[i], points[i + 1], points[i + 2] ?? points[i + 1]]; const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6]; const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6]; d += `C${f(c1[0])} ${f(c1[1])} ${f(c2[0])} ${f(c2[1])} ${f(p2[0])} ${f(p2[1])}`; } return d; } function signatureSvg() { // 'M. Tessin' in a quick, forward-leaning hand const rand = mulberry32(5008); const SLANT = 0.32; // the lean: x moves right by a third of the height above the baseline const hand = (points) => points.map(([x, y]) => { // shake each point a little, then lean it const [jx, jy] = [x + (rand() - 0.5) * 1.4, y + (rand() - 0.5) * 1.4]; return [jx + SLANT * (60 - jy), jy]; }); const strokes = [ // x, y on a 190 × 82 sheet, baseline at 60 [[3, 63], [7, 44], [11, 18], [14, 9], [17, 22], [19, 44], [21, 60], [23, 44], [27, 22], [30, 17], [32, 30], [33, 48], [35, 61], [40, 63], [45, 57]], // M [[48.5, 61], [49.5, 60]], // the full stop [[50, 25], [55, 20], [78, 15], [102, 12], [128, 9]], // the bar of the T [[80, 13], [79, 30], [76, 48], [73, 61], [77, 66], [84, 61], [89, 52], [91, 46], [88, 43], [85, 48], [87, 57], [93, 62], [97, 56], [100, 47], [102, 44], [104, 51], [102, 58], [99, 61], [103, 62], [108, 58], [111, 48], [113, 44], [115, 51], [113, 58], [110, 61], [114, 62], [120, 58], [123, 52], [125, 46], [126, 58], [130, 63], [134, 57], [137, 47], [140, 48], [141, 62], [144, 52], [148, 46], [152, 48], [153, 61], [159, 64], [168, 57]], [[127, 37], [128.5, 36]], // the dot on the i [[168, 57], [174, 50], [172, 58], [156, 70], [120, 76], [80, 76], [50, 72], [36, 68]], ]; const pen = `fill="none" stroke="${palette.pen}" stroke-width="1.9" stroke-linecap="round" ` + 'stroke-linejoin="round"'; // The viewBox starts 3 units above the sheet, which lowers the ink towards the typed name. return '<svg xmlns="http://www.w3.org/2000/svg" width="760" height="328" viewBox="0 -3 190 82">' + strokes.map((points) => `<path d="${catmull(hand(points))}" ${pen}/>`).join('') + '</svg>'; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages paint, loaded before the first build (gotcha: fonts-first). const FONTS = { 'Nunito Sans': ['400', '700'], 'Familjen Grotesk': ['700'], 'Reddit Mono': ['400', '500'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadSvg('logo.svg', logoSvg(palette.paper)); await loadSvg('signature.svg', signatureSvg()); await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Business letter to DIN 5008', es: 'Carta comercial DIN 5008' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);キット · core, fonts, viewer, pdf, images:全レシピ共通 · 310行
// ─── 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 ───────────────────────────────────────────────────────────────────────
組み立てたscript.jsはそのまま動きます。任意のページのモジュールスクリプトに貼り付けるか、レシピをCodePenで開いてください。 GitHub上のレシピのフォルダー ↗ (新しいタブで開きます)
よくあるつまずき
つまずき
章扉はページに固定した要素のうち最も下のものまで高さを確保する
詳細デザインの章扉は、最も下にある要素までの高さを確保します。見出しより下にあるページや裁ち落としに固定した要素もこれに含まれるため、ページの下端に置いた装飾があると本文が次のページへ押し出されます。こうした装飾は見出しより上に置くか、ヘッダーやフッターのスロットに移すか、minHeightで確保する高さを指定してください。 デザインした章扉 →
つまずき
属性内の\nが改行になるのはparagraphIndent > 0のときだけ
デザインのテキスト要素で、属性値に書いた\nが改行になるのは、paragraphIndentが0より大きいか、ドロップキャップを設定したときだけです。それ以外では1行のままになります。paragraphIndentをごくわずかな値(0.01 pt)にするか、1行ごとに属性を分けてください。 ページデザインのテキスト・罫・ボックス →
つまずき
ヘッダーとフッターの要素は本文の上に描かれる
ヘッダーとフッターの要素はページの上に重ねて描かれ、本文の領域はそのための場所を空けません。要素は余白の中に収めてください。その場所を確保するのは余白です。 柱とノンブル →
つまずき
headingsオブジェクトを渡すとH1の改ページが消える
既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →
つまずき
デザインのテキストのoverflowの既定値は'ellipsis-end'
幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。 ページデザインのテキスト・罫・ボックス →
つまずき
デザインのテキストのlineHeightは倍率で、寸法ではない
デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です(lineHeight: 1.05)。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。 ページデザインのテキスト・罫・ボックス →
つまずき
パレットを差し替えても、デザイン要素と参照色は変わらない
postext 1.4.1はcolorPaletteをテキストのスタイル(本文、見出し、リスト、キャプション、表、囲み)には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。 セマンティックカラーパレット →
つまずき
ハイフネーションできるのは8つのロケールだけで、コードは完全一致
ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。 ハイフネーションと文書の言語 →
つまずき
PDFはすべてのファミリーのすべてのウェイトとスタイルを要求する
renderToPdfは、ブロックが使う可能性のあるすべてのファミリーについて、実際には印字されないものも含め、ボールド、イタリック、ボールドイタリックのフォントをフォントプロバイダーに求めます。1つでも拒否されると書き出しが止まります。プロバイダーは、そのファミリーが持つ最も近いウェイトを返し、イタリックがなければ立体にフォールバックする必要があります。 PDFに埋め込むフォント →
- 属性中の
\nについての注意書きは、paragraphIndentが0ならテキストは1行のままだと説明しています。1.4.1では宛名は75 mmの幅で折り返し、改行すべき箇所に\nの2文字が印刷されます。 {totalPages}は文書全体のページを数えるので、1つのMarkdownファイルに手紙を2通入れると、1通目の2枚目は「Seite 2 von 4」になります。手紙は1通ずつ組んでください。
クレジット
- 本文
- 書き下ろしの文章, CC BY 4.0
- フォント
- Nunito Sans (SIL OFL 1.1) · Familjen Grotesk (SIL OFL 1.1) · Reddit Mono (SIL OFL 1.1)


