En poques paraules
Una guia de reciclatge de quatre pàgines d'un ajuntament. Ensenya a crear un PDF que els lectors de pantalla poden seguir, amb descripcions de les imatges i taules que poden llegir en veu alta.
Què compondràs
Reciclar al barri és la guia de reciclatge d'un ajuntament inventat: quatre pàgines A4 per llegir en pantalla o imprimir a casa. La coberta, blau marí, porta els cinc contenidors sobre una vorada groga. La pàgina 2 reuneix l'índex, tres requadres i la carta de la regidora; la 3 i la 4, les dues seccions, a dues columnes en bandera. El PDF etiquetat que exporta el codi supera les comprovacions PDF/UA-1 de veraPDF, amb títols, llistes, requadres i taules en ordre de lectura, text alternatiu a la figura i cel·les de capçalera a les taules. El fitxer declara títol i llengua (es), i els marcadors i les files de l'índex són enllaços. Com que les etiquetes no s'imprimeixen, el codi les dibuixa només a la previsualització de la pàgina 3, la que veus aquí, amb un requadre numerat per bloc i el nom de la seva etiqueta.
Aquesta recepta respon a
- Com genero un PDF accessible (etiquetat, PDF/UA) amb text alternatiu i llengua del document?
- Com aconsegueixo les etiquetes «Figura» i «Taula» en la llengua del meu document?
- Com afegeixo un índex que s'actualitzi sol (línies de punts, números de pàgina, autors, files de part)?
- Com exporto un PDF de debò al navegador, amb les fonts incrustades?
La resposta curta
// 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
Ingredients
- Funcions
- PDF accessible etiquetatÍndex de contingutsExportació a PDFMarcadors del PDFFigura i Taula en la teva llenguaTaules a partir de dadesFigures just aquíMostres de colorCites que col·loquen les figuresMetadades del documentEstils de títolCapítols sense númeroObertures dissenyadesBanda de capítol d'amplada completaCobertes, portades i colofonsImatges als dissenys de pàginaRequadresFonts abans de compondrePàgines en un canvas
- També fa servir
- Atributs de títolTítols numeratsSortir de la retícula a propòsitSalts de pàgina i de columnaEstils de paràgrafFonts incrustades al PDFTipus de recurs propisFigures i taules com a recursosGeometria per seccióCapçaleres per seccióSalts de línia als títols
- Tipografia
- Atkinson Hyperlegible Next, Atkinson Hyperlegible Mono, Public Sans (SIL OFL 1.1)
- Recursos
- Cap: totes les imatges es dibuixen en codi
Elaboració
#1 · La llengua i el títol del document
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 escriu la llengua de les etiquetes a partir de locale, aquí /Lang es, perquè un lector de pantalla llegeixi la guia amb pronunciació espanyola; si falta, el fitxer declara en-US. El title i l'author del frontmatter de content.es.md donen el títol i l'autor del PDF, i el fitxer demana als visors que mostrin aquest títol a la barra de la finestra en lloc del nom del fitxer. defaultResourceTypes(LANG) dona als peus i a les cites els noms en espanyol: «Figura 1.1», «tabla 1.1». El text accessible de la figura i de les taules és a la resposta curta: l'altText dona la descripció alternativa de la figura (/Alt) i el resum de cada taula (/Summary), i la primera fila i la primera columna de les dues taules s'etiqueten com a cel·les de capçalera.
#2 · Un sol esquema, i una banda de coberta amb el títol i res més
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
] };
L'arbre d'etiquetes pren els elements H1 i H2 dels títols del Markdown, i els marcadors del PDF surten de la mateixa llista, de manera que la coberta, l'índex, la carta i les dues seccions són de nivell 1, i els intertítols, de nivell 2. postext-pdf etiqueta cada text del disseny d'un títol dins d'aquest títol. Per això la banda de la coberta porta només el títol, i l'entradeta i el nom de l'ajuntament van a sota, com a paràgrafs. Les bandes blau marí, la vorada i la fila de contenidors són elements de disseny, i el PDF marca les caixes i les imatges de disseny com a artefactes, així que no necessiten text alternatiu i el lector de pantalla se les salta.
#3 · Un índex amb enllaços
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 recull els H1 i els H2 amb el número de pàgina que imprimeixen, i al PDF cadascuna de les seves onze files enllaça amb el començament de la seva pàgina. Les cites :ref de la pàgina 3, «figura 1.1» i «tabla 1.1», i la de la pàgina 4, «tabla 2.1», porten a la figura i a les taules. Les línies de punts fan servir la lletra dels números de pàgina, Atkinson Hyperlegible Next Bold, perquè en Public Sans una sèrie de punts ocupa més que el punt solt amb què es calcula la línia, i els punts es muntaven sobre els números.
#4 · L'ordre de lectura, dibuixat a la pàgina
// 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))];
}
Un lector de pantalla segueix les etiquetes, i renderToPdf etiqueta cada pàgina en l'ordre en què la pinta: el títol de la banda, la primera columna de dalt a baix, la segona i, al final, els flotants de la pàgina. Aquesta funció repeteix aquest ordre per numerar els requadres de la pàgina 3, i l'arbre d'estructura del PDF dona els mateixos catorze elements en el mateix ordre. Aquest ordre obliga a posar la figura i les dues taules al flux, amb placement: { position: 'here' } i una línia ::resource. Si flotés al peu de la primera columna, la figura 1.1 s'etiquetaria després de la taula 1.1, l'últim bloc de la seva pàgina. El paràgraf del vidre que continua a la segona columna conserva un sol número, el 9, perquè el PDF etiqueta les dues parts com un únic paràgraf.
#5 · Colors triats pel seu contrast
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 no comprova el color, però WCAG 2.2 demana 4,5:1 entre el text i el seu fons. La tinta dona 15,6:1 sobre blanc; la lletra blanca, 12,5:1 sobre les bandes blau marí, i el gris del peu de pàgina, 7,0:1. El groc dona 1,8:1 sobre blanc, així que només apareix sobre blau marí (6,9:1) i com a farciment. Els pictogrames segueixen el 3:1 que WCAG demana als gràfics: blanc quan arriba a aquesta proporció sobre el seu contenidor i tinta sobre el groc. cell() escriu el nom de cada contenidor al costat de la seva mostra de color, i dona a la deixalleria un quadrat buit, de manera que cap indicació no depèn de distingir colors.
La recepta completa
// ═══ 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`---Mostra en Markdown · 121 línies · 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 ContenedorMostra en Markdown · 11 línies · 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 DesdeMostra en Markdown · 5 línies · 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`);Kit · core, fonts, viewer, pdf, images: igual a totes les receptes · 310 línies
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)
Variants
#Deixa flotar la figura
Treu la col·locació i la figura 1.1 flota al peu de la primera columna de la pàgina 3, i el PDF l'etiqueta després de la taula 1.1, l'últim bloc de la pàgina.
- { id: 'contenedores', typeId: 'figure', kind: 'svg', placement: { position: 'here' },
+ { id: 'contenedores', typeId: 'figure', kind: 'svg',Errors freqüents
Error freqüent
Una figura flotant es llegeix després del text de la seva pàgina
renderToPdf etiqueta cada pàgina en l'ordre en què la pinta: el títol de la banda d'obertura, cada columna de dalt a baix i, al final, els flotants de la pàgina. Una figura o una taula que flota al capdamunt d'una columna es llegeix, per tant, després de l'últim paràgraf i de l'última taula de la seva pàgina, lluny de la frase que la cita. Posa en línia les que el lector hagi de trobar on es citen, amb placement 'here' i una línia ::resource. PDF accessible etiquetat →
Error freqüent
Les línies de punts de l'índex es desborden amb una lletra que separa els punts seguits
postext 1.4.1 compta els punts d'una línia de punts per l'amplada d'un sol punt i els compon amb la lletra dels números de pàgina. Algunes lletres separen més els punts seguits (Public Sans Bold a 12 pt: 6,7 px un punt solt a 150 ppp i 7,6 px cada punt d'una sèrie), així que els punts surten del seu espai, toquen el número de pàgina i deixen d'alinear-se d'una fila a l'altra. Dona a toc.pageNumber un fontFamily els punts del qual conservin l'amplada en sèrie, com la lletra del text. Índex de continguts →
Error freqüent
En el text en bandera no s'eviten les línies curtes
optimalLineBreaking, avoidRunts, runtPenalty i runtMinCharacters actuen sobre l'algorisme de Knuth–Plass, que postext 1.4.1 només aplica al text justificat. Un paràgraf en bandera es talla línia a línia i pot acabar en una sola paraula curta, diguin el que diguin aquestes opcions. Revisa les últimes línies del text en bandera i reescriu el paràgraf que acabi en una línia curta. Vídues, òrfenes i línies curtes →
Error freqüent
Posa entre cometes cada valor del frontmatter
YAML llegeix title: 1984 com un nombre i una data com un objecte Date, i els valors que no són cadenes s'imprimeixen buits als marcadors i deixen el PDF sense títol. Posa entre cometes cada valor: title: "1984". Metadades del document →
Error freqüent
Tradueix Figura i Taula amb defaultResourceTypes(locale)
El locale de la configuració fixa la partició de mots, no els peus: sense resourceTypes, els tipus de sèrie diuen Figure i Table en anglès. Passa resourceTypes: defaultResourceTypes('es') per al castellà; per a qualsevol altra llengua, escriu tu mateix els noms a resourceTypes. Figura i Taula en la teva llengua →
Error freqüent
En el text en bandera no hi ha partició de mots
La partició de mots només s'aplica al text justificat; el text en bandera talla entre paraules, així que una columna estreta en bandera queda molt desigual. Justifica el passatge o eixampla la mesura. Partició de mots i llengua del document →
Error freqüent
Un :ref desconegut imprimeix «?» sense avís del motor
Un :ref a un id que no té cap recurs imprimeix «?» i no col·loca res, i només el Sandbox n'avisa. Comprova que existeix cada id que cites. Cites que col·loquen les figures →
Error freqüent
Qualsevol objecte headings desactiva el salt de pàgina de l'H1
Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →
Error freqüent
Un estil de títol hereta el salt de pàgina del seu nivell
Una entrada de headingStyles pren del seu nivell de títol tot allò que no fixa, també breakBefore. Un índex o un colofó amb estil sobre un H1 després d'un :::pagebreak hereta la paritat 'odd' i cau darrere d'una pàgina en blanc. Dona a aquest estil breakBefore: { enabled: false }. Estils de títol →
Error freqüent
Una paleta canviada no arriba als elements de disseny ni al color de les remissions
postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →
Error freqüent
El desbordament del text de disseny és 'ellipsis-end' per defecte
Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
Una configuració es desa a la memòria cau per identitat: crea un objecte nou
El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config(). Pàgines en un canvas →
Error freqüent
Carrega totes les fonts abans de compondre
La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →
Error freqüent
Els fitxers latin de Fontsource només porten glifs de l'interval llatí
El proveïdor del PDF incrusta els fitxers latin de Fontsource, que cobreixen el castellà i les llengües de l'Europa occidental però no →, ≈, ✓, ★, el grec ni les lletres de l'Europa central; aquests glifs falten al PDF. Mantén el text del PDF dins de l'interval latin. Fonts incrustades al PDF →
El codi no pot validar el seu propi PDF, perquè veraPDF funciona fora del navegador. Baixa el fitxer i executa verapdf -f ua1 accessible-tagged-pdf.pdf; el PDF capturat per a aquesta pàgina passa la validació. El validador no sap si un text alternatiu descriu la seva imatge, així que escolta també la guia amb un lector de pantalla.
Aquesta guia es va capturar amb postext 1.4.1, en què un enllaç de Markdown imprimia les seves paraules sense afegir cap enllaç al PDF; per això el colofó imprimeix l'adreça web completa. Des de postext 1.10 un enllaç [texto](url) funciona al PDF i s'etiqueta com a element Link el text del qual són les paraules enllaçades.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Text original, CC BY 4.0
- Fonts
- Atkinson Hyperlegible Next (SIL OFL 1.1) · Atkinson Hyperlegible Mono (SIL OFL 1.1) · Public Sans (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


