かんたんな説明
町の役所が配る4ページのごみ分別ガイドです。画面読み上げソフトがたどれるPDFの作り方を示します。絵には説明文が付き、表も読み上げられます。
できあがり
『Reciclar en el barrio』は、架空のスペインの役所が作ったA4判4ページのごみ分別ガイドです。画面で読むことも、家で印刷することも想定しています。紺の表紙には、黄色の縁石の上に5つの街頭ごみ容器が並びます。2ページ目には目次と3つの囲み、その横に環境担当議員の挨拶文を置き、3・4ページ目に2つの節を、Atkinson Hyperlegible Nextの左そろえ(行末不ぞろい)2段組みで組みます。CodePenのサンプルは、veraPDFのPDF/UA-1検査に合格するタグ付きPDFを書き出します。見出し、リスト、囲み、表が読み上げ順に並び、図には代替テキストが、2つの表には見出しセルが付きます。ファイルは題と言語(es)を宣言し、しおりと目次の各行はそれぞれのページにリンクします。タグは印刷されないため、サンプルはプレビューの3ページ目にタグを描き込みます。ここに載せたのがそのページで、タグ付きの各ブロックを番号付きの枠で囲み、タグ名を添えています。ダウンロードしたPDFにはこの重ね描きはありません。
このレシピが答える質問
- 代替テキストと文書の言語を備えたアクセシブルなPDF(タグ付き、PDF/UA)を作るには?
- 「図」や「表」のラベルを文書の言語で表示するには?
- 自動で更新される目次(リーダー、ノンブル、著者名、部の行)を追加するには?
- フォントを埋め込んだ本物のPDFをブラウザーで書き出すには?
手短な答え
// renderToPdf writes a tagged PDF by default: the tag tree follows the headings, paragraphs,
// lists and boxes of the Markdown. Pictures and tables carry their own accessible text here.
const table = (id, tsv, columnWidths, caption, altText) => ({ id, typeId: 'table', kind: 'table',
caption, altText, createdAt: 0, updatedAt: 0, // altText → the Table's /Summary
placement: { position: 'here' }, // read where cited, not after the page (gotcha: float-read-last)
table: { model: { headerRowCount: 1, columnWidths, // row 0: TH cells, scope Column
rows: parseTSV(tsv).rows.map((row) => row.map(({ content }, c) => ({ content: cell(content),
...(c === 0 && { isHeader: true }) }))) } } }); // column 0: TH cells, scope Row
const resources = () => [
{ id: 'contenedores', typeId: 'figure', kind: 'svg', placement: { position: 'here' },
svg: { fileId: 'contenedores.svg', width: FIGURE[0] * PX, height: FIGURE[1] * PX },
caption: 'Una isla del barrio: de izquierda a derecha, amarillo, azul, verde, marrón y gris.',
altText: 'Cinco contenedores en fila: amarillo con una botella de plástico, azul con una caja '
+ 'de cartón, verde con una botella de vidrio, marrón con un corazón de manzana y gris con '
+ 'una bolsa de basura cerrada.', createdAt: 0, updatedAt: 0 }, // → the Figure's /Alt
table('dudas', dudas, [3, 2], 'Los residuos que más dudas dan.',
'Once residuos y el contenedor de cada uno.'),
table('horarios', horarios, [2.2, 4, 1.4], 'Días y horas de recogida.',
'Qué días y desde qué hora se vacía cada contenedor.'),
{ id: 'calle', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, // the cover's row:
svg: { fileId: 'calle.svg', width: STREET[0] * PX, height: STREET[1] * PX } }, // an artifact
];
const exportPdf = (doc) => renderToPdf(doc, { fontProvider: fontsourceProvider,
resourceBytes: imageBytes }); // accessible and outlines default to true
材料
- 機能
- アクセシブルなタグ付きPDF目次PDFの書き出しPDFのしおり「図」と「表」を文書の言語でデータから作る表その場に置く図色見本図を配置する参照文書のメタデータ見出しスタイル番号のない章デザインした章扉段抜きの章見出し帯表紙、扉、奥付ページデザインの画像囲みレイアウト前のフォント読み込みキャンバス上のページ
- 種類
- Atkinson Hyperlegible Next, Atkinson Hyperlegible Mono, Public Sans (SIL OFL 1.1)
- 素材
- なし:図はすべてコードで描画
作り方
#1 · 文書の言語と題
locale: LANG, // → /Lang es (it hyphenates justified text only: gotcha ragged-no-hyphenation)
resourceTypes: defaultResourceTypes(LANG), // "Figura", "Tabla" (gotcha: resource-types-locale)
// /Title and /Author come from the frontmatter, every value quoted (gotcha: quote-frontmatter)
postext-pdfはタグの言語をlocaleから書き込みます。ここでは/Lang esとなり、画面読み上げソフトはガイドをスペイン語の発音で読みます。指定がなければファイルはen-USを宣言します。content.es.mdのフロントマターにあるtitleとauthorはPDFの題と著者になり、ファイルはビューアーに対して、ウィンドウのタイトルバーにファイル名ではなくこの題を表示するよう求めます。defaultResourceTypes(LANG)は、キャプションと引用にスペイン語の名前を与えます(「Figura 1.1」「tabla 1.1」)。手短な答えで、図と表にアクセシブルなテキストを与えます。altTextは図の代替説明(/Alt)と各表の/Summaryになり、2つの表の1行目と1列目は見出しセルとしてタグ付けされます。
#2 · ひとつのアウトラインと、題だけを載せた表紙の帯
const [BAND, COVER] = [54, 176]; // mm from the trim top to the foot of each band
const band = (height) => ({ kind: 'box', id: 'band', style: { backgroundColor: col('navy') },
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(height) } } });
const display = (size, hue) => face('Public Sans', 800, size, { lineHeight: 1, color: col(hue) });
const section = { enabled: true, slot: { elements: [band(BAND),
text('number', '{number}', display(64, 'signal'), at('container', 'top-left', 0, 4)),
text('title', '{titleText}', display(28, 'paper'),
{ ...at('#number', 'right-of', 6, 3.5), size: { width: mm(130) } })] } };
// A heading design's text is tagged as the heading, so the cover's band holds only the title.
const cover = { enabled: true, minHeight: mm(COVER - TOP + 24), slot: { elements: [band(COVER),
{ kind: 'box', id: 'kerb', style: { backgroundColor: col('signal') },
placement: { ...at('bleed', 'top-left', 0, COVER), size: { width: 'fill', height: mm(3) } } },
{ kind: 'image', id: 'bins', resourceId: 'calle', // the wheels stand on the kerb
placement: { ...at('#kerb', 'align-bottom', SIDE), size: { width: mm(STREET[0]) } } },
text('title', '{titleText}', display(66, 'paper'),
{ ...at('container', 'top-left', 0, 26), size: { width: mm(STREET[0]) } })] } };
const plain = (id, more) => ({ id, numbered: false, span: 'column', ...more,
advancedDesign: { enabled: false }, fontSize: pt(26), lineHeight: pt(2 * LEAD) });
const headingStyles = [
{ id: 'portada', numbered: false, toc: false, span: 'page', advancedDesign: cover,
layout: { layoutType: 'single' }, footer: { elements: [] } },
plain('indice', { toc: false }), // the contents leave their own heading out
plain('presentacion', { breakBefore: { enabled: false } }), // gotcha: style-inherits-break
];
const headings = { fontFamily: 'Public Sans', fontWeight: 800, color: col('ink'),
levels: [ // restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break)
{ level: 1, numberingTemplate: '{1}', breakBefore: { enabled: true, parity: 'any' },
span: 'page', advancedDesign: section, marginBottom: pt(LEAD) }, // 9.7 mm under the band
{ level: 2, fontWeight: 700, fontSize: pt(13.5), lineHeight: pt(LEAD), marginTop: pt(0),
marginBottom: pt(0) }, // the line above an H2 is the paragraph's, or the resource's, gap
] };
タグツリーのH1要素とH2要素はMarkdownの見出しから作られ、PDFのしおりも同じ一覧から作られます。そのため表紙、目次、挨拶文、2つの節はレベル1、中見出しはレベル2になります。postext-pdfは見出しのデザインに含まれるテキスト要素をすべてその見出しの一部としてタグ付けします。そこで表紙の帯には題だけを載せ、リード文と役所の名前は帯の下に段落として組みます。紺の帯、縁石、並んだ容器はデザイン要素です。PDFはデザインの箱と画像をアーティファクトとして扱うので、代替テキストは要らず、画面読み上げソフトはこれらを読み飛ばします。
#3 · リンクする目次
const [NUMBER, GAP] = [6, 2.5]; // mm: the number column and the gap before a title
const toc = { levels: [{ level: 1, ...face('Public Sans', 700, 12), numberWidth: mm(NUMBER),
numberGap: mm(GAP), marginTop: pt(LEAD / 2) }, { level: 2, indent: mm(NUMBER + GAP) }],
unnumbered: { indent: mm(NUMBER + GAP) }, leader: { gap: mm(1.5) }, // Presentación: no number
// The leaders take this face, not Public Sans (gotcha: toc-leader-kerning)
pageNumber: { fontFamily: 'Atkinson Hyperlegible Next', fontWeight: 700, width: mm(8) } };
:::tocはH1とH2を、印刷されるページ表記とともに並べます。PDFでは11行それぞれが該当ページの先頭にリンクします。3ページ目の:refによる引用「figura 1.1」「tabla 1.1」と、4ページ目の「tabla 2.1」は、図と表にリンクします。リーダーにはページ番号と同じ書体、Atkinson Hyperlegible Next Boldを使います。Public Sansではピリオドを続けて組むと、リーダーの計算の基準にする1つの点より間隔が広くなり、点がページ番号に食い込んでいたためです。
#4 · ページに描いた読み上げ順序
// An opener band's title, each column from top to bottom, then the page's floats. A paragraph
// continued in the next column stays one element, so its second part keeps its number.
const tagOf = (b) => ({ heading: `H${b.headingLevel ?? 1}`, callout: 'Div', listItem: 'LI',
resource: b.resourceBlock?.kind === 'table' ? 'Table' : 'Figure' })[b.type] ?? 'P';
function readingOrder(page) {
const ids = new Map(); // element → its number
const add = (block, box) => {
const key = block.id.replace(/-cont-\d+$/, ''); // fragments share their block's id
if (!ids.has(key)) ids.set(key, ids.size + 1);
return { n: ids.get(key), tag: tagOf(block), box, cont: key !== block.id };
};
const blocks = page.columns.flatMap((c) => c.blocks);
const title = blocks.find((b) => b.hidden && b.type === 'heading'); // drawn by the band
return [...(page.openerBand && title ? [add(title, union(page.openerBand.blocks
.filter((b) => b.kind === 'text')))] : []),
...blocks.filter((b) => !b.hidden).map((b) => add(b, b.bbox)),
...(page.floats ?? []).map((b) => add(b, b.bbox))];
}
画面読み上げソフトはタグをたどります。renderToPdfは各ページを描く順にタグを付けます。帯の題、1段目の上から下、2段目、最後にそのページのフロートという順です。この関数は同じ順序をたどり直して3ページ目の枠に番号を振ります。PDFの構造ツリーにも同じ14の要素が同じ順に並びます。図と2つの表をplacement: { position: 'here' }と::resource行で本文の流れの中に置いたのは、この順序のためです。1段目の下端へフロートとして配置すると、図1.1はそのページ最後のブロックである表1.1の後にタグ付けされてしまいます。2段目へ続くガラスの段落の番号が9ひとつなのは、PDFが2つの部分をひとつの段落としてタグ付けするからです。
#5 · コントラスト比で選んだ色
const palette = {
ink: '#14243a', // text: 15.6:1 on white
navy: '#173556', // bands: white type on it 12.5:1
signal: '#f2b705', // yellow: only on navy (6.9:1) or as a fill, never as text on white
tint: '#e8eef5', // header cells and boxes: ink on it 13.4:1
rule: '#aebccb', // table rules
muted: '#4a5a6e', // footer and colophon: 7.0:1 on white
paper: '#ffffff',
};
// hex beside the id: designs read the hex (gotcha: palette-skips-designs)
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// 'main-color' is the id the engine's default styles link to: any default left in them is navy
const colorPalette = [...Object.entries(palette), ['main-color', palette.navy]]
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const BIN = { amarillo: '#f2b705', azul: '#1f6fc5', verde: '#2e8b4a', marron: '#8a5a2f',
gris: '#6b7480' }; // the street bins' colours, for the pictograms and the swatches
const NAME = { amarillo: 'Amarillo', azul: 'Azul', verde: 'Verde', marron: 'Marrón', gris: 'Gris',
punto: 'Punto limpio' }; // a table cell never shows a colour alone: its name goes beside it
const cell = (text) => (text in NAME // an unknown colour ('none') draws an empty square
? `:swatch{color="${BIN[text] ?? 'none'}"} ${NAME[text]}` : text);
PDF/UA-1は色を検査しませんが、WCAG 2.2は文字と背景の間に4.5:1を求めます。墨色は白地で15.6:1、紺の帯の上の白文字は12.5:1、フッターの灰色は7.0:1です。黄色は白地では1.8:1しかないため、紺の上(6.9:1)と塗りにだけ使います。ピクトグラムはWCAGがグラフィックに求める3:1に従い、容器に対してその比に届くものは白、黄色の容器だけは墨色にしています。cell()は各容器の名前を色見本の横に書き、リサイクルセンターには空の四角を付けます。色の見分けに頼る指示はひとつもありません。
レシピの全体
// ═══ Postext Cookbook · Nº 039 · Accessible tagged PDF (PDF/UA) ═════════════════════ // https://postext.dev/en/cookbook/accessible-tagged-pdf // Code: MIT · Text: original (CC BY 4.0) · Pictograms: generated in code (CC BY 4.0) // Fonts: Atkinson Hyperlegible Next and Mono, Public Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, parseTSV, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'es'; // @lang: the language of the sample document (this recipe is Spanish only) const RECIPE = 'accessible-tagged-pdf'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: text colours chosen for their contrast, and never a colour without its name const palette = { ink: '#14243a', // text: 15.6:1 on white navy: '#173556', // bands: white type on it 12.5:1 signal: '#f2b705', // yellow: only on navy (6.9:1) or as a fill, never as text on white tint: '#e8eef5', // header cells and boxes: ink on it 13.4:1 rule: '#aebccb', // table rules muted: '#4a5a6e', // footer and colophon: 7.0:1 on white paper: '#ffffff', }; // hex beside the id: designs read the hex (gotcha: palette-skips-designs) const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // 'main-color' is the id the engine's default styles link to: any default left in them is navy const colorPalette = [...Object.entries(palette), ['main-color', palette.navy]] .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const BIN = { amarillo: '#f2b705', azul: '#1f6fc5', verde: '#2e8b4a', marron: '#8a5a2f', gris: '#6b7480' }; // the street bins' colours, for the pictograms and the swatches const NAME = { amarillo: 'Amarillo', azul: 'Azul', verde: 'Verde', marron: 'Marrón', gris: 'Gris', punto: 'Punto limpio' }; // a table cell never shows a colour alone: its name goes beside it const cell = (text) => (text in NAME // an unknown colour ('none') draws an empty square ? `:swatch{color="${BIN[text] ?? 'none'}"} ${NAME[text]}` : text); // #endregion const TRIM = [210, 297]; // A4, the size residents print at home const [TOP, FOOT, SIDE, LEAD] = [20, 22, 18, 15.5]; // margins in mm, not mirrored; leading in pt const [PX, FIGURE, STREET] = [12, [83, 30], [174, 44]]; // drawings: px per mm, sizes in mm const face = (fontFamily, fontWeight, size, more) => ({ fontFamily, fontWeight, fontSize: pt(size), ...more }); const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const text = (id, content, style, placement) => ({ kind: 'text', id, content, align: 'left', overflow: 'wrap', ...style, placement }); // default: '…' (gotcha: overflow-ellipsis-default) // #region answer: the alt text and header cells the tags take from the resources // renderToPdf writes a tagged PDF by default: the tag tree follows the headings, paragraphs, // lists and boxes of the Markdown. Pictures and tables carry their own accessible text here. const table = (id, tsv, columnWidths, caption, altText) => ({ id, typeId: 'table', kind: 'table', caption, altText, createdAt: 0, updatedAt: 0, // altText → the Table's /Summary placement: { position: 'here' }, // read where cited, not after the page (gotcha: float-read-last) table: { model: { headerRowCount: 1, columnWidths, // row 0: TH cells, scope Column rows: parseTSV(tsv).rows.map((row) => row.map(({ content }, c) => ({ content: cell(content), ...(c === 0 && { isHeader: true }) }))) } } }); // column 0: TH cells, scope Row const resources = () => [ { id: 'contenedores', typeId: 'figure', kind: 'svg', placement: { position: 'here' }, svg: { fileId: 'contenedores.svg', width: FIGURE[0] * PX, height: FIGURE[1] * PX }, caption: 'Una isla del barrio: de izquierda a derecha, amarillo, azul, verde, marrón y gris.', altText: 'Cinco contenedores en fila: amarillo con una botella de plástico, azul con una caja ' + 'de cartón, verde con una botella de vidrio, marrón con un corazón de manzana y gris con ' + 'una bolsa de basura cerrada.', createdAt: 0, updatedAt: 0 }, // → the Figure's /Alt table('dudas', dudas, [3, 2], 'Los residuos que más dudas dan.', 'Once residuos y el contenedor de cada uno.'), table('horarios', horarios, [2.2, 4, 1.4], 'Días y horas de recogida.', 'Qué días y desde qué hora se vacía cada contenedor.'), { id: 'calle', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, // the cover's row: svg: { fileId: 'calle.svg', width: STREET[0] * PX, height: STREET[1] * PX } }, // an artifact ]; const exportPdf = (doc) => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }); // accessible and outlines default to true // #endregion // #region headings: one outline: cover and contents unnumbered, then sections 1 and 2 with H2s const [BAND, COVER] = [54, 176]; // mm from the trim top to the foot of each band const band = (height) => ({ kind: 'box', id: 'band', style: { backgroundColor: col('navy') }, placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(height) } } }); const display = (size, hue) => face('Public Sans', 800, size, { lineHeight: 1, color: col(hue) }); const section = { enabled: true, slot: { elements: [band(BAND), text('number', '{number}', display(64, 'signal'), at('container', 'top-left', 0, 4)), text('title', '{titleText}', display(28, 'paper'), { ...at('#number', 'right-of', 6, 3.5), size: { width: mm(130) } })] } }; // A heading design's text is tagged as the heading, so the cover's band holds only the title. const cover = { enabled: true, minHeight: mm(COVER - TOP + 24), slot: { elements: [band(COVER), { kind: 'box', id: 'kerb', style: { backgroundColor: col('signal') }, placement: { ...at('bleed', 'top-left', 0, COVER), size: { width: 'fill', height: mm(3) } } }, { kind: 'image', id: 'bins', resourceId: 'calle', // the wheels stand on the kerb placement: { ...at('#kerb', 'align-bottom', SIDE), size: { width: mm(STREET[0]) } } }, text('title', '{titleText}', display(66, 'paper'), { ...at('container', 'top-left', 0, 26), size: { width: mm(STREET[0]) } })] } }; const plain = (id, more) => ({ id, numbered: false, span: 'column', ...more, advancedDesign: { enabled: false }, fontSize: pt(26), lineHeight: pt(2 * LEAD) }); const headingStyles = [ { id: 'portada', numbered: false, toc: false, span: 'page', advancedDesign: cover, layout: { layoutType: 'single' }, footer: { elements: [] } }, plain('indice', { toc: false }), // the contents leave their own heading out plain('presentacion', { breakBefore: { enabled: false } }), // gotcha: style-inherits-break ]; const headings = { fontFamily: 'Public Sans', fontWeight: 800, color: col('ink'), levels: [ // restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break) { level: 1, numberingTemplate: '{1}', breakBefore: { enabled: true, parity: 'any' }, span: 'page', advancedDesign: section, marginBottom: pt(LEAD) }, // 9.7 mm under the band { level: 2, fontWeight: 700, fontSize: pt(13.5), lineHeight: pt(LEAD), marginTop: pt(0), marginBottom: pt(0) }, // the line above an H2 is the paragraph's, or the resource's, gap ] }; // #endregion // #region contents: the H1s and H2s with their page numbers; each row links in the PDF const [NUMBER, GAP] = [6, 2.5]; // mm: the number column and the gap before a title const toc = { levels: [{ level: 1, ...face('Public Sans', 700, 12), numberWidth: mm(NUMBER), numberGap: mm(GAP), marginTop: pt(LEAD / 2) }, { level: 2, indent: mm(NUMBER + GAP) }], unnumbered: { indent: mm(NUMBER + GAP) }, leader: { gap: mm(1.5) }, // Presentación: no number // The leaders take this face, not Public Sans (gotcha: toc-leader-kerning) pageNumber: { fontFamily: 'Atkinson Hyperlegible Next', fontWeight: 700, width: mm(8) } }; // #endregion const label = (size, color, weight = 400) => face('Atkinson Hyperlegible Mono', weight, size, { color: col(color) }); const footer = { elements: [ // the PDF tags these as pagination artifacts text('where', '{title} · Castrovalle', { ...label(7.5, 'muted'), letterSpacing: pt(0.6), overflow: 'clip' }, at('container', 'bottom-left', 0, -11)), text('folio', '{pageNumber}', { ...label(9, 'ink', 700), align: 'right', overflow: 'clip' }, at('container', 'bottom-right', 0, -10.6))] }; const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity) // #region identity: the language the PDF declares, and captions in that language locale: LANG, // → /Lang es (it hyphenates justified text only: gotcha ragged-no-hyphenation) resourceTypes: defaultResourceTypes(LANG), // "Figura", "Tabla" (gotcha: resource-types-locale) // /Title and /Author come from the frontmatter, every value quoted (gotcha: quote-frontmatter) // #endregion colorPalette, headings, headingStyles, toc, footer, header: { elements: [] }, // each page opens with an H1, so the folio goes in the footer page: { width: mm(TRIM[0]), height: mm(TRIM[1]), dpi: 150, margins: { top: mm(TOP), bottom: mm(FOOT), left: mm(SIDE), right: mm(SIDE) } }, layout: { gutterWidth: mm(8) }, // two columns: the default layout bodyText: { fontFamily: 'Atkinson Hyperlegible Next', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true }, // ragged, so no runt check: ragged-runts unorderedLists: { color: col('ink'), marginTop: pt(0), marginBottom: pt(LEAD) }, orderedLists: { color: col('ink'), fontWeight: 700, marginTop: pt(0), marginBottom: pt(LEAD) }, paragraphStyles: [{ id: 'entradilla', fontSize: pt(17), lineHeight: pt(24) }, { id: 'carta', fontSize: pt(12), lineHeight: pt(18), spaceBetween: pt(9) }, // the council's imprint drops seven lines below the lead, to the cover's last line { id: 'sello', ...label(9, 'ink'), lineHeight: pt(LEAD), marginTop: pt(7 * LEAD) }, { id: 'colofon', ...label(7, 'muted'), lineHeight: pt(10), marginTop: pt(LEAD) }], calloutStyles: [{ id: 'formatos', background: col('tint'), padding: mm(4), snapToGrid: false, titleStyle: { ...label(8, 'navy', 700), letterSpacing: pt(0.6), textTransform: 'uppercase' }, marginTop: mm(4.5), body: { fontSize: pt(10), lineHeight: pt(14.5) } }], tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('tint'), headerColor: col('ink'), headerFontSize: pt(9.5), bodyFontSize: pt(9.5), cellPadding: mm(1.35) }, // faces and colours follow the body text captionStyle: { fontSize: pt(9) }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdownの見本 · 121行 · content.es.md
title: "Reciclar en el barrio" subtitle: "Guía de residuos del barrio de la Estación" author: "Ayuntamiento de Castrovalle" --- # Reciclar \\ en el barrio {style="portada"} :::paragraphs{style="entradilla"} Dónde va cada residuo del barrio de la Estación, del brik de leche al sofá viejo, y a qué hora pasa cada camión. ::: :::paragraphs{style="sello"} **Ayuntamiento de Castrovalle** · Concejalía de Medio Ambiente · Enero de 2026 ::: # Índice {style="indice"} :::toc :::callout{type="formatos" title="Cómo usar esta guía"} La sección 1 recorre la isla de contenedores de izquierda a derecha, y su tabla resuelve los residuos que más dudas dan. La sección 2 trata lo que no va al contenedor: el punto limpio, los horarios de recogida y la retirada de muebles. Cada cuadrado de color de las tablas lleva al lado el nombre de su contenedor, para que nadie tenga que distinguir los colores. ::: :::callout{type="formatos" title="Esta guía, en otros formatos"} Este PDF está etiquetado. Un lector de pantalla recorre sus títulos, listas y tablas en el orden de lectura y lee la descripción de cada figura, y cada fila del índice lleva a su página. Si la prefieres en letra grande, en lectura fácil o en papel, pídela en el 010 o en la Oficina de Atención a la Ciudadanía. Te la enviamos a casa. ::: :::callout{type="formatos" title="Datos útiles"} - **Información municipal:** 010, de lunes a sábado, de 8:00 a 20:00. - **Punto limpio:** avenida de los Álamos, 14. - **Muebles y enseres:** cita previa en el 010. - **Atención a la Ciudadanía:** plaza Mayor, 1. ::: :::columnbreak # Presentación {style="presentacion"} :::paragraphs{style="carta"} En el barrio de la Estación viven unas 6400 personas, y cada una tira algo más de un kilo de basura al día. Hasta hace un año, casi todo acababa en el mismo sitio. Desde marzo hay contenedor marrón en todas las calles, y con él son cinco los contenedores de cada isla. Esta guía explica qué va en cada contenedor y qué hacer con lo que no cabe en ninguno: el sofá viejo, el aceite de la sartén, las pilas o una lámpara rota. Los horarios de recogida y la dirección del punto limpio están en la última página. Lo que se echa al contenedor marrón se convierte en compost en la planta de la comarca y vuelve a los parques y jardines del municipio. Para que ese compost sirva, la materia orgánica tiene que llegar limpia, sin bolsas de plástico ni restos de vidrio. En la planta de clasificación, un envase echado al contenedor equivocado se aparta a mano. Una bolsa de basura en el contenedor del papel moja y mancha el cartón, y puede echar a perder la carga entera del camión. Entre marzo y diciembre, el barrio llevó al contenedor marrón 312 toneladas de restos de comida, una de cada siete toneladas de la basura que tiró en esos meses. Este año queremos llegar a una de cada cuatro, y para eso basta con que cada casa separe sus restos de comida. *Marta Ibarra, concejala de Medio Ambiente* ::: # Qué va en cada contenedor En cada calle del barrio hay una isla de cinco contenedores, siempre en el mismo orden, el de la :ref{id="contenedores" style="full" case="lower"}. Cada color recoge un tipo de residuo, pero no todo el plástico va al amarillo ni todo el vidrio al verde. ::resource{id="contenedores"} ## Amarillo: envases Envases de plástico, latas y briks: botellas de agua y de refresco, botes de champú y de detergente, latas de conserva y de bebida, bolsas de plástico, bandejas de corcho blanco, papel de aluminio, chapas y tapas de metal. Vacíalos antes de tirarlos; no hace falta lavarlos. Un juguete o un cubo no son envases, aunque sean de plástico, y van al contenedor gris. ## Azul: papel y cartón Periódicos, revistas, folletos, sobres, cajas de cartón y hueveras de cartón. Pliega las cajas antes de echarlas, para que quepan más. Las servilletas y el papel de cocina usados van al marrón, y los tiques de compra, de papel térmico, al gris. ## Verde: vidrio Botellas, tarros y frascos de vidrio, sin tapas ni tapones: los de metal y plástico van al amarillo, y los de corcho, al marrón. Los vasos, las copas y los platos no son vidrio de envase: funden a otra temperatura y estropean el vidrio que se recicla. Van al gris, y los espejos y los cristales de ventana, al punto limpio. ## Marrón: orgánico Restos de comida, crudos o cocinados: mondas de fruta, cáscaras de huevo, espinas, posos de café e infusiones, flores secas, tapones de corcho y papel de cocina sucio. Usa bolsas compostables, con la marca de la norma UNE-EN 13432: en la planta, las bolsas de plástico se retiran a mano antes de hacer el compost. ## Gris: resto Lo que no va en ninguno de los otros cuatro: pañales y compresas, colillas, excrementos de mascotas y arena del gato, polvo de barrer, chicles, cerámica y loza rota. Antes de echar algo al gris, busca en la :ref{id="dudas" style="full" case="lower"} si tiene un sitio mejor, y echa siempre la bolsa cerrada. ::resource{id="dudas"} # Lo que no va al contenedor Algunos residuos no caben en ninguna isla, por su tamaño o porque contaminan. Se llevan al punto limpio, se dejan en los puntos de recogida de las tiendas o se recogen a domicilio. ## El punto limpio Está en la avenida de los Álamos, 14, junto a la rotonda del polígono, y abre de martes a sábado, de 9:00 a 14:00 y de 16:00 a 19:30. Allí se dejan, sin coste para los vecinos: - aceite de cocina usado, en una botella de plástico cerrada; - pilas, baterías y bombillas; - pequeños aparatos eléctricos, como secadores, móviles o cargadores; - pintura, disolventes y aerosoles con restos; - espejos, cristales de ventana y radiografías; - restos de poda, en sacos de hasta 25 kilos. Las pilas y las bombillas también se pueden dejar en los contenedores de las tiendas que las venden, y los medicamentos, con su caja, en cualquier farmacia. La ropa y el calzado usados tienen sus propios contenedores, en la plaza del Mercado y junto al centro de salud. ## Cuándo pasa cada camión Los contenedores se vacían de noche, salvo el del vidrio, que se vacía por la mañana. La :ref{id="horarios" style="full" case="lower"} da el día y la hora de cada recogida. Echa las bolsas a partir de las 20:00, para que pasen el menor tiempo posible en la calle, y el vidrio, de día, porque hace ruido. ::resource{id="horarios"} ## Muebles y enseres Un colchón, una silla o una lavadora no se dejan junto a los contenedores. Si compras un electrodoméstico nuevo, la tienda se lleva el viejo sin cobrarte nada. Lo demás lo retira gratis el servicio municipal, en la puerta de casa: 1. Llama al 010 o pide cita en la sede electrónica del Ayuntamiento. 2. Apunta el día y el número de recogida. 3. Pega en cada objeto un papel con ese número. 4. Esa noche, desde las 21:00, saca los objetos a la acera de tu portal. Si tienes una duda que esta guía no resuelve, llama al 010, de lunes a sábado de 8:00 a 20:00, o pregunta en el punto limpio. Las preguntas que más se repitan entrarán en la próxima edición. :::paragraphs{style="colofon"} Castrovalle es un municipio imaginario, y esta guía se escribió para el Recetario de Postext (postext.dev). Compuesta en Atkinson Hyperlegible Next, Atkinson Hyperlegible Mono y Public Sans (SIL Open Font License). Texto e ilustraciones: CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook const dudas = String.raw`Residuo ContenedorMarkdownの見本 · 11行 · content.dudas.es.md
Brik de leche o de zumo amarillo Tapa de un tarro de cristal amarillo Papel de aluminio amarillo Bolsa de patatas fritas amarillo Caja de pizza azul Frasco de colonia verde Servilleta de papel usada marron Vaso o copa de cristal gris Tique de compra gris Bombilla punto Aceite de cocina usado punto`; // TSV, as a spreadsheet exports it: residuo, contenedor const horarios = String.raw`Contenedor Días DesdeMarkdownの見本 · 5行 · content.horarios.es.md
amarillo Lunes, miércoles y viernes 22:00 azul Martes y sábados 22:00 verde Jueves alternos 8:00 marron Todos los días 23:00 gris Todos los días 23:00`; // TSV: contenedor, días, desde qué hora // #region reading-order: the order of the tags, which is the order renderToPdf paints the page in // An opener band's title, each column from top to bottom, then the page's floats. A paragraph // continued in the next column stays one element, so its second part keeps its number. const tagOf = (b) => ({ heading: `H${b.headingLevel ?? 1}`, callout: 'Div', listItem: 'LI', resource: b.resourceBlock?.kind === 'table' ? 'Table' : 'Figure' })[b.type] ?? 'P'; function readingOrder(page) { const ids = new Map(); // element → its number const add = (block, box) => { const key = block.id.replace(/-cont-\d+$/, ''); // fragments share their block's id if (!ids.has(key)) ids.set(key, ids.size + 1); return { n: ids.get(key), tag: tagOf(block), box, cont: key !== block.id }; }; const blocks = page.columns.flatMap((c) => c.blocks); const title = blocks.find((b) => b.hidden && b.type === 'heading'); // drawn by the band return [...(page.openerBand && title ? [add(title, union(page.openerBand.blocks .filter((b) => b.kind === 'text')))] : []), ...blocks.filter((b) => !b.hidden).map((b) => add(b, b.bbox)), ...(page.floats ?? []).map((b) => add(b, b.bbox))]; } // #endregion // #region art: the five street bins, and the reading order painted over page 3 const n = (v) => Math.round(v * 100) / 100; const rgb = (hex) => hex.slice(1).match(/../g).map((c) => parseInt(c, 16)); const shade = (hex, k) => `#${rgb(hex).map((c) => Math.round(c * k).toString(16).padStart(2, '0')) .join('')}`; const luminance = (hex) => rgb(hex).map((c) => c / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)).reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0); // A white glyph where it reaches 3:1 against the bin (WCAG's figure for graphics), else ink. const glyphOn = (hex) => (1.05 / (luminance(hex) + 0.05) >= 3 ? '#ffffff' : palette.ink); // Pictograms in an 8 × 10 box centred on (0, 0): a bottle, a box, a wine bottle, an apple core // and a tied bag. Strokes only, so they stay vector in the PDF. const GLYPH = { amarillo: 'M-1.2 -5L1.2 -5L1.2 -3.6C2.8 -3 3 -2 3 -1L3 4.2C3 4.8 2.6 5 2 5L-2 5C-2.6 5 -3 4.8 ' + '-3 4.2L-3 -1C-3 -2 -2.8 -3 -1.2 -3.6Z M-3 0.6L3 0.6', azul: 'M-4 -1L0 -3L4 -1L4 4L0 5.6L-4 4Z M-4 -1L0 1L4 -1 M0 1L0 5.6 M-4 -1L-5 -3.4L-1 -5.4L0 -3', verde: 'M-0.9 -5.4L0.9 -5.4L0.9 -2.2C2.6 -1.4 2.8 -0.4 2.8 0.8L2.8 4.6C2.8 5.1 2.5 5.4 2 5.4L-2 ' + '5.4C-2.5 5.4 -2.8 5.1 -2.8 4.6L-2.8 0.8C-2.8 -0.4 -2.6 -1.4 -0.9 -2.2Z', marron: 'M-2.6 -3.2C-0.6 -3.8 0.6 -3.8 2.6 -3.2C1.2 -1.6 1.2 1.6 2.6 3.6C0.6 4.4 -0.6 4.4 ' + '-2.6 3.6C-1.2 1.6 -1.2 -1.6 -2.6 -3.2Z M0 -3.6L0.4 -5.6 M0.4 -5C1.6 -6 2.8 -5.6 3.2 -5', gris: 'M-3.4 -1.6C-3.8 1.4 -3.4 5 0 5C3.4 5 3.8 1.4 3.4 -1.6C2.6 -2.6 1 -3 0 -3.2C-1 -3 ' + '-2.6 -2.6 -3.4 -1.6Z M-1.6 -3.1L-2.4 -5.2L0 -4L2.4 -5.2L1.6 -3.1', }; function binsSvg([w, h], size) { // five bins across w mm, their wheels on the foot of the drawing const step = w / 5; const bins = Object.keys(BIN).map((id, i) => { const [cx, bw, r] = [step * (i + 0.5), size * 0.68, size * 0.06]; // centre, width, wheel const [top, foot] = [h - size - r, h - r]; const lid = `M${n(cx - bw / 2 - r / 2)} ${n(top)}L${n(cx + bw / 2 + r / 2)} ${n(top)}` + `L${n(cx + bw / 2)} ${n(top - size * 0.1)}L${n(cx - bw / 2)} ${n(top - size * 0.1)}Z`; const body = `M${n(cx - bw / 2)} ${n(top)}L${n(cx + bw / 2)} ${n(top)}L${n(cx + bw * 0.46)} ` + `${n(foot)}L${n(cx - bw * 0.46)} ${n(foot)}Z`; const wheel = (x) => `<circle cx="${n(x)}" cy="${n(foot)}" r="${n(r)}" fill="${palette.ink}"/>`; return `<path d="${body}" fill="${BIN[id]}"/><path d="${lid}" fill="${shade(BIN[id], 0.72)}"/>` + wheel(cx - bw * 0.34) + wheel(cx + bw * 0.34) + `<path d="${GLYPH[id]}" transform="translate(${n(cx)} ${n(top + size * 0.48)}) ` + `scale(${n(size / 16)})" fill="none" stroke="${glyphOn(BIN[id])}" stroke-width="0.8" ` + 'stroke-linejoin="round" stroke-linecap="round"/>'; // the glyphs are drawn for a 16 mm bin }); return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" height="${h * PX}" ` + `viewBox="0 0 ${w} ${h}">${bins.join('')}</svg>`; } function union(boxes) { // the box around several design elements return boxes.reduce((u, { bbox: b }) => { const [x, y] = [Math.min(u.x, b.x), Math.min(u.y, b.y)]; return { x, y, width: Math.max(u.x + u.width, b.x + b.width) - x, height: Math.max(u.y + u.height, b.y + b.height) - y }; }, boxes[0].bbox); } function drawReadingOrder(ctx, page, scale) { const mmPx = (v) => (v * page.width * scale) / TRIM[0]; const [r, pad, ink] = [mmPx(2.8), mmPx(1.2), '#d6146e']; // disc radius, box padding, magenta const inset = mmPx(0.6); // a gap under a box another one touches, such as a heading const boxOf = ({ x, y, width, height }, grow = 0) => ({ x: x * scale - pad, y: y * scale + inset - grow, w: width * scale + 2 * pad, h: height * scale - inset + 2 * grow }); const tab = (label, { x, y, w }, fill) => { // a label straddling the box's top-right corner ctx.font = `700 ${r * 0.95}px "Atkinson Hyperlegible Mono"`; const lw = ctx.measureText(label).width + r * 0.8; ctx.fillStyle = fill; ctx.fillRect(x + w - lw, y - r * 0.55, lw, r * 1.1); ctx.fillStyle = '#ffffff'; ctx.fillText(label, x + w - lw / 2, y + r * 0.02); }; const marks = readingOrder(page).map((m) => ({ ...m, ...boxOf(m.box) })); ctx.save(); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineWidth = r / 4.5; ctx.strokeStyle = ink; marks.forEach((m, i) => { // a line down each column, from one number to the next const p = marks[i - 1]; if (!p || m.y < p.y) return; // no line for the jump to the next column ctx.beginPath(); ctx.moveTo(p.x - r * 1.3, p.y + r); ctx.lineTo(m.x - r * 1.3, m.y + r); ctx.stroke(); }); for (const m of marks) { ctx.fillStyle = 'rgba(214, 20, 110, 0.07)'; ctx.fillRect(m.x, m.y, m.w, m.h); ctx.setLineDash(m.cont ? [r / 2, r / 3] : []); ctx.strokeRect(m.x, m.y, m.w, m.h); ctx.setLineDash([]); ctx.fillStyle = ink; ctx.beginPath(); ctx.arc(m.x - r * 1.3, m.y + r, r, 0, Math.PI * 2); ctx.fill(); tab(m.cont ? `${m.tag} (cont.)` : m.tag, m, ink); ctx.font = `700 ${r * 1.15}px "Atkinson Hyperlegible Mono"`; ctx.fillText(String(m.n), m.x - r * 1.3, m.y + r * 1.05); } if (page.footer?.blocks.length) { // running heads and folios: artifacts, never read const foot = boxOf(union(page.footer.blocks), pad); ctx.strokeStyle = '#6b7480'; ctx.setLineDash([r / 2, r / 3]); ctx.strokeRect(foot.x, foot.y, foot.w, foot.h); tab('Artifact', foot, '#6b7480'); } ctx.restore(); } function showReadingOrder(page) { // over the viewer's page, and in the Cookbook's page images const canvas = [...document.querySelectorAll('#pages canvas')] .find((c) => c.postext.page === page); const layer = Object.assign(document.createElement('canvas'), { width: canvas.clientWidth * 2, height: canvas.clientHeight * 2 }); // transparent, over the painted page layer.style.cssText = 'position:absolute;top:0;left:0;width:100%;background:none;box-shadow:none'; canvas.parentElement.style.position = 'relative'; canvas.after(layer); drawReadingOrder(layer.getContext('2d'), page, layer.width / page.width); window.__postextOverlay = (ctx, p, _, scale) => p.index === page.index && drawReadingOrder(ctx, p, scale); } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Loaded before layout (gotcha: fonts-first); the PDF embeds the same files (gotcha: latin-subset) const FONTS = { 'Atkinson Hyperlegible Next': ['400', '400i', '700', '700i'], 'Atkinson Hyperlegible Mono': ['400', '700'], 'Public Sans': ['700', '800'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('contenedores.svg', binsSvg(FIGURE, 23)); await loadSvg('calle.svg', binsSvg(STREET, 36)); const content = { markdown, resources: resources() }; const doc = await buildWithFonts(() => buildDocument(content, config()), markdown); showPages(doc, { title: 'Reciclar en el barrio · PDF accesible' }); showReadingOrder(doc.pages[2]); // page 3: every tagged block numbered in reading order offerPdf(() => exportPdf(doc), `${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上のレシピのフォルダー ↗ (新しいタブで開きます)
アレンジ
#図をフロートさせる
placementを外すと、図1.1は3ページ目の1段目の下端へフロートし、PDFはそれをページ最後のブロックである表1.1の後にタグ付けします。
- { id: 'contenedores', typeId: 'figure', kind: 'svg', placement: { position: 'here' },
+ { id: 'contenedores', typeId: 'figure', kind: 'svg',よくあるつまずき
つまずき
フロートの図は、ページの本文のあとに読まれる
renderToPdfは、ページを描く順にタグを付けます。章扉の帯のタイトル、上から下へ各段、最後にページのフロートです。そのため、段の頭にフロートした図や表は、そのページの最後の段落と最後の表のあとに読まれ、参照する文から離れてしまいます。読者が参照した位置で出会う必要があるものは、placement 'here'と::resourceの行でインラインに置いてください。 アクセシブルなタグ付きPDF →
つまずき
ピリオドをカーニングする書体では目次のリーダーがはみ出す
postext 1.4.1は、ドット1つの幅からリーダーのドットの数を数え、ページ番号の書体で組みます。書体によってはピリオドを連ねるとそれより広い間隔になるため(Public Sans Boldの12 ptで、150 dpiでドット1つは6.7 px、連続したドットは1つあたり7.6 px)、ドットが割り当てられた幅を超えてページ番号に触れ、行ごとにそろわなくなります。toc.pageNumberには、連ねてもピリオドの幅が変わらないfontFamily(本文の書体など)を指定してください。 目次 →
つまずき
行末不ぞろいのテキストはラントを検査されない
optimalLineBreaking、avoidRunts、runtPenalty、runtMinCharactersはKnuth–Plassの改行処理に働きますが、postext 1.4.1がこれを使うのは両端そろえのテキストだけです。左そろえの段落は1行ずつ改行されるため、これらの設定にかかわらず短い一語で終わることがあります。左そろえのテキストの最終行を読み、ラントで終わる段落は書き換えてください。 ウィドウ、オーファン、ラント →
つまずき
フロントマターの値はすべて引用符で囲む
YAMLはtitle: 1984を数値として、日付をDateオブジェクトとして読みます。文字列でない値はプレースホルダーに空で出力され、PDFにもタイトルが付きません。値はすべて引用符で囲んでください(title: "1984")。 文書のメタデータ →
つまずき
Figure/TableはdefaultResourceTypes(locale)でローカライズする
設定のlocaleが決めるのはハイフネーションで、キャプションではありません。resourceTypesがなければ、組み込みの種類は英語でFigureとTableと表示されます。スペイン語ならresourceTypes: defaultResourceTypes('es')を渡し、それ以外の言語ではresourceTypesに名前を自分で書いてください。 「図」と「表」を文書の言語で →
つまずき
行末不ぞろいのテキストはハイフネーションされない
ハイフネーションが効くのは両端そろえのテキストだけです。左そろえのテキストは語と語の間で改行するため、幅の狭い左そろえの段では行末の凹凸が大きくなります。その箇所を両端そろえにするか、行長を広げてください。 ハイフネーションと文書の言語 →
つまずき
不明なidの:refは'?'を印字し、エンジンは警告しない
どのリソースにもないidへの:refは「?」を印字して何も配置せず、警告するのはSandboxだけです。参照するidがすべて存在するか確認してください。 図を配置する参照 →
つまずき
headingsオブジェクトを渡すとH1の改ページが消える
既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →
つまずき
見出しスタイルはその階層の改ページを受け継ぐ
headingStylesの項目は、指定しなかったフィールドをすべて見出しの階層から受け継ぎ、breakBeforeも例外ではありません。:::pagebreakのあとのH1に付けた目次や奥付のスタイルはparity 'odd'を受け継ぎ、白ページの後ろに置かれます。こうしたスタイルにはbreakBefore: { enabled: false }を指定してください。 見出しスタイル →
つまずき
パレットを差し替えても、デザイン要素と参照色は変わらない
postext 1.4.1はcolorPaletteをテキストのスタイル(本文、見出し、リスト、キャプション、表、囲み)には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。 セマンティックカラーパレット →
つまずき
デザインのテキストのoverflowの既定値は'ellipsis-end'
幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。 ページデザインのテキスト・罫・ボックス →
つまずき
設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る
エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。 キャンバス上のページ →
つまずき
レイアウトの前にすべてのフォントを読み込む
レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →
つまずき
Fontsourceのlatinファイルにはラテン文字以外のグリフがない
PDFプロバイダーが埋め込むのはFontsourceのlatinファイルです。スペイン語や西欧語のテキストは収まりますが、→、≈、✓、★、ギリシャ文字、中欧語の文字は含まれず、PDFではそれらのグリフが欠けます。PDFのテキストはlatinの範囲に収めてください。 PDFに埋め込むフォント →
veraPDFはブラウザーの外で動くため、サンプルは自分のPDFを検証できません。ファイルをダウンロードしてverapdf -f ua1 accessible-tagged-pdf.pdfを実行してください。このページのために取得したファイルは合格しています。代替テキストが絵を正しく説明しているかは検査ツールには判断できないので、画面読み上げソフトでガイドを聞いて確かめてください。
このガイドはpostext 1.4.1で取得しました。このバージョンではMarkdownのリンクは文字を印刷するだけでPDFにリンクを加えなかったため、奥付にはウェブアドレスを省略せずに印刷しています。postext 1.10からは[text](url)のリンクがPDF上で有効になり、リンクされた語をテキストとするLink要素としてタグ付けされます。
クレジット
- 本文
- 書き下ろしの文章, CC BY 4.0
- フォント
- Atkinson Hyperlegible Next (SIL OFL 1.1) · Atkinson Hyperlegible Mono (SIL OFL 1.1) · Public Sans (SIL OFL 1.1)


