跳到主要内容
食谱编号39

排版食谱 · 第10章 · 输出与集成

带标签的无障碍PDF(PDF/UA)

一份垃圾分类指南,导出为符合PDF/UA-1的带标签PDF,带替代文本、表头单元格、文档语言和可跳转的目录;预览在第3页上画出了各个标签。

本页内容

第2–3页,共4页

  • 西班牙文样例:尚无中文版本
  • 成品尺寸210 × 297 mm
  • 2栏, 栏间距8 mm
  • Atkinson Hyperlegible Next 10.5/15.5
  • Atkinson Hyperlegible Mono
  • Public Sans
  • 4页
  • 难度
  • Postext 1.4.1
  • 排版用时13 ms
  • 179行代码

成品一览

Reciclar en el barrio是一份四页A4的垃圾分类指南,出自一个虚构的西班牙市政府,适合在屏幕上阅读,也可以在家打印。藏青色封面上,五个街头垃圾桶立在黄色路缘上。第2页是目录和三个框,旁边是市议员的一封信;第3、4页是两个章节,用Atkinson Hyperlegible Next排成两栏齐左文字。这支pen导出的带标签PDF通过了veraPDF的PDF/UA-1检查:标题、列表、框和表格按阅读顺序排列,图带有替代文本,两张表都有表头单元格。文件声明了标题和语言(es),书签和目录各行都链接到对应页面。标签不会打印出来,所以pen在预览的第3页上把它们画了出来(即此处所示):每个带标签的块套在一个编号框里,并注明标签名。下载的PDF里没有这层叠加。

这道食谱解答

  • 怎样生成带替代文本和文档语言的无障碍PDF(带标签,PDF/UA)?
  • 怎样让“图”和“表”的标签使用文档的语言?
  • 怎样添加自动更新的目录(前导符、页码、作者、篇名行)?
  • 怎样在浏览器中导出嵌入字体的真正PDF?

简短回答

script.js · 第48–71行在完整代码中
// 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

用料

类型
Atkinson Hyperlegible Next, Atkinson Hyperlegible Mono, Public Sans(SIL OFL 1.1)
素材
无:所有图片都用代码绘制

做法

#1 · 文档的语言和标题

script.js · 第127–129行在完整代码中
  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 · 一份大纲,以及只放标题的封面色带

script.js · 第75–105行在完整代码中
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书签也来自同一份列表,所以封面、目录、那封信和两个章节都是1级,小标题是2级。postext-pdf会把标题设计里的每个文本元素都标记为该标题的一部分,所以封面色带里只放标题,导语和市政府名称作为段落排在色带下方。藏青色色带、路缘和那排垃圾桶都是设计元素,PDF把设计框和图像标记为artifact,因此它们不需要替代文本,屏幕阅读器也会跳过。

#3 · 可跳转的目录

script.js · 第109–114行在完整代码中
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中,这十一行每一行都链接到对应页面顶部。第3页的:ref引用“figura 1.1”“tabla 1.1”和第4页的“tabla 2.1”都链接到对应的图和表。前导符用页码的字体Atkinson Hyperlegible Next Bold,原因是在Public Sans里,一串句点的间距比计算前导符所依据的单个点更宽,点会撞上页码。

#4 · 画在页面上的阅读顺序

script.js · 第301–318行在完整代码中
// 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按绘制顺序给每页加标签:先是色带里的标题,然后第一栏从上到下,再是第二栏,最后是该页的浮动体。这个函数重放这一顺序,给第3页的框编号;PDF的结构树也按同样的顺序列出这十四个元素。正因为这个顺序,图和两张表都用placement: { position: 'here' }加一行::resource留在文本流中。如果让图1.1浮动到第一栏底部,它会在表1.1之后才被加标签,而表1.1是该页最后一个块。延续到第二栏的玻璃那一段只有一个编号9,因为PDF把前后两部分标记为同一个段落。

#5 · 按对比度选颜色

script.js · 第16–35行在完整代码中
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.mdtitle: "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 Contenedor
Markdown样例 · 11行 · content.dudas.es.mdBrik 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 Desde
Markdown样例 · 5行 · content.horarios.es.mdamarillo 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页第一栏底部,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按一个点的宽度计算前导符的点数,并用页码的字体排这些点。有些字体连排句点时比单个点宽(Public Sans Bold 12 pt在150 dpi下:单个点6.7 px,连排时每个点7.6 px),于是点超出了留给它们的空间,碰到页码,各行之间也不再对齐。给toc.pageNumber指定一个句点连排时宽度不变的fontFamily,例如正文字体。 目录 →

易错点

齐左文字从不检查孤字

optimalLineBreaking、avoidRunts、runtPenalty和runtMinCharacters作用于Knuth–Plass断行器,而postext 1.4.1只对两端对齐的文字运行它。齐左段落逐行断行,无论这些设置怎么写,都可能以一个短词结尾。检查齐左文字的末行,改写以孤字结尾的段落。 段末孤行、段首孤行与孤字 →

易错点

frontmatter的每个值都加引号

YAML会把title: 1984读成数字,把日期读成Date对象;非字符串的值在占位符中打印为空,PDF也会没有标题。每个值都加引号:title: "1984"。 文档元数据 →

易错点

用defaultResourceTypes(locale)本地化Figure/Table

配置的locale决定断词,不决定题注:没有resourceTypes时,内置类型用英文写作Figure和Table。西班牙语传入resourceTypes: defaultResourceTypes('es');其他语言请在resourceTypes中自己写出名称。 用你的语言显示“图”和“表” →

易错点

齐左文字从不断词

断词只用于两端对齐的文字;左对齐不齐行的文字在词间断行,所以窄栏的齐左文字行尾会很参差。把这段改为两端对齐,或加大行长。 断词与文档语言 →

易错点

未知的:ref id打印'?',引擎不报警告

引用了不存在的资源id的:ref会打印"?",什么也不放置,只有沙盒会就此给出警告。检查每个被引用的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旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →

易错点

设计文本的overflow默认为'ellipsis-end'

宽度放不下的设计文本元素默认以省略号结尾。需要折成多行的标题,设置overflow: 'wrap'。 页面设计中的文字、线条和框 →

易错点

配置按对象身份缓存:每次新建一个对象

引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →

易错点

排版前加载所有字体

排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →

易错点

Fontsource的latin文件不含拉丁字母以外的字形

PDF字体提供函数嵌入的是Fontsource的latin文件,它们覆盖西班牙语和西欧文字,但不包括→、≈、✓、★、希腊字母或中欧字母;这些字形在PDF中会缺失。PDF中的文字要保持在latin范围内。 嵌入PDF的字体 →

pen无法校验自己生成的PDF,因为veraPDF在浏览器之外运行。下载文件后运行verapdf -f ua1 accessible-tagged-pdf.pdf;本页截取的这份文件可以通过。检查工具判断不了替代文本是否真的描述了图片,所以还要用屏幕阅读器把指南听一遍。

在postext 1.4.1中,Markdown链接只印出文字,不会在PDF里加链接;只有:ref引用和目录各行可以跳转。版权页把网址完整印了出来。

致谢

文本
原创文字, CC BY 4.0
字体
Atkinson Hyperlegible Next (SIL OFL 1.1) · Atkinson Hyperlegible Mono (SIL OFL 1.1) · Public Sans (SIL OFL 1.1)
沙盒PDF