Saltar al contingut principal
Recepta número 40

Receptari · Capítol 10 · Sortida i integració

Fonts de marca a la composició, al PDF i al paquet

El manual d'identitat d'un metro fictici amb els fitxers de font de la marca, baixats un sol cop per a la composició, el PDF i un paquet .postext.

En aquesta pàgina
  • Mostra en anglès: encara no hi ha edició en català
  • Format 210 × 280 mm
  • 2 columnes, espai entre columnes de 8 mm
  • Public Sans 9,8/14
  • Big Shoulders Display
  • Spline Sans Mono
  • 4 pàgines
  • Nivell
  • Postext 1.4.1
  • Compost en 48 ms
  • 249 línies de codi

En poques paraules

Un manual d'identitat d'un metro inventat. Ensenya a baixar una sola vegada les fonts pròpies d'una marca i a fer-les servir a la previsualització, al PDF i al fitxer desat.

Què compondràs

Quatre pàgines, en anglès, del manual d'identitat del Metro de Alba, una xarxa fictícia de cinc línies. A la coberta, les cinc línies surten juntes de l'angle inferior esquerre i se separen cap a la dreta després de travessar un riu gris, sota el nom en Big Shoulders Display. Cada secció s'obre sota una franja negra, amb un número de dues xifres en el color d'una línia. A 01 Colour, la primera columna de la taula és el color mateix; 02 Type reuneix línies de mostra i un rètol d'andana, i 03 Usage tanca amb dos plànols emmarcats, un dibuixat segons les regles (Do) i un altre que se les salta (Don't). Tot el text surt de sis fitxers de font que el codi baixa abans de compondre. El PDF es dibuixa amb aquests sis fitxers i el .postext en porta cinc, perquè la font de rètols està marcada com a no redistribuïble.

Aquesta recepta respon a

  • Com faig servir les meves fonts corporatives o amb llicència en la composició i les incrusto al PDF?
  • Com exporto un PDF de debò al navegador, amb les fonts incrustades?
  • Com evito que els títols, les negretes i les vinyetes surtin en blau?
  • Com afegeixo mostres de color com a llegenda en el text, els peus o les notes de taula?
  • Com creo un paquet .postext des del codi per passar un document al Sandbox o a un altre programa?

La resposta curta

script.js · línies 16–54al codi complet
// Your licensed files, one per face in FONTS. Fontsource's copies stand in for them here:
// point FONT_URL at your own server (same origin, or one that lets this page in by CORS).
const slug = (family) => family.toLowerCase().replaceAll(' ', '-');
const FONT_URL = ({ family, weight, style }) => `https://cdn.jsdelivr.net/npm/@fontsource/`
  + `${slug(family)}@5/files/${slug(family)}-latin-${weight}-${style}.woff2`;
const brandFaces = () => Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => {
  const weight = parseInt(spec, 10), style = spec.endsWith('i') ? 'italic' : 'normal';
  return { family, weight, style, fileId: `${slug(family)}-${weight}-${style}.woff2` };
}));
const fontFiles = new Map(); // fileId → the WOFF2 bytes, fetched once

// 1 · Layout measures with document.fonts: register every face before the first build.
async function loadBrandFonts() {
  await Promise.all(brandFaces().map(async ({ family, weight, style, fileId }) => {
    const res = await fetch(FONT_URL({ family, weight, style }));
    if (!res.ok) throw new Error(`No file for ${family} ${weight} ${style}`);
    fontFiles.set(fileId, new Uint8Array(await res.arrayBuffer()));
    const face = new FontFace(family, fontFiles.get(fileId), { weight: `${weight}`, style });
    document.fonts.add(await face.load());
  }));
}

// 2 · The PDF embeds the same bytes as TrueType. It also asks for faces no text uses (the
// display face's italic, the monospace's SemiBold): answer with the family's closest file.
// 1.4.1 writes an unused copy of that file for each of them (gotcha: pdf-font-copies).
async function brandFontProvider(family, weight, style) {
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const own = brandFaces().filter((f) => f.family === family);
  if (!own.length) throw new Error(`${family} is not one of the brand's fonts`);
  return decompressWoff2(fontFiles.get(own.reduce((a, b) => (cost(b) < cost(a) ? b : a)).fileId));
}

// 3 · The bundle: customFonts names each face's file by its fileId; createBundle packs the bytes
// of every family it may hand on. Layout never reads it (gotcha: custom-fonts-declarative).
const customFonts = () => Object.keys(FONTS).map((name) => ({
  name, redistributable: name !== DISPLAY, // the display face reaches suppliers another way
  variants: brandFaces().filter((f) => f.family === name)
    .map(({ weight, style, fileId }) => ({ weight, style, fileId, format: 'woff2' })),
}));

Ingredients

Tipografia
Public Sans, Big Shoulders Display, Spline Sans Mono (SIL OFL 1.1)
Recursos
  • The network drawing on the cover and the two map examples, drawn in code (Ignacio Ferro, CC BY 4.0)

Elaboració

#1 · Registra les fonts de la marca, compon i dona al PDF els mateixos bytes

script.js · línies 451–456al codi complet
await loadBrandFonts(); // the answer, step 1 (gotcha: fonts-first)
for (const [fileId, markup] of Object.entries(ART)) await loadSvg(fileId, markup);
const doc = buildDocument({ markdown, resources }, config());
showPages(doc, { title: 'Metro de Alba · Identity manual' });
offerPdf(() => renderToPdf(doc, { fontProvider: brandFontProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`); // step 2: the provider hands renderToPdf the same files

La composició mesura cada paraula amb les fonts de document.fonts, així que loadBrandFonts(), a la resposta curta, baixa cada fitxer una sola vegada, en desa els bytes i hi registra un FontFace abans de cridar buildDocument. El loadFonts del kit només baixa de Fontsource, i una marca serveix els seus fitxers des del seu propi servidor. A postext-pdf 1.4.1, renderToPdf demana deu variants, quatre de les quals no apareixen en cap text (la cursiva de la font de rètols i la cursiva, la seminegreta i la seminegreta cursiva de la monoespaiada), i brandFontProvider respon cada petició amb el fitxer més proper de la mateixa família (Per què un proveïdor de fonts?). Les pàgines només fan servir els sis fitxers que va mesurar el canvas, però la 1.4.1 hi escriu a més una còpia no utilitzada del fitxer substitut per cadascuna d'aquestes quatre peticions.

#2 · El sistema de color és una sola llista

script.js · línies 60–85al codi complet
const COLOURS = [ // id, name, screen, print (coated stock)
  ['line-1', 'Line 1 · Tile red', '#e4572e', '0 75 85 0'],
  ['line-2', 'Line 2 · Harbour teal', '#17bebb', '75 0 32 0'],
  ['line-3', 'Line 3 · Broom yellow', '#ffc914', '0 22 95 0'],
  ['line-4', 'Line 4 · Heather', '#6c4f9e', '65 75 0 0'],
  ['line-5', 'Line 5 · Pine', '#3f9b4a', '76 12 90 2'],
  ['signal-red', 'Signal red', '#c0391b', '10 88 100 2'],
  ['signal-green', 'Signal green', '#2b7d3c', '84 25 95 10'],
  ['ink', 'Ink', '#1f2124', '72 62 55 78'],
  ['rule', 'Rule grey', '#d9d9d4', '14 10 14 0'],
];
const palette = { ...Object.fromEntries(COLOURS.map(([id, , hex]) => [id, hex])),
  paper: '#ffffff', section: '#e4572e' }; // section: the line colour of the current section
// col() writes the hex too: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [...Object.entries(palette), ['main-color', palette.ink]]
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(' ');
const swatchTable = () => COLOURS.reduce( // an empty first cell, filled with the colour itself
  (model, [id], i) => setCellBackground(model, { row: i + 1, col: 0 }, col(id)),
  { headerRowCount: 1, columnWidths: [22, 34, 14, 15, 15], rows: [
    ['COLOUR', 'NAME', 'HEX', 'RGB', 'CMYK'].map((content) => ({ content, isHeader: true })),
    ...COLOURS.map(([, name, hex, cmyk]) =>
      ['', name, hex.toUpperCase(), rgb(hex), cmyk].map((content) => ({ content }))),
  ] });

Com que la paleta i la taula 1.1 es construeixen amb les mateixes nou files, la taula no pot donar un valor que les pàgines no facin servir. setCellBackground omple la primera cel·la de cada fila, que va buida, amb el color d'aquella fila enllaçat a la paleta. Dins el text, :swatch{color="line-2"} rep l'identificador d'una entrada de la paleta i dibuixa un quadrat perfilat amb el color del text. main-color pren l'hex d'Ink, de manera que els títols, les negretes i les cursives, que hi estan enllaçats per defecte, surten en Ink i no en el blau del motor. A la 1.4.1 les remissions conserven aquest blau encara que canviïs main-color; per això bodyText.referenceColor: col('ink') les posa també en Ink (Paleta de colors).

#3 · Una obertura, tres colors de línia

script.js · línies 94–112al codi complet
// Texts wrap (gotcha: overflow-ellipsis-default); lineHeight is a multiple (gotcha:
// design-lineheight-multiple). The body starts ten grid lines down, in both columns.
const opener = { enabled: true, minHeight: pt(10 * LEAD), slot: { elements: [
  { kind: 'text', id: 'number', content: '{number}', fontFamily: DISPLAY, fontWeight: 800,
    fontSize: pt(130), lineHeight: 1, color: col('section'), align: 'left', overflow: 'wrap',
    // Nudged by eye on the capture: the cap tops of number and title on one line.
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(-1.5), y: mm(1.3) } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 800,
    fontSize: pt(34), lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(COL + GUTTER), y: mm(1) }, size: { width: mm(COL) } } },
  { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(12.5),
    lineHeight: 1.36, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(3) },
      size: { width: mm(COL) } } },
] } };
// Each section sets `section` to its line's colour; the number and the head square use it.
const section = (id) => ({ id, palette: { section: palette[id] } });

numberingTemplate: '{1:01}' escriu el comptador amb dues xifres, i {number} imprimeix 01, 02 i 03 a l'obertura; el títol de la coberta porta numbered: false i es queda sense número. Els tres estils de secció només es diferencien en l'entrada section de la paleta, a la qual cadascun dona el color de la seva línia. El número i el quadrat de la franja estan enllaçats a section, de manera que un únic disseny d'obertura serveix per a les tres seccions. minHeight reserva per a l'obertura deu línies de la retícula de base de 14 pt, i el text de cada secció comença a la mateixa línia de la seva pàgina.

#4 · Do i Don't són tipus de figura

script.js · línies 161–166al codi complet
const example = (id, name, colour) => ({ id, name, shortLabel: name, captionPrefix: name,
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  captionStyle: { labelColor: col(colour) } });
const resourceTypes = [...defaultResourceTypes(LANG),
  example('do', 'Do', 'signal-green'), example('dont', 'Don’t', 'signal-red')];
const FOOT = { position: 'bottom' }; // cited in one paragraph: its page's foot, one per column

Un tipus de recurs admet un captionStyle parcial, i aquests dos només canvien el color de l'etiqueta: Signal green a Do i Signal red a Don't (Tipus de recurs). Els dos colors de senyal arriben a 5,1:1 i 5,5:1 sobre blanc, mentre que les línies 1, 2, 3 i 5 es queden per sota del 4,5:1 que demana el text petit. Els dos dibuixos floten amb position: 'bottom' i se citen al mateix paràgraf, el primer de Drawing the map, així que comparteixen el peu de la pàgina 4, Do a la columna esquerra i Don't a la dreta.

#5 · El paquet porta les fonts que pot compartir

script.js · línies 460–474al codi complet
const pack = Object.assign(document.createElement('button'), { type: 'button',
  textContent: 'Build the .postext' });
pack.addEventListener('click', async () => {
  const { bytes, manifest, warnings } = await createBundle({
    name: 'Metro de Alba identity manual', locale: LANG, markdown, config: config(), resources,
    files: new Map([...Object.entries(ART), ...fontFiles]), // fileId → SVG markup or font bytes
  });
  const size = `${Math.round(bytes.length / 1024)} KB`;
  const packed = `fonts inside: ${manifest.fonts.map((font) => font.name).join(', ')}`;
  kitStatus(['.postext', size, packed, ...warnings].join(' · ')); // warnings: what stayed out
  pack.replaceWith(Object.assign(document.createElement('a'), { download: `${RECIPE}.postext`,
    href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })),
    textContent: `Download ${RECIPE}.postext · ${size}` }));
});
document.getElementById('pt-actions').append(pack);

createBundle llegeix config.customFonts, busca a files els bytes de cada variant pel seu fileId i els desa a fonts/, amb una entrada fonts al manifest. La família de rètols porta redistributable: false, que aquí simula una llicència que no deixa repartir els fitxers (la de Big Shoulders Display, l'OFL, sí que ho permet). El seu fitxer es queda fora i warnings anomena la família. El fitxer de 81 KB conté el capítol, la configuració, els tres dibuixos i cinc fitxers de font. La composició no llegeix mai customFonts, i sense ell les pàgines surten iguals (Crear un paquet).

La recepta completa

Sandbox
// ═══ Postext Cookbook · Nº 040 · Brand fonts in layout, PDF and bundle ════════════
// https://postext.dev/en/cookbook/brand-fonts-identity-manual
// Code: MIT · Text and drawings: original (CC BY 4.0) · Metro de Alba is a fictional network
// Fonts: Public Sans, Big Shoulders Display, Spline Sans Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes, setCellBackground, createBundle,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'brand-fonts-identity-manual';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region answer: the brand's font files, fetched once for the layout, the PDF and the bundle
// Your licensed files, one per face in FONTS. Fontsource's copies stand in for them here:
// point FONT_URL at your own server (same origin, or one that lets this page in by CORS).
const slug = (family) => family.toLowerCase().replaceAll(' ', '-');
const FONT_URL = ({ family, weight, style }) => `https://cdn.jsdelivr.net/npm/@fontsource/`
  + `${slug(family)}@5/files/${slug(family)}-latin-${weight}-${style}.woff2`;
const brandFaces = () => Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => {
  const weight = parseInt(spec, 10), style = spec.endsWith('i') ? 'italic' : 'normal';
  return { family, weight, style, fileId: `${slug(family)}-${weight}-${style}.woff2` };
}));
const fontFiles = new Map(); // fileId → the WOFF2 bytes, fetched once

// 1 · Layout measures with document.fonts: register every face before the first build.
async function loadBrandFonts() {
  await Promise.all(brandFaces().map(async ({ family, weight, style, fileId }) => {
    const res = await fetch(FONT_URL({ family, weight, style }));
    if (!res.ok) throw new Error(`No file for ${family} ${weight} ${style}`);
    fontFiles.set(fileId, new Uint8Array(await res.arrayBuffer()));
    const face = new FontFace(family, fontFiles.get(fileId), { weight: `${weight}`, style });
    document.fonts.add(await face.load());
  }));
}

// 2 · The PDF embeds the same bytes as TrueType. It also asks for faces no text uses (the
// display face's italic, the monospace's SemiBold): answer with the family's closest file.
// 1.4.1 writes an unused copy of that file for each of them (gotcha: pdf-font-copies).
async function brandFontProvider(family, weight, style) {
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const own = brandFaces().filter((f) => f.family === family);
  if (!own.length) throw new Error(`${family} is not one of the brand's fonts`);
  return decompressWoff2(fontFiles.get(own.reduce((a, b) => (cost(b) < cost(a) ? b : a)).fileId));
}

// 3 · The bundle: customFonts names each face's file by its fileId; createBundle packs the bytes
// of every family it may hand on. Layout never reads it (gotcha: custom-fonts-declarative).
const customFonts = () => Object.keys(FONTS).map((name) => ({
  name, redistributable: name !== DISPLAY, // the display face reaches suppliers another way
  variants: brandFaces().filter((f) => f.family === name)
    .map(({ weight, style, fileId }) => ({ weight, style, fileId, format: 'woff2' })),
}));
// #endregion

const TEXT = 'Public Sans', DISPLAY = 'Big Shoulders Display', MONO = 'Spline Sans Mono';

// #region colours: the colour system, from which the palette and Table 1.1 are both built
const COLOURS = [ // id, name, screen, print (coated stock)
  ['line-1', 'Line 1 · Tile red', '#e4572e', '0 75 85 0'],
  ['line-2', 'Line 2 · Harbour teal', '#17bebb', '75 0 32 0'],
  ['line-3', 'Line 3 · Broom yellow', '#ffc914', '0 22 95 0'],
  ['line-4', 'Line 4 · Heather', '#6c4f9e', '65 75 0 0'],
  ['line-5', 'Line 5 · Pine', '#3f9b4a', '76 12 90 2'],
  ['signal-red', 'Signal red', '#c0391b', '10 88 100 2'],
  ['signal-green', 'Signal green', '#2b7d3c', '84 25 95 10'],
  ['ink', 'Ink', '#1f2124', '72 62 55 78'],
  ['rule', 'Rule grey', '#d9d9d4', '14 10 14 0'],
];
const palette = { ...Object.fromEntries(COLOURS.map(([id, , hex]) => [id, hex])),
  paper: '#ffffff', section: '#e4572e' }; // section: the line colour of the current section
// col() writes the hex too: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [...Object.entries(palette), ['main-color', palette.ink]]
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(' ');
const swatchTable = () => COLOURS.reduce( // an empty first cell, filled with the colour itself
  (model, [id], i) => setCellBackground(model, { row: i + 1, col: 0 }, col(id)),
  { headerRowCount: 1, columnWidths: [22, 34, 14, 15, 15], rows: [
    ['COLOUR', 'NAME', 'HEX', 'RGB', 'CMYK'].map((content) => ({ content, isHeader: true })),
    ...COLOURS.map(([, name, hex, cmyk]) =>
      ['', name, hex.toUpperCase(), rgb(hex), cmyk].map((content) => ({ content }))),
  ] });
// #endregion

const PAGE = { width: 210, height: 280 }; // mm
const MARGIN = { top: 24, bottom: 22, inner: 18, outer: 18 };
const GUTTER = 8, COL = (PAGE.width - MARGIN.inner - MARGIN.outer - GUTTER) / 2; // 83 mm
const BAND = 13, LEAD = 14; // mm: the ink band at the head of each page; pt: the leading

// #region sections: a giant zero-padded number in the section's line colour
// Texts wrap (gotcha: overflow-ellipsis-default); lineHeight is a multiple (gotcha:
// design-lineheight-multiple). The body starts ten grid lines down, in both columns.
const opener = { enabled: true, minHeight: pt(10 * LEAD), slot: { elements: [
  { kind: 'text', id: 'number', content: '{number}', fontFamily: DISPLAY, fontWeight: 800,
    fontSize: pt(130), lineHeight: 1, color: col('section'), align: 'left', overflow: 'wrap',
    // Nudged by eye on the capture: the cap tops of number and title on one line.
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(-1.5), y: mm(1.3) } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 800,
    fontSize: pt(34), lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(COL + GUTTER), y: mm(1) }, size: { width: mm(COL) } } },
  { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(12.5),
    lineHeight: 1.36, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(3) },
      size: { width: mm(COL) } } },
] } };
// Each section sets `section` to its line's colour; the number and the head square use it.
const section = (id) => ({ id, palette: { section: palette[id] } });
// #endregion

// Running heads: an ink band across the head of the page, as on the platform signs.
const PT = 25.4 / 72; // mm in a point
const HEAD = 7.5, SQUARE = 4.5, STEP = 8; // pt: head size; mm: the square, and the spacing
const label = (size, colour) => ({ fontFamily: TEXT, fontSize: pt(size), fontWeight: 600,
  letterSpacing: pt(size * 0.16), textTransform: 'uppercase', color: col(colour) });
const inBand = (edge, x, height) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm((BAND - height) / 2) } }); // centred in the band
const head = (id, content, parity, edge, x) => ({ kind: 'text', id, content, parity,
  lineHeight: 1, ...label(HEAD, 'paper'), placement: inBand(edge, x, HEAD * PT) });
const square = (id, parity, edge, x) => ({ kind: 'box', id, parity,
  style: { backgroundColor: col('section') }, // the section's line, as on its signs
  placement: { ...inBand(edge, x, SQUARE), size: { width: mm(SQUARE), height: mm(SQUARE) } } });
const header = { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('ink') },
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: 'fill', height: mm(BAND) } } },
  square('verso-line', 'even', 'top-left', MARGIN.outer),
  head('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer + STEP),
  head('verso-title', '{title} · {subtitle}', 'even', 'top-left', MARGIN.outer + 2 * STEP),
  square('recto-line', 'odd', 'top-right', -MARGIN.outer),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -(MARGIN.outer + STEP)),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(MARGIN.outer + 2 * STEP)),
] };

// The cover: the network drawing fills the page; the heading and frontmatter give the texts.
const coverText = (id, content, placement, style) => ({ kind: 'text', id, content, placement,
  overflow: 'wrap', align: 'left', ...style });
const under = (id, gap) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(gap) },
  size: { width: mm(PAGE.width - 2 * MARGIN.outer) } });
const cover = { id: 'cover', numbered: false, span: 'page',
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'map', resourceId: 'network',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    coverText('kicker', '{attr.kicker}', { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(MARGIN.outer), y: mm(MARGIN.top - 2) } }, label(8, 'ink')),
    // At lineHeight 0.86 the capitals rise about 3 mm above the title's box: hence 6.5 mm.
    coverText('title', '{titleText}', under('kicker', 6.5), { fontFamily: DISPLAY,
      fontWeight: 800, fontSize: pt(88), lineHeight: 0.86, color: col('ink') }),
    coverText('subtitle', '{subtitle}', under('title', 3), { fontFamily: TEXT, fontWeight: 600,
      fontSize: pt(20), lineHeight: 1.2, color: col('ink') }),
    coverText('edition', '{attr.edition}', under('subtitle', 1.5), { fontFamily: MONO,
      fontSize: pt(9), lineHeight: 1.3, color: col('ink') }),
  ] } } };

// #region do-dont: two figure types whose captions carry their own label colour
const example = (id, name, colour) => ({ id, name, shortLabel: name, captionPrefix: name,
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  captionStyle: { labelColor: col(colour) } });
const resourceTypes = [...defaultResourceTypes(LANG),
  example('do', 'Do', 'signal-green'), example('dont', 'Don’t', 'signal-red')];
const FOOT = { position: 'bottom' }; // cited in one paragraph: its page's foot, one per column
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  colorPalette, resourceTypes, customFonts: customFonts(),
  page: { width: mm(PAGE.width), height: mm(PAGE.height), dpi: 150,
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  bodyText: { fontFamily: TEXT, fontSize: pt(9.8), lineHeight: pt(LEAD), color: col('ink'),
    referenceColor: col('ink'), // references skip the palette (gotcha: palette-skips-designs)
    boldFontWeight: 600, // the brand has no Bold: its SemiBold sets **emphasis**
    textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true },
  headings: { fontFamily: DISPLAY, fontWeight: 800, levels: [ // ink: main-color
    // span: 'page' breaks already; restated in case it goes (gotcha: headings-drop-h1-break)
    { level: 1, span: 'page', numberingTemplate: '{1:01}', marginBottom: pt(0),
      breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, fontSize: pt(17), lineHeight: pt(2 * LEAD), marginTop: pt(0), // two lines,
      marginBottom: pt(0) }, // so a column that opens with one starts on the same line
  ] },
  headingStyles: [cover, section('line-1'), section('line-4'), section('line-5')],
  paragraphStyles: [
    { id: 'specimen', fontSize: pt(15), lineHeight: pt(LEAD * 1.5), marginBottom: pt(LEAD) },
    { id: 'specimen-mono', fontFamily: MONO, fontSize: pt(11), lineHeight: pt(LEAD * 1.5),
      marginBottom: pt(LEAD) },
    { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10.5) },
  ],
  calloutStyles: [
    { id: 'sign', span: 'page', placement: 'bottom', background: col('ink'),
      padding: { top: mm(6), right: mm(8), bottom: mm(6), left: mm(8) },
      titleStyle: { fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(60), gap: mm(2),
        color: col('paper') }, body: { fontSize: pt(16), lineHeight: pt(LEAD * 1.5),
        color: col('paper'), boldColor: col('paper') } },
  ],
  tableStyles: [
    { id: 'swatches', rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
      headerBackground: col('ink'), headerColor: col('paper'), headerFontSize: pt(7.5),
      bodyFontFamily: MONO, bodyFontSize: pt(8.5), cellPadding: mm(2) },
  ],
  captionStyle: { fontSize: pt(8.3) }, // face and ink from bodyText; the label in SemiBold
  header, footer: { elements: [] }, // the folio sits in the band
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Mostra en Markdown · 90 línies · content.en.mdtitle: "Metro de Alba" subtitle: "Identity manual" author: "Metro de Alba brand office" --- # Metro de Alba {style="cover" kicker="Brand office · Lines and stations" edition="Edition 3 · September 2026"} # Colour {style="line-1" lead="Riders learn the colour of a line before its number. These values are fixed for print, screen and enamel."} ## Five lines, five colours The network has five lines, and each owns one colour, listed in :ref{id="colours" style="full"}. The colour marks the line wherever it appears: the stripe on a platform wall, the band along a train, the line’s badge and its path on the map. It marks nothing else, so a leaflet about fares is printed in ink on paper and red always means line 1. Take the values from the table, never from a screenshot, an old sign or a colour picker. Screens use the HEX value, and print uses the CMYK recipe on coated stock; for uncoated paper and newsprint, ask the brand office for the matching recipe. Enamel panels and vinyl are matched to the printed swatch card that the brand office keeps. ## Colour and contrast Line colours are for fills. A line badge is a square in the line colour with the number inside it: white on lines :swatch{color="line-1"} 1, :swatch{color="line-4"} 4 and :swatch{color="line-5"} 5, ink on lines :swatch{color="line-2"} 2 and :swatch{color="line-3"} 3, whose teal and yellow measure 2.3:1 and 1.5:1 against white, below the 3:1 that large type needs. Small text is never set in a line colour. Warnings and confirmations use :swatch{color="signal-red"} Signal red and :swatch{color="signal-green"} Signal green, which reach 5.5:1 and 5.1:1 on white. ## Neutrals Ink, a blue-black, sets text and outlines. Paper is plain white, since a tint would dull the yellow of line 3. Rule grey draws rules and the river. # Type {style="line-4" lead="Names and headings are set in a condensed display face. Running text is set in a sans, and a monospace takes the figures that have to line up."} ## Big Shoulders Display Station names, line names and headings are set in Big Shoulders Display ExtraBold. It is narrow, so a long name such as Puerta del Mercado still fits a platform sign at a size that reads from the far end of the platform. Set it in capitals and lowercase, never in capitals alone, and never below 14 pt, where its narrow counters start to fill in. A platform sign is an ink band 400 mm deep. The station name has a cap height of 150 mm and sits on a 100 mm margin. Under it, a square in the colour of each line that stops there comes before the line numbers, set in Public Sans SemiBold. :::callout{type="sign" title="Puerta del Mercado"} :swatch{color="line-2"} :swatch{color="line-3"} :swatch{color="line-5"} **Lines 2, 3 and 5** ::: ## Public Sans Anything people read in sentences, such as a notice or this manual, is set in Public Sans. Text is 9.8 pt on a 14 pt line, ragged right, with no indents and a space between paragraphs. Captions in print are 8.3 pt, and nothing a rider needs to read is set smaller. On screen, text starts at 16 px and never drops below 14 px. ## Weights Public Sans is used in four faces. There is no Bold, and the SemiBold takes its place: :::paragraphs{style="specimen"} Regular and *Italic* **SemiBold** and ***SemiBold Italic*** ::: Emphasis is **SemiBold, in ink**, as in this sentence; a heavier weight would compete with the station names. Italic marks the titles of documents and words in another language, such as *andén* on a bilingual sign. ## Spline Sans Mono Departure times, platform codes and colour values are set in Spline Sans Mono, so that the figures on a departure board line up in columns without tabs: :::paragraphs{style="specimen-mono"} 07:42 · 07:46 · 07:51 · #E4572E ::: Use it for values only: at the same size, a sentence in it runs about 30% wider than in Public Sans. # Usage {style="line-5" lead="The network map is the drawing we print most. The rules below apply to every copy of it, printed or on screen."} ## Drawing the map Lines run horizontally, vertically or at 45 degrees, and change direction on a curve three line widths in radius, as in :ref{id="do-grid"}. Lines that share track run side by side at an equal spacing, in the order of their numbers, and bend round one centre. A station is a white dot with an ink rim, and an interchange is a single white capsule drawn across every line that stops there. :ref{id="dont-grid"} breaks each of these rules: free angles, curves of any radius, uneven spacing and stations in the line colour. ## Names on the map Station names are set in Public Sans SemiBold, always horizontal, on the side of the line that has no other line. An interchange carries its name once, beside the capsule. ## Geography Stations are evenly spaced. The one geographical feature on the map is the river, a band of Rule grey. ## Line width and scale The line width sets the scale of the whole drawing: 6 mm on the platform map, 1.5 mm on the pocket map and 4 px on screen at the default zoom. A station dot is 1.4 line widths across with a rim of a quarter of a line width, and a capsule is a dot stretched across the lines it serves. ## Files for suppliers Suppliers receive this manual as a PDF with its typefaces embedded, and as an editable source file that carries Public Sans and Spline Sans Mono. The display face is sent separately from the brand office’s type folder, so that every supplier sets names from the same version of it. Map artwork keeps station names as live text, never as outlines, so that a station renamed by the city can be corrected in every file. :::paragraphs{style="colophon"} Metro de Alba is a fictional network. Set in Public Sans, Big Shoulders Display and Spline Sans Mono, under the SIL Open Font License 1.1. Text and drawings: CC BY 4.0. :::
`; // content.<lang>.md, inlined by the Cookbook // Its frontmatter fills {title} · {subtitle} in the band and the PDF's title and author: // every value is quoted (gotcha: quote-frontmatter). const ART = {}; // fileId → SVG markup: registered for the pages, packed into the bundle const drawing = (id, typeId, fileId, [w, h], caption, altText, placement) => ({ id, typeId, kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId, width: w * 10, height: h * 10 }, caption, altText, placement }); const resources = [ { id: 'colours', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: { span: 'page' }, caption: 'The colour system. Line colours are fills; only Ink and the two signals set text.', note: 'HEX and RGB for screens; CMYK recipes for coated stock.', table: { styleId: 'swatches', model: swatchTable() } }, drawing('network', 'figure', 'network.svg', [PAGE.width, PAGE.height], '', 'Five coloured metro lines share a track from the lower left, then fan out to the right.'), drawing('do-grid', 'do', 'do-grid.svg', [COL, 43.5], 'Lines at 0°, 45° and 90°, bent together at an even spacing; one capsule for the interchange.', 'Three parallel lines bend at 45 degrees together; white stations with ink rims.', FOOT), drawing('dont-grid', 'dont', 'dont-grid.svg', [COL, 43.5], 'Free angles, mixed radii, uneven spacing and stations drawn in the line colour.', 'The same three lines drawn at free angles with coloured station dots.', FOOT), ]; // #region art: the drawings, made by the rules of section 03 const f = (n) => +n.toFixed(2); const P = ([x, y]) => `${f(x)} ${f(y)}`; const sub = (a, b) => [a[0] - b[0], a[1] - b[1]]; const add = (a, b, k = 1) => [a[0] + b[0] * k, a[1] + b[1] * k]; const unit = (v) => { const l = Math.hypot(v[0], v[1]); return [v[0] / l, v[1] / l]; }; const dotp = (a, b) => a[0] * b[0] + a[1] * b[1]; const left = ([x, y]) => [y, -x]; // the normal on the left of a direction (y runs down) const HEADING = { E: [1, 0], NE: [1, -1], SE: [1, 1], S: [0, 1] }; /** From a point, a run of moves such as ['NE', 40]: 0°, 45° and 90° only. */ const walk = (from, moves) => moves.reduce((pts, [h, len]) => [...pts, add(pts[pts.length - 1], unit(HEADING[h]), len)], [from]); const dirs = (pts, i) => { const a = unit(sub(pts[i], pts[i - 1] ?? pts[i])), b = unit(sub(pts[i + 1] ?? pts[i], pts[i])); return [Number.isNaN(a[0]) ? b : a, Number.isNaN(b[0]) ? a : b]; }; /** A polyline moved d mm to its left, mitred at each bend, so parallel lines stay parallel. */ const shift = (pts, d) => pts.map((p, i) => { const [n1, n2] = dirs(pts, i).map(left); return add(p, add(n1, n2), d / (1 + dotp(n1, n2))); }); /** A path through the points, each bend rounded: radius R, or R ± d for a line d mm off a * shared centre line, so that the bends of parallel lines stay concentric. */ function track(pts, R, offsets = []) { let out = `M${P(pts[0])}`; for (let i = 1; i < pts.length - 1; i++) { const [a, b] = dirs(pts, i); const turn = Math.acos(Math.max(-1, Math.min(1, dotp(a, b)))); const r = R + (offsets[i] ?? 0) * Math.sign(a[0] * b[1] - a[1] * b[0]); const cut = r * Math.tan(turn / 2); out += ` L${P(add(pts[i], a, -cut))} Q${P(pts[i])} ${P(add(pts[i], b, cut))}`; } return `${out} L${P(pts[pts.length - 1])}`; } /** The point `dist` mm along a polyline. */ function along(pts, dist) { for (let i = 1; i < pts.length; i++) { const len = Math.hypot(...sub(pts[i], pts[i - 1])); if (dist <= len) return add(pts[i - 1], unit(sub(pts[i], pts[i - 1])), dist); dist -= len; } return pts[pts.length - 1]; } const stroke = (d, colour, w, cap = 'butt') => `<path d="${d}" fill="none" stroke="${colour}" ` + `stroke-width="${f(w)}" stroke-linecap="${cap}" stroke-linejoin="round"/>`; /** A station: a white dot with an ink rim. An interchange: one capsule from a to b. */ const station = (p, w) => `<circle cx="${f(p[0])}" cy="${f(p[1])}" r="${f(0.7 * w)}" ` + `fill="${palette.paper}" stroke="${palette.ink}" stroke-width="${f(w / 4)}"/>`; const capsule = (a, b, w) => stroke(`M${P(a)} L${P(b)}`, palette.ink, 1.65 * w, 'round') + stroke(`M${P(a)} L${P(b)}`, palette.paper, 1.15 * w, 'round'); // a dot's cross-section const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}"><clipPath id="frame"><rect width="${w}" ` + `height="${h}"/></clipPath><g clip-path="url(#frame)">${body}</g></svg>`; const LINES = ['line-1', 'line-2', 'line-3', 'line-4', 'line-5']; function networkArt(W, H) { const w = 6.5, gap = 9.5, R = 3 * w; // line width, spacing and corner radius, in mm const trunk = walk([-20, 272], [['NE', 100], ['E', 72]]); // shared track from the lower left const offsets = LINES.map((_, i) => (2 - i) * gap); // line 1 on the left, line 5 on the right const branches = [[['E', 8], ['NE', 95], ['E', 60]], [['E', 34], ['NE', 38], ['E', 60]], [['E', 110]], [['E', 26], ['SE', 36], ['E', 60]], [['E', 4], ['SE', 44], ['S', 60]]]; const river = walk([-10, 148], [['E', 40], ['SE', 52], ['S', 120]]); let out = `<rect width="${W}" height="${H}" fill="${palette.paper}"/>` + stroke(track(river, 28), palette.rule, 15); const lines = LINES.map((id, i) => { const own = shift(trunk, offsets[i]); const branch = walk(own[own.length - 1], branches[i]); const d = [...own.map(() => offsets[i]), ...branches[i].map(() => 0)]; out += stroke(track([...own, ...branch.slice(1)], R, d), palette[id], w); return branch; }); const across = (p, dir) => [add(p, left(dir), 2 * gap + 0.05 * w), add(p, left(dir), -2 * gap - 0.05 * w)]; out += capsule(...across(along(trunk, 62), unit(HEADING.NE)), w) + capsule(...across(along(trunk, 158), HEADING.E), w); const stops = [[45, 80], [12, 90], [34, 68], [44, 82], [32, 88]]; // on straights only lines.forEach((branch, i) => { for (const s of stops[i]) out += station(along(branch, s), w); }); return svg(W, H, out); } /** Three lines and their stops, drawn by the rules (good) or against every one of them. */ function gridArt(W, H, good) { const w = 3.6, gap = 5.4, R = 3 * w; const ids = ['line-1', 'line-4', 'line-5']; const dot = (p, id) => `<circle cx="${f(p[0])}" cy="${f(p[1])}" r="${f(0.75 * w)}" ` + `fill="${palette[id]}"/>`; let out = `<rect width="${W}" height="${H}" fill="${palette.paper}"/>`; if (good) { const trunk = walk([-4, 11], [['E', 30], ['SE', 20]]); const lines = [['E', 70], ['E', 70], ['S', 40]].map((move, i) => shift([...trunk, ...walk(trunk[2], [move]).slice(1)], (1 - i) * gap)); lines.forEach((pts, i) => { const shared = (k) => k === 1 || (k === 2 && i < 2); // bends two or three lines share out += stroke(track(pts, R, pts.map((_, k) => (shared(k) ? (1 - i) * gap : 0))), palette[ids[i]], w); }); const hub = along(trunk, 12); // the interchange, on the shared straight out += capsule(add(hub, [0, -gap - 0.05 * w]), add(hub, [0, gap + 0.05 * w]), w); for (const [i, s] of [[0, 72], [1, 84], [2, 52]]) out += station(along(lines[i], s), w); } else { // free angles, radii and spacing; stops as coloured dots, three dots for the hub const lines = [[[-4, 7], [22, 7], [44, 19], [90, 17]], [[-4, 15], [30, 14], [46, 31], [90, 31]], [[-4, 20], [24, 21], [40, 35], [45, 60]]]; lines.forEach((pts, i) => { out += stroke(track(pts, [2, 15, 6][i]), palette[ids[i]], w); }); lines.forEach((pts, i) => { out += dot(along(pts, 12), ids[i]); }); for (const [i, s] of [[0, 72], [1, 84], [2, 58]]) out += dot(along(lines[i], s), ids[i]); } const frame = `<rect x="0.15" y="0.15" width="${W - 0.3}" height="${H - 0.3}" fill="none" ` + `stroke="${palette.rule}" stroke-width="0.3"/>`; // a hairline in Rule grey return svg(W, H, out + frame); } ART['network.svg'] = networkArt(PAGE.width, PAGE.height); ART['do-grid.svg'] = gridArt(COL, 43.5, true); ART['dont-grid.svg'] = gridArt(COL, 43.5, false); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // The brand's files and no others: no Bold (the SemiBold stands in), the display face in // ExtraBold only. The PDF shows Fontsource's names ('PublicSansThin-SemiBold'); yours show theirs. const FONTS = { 'Public Sans': ['400', '400i', '600', '600i'], 'Big Shoulders Display': ['800'], 'Spline Sans Mono': ['400'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: the brand's faces first, then the pages, then a PDF from the same bytes await loadBrandFonts(); // the answer, step 1 (gotcha: fonts-first) for (const [fileId, markup] of Object.entries(ART)) await loadSvg(fileId, markup); const doc = buildDocument({ markdown, resources }, config()); showPages(doc, { title: 'Metro de Alba · Identity manual' }); offerPdf(() => renderToPdf(doc, { fontProvider: brandFontProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // step 2: the provider hands renderToPdf the same files // #endregion // #region bundle: one .postext file with the text, the design, the drawings and the fonts const pack = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the .postext' }); pack.addEventListener('click', async () => { const { bytes, manifest, warnings } = await createBundle({ name: 'Metro de Alba identity manual', locale: LANG, markdown, config: config(), resources, files: new Map([...Object.entries(ART), ...fontFiles]), // fileId → SVG markup or font bytes }); const size = `${Math.round(bytes.length / 1024)} KB`; const packed = `fonts inside: ${manifest.fonts.map((font) => font.name).join(', ')}`; kitStatus(['.postext', size, packed, ...warnings].join(' · ')); // warnings: what stayed out pack.replaceWith(Object.assign(document.createElement('a'), { download: `${RECIPE}.postext`, href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })), textContent: `Download ${RECIPE}.postext · ${size}` })); }); document.getElementById('pt-actions').append(pack); // #endregion
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

#Inclou també la font de rètols

Si la llicència et permet repartir els fitxers, treu redistributable i el paquet portarà les tres famílies en 96 KB.

-  name, redistributable: name !== DISPLAY, // the display face reaches suppliers another way
+  name,

#Fes servir una negreta de debò

Com que la marca no té negreta, boldFontWeight: 600 compon en seminegreta les negretes, les capçaleres de taula i les etiquetes dels peus; si la teva família en té, afegeix-ne els fitxers a FONTS, esborra l'ajust i canvia la línia de mostra del text d'exemple i el seu «There is no Bold», que deixarien de ser certs.

-    boldFontWeight: 600, // the brand has no Bold: its SemiBold sets **emphasis**
-  'Public Sans': ['400', '400i', '600', '600i'],
+  'Public Sans': ['400', '400i', '600', '600i', '700', '700i'],

Errors freqüents

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

customFonts no carrega cap font

config.customFonts només anomena fitxers de font pel seu fileId: buildDocument i els renderitzadors de canvas, HTML i PDF no el llegeixen mai, així que una família que hi figura i no s'ha registrat es mesura i es pinta amb una font de reserva. Registra tu mateix cada estil (un FontFace amb els seus bytes) abans de la primera composició i passa aquests mateixos bytes al proveïdor de fonts del PDF. createBundle llegeix la llista per ficar els fitxers en un .postext, i el Sandbox, per registrar les fonts. Les teves pròpies fonts →

Error freqüent

El PDF demana tots els gruixos i estils de cada família

renderToPdf demana al proveïdor de fonts la negreta, la cursiva i la negreta cursiva de cada família que un bloc podria fer servir, encara que no s'imprimeixi mai, i un sol rebuig atura l'exportació. El proveïdor s'ha d'ajustar al gruix més proper que tingui la família i tornar a la rodona quan no hi hagi cursiva. Fonts incrustades al PDF →

Error freqüent

La negreta o la cursiva que la família no porta se simula a la pantalla, no al PDF

Quan un text demana un pes o un estil que la seva família no porta (una capçalera de taula en negreta amb una font de retolació d'un sol estil, cursiva en una sans sense cursives), el navegador el sintetitza al canvas i a l'HTML: engruixeix o inclina la rodona amb les mateixes amplades. Un PDF només incrusta fonts reals, així que allà s'imprimeix la font més propera que doni el proveïdor, sense engruixir ni inclinar. Posa a la llista de fonts només les que la família porta i ajusta cada estil a aquestes, per exemple amb tableStyle.headerBold: false. Fonts incrustades al PDF →

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 →

Error freqüent

El PDF desa una còpia de font per cada estil que demana

postext-pdf 1.4.1 escriu un programa de font per cada família, pes i estil que demana al proveïdor de fonts, encara que el proveïdor respongui a diversos amb el mateix fitxer i cap pàgina dibuixi amb aquest estil. Per això, cada substitut d'una cursiva o d'un pes que falta afegeix una altra còpia del fitxer que manlleva, que no es fa servir si cap text no va en aquest estil. pdffonts només llista les fonts que fan servir les pàgines, però les còpies són igualment al fitxer, i res del que faci un pen no les treu. Fonts incrustades al PDF →

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 lineHeight d'un text de disseny és un múltiple, mai una mesura

En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol. Textos, filets i caixes als dissenys de pàgina →

Error freqüent

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

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

Un espai de no separació continua partint la línia

A postext 1.4.1 l'algorisme de tall tracta U+00A0 com un espai normal, de manera que 0,08 %, 2,006 s o secció 2 poden quedar en dues línies. Ajunta els dos elements (0,08%) o reescriu la frase. Escapaments i caràcters literals →

Passa a createBundle els bytes de cada font amb el fileId que els dona customFonts. Una variant sense bytes es queda fora amb l'avís «missing file, skipped», i la família que es queda sense variants desapareix del manifest.

Crèdits

Text
  • The Metro de Alba identity manual, a fictional network · Ignacio Ferro · CC BY 4.0
Imatges
  • The network drawing on the cover and the two map examples, drawn in code · Ignacio Ferro · CC BY 4.0
Fonts
Public Sans (SIL OFL 1.1) · Big Shoulders Display (SIL OFL 1.1) · Spline Sans Mono (SIL OFL 1.1)
SandboxPDF