En bref
Une charte graphique pour un réseau de métro imaginaire. On y voit comment télécharger une seule fois les polices propres à une marque et s’en servir pour l’aperçu des pages, le PDF et le fichier enregistré.
Ce que vous allez composer
Quatre pages du manuel d’identité visuelle de Metro de Alba, un métro fictif de cinq lignes. Sur la couverture, les cinq lignes partent du coin inférieur gauche sur une voie commune et bifurquent vers la droite après avoir franchi une rivière grise, sous le nom en Big Shoulders Display. Chaque section s’ouvre sous une bande d’encre, avec un grand numéro à deux chiffres dans la couleur d’une ligne. Dans 01 Colour, la première colonne du tableau est la couleur elle-même ; 02 Type présente des lignes de spécimen et un panneau de quai, et 03 Usage se termine sur deux dessins de plan encadrés, l’un tracé selon les règles (« Do ») et l’autre à leur encontre (« Don't »). Tout le texte est composé dans six fichiers de police que le script télécharge avant la première mise en page. Le PDF est dessiné avec les mêmes six fichiers, et le fichier .postext en porte cinq, parce que le caractère de titrage est marqué comme non redistribuable.
Cette recette répond à
- Comment utiliser mes propres polices (de marque ou sous licence) dans la mise en page et les incorporer au PDF ?
- Comment exporter un vrai PDF dans le navigateur avec les polices incorporées ?
- Comment empêcher les titres, les mots en gras et les puces de sortir en bleu ?
- Comment ajouter des pastilles de couleur de légende au texte, aux légendes ou aux notes de tableau ?
- Comment créer un paquet .postext depuis le code, pour remettre un document au Sandbox ou à un autre programme ?
La réponse courte
// 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. renderToPdf asks for the faces the pages
// paint; a weight or slant the brand lacks gets the family's closest file, embedded once.
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' })),
}));
Ingrédients
- Fonctionnalités
- Vos propres policesPalette de couleurs sémantiqueFonds de cellulePastilles de couleurOuvertures composéesTitres numérotésBandeau de chapitre sur toute la largeurStyles de tableau nommésStyles de paragrapheTypes de ressource personnalisésStyle des légendesPlacement des figuresTexte, filets et boîtes dans les maquettes de pageTexte courantUne ou deux colonnesLes polices avant la mise en pageExport PDFPaquets .postextPages sur un canvasFigures et tableaux comme ressources
- Utilise aussi
- EncadrésDes citations qui placent les figuresAttributs de titreStyles de titreFigure et Tableau dans votre langueCouleurs par partiePolices intégrées au PDFTitres courants par sectionChapitres non numérotés
- Typographie
- Public Sans, Big Shoulders Display, Spline Sans Mono (SIL OFL 1.1)
- Ressources
- The network drawing on the cover and the two map examples, drawn in code (Ignacio Ferro, CC BY 4.0)
Préparation
#1 · Enregistrer les caractères de la marque, composer et donner au PDF les mêmes octets
await loadBrandFonts(); // the answer, step 1
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 mise en page mesure chaque mot avec les caractères de document.fonts : loadBrandFonts(), dans la réponse courte, télécharge donc chaque fichier une fois, garde ses octets et enregistre un FontFace construit à partir d’eux avant que buildDocument ne s’exécute. Le kitFonts du kit ne télécharge que depuis Fontsource, alors qu’une marque sert ses fichiers depuis son propre serveur. renderToPdf demande les caractères que peignent les pages, et brandFontProvider répond à chaque demande avec les octets déjà téléchargés ; une graisse ou une inclinaison absente de la marque recevrait le fichier le plus proche de la même famille (Pourquoi un fournisseur de polices ?). Le PDF intègre les six fichiers que le canvas a mesurés, chacun une fois.
#2 · Le système de couleurs tient dans une seule liste
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(): a colour linked to its palette entry, with the entry's hex beside the link.
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 }))),
] });
Comme la palette et le tableau 1.1 sont construits à partir des mêmes neuf rangées, le tableau ne peut pas lister une valeur que les pages n’utilisent pas. setCellBackground remplit la première cellule vide de chaque rangée avec la couleur de cette rangée, liée à la palette. Dans le texte, :swatch{color="line-2"} prend un id de palette et dessine un carré bordé de la couleur du texte. main-color porte l’hexadécimal de « Ink » : les titres et les passages en gras et en italique, dont les valeurs par défaut y sont liées, s’impriment donc en « Ink » au lieu du bleu du moteur. bodyText.referenceColor: col('ink') désigne aussi « Ink » pour les renvois (Palette de couleurs).
#3 · Une ouverture, trois couleurs de ligne
// 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}' écrit le compteur sur deux chiffres, et {number} imprime 01, 02 et 03 dans l’ouverture ; le titre de couverture est numbered: false et ne reçoit pas de numéro. Les trois styles de section ne diffèrent que par l’entrée de palette section, que chacun règle sur la couleur de sa ligne. Le numéro et le carré de la bande sont liés à section : une seule maquette d’ouverture sert ainsi aux trois sections. minHeight réserve dix lignes de la grille de 14 pt à l’ouverture, et le texte de chaque section commence sur la même ligne de grille de sa page.
#4 · « Do » et « Don't » sont des types de figure
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 type de ressource peut porter un captionStyle partiel, et ces deux-là ne règlent que la couleur de l’étiquette, « Signal green » pour « Do » et « Signal red » pour « Don't » (Types de ressources). Les couleurs de signalisation atteignent 5,1:1 et 5,5:1 sur blanc, alors que les lignes 1, 2, 3 et 5 restent sous le 4,5:1 qu’exige le texte en petit corps. Les deux dessins flottent avec position: 'bottom' et sont cités dans le même paragraphe, le premier sous « Drawing the map » : ils partagent donc le pied de la page 4, « Do » dans la colonne de gauche et « Don't » dans celle de droite.
#5 · Le paquet embarque les polices qu’il a le droit de partager
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 lit config.customFonts, cherche les octets de chaque variante dans files d’après son fileId et les écrit sous fonts/, avec une entrée fonts dans le manifeste. La famille de titrage porte redistributable: false, pour figurer une licence qui interdirait de transmettre les fichiers (Big Shoulders Display est en réalité sous OFL). Son fichier reste dehors, et warnings nomme la famille. Le fichier de 81 Ko contient le chapitre, la configuration, les trois dessins et cinq fichiers de police. La mise en page ne lit jamais customFonts ; les pages sortent identiques sans lui (Créer un paquet).
La recette complète
// ═══ 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 (OFL 1.1) · Needs postext ≥ 1.25.0 import { buildDocument, renderPageToCanvas, 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. renderToPdf asks for the faces the pages // paint; a weight or slant the brand lacks gets the family's closest file, embedded once. 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(): a colour linked to its palette entry, with the entry's hex beside the link. 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 // 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 = () => ({ locale: 'en-us', // the sample is in English 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 in Ink, like the text around them 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 // Each section opens a page, on either side. { 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`---Exemple Markdown · 90 lignes · content.en.md
title: "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. 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 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); // #endregionKit · core, fonts, viewer, pdf, images : identique dans toutes les recettes · 315 lignes
// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { 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', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes 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; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { 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 text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 ───────────────────────────────────────────────────────────────────────
Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)
Variantes
#Embarquer aussi le caractère de titrage
Si la licence vous autorise à transmettre les fichiers, retirez l’indicateur, et le paquet porte les trois familles en 96 Ko.
- name, redistributable: name !== DISPLAY, // the display face reaches suppliers another way
+ name,#Utiliser un vrai gras
boldFontWeight: 600 compose les passages en gras, les en-têtes de tableau et les étiquettes de légende dans le SemiBold, parce que la marque n’a pas de Bold ; si votre famille en a un, ajoutez ses fichiers à FONTS, supprimez le réglage et réécrivez la ligne de spécimen de l’exemple et son « There is no Bold », qui ne serait plus vrai.
- 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'],Erreurs fréquentes
Erreur fréquente
customFonts ne charge aucune police
config.customFonts ne fait que nommer des fichiers de police par leur fileId : buildDocument et les moteurs de rendu canvas, HTML et PDF ne le lisent jamais, si bien qu’une famille listée là sans être enregistrée est mesurée et peinte dans une police de substitution. Enregistrez vous-même chaque style (un FontFace créé à partir de ses octets) avant la première composition, et donnez les mêmes octets au fournisseur de polices du PDF. createBundle lit la liste pour ranger les fichiers dans un .postext, et le Sandbox la lit pour enregistrer les polices. Vos propres polices →
Erreur fréquente
Un gras ou un italique absent de la famille est simulé à l’écran, pas dans le PDF
Quand un texte demande une graisse ou un style que sa famille ne fournit pas (un en-tête de tableau en gras dans une police d’étiquettes à un seul style, de l’italique dans une linéale sans italiques), le navigateur le synthétise sur le canvas et en HTML, en épaississant ou en inclinant le romain sur les mêmes chasses. Un PDF n’incorpore que de vraies polices : le style le plus proche que propose le fournisseur s’y imprime donc sans effet. Ne mettez dans votre liste de polices que les styles que la famille fournit, et réglez chaque style en conséquence, par exemple tableStyle.headerBold: false. Polices intégrées au PDF →
Erreur fréquente
Les fichiers Fontsource couvrent les lettres, pas les symboles
Le kit intègre le fichier latin Fontsource d’un caractère, plus son fichier latin-ext pour č, ł, † et son fichier greek pour α, χ (dans une famille qui en fournit un : ce n’est pas le cas de Lora et de Gelasio), quand le texte les compose, à l’écran et dans le PDF. Les symboles comme →, ≈, ✓ et ★ ne figurent dans aucun de ces fichiers et manquent dans le PDF : dessinez-les, composez-les en maths, ou choisissez un caractère dont les fichiers les contiennent. Polices intégrées au PDF →
Passez à createBundle les octets de chaque police sous le fileId que lui attribue customFonts. Une variante sans octets est laissée de côté avec un avertissement « missing file, skipped », et une famille qui perd toutes ses variantes disparaît du manifeste.
Crédits
- Recette
- Ignacio Ferro
- Texte
- The Metro de Alba identity manual, a fictional network · Ignacio Ferro · CC BY 4.0
- Images
- The network drawing on the cover and the two map examples, drawn in code · Ignacio Ferro · CC BY 4.0
- Polices
- Public Sans (SIL OFL 1.1) · Big Shoulders Display (SIL OFL 1.1) · Spline Sans Mono (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


