In breve
Un libretto di quattro pagine che mette in mostra tre caratteri. Mostra perché tutti i font devono finire di caricarsi prima di impaginare le pagine, altrimenti le righe vanno a capo nei punti sbagliati.
Che cosa comporrai
House Specimen Nº 3, dell’immaginaria Pellow Lane Press, è un campionario di quattro pagine per tre caratteri. In copertina un campo blu oltremare contiene una Ag bianca in Noto Serif Display corsivo a 240 pt e due righe in monospaziato che nominano i tre caratteri; il titolo sta sotto il campo. La pagina 2 fa scorrere Ysabeau Office in una cascata da 7 a 14 pt, poi compone pangrammi spagnoli, polacchi e cechi le cui ż, ř e ů richiedono un secondo file di font. La pagina 3 è la verifica: una tabella in IBM Plex Mono, compilata dal pen, dei dieci caratteri dichiarati dal libretto, dei sei in cui sono composte le sue pagine e dei file che il browser ha scaricato per ciascuno. La pagina 4 compone due volte il primo paragrafo di pagina 1, da una costruzione anticipata e dall’ultima. La costruzione anticipata è partita prima che il browser avesse un solo file di font, quindi le sue righe finiscono in altri punti e alcune superano la giustezza.
Questa ricetta risponde a
- Perché cambiano i miei a capo, o le parole si sovrappongono nel PDF, e come carico correttamente i font?
- Come uso i font del mio marchio o con licenza nell’impaginazione e li incorporo nel PDF?
- Come scopro che cosa non va nel mio documento (avvisi, sforamenti, impaginazione che non converge)?
La risposta breve
// buildDocumentWithFonts starts with prepareFonts: it reads the config and the text for every
// face they ask for (text, headings, chips, table, captions, opener, running heads), in each
// weight and slant, and loads them for the characters the document sets. The page declares its
// files (declareFaces), so they load through document.fonts; a face nothing declares would be
// asked of a resolve(family, weight, style) option. Then it builds, reads the faces the pages
// are set in, loads any that the config did not name and builds again.
let report; // { loaded, missing, synthesized }: lists of { family, weight, style }
const build = () => buildDocumentWithFonts(content(), config(),
{ onFonts: (found) => { report = found; } }); // what the last look at the pages found
kitStatus('Loading fonts…'); // the kit's bar: it also reports any error thrown below
const early = buildDocument(content(), config()); // on purpose: the page has no font yet (page 4)
declareFaces(FONTS);
const fitted = await build(); // every face loaded before a line is measured
audit = auditOf(fitted, report); // page 3's table
drawProof(early, fitted); // page 4's picture
const doc = await build(); // nothing left to load: the same pages, table and picture filled in
showPages(doc, { title: 'Load every font before layout' });
Ingredienti
- Funzionalità
- I font prima dell’impaginazioneCarattere del testo correntePagine su un canvasFigure e tabelle come risorseFigure proprio quiTabelle dai datiStile delle tabelleStile delle didascalieTipi di risorsa personalizzatiAperture progettateAttributi dei titoliAncoraggio degli elementi del progettoStili di paragrafoChip in lineaFormato della paginaMargini speculariTavolozza di colori semanticaTestatine e folioTestatine secondo il ruolo della pagina
- Usa anche
- Figura e Tabella nella tua linguaFascia di capitolo a tutta larghezzaInterruzioni di pagina e di colonna
- Tipo
- Ysabeau Office, Noto Serif Display, IBM Plex Mono (SIL OFL 1.1)
- Risorse
- Nessuna: ogni immagine è disegnata nel codice
Preparazione
#1 · Dichiara ogni file senza scaricarlo
const SUBSETS = { // the characters each file covers, copied from the family's @font-face CSS
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 FILES = new Map(); // FontFace → the subset its file covers, for the audit
function declareFaces(fonts) { // Fontsource's static files stand in for your own /fonts/ folder
for (const { family, weight, style } of facesOf(fonts)) {
const id = family.toLowerCase().replaceAll(' ', '-');
for (const [subset, unicodeRange] of Object.entries(SUBSETS)) {
const file = `${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
// Adding a face fetches nothing: the file downloads when a load or a line needs it.
const face = new FontFace(family, `url(https://cdn.jsdelivr.net/npm/@fontsource/${file})`,
{ weight: `${weight}`, style, unicodeRange });
FILES.set(face, subset);
document.fonts.add(face);
}
}
}
Un FontFace è un solo file, come una regola @font-face, quindi un carattere con un file latin e uno latin-ext si dichiara con due oggetti FontFace che condividono famiglia, peso e stile e differiscono per unicodeRange, copiato dal CSS della famiglia. Aggiungerli a document.fonts non scarica niente: il browser scarica un file quando una chiamata a load() o una riga di testo ha bisogno dei suoi caratteri. Postext carica i caratteri che una pagina dichiara tramite document.fonts; un carattere che nessuno dichiara viene chiesto all’opzione resolve, ed è così che le altre ricette prendono dal kit i loro file Fontsource. customFonts in una configurazione descrive solo i file di un pacchetto .postext, quindi una pagina costruita dal Markdown dichiara i propri font o passa un resolve.
#2 · Carica i caratteri, poi costruisci
Il codice è nella risposta breve qui sopra. buildDocumentWithFonts comincia con prepareFonts, che legge nella configurazione e nel testo ogni carattere richiesto, in ogni peso e inclinazione, e li carica usando come campione i caratteri del documento stesso, così i file latin-ext arrivano con i pangrammi polacchi e cechi (Caricare i font prima dell’impaginazione). Poi costruisce, legge i caratteri in cui sono composte le pagine, carica quelli che la configurazione non nominava e costruisce di nuovo. onFonts riceve il resoconto di quest’ultimo controllo: i caratteri trovati loaded, quelli missing e quelli che il browser avrebbe synthesized da un altro peso o un’altra inclinazione. Il pen costruisce tre volte perché il libretto stampa la propria prova: una volta troppo presto, una volta con i caratteri e un’ultima volta con la tabella e l’immagine compilate.
#3 · Costruisci la verifica dal resoconto
const SET = { loaded: 'yes', synthesized: 'faked', missing: 'in a fallback' }; // the report
const nameOf = (f) => `${f.family.replaceAll('"', '')} ${f.weight} ${f.style}`; // or a FontFace
function auditOf(doc, report) {
const found = Object.keys(SET).flatMap((kind) => report[kind].map((f) => [nameOf(f), kind]));
const inPages = new Map(found);
const loaded = [...FILES].filter(([face]) => face.status === 'loaded');
const faces = new Map([...facesOf(FONTS), ...Object.keys(SET).flatMap((kind) => report[kind])]
.map((f) => [nameOf(f), f])); // the declared faces, then any the pages set without a file
const rows = [...faces].map(([name, f], i, all) => [
i && all[i - 1][1].family === f.family ? '' : f.family, // each family named once
`${f.weight}${f.style === 'italic' ? ' italic' : ''}`, SET[inPages.get(name)] ?? 'no',
loaded.filter(([face]) => nameOf(face) === name).map(([, subset]) => subset).join(' · ')
|| 'none'].map((cell) => ({ content: cell })));
// What else to read in a finished layout: its content warnings, its passes, its warnings.
return { rows, note: `${report.loaded.length} of ${rows.length} faces set in the pages · `
+ `${loaded.length} of ${FILES.size} files fetched · fontFallback warnings: `
+ `${fallbacksIn(doc) || 'none'} · ${doc.converged ? '' : 'not '}converged · layout warnings: `
+ `${doc.warnings?.length || 'none'}` };
}
La tabella parte dai caratteri che FONTS dichiara e aggiunge ciò che il resoconto dice di ciascuno: composto nelle pagine, simulato dal browser o composto con un ripiego. L’ultima colonna elenca i file il cui FontFace è arrivato a loaded. Ne sono stati scaricati sedici su venti: Postext carica tutti e quattro gli stili della famiglia del testo, perché ** e * possono richiederli in qualunque paragrafo, e i due corsivi monospaziati, che nessuna impostazione nomina, non sono mai stati scaricati. La nota legge altre tre cose dal documento finito: contentWarnings, dove un carattere misurato con un ripiego compare come fontFallback; converged, vero quando i passaggi dell’impaginazione si sono stabilizzati; e warnings. Controlla tutti e tre prima di mostrare o esportare una pagina.
#4 · Conserva una costruzione anticipata come prova
const STRIP = { lines: 8, overrun: 10 }; // page 1's first paragraph; mm shown past the measure
const PROOF = { // px: two strips a lead apart, cut at 300 dpi
width: Math.round(((MEASURE + STRIP.overrun) / 25.4) * 2 * DPI),
height: Math.round((((2 * STRIP.lines + 1) * LEAD) / 72) * 2 * DPI) };
const proof = { moved: 0, total: 0, faces: 0 }; // lines the early build broke elsewhere, of all
const fallbacksIn = (doc) => // a build lists each face its font set could not give
(doc.contentWarnings ?? []).filter((w) => w.kind === 'fontFallback').length;
const proofFigure = () => ({ id: 'proof', typeId: 'figure', kind: 'bitmap', createdAt: 0,
updatedAt: 0, placement: here,
bitmap: { fileId: 'proof.png', format: 'png', width: PROOF.width, height: PROOF.height },
caption: 'The first paragraph of page 1 as the early build set it, measured before the fonts '
+ 'had arrived (above), and as the last build set it (below). The early build broke '
+ `${proof.moved} of its ${proof.total} lines of text elsewhere, and its own warnings name `
+ `${proof.faces} faces set in a fallback. The rule marks the measure.`,
altText: `Two strips of the same ${STRIP.lines} lines of text. In the upper strip the lines `
+ 'break in other places and some run past a vertical rule; in the lower one every line '
+ 'stops short of it.' });
function drawProof(first, last) {
const linesOf = (doc) => doc.blocks.filter((b) => b.type === 'paragraph')
.map((b) => b.lines.map((l) => l.text));
const [before, after] = [first, last].map(linesOf);
proof.total = before.flat().length;
proof.faces = fallbacksIn(first);
proof.moved = before.flatMap((lines, i) => lines.filter((t, j) => t !== after[i]?.[j])).length;
const canvas = Object.assign(document.createElement('canvas'), PROOF);
const ctx = canvas.getContext('2d');
const strip = (PROOF.height * STRIP.lines) / (2 * STRIP.lines + 1);
const edge = Math.round((PROOF.width * MEASURE) / (MEASURE + STRIP.overrun));
// The renderer clips each column 2 pt past its edge, which would cut the early build's lines
// at the measure: paint a copy of page 1 whose column reaches across the whole strip.
const wide = (column) => ({ ...column,
bbox: { ...column.bbox, width: column.bbox.width + (STRIP.overrun / 25.4) * DPI } });
[first, last].forEach((doc, i) => {
const page = document.createElement('canvas');
renderPageToCanvas({ ...doc.pages[0], columns: doc.pages[0].columns.map(wide) }, doc, page,
{ scale: 2 }); // 300 dpi
const { x, y } = doc.pages[0].columns[0].blocks.find((b) => b.type === 'paragraph').bbox;
ctx.drawImage(page, 2 * x, 2 * y, PROOF.width, strip,
0, i * (PROOF.height - strip), PROOF.width, strip);
});
ctx.fillStyle = `${palette.ultramarine}1f`; // a pale wash over the margin past the measure
ctx.fillRect(edge, 0, PROOF.width - edge, PROOF.height);
ctx.fillStyle = palette.ultramarine; // a hairline at the measure, and each strip's name
ctx.fillRect(edge, 0, 2, PROOF.height);
ctx.font = `700 ${(7 / 72) * 2 * DPI}px "IBM Plex Mono"`; // 7 pt, loaded by now
['early', 'last'].forEach((name, i) => // on the last line of each strip
ctx.fillText(name, edge + 12, (i ? PROOF.height : strip) - 16));
registerResourceImage('proof.png', canvas);
}
Il pen chiama buildDocument una volta prima di dichiarare un solo carattere, quindi quella costruzione misura ogni parola nel font di ripiego del browser. Quando ci sono i caratteri veri, il pen la ridipinge e la confronta riga per riga con l’ultima costruzione; il numero di righe nella didascalia viene da quel confronto, e il numero di caratteri dagli avvisi fontFallback della costruzione anticipata stessa. Una riga semplice misurata con un ripiego più stretto supera la giustezza finché il ritaglio della colonna, 2 pt oltre il suo bordo, non ne taglia le ultime lettere, quindi il pen dipinge la pagina 1 con una colonna allargata per mostrare tutto lo sconfinamento. Il canvas e postext-pdf disegnano ogni tratto di una riga giustificata o con stili misti dove l’impaginazione l’ha misurato, quindi un carattere vero più largo del ripiego si stampa sopra la parola successiva, e le parole di un PDF costruito prima che arrivassero i suoi font si sovrappongono. Nulla della costruzione anticipata passa alla successiva: prepareFonts trova i nuovi caratteri in document.fonts e scarta le larghezze misurate nelle loro famiglie, quindi non serve chiamare clearMeasurementCache().
#5 · Una cascata sul ritmo del testo
const bodyText = () => ({
fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }); // ragged and spaced
// Every waterfall size and pangram is two leads (31 pt) deep, on the text's 15.5 pt rhythm.
const line = (size) => ({ fontSize: pt(size), lineHeight: pt(2 * LEAD) });
const paragraphStyles = () => [
...[7, 8, 9, 10, 11, 12, 14].map((size) => ({ id: `s${size}`, ...line(size) })),
{ id: 'pangram', ...line(13) },
{ id: 'colophon', fontSize: pt(7), lineHeight: pt(10), fontFamily: MONO, color: col('muted') }];
// Size labels: boxless mono chips. A Plex Mono letter is 0.6 em wide, so a one-digit label gets
// half a letter each side and the samples start on one edge.
const tag = { fontFamily: MONO, fontSize: pt(7), color: col('ultramarine'),
backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0), gap: mm(2.5) };
const chipStyles = () => [{ id: 'size', ...tag }, { id: 'size-1', ...tag, paddingX: em(0.3) }];
Ysabeau Office compone il testo in 11 su 15,5 pt, con una riga vuota tra i paragrafi invece di un rientro. Il testo è in bandiera, quindi ogni spazio tra le parole mantiene la sua larghezza naturale. Grassetto, corsivo e rimandi sono nel colore dell’inchiostro; lasciati a sé, prenderebbero il colore principale. Ogni corpo della cascata e ogni pangramma è uno stile di paragrafo alto due interlinee (31 pt), così la pagina mantiene il ritmo di 15,5 pt del testo. Le etichette dei corpi sono chip monospaziati, e i corpi di una cifra ricevono mezza lettera di padding per lato, così ogni campione comincia sullo stesso bordo.
#6 · Tre caratteri in una copertina
const Y = { kicker: 14, glyphs: 17, label: FIELD - 12, title: FIELD + 10, // mm from the top edge
end: FIELD + 42 }; // where the opener ends: under the title, the lead and a line of air
const ITALIC_FOOT = 5; // mm: the italic A's foot reaches this far left of the glyphs' origin
const at = (x, y, width) => ({ anchor: { to: 'page', edge: 'top-left' },
offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const text = (id, content, style, placement) => ({ kind: 'text', id, content, align: 'left',
overflow: 'wrap', ...style, placement }); // a running head wraps too, never '…'
const cover = () => ({ level: 1, fontSize: pt(30), italic: true, // headings.levels[0]
breakBefore: { enabled: true, parity: 'odd' }, // the cover is a recto
span: 'page', // the heading is set across the page; its field hangs from the bleed
advancedDesign: { enabled: true, minHeight: mm(Y.end - MARGIN.top), // from the top margin
slot: { elements: [
{ kind: 'box', id: 'field', style: { backgroundColor: col('ultramarine') }, placement: {
anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(FIELD) } } },
text('kicker', '{attr.kicker}', { ...label, fontWeight: 700, color: col('paper') },
at(MARGIN.inner, Y.kicker)),
text('glyphs', '{attr.glyphs}', { ...display, fontSize: pt(240), lineHeight: 1,
color: col('paper') }, at(MARGIN.inner + ITALIC_FOOT, Y.glyphs)),
text('label', '{attr.label}', { ...label, color: col('mist') }, at(MARGIN.inner, Y.label)),
text('faces', '{attr.faces}', { ...label, color: col('mist') },
{ anchor: { to: '#label', edge: 'below' }, offset: { y: mm(1.2) } }),
text('title', '{titleText}', { ...display, fontSize: pt(30), lineHeight: 1.05,
color: col('ink') }, at(MARGIN.inner, Y.title, PAGE.width - 2 * MARGIN.inner)),
text('lead', '{attr.lead}', { fontFamily: TEXT, italic: true, fontSize: pt(12),
lineHeight: 1.35, color: col('ink') }, { anchor: { to: '#title', edge: 'below' },
offset: { y: mm(3) }, size: { width: mm(MEASURE) } }),
] } } });
La copertina è il design del livello H1: le lettere dell’attributo glyphs del titolo nel carattere da titolazione a 240 pt e, sotto, due righe in monospaziato che nominano quel carattere e quelli del testo e delle etichette, come nel foglio campione di una fonderia di caratteri. span: 'page' stende il titolo su tutta la pagina, e il campo pende dall’angolo superiore sinistro dell’abbondanza, quindi esce dal bordo superiore. minHeight conta dal margine superiore e riserva lo spazio fino a Y.end, 42 mm sotto il campo; senza di esso il testo comincia una riga più in alto, stretto sotto l’attacco.
La ricetta completa
// ═══ Postext Cookbook · Nº 026 · Type specimen with every font loaded before layout ═════════ // https://postext.dev/en/cookbook/fonts-before-layout // Code: MIT · Text: original (CC BY 4.0) · Picture: cut from the pen's own early and last builds // Fonts: Ysabeau Office, Noto Serif Display, IBM Plex Mono (SIL OFL 1.1) · Needs postext ≥ 1.25.0 import { buildDocument, buildDocumentWithFonts, renderPageToCanvas, defaultResourceTypes, registerResourceImage } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'fonts-before-layout'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const PAGE = { width: 180, height: 240 }; // mm const MARGIN = { top: 22, bottom: 24, inner: 20, outer: 48 }; // mm: inner is the spine side const MEASURE = PAGE.width - MARGIN.inner - MARGIN.outer; // 112 mm: about 70 letters at 11 pt const FIELD = 122; // mm from the top edge: the ultramarine field of the opener const LEAD = 15.5; // pt: the body leading and the step of every vertical space const DPI = 150; // the page's resolution: the proof on page 4 is cut at twice this const [TEXT, DISPLAY, MONO] = ['Ysabeau Office', 'Noto Serif Display', 'IBM Plex Mono']; const palette = { ink: '#16161a', ultramarine: '#3246d3', mist: '#c9d0f6', // mist: 4.6:1 on rule: '#cfc9bd', muted: '#6b6a70', paper: '#ffffff' }; // ultramarine, for labels on the field // Every colour is linked to its palette entry; main-color catches any default left unstated. const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = () => Object.entries({ ...palette, 'main-color': palette.ultramarine }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const label = { fontFamily: MONO, fontSize: pt(7.5), letterSpacing: pt(1.2), textTransform: 'uppercase' }; const display = { fontFamily: DISPLAY, fontWeight: 900, italic: true }; // #region type: the text face at 11 on 15.5 pt, and a waterfall of it on two leads a line const bodyText = () => ({ fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }); // ragged and spaced // Every waterfall size and pangram is two leads (31 pt) deep, on the text's 15.5 pt rhythm. const line = (size) => ({ fontSize: pt(size), lineHeight: pt(2 * LEAD) }); const paragraphStyles = () => [ ...[7, 8, 9, 10, 11, 12, 14].map((size) => ({ id: `s${size}`, ...line(size) })), { id: 'pangram', ...line(13) }, { id: 'colophon', fontSize: pt(7), lineHeight: pt(10), fontFamily: MONO, color: col('muted') }]; // Size labels: boxless mono chips. A Plex Mono letter is 0.6 em wide, so a one-digit label gets // half a letter each side and the samples start on one edge. const tag = { fontFamily: MONO, fontSize: pt(7), color: col('ultramarine'), backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0), gap: mm(2.5) }; const chipStyles = () => [{ id: 'size', ...tag }, { id: 'size-1', ...tag, paddingX: em(0.3) }]; // #endregion // #region opener: the H1 as a bleed field, the display face at 240 pt, labels naming the faces const Y = { kicker: 14, glyphs: 17, label: FIELD - 12, title: FIELD + 10, // mm from the top edge end: FIELD + 42 }; // where the opener ends: under the title, the lead and a line of air const ITALIC_FOOT = 5; // mm: the italic A's foot reaches this far left of the glyphs' origin const at = (x, y, width) => ({ anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) }); const text = (id, content, style, placement) => ({ kind: 'text', id, content, align: 'left', overflow: 'wrap', ...style, placement }); // a running head wraps too, never '…' const cover = () => ({ level: 1, fontSize: pt(30), italic: true, // headings.levels[0] breakBefore: { enabled: true, parity: 'odd' }, // the cover is a recto span: 'page', // the heading is set across the page; its field hangs from the bleed advancedDesign: { enabled: true, minHeight: mm(Y.end - MARGIN.top), // from the top margin slot: { elements: [ { kind: 'box', id: 'field', style: { backgroundColor: col('ultramarine') }, placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(FIELD) } } }, text('kicker', '{attr.kicker}', { ...label, fontWeight: 700, color: col('paper') }, at(MARGIN.inner, Y.kicker)), text('glyphs', '{attr.glyphs}', { ...display, fontSize: pt(240), lineHeight: 1, color: col('paper') }, at(MARGIN.inner + ITALIC_FOOT, Y.glyphs)), text('label', '{attr.label}', { ...label, color: col('mist') }, at(MARGIN.inner, Y.label)), text('faces', '{attr.faces}', { ...label, color: col('mist') }, { anchor: { to: '#label', edge: 'below' }, offset: { y: mm(1.2) } }), text('title', '{titleText}', { ...display, fontSize: pt(30), lineHeight: 1.05, color: col('ink') }, at(MARGIN.inner, Y.title, PAGE.width - 2 * MARGIN.inner)), text('lead', '{attr.lead}', { fontFamily: TEXT, italic: true, fontSize: pt(12), lineHeight: 1.35, color: col('ink') }, { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(3) }, size: { width: mm(MEASURE) } }), ] } } }); // #endregion // Running heads at the outer edge of the text; a drop folio there too on the opener (a recto). const HEADS = { top: 13, bottom: 12 }; // mm from the top and the bottom edge of the page const head = (id, content, parity, edge, x, pages = 'body') => text(id, content, { parity, pages, fontFamily: MONO, fontSize: pt(7.5), color: col('muted') }, { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(edge.startsWith('top') ? HEADS.top : -HEADS.bottom) } }); const header = () => ({ elements: [ head('verso', '{pageNumber} · {title}', 'even', 'top-left', MARGIN.outer), head('recto', '{chapterTitle} · {pageNumber}', 'odd', 'top-right', -MARGIN.outer)] }); const footer = () => ({ elements: [ head('drop-folio', '{pageNumber}', 'all', 'bottom-right', -MARGIN.outer, 'opener')] }); const config = () => ({ locale: 'en-us', // the sample is in English // "Table 1", not "Table 1.1": the booklet has one chapter. Table captions sit above. resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}', ...(type.id === 'table' && { captionStyle: { position: 'above' } }) })), colorPalette: colorPalette(), layout: { layoutType: 'single' }, page: { width: mm(PAGE.width), height: mm(PAGE.height), dpi: DPI, margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, bodyText: { ...bodyText(), paragraphContainerSpacing: 'add' }, // samples stay two leads apart paragraphStyles: paragraphStyles(), chipStyles: chipStyles(), headings: { fontFamily: DISPLAY, fontWeight: 900, color: col('ink'), levels: [cover(), { level: 2, fontSize: pt(16), lineHeight: pt(2 * LEAD), marginTop: pt(LEAD), marginBottom: pt(0) }] }, tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('ultramarine'), headerColor: col('paper'), headerFontFamily: MONO, headerFontSize: pt(7.5), bodyFontFamily: MONO, bodyFontSize: pt(7.5), cellPadding: mm(1) }, captionStyle: { fontFamily: MONO, fontSize: pt(7.5), labelColor: col('ultramarine'), note: { color: col('muted') } }, header: header(), footer: footer(), }); // ─── 2 · Content ──────────────────────────────────────────────────────────── // The table and the picture come from the builds themselves (section 4). let audit = { rows: [], note: '' }; const content = () => ({ markdown, resources: resources() }); // read again for each build const here = { position: 'here' }; // both sit where ::resource puts them const resources = () => [ { id: 'faces', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: here, caption: 'Every face this booklet declares: whether Postext found it set in the pages, and ' + 'the files the browser fetched for it.', note: audit.note, table: { model: { headerRowCount: 1, columnWidths: [31, 19, 28, 34], rows: [ ['Family', 'Face', 'In the pages', 'Files fetched'] .map((cell) => ({ content: cell, isHeader: true })), ...audit.rows] } } }, proofFigure(), // drawn from the builds just below ]; // #region art-proof: page 1's first paragraph from the early build, over the same from the last const STRIP = { lines: 8, overrun: 10 }; // page 1's first paragraph; mm shown past the measure const PROOF = { // px: two strips a lead apart, cut at 300 dpi width: Math.round(((MEASURE + STRIP.overrun) / 25.4) * 2 * DPI), height: Math.round((((2 * STRIP.lines + 1) * LEAD) / 72) * 2 * DPI) }; const proof = { moved: 0, total: 0, faces: 0 }; // lines the early build broke elsewhere, of all const fallbacksIn = (doc) => // a build lists each face its font set could not give (doc.contentWarnings ?? []).filter((w) => w.kind === 'fontFallback').length; const proofFigure = () => ({ id: 'proof', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, placement: here, bitmap: { fileId: 'proof.png', format: 'png', width: PROOF.width, height: PROOF.height }, caption: 'The first paragraph of page 1 as the early build set it, measured before the fonts ' + 'had arrived (above), and as the last build set it (below). The early build broke ' + `${proof.moved} of its ${proof.total} lines of text elsewhere, and its own warnings name ` + `${proof.faces} faces set in a fallback. The rule marks the measure.`, altText: `Two strips of the same ${STRIP.lines} lines of text. In the upper strip the lines ` + 'break in other places and some run past a vertical rule; in the lower one every line ' + 'stops short of it.' }); function drawProof(first, last) { const linesOf = (doc) => doc.blocks.filter((b) => b.type === 'paragraph') .map((b) => b.lines.map((l) => l.text)); const [before, after] = [first, last].map(linesOf); proof.total = before.flat().length; proof.faces = fallbacksIn(first); proof.moved = before.flatMap((lines, i) => lines.filter((t, j) => t !== after[i]?.[j])).length; const canvas = Object.assign(document.createElement('canvas'), PROOF); const ctx = canvas.getContext('2d'); const strip = (PROOF.height * STRIP.lines) / (2 * STRIP.lines + 1); const edge = Math.round((PROOF.width * MEASURE) / (MEASURE + STRIP.overrun)); // The renderer clips each column 2 pt past its edge, which would cut the early build's lines // at the measure: paint a copy of page 1 whose column reaches across the whole strip. const wide = (column) => ({ ...column, bbox: { ...column.bbox, width: column.bbox.width + (STRIP.overrun / 25.4) * DPI } }); [first, last].forEach((doc, i) => { const page = document.createElement('canvas'); renderPageToCanvas({ ...doc.pages[0], columns: doc.pages[0].columns.map(wide) }, doc, page, { scale: 2 }); // 300 dpi const { x, y } = doc.pages[0].columns[0].blocks.find((b) => b.type === 'paragraph').bbox; ctx.drawImage(page, 2 * x, 2 * y, PROOF.width, strip, 0, i * (PROOF.height - strip), PROOF.width, strip); }); ctx.fillStyle = `${palette.ultramarine}1f`; // a pale wash over the margin past the measure ctx.fillRect(edge, 0, PROOF.width - edge, PROOF.height); ctx.fillStyle = palette.ultramarine; // a hairline at the measure, and each strip's name ctx.fillRect(edge, 0, 2, PROOF.height); ctx.font = `700 ${(7 / 72) * 2 * DPI}px "IBM Plex Mono"`; // 7 pt, loaded by now ['early', 'last'].forEach((name, i) => // on the last line of each strip ctx.fillText(name, edge + 12, (i ? PROOF.height : strip) - 16)); registerResourceImage('proof.png', canvas); } // #endregion const markdown = String.raw`---Esempio Markdown · 77 righe · content.en.md
title: "House Specimen" author: "Pellow Lane Press" --- # Three faces, proofed {kicker="Pellow Lane Press · House specimen Nº 3" lead="Our text, display and label faces at work, and proof that each of them had arrived before these lines were set." glyphs="Ag" label="Noto Serif Display 900 italic · 240 pt" faces="Text: Ysabeau Office · Labels: IBM Plex Mono"} A compositor in a metal shop could only set a line in a face that was in the case. Postext measures every word with the fonts the browser holds at that moment, so a page built a second too early is broken for a fallback face. These pages were built three times: once too early, on purpose, with no font file in the browser; again once Postext had loaded every face the design asks for; and a last time to print their list on page 3 and, on page 4, what the early build got wrong. ## Seven sizes of the text face Ysabeau, drawn by Christian Thalmann, carries the letterforms of the Garamond tradition into a low-contrast sans serif. Its Office cut sets tabular lining figures and a level hyphen by default. :::paragraphs{style="s7"} :chip[7 pt]{style="size-1"} Credits and map legends, where small print needs open counters. ::: :::paragraphs{style="s8"} :chip[8 pt]{style="size-1"} Captions and table notes, where the tabular figures keep 1,048 and 2,096 in step. ::: :::paragraphs{style="s9"} :chip[9 pt]{style="size-1"} A reference column set close; the long ascenders keep the lines apart. ::: :::paragraphs{style="s10"} :chip[10 pt]{style="size"} Notes and asides, a size below the text they sit beside. ::: :::paragraphs{style="s11"} :chip[11 pt]{style="size"} The text of this booklet, eleven on fifteen and a half. ::: :::paragraphs{style="s12"} :chip[12 pt]{style="size"} A standfirst, or a first reader for children. ::: :::paragraphs{style="s14"} :chip[14 pt]{style="size"} A heading, or a line on a poster. ::: ## Beyond Latin-1 Spanish needs nothing beyond the latin file of each face. Polish and Czech need more: ż, ł, ř and ů live in a second file, latin-ext, which the browser fetches only when a load or a line asks for those letters. :::paragraphs{style="pangram"} :chip[es]{style="size"} El veloz murciélago hindú comía feliz cardillo y kiwi. :chip[pl]{style="size"} Zażółć gęślą jaźń. :chip[cs]{style="size"} Příliš žluťoučký kůň úpěl ďábelské ódy. ::: :::pagebreak ## The proof The table below is Postext’s own report. Before it measured a line of the second build, it read the design and the text for every face they ask for, in the text, headings, chips, table, captions, opener and running heads, and loaded each one for the letters these pages set. Once the pages were laid out it looked at the faces their lines were set in, and found none missing. ::resource{id="faces"} Some faces in the table set nothing in this booklet. Postext loads the regular, the italic, the bold and the bold italic of the text face, because any paragraph may ask for them with a pair of asterisks. The two italics of the mono face were declared and never asked for, so the browser fetched no file for them. A face with no file would not pass unnoticed. The browser sets a family nobody declared in a system face and fakes a missing bold by thickening the regular; Postext names both in its report and among the warnings of the finished document. :::pagebreak ## What the early build got wrong The early build ran before the browser had any of these files, so its words were measured in a fallback face. Drawn in the real faces, its lines no longer fit the measure. ::resource{id="proof"} When the faces arrived, Postext dropped every width it had measured in their families, and the next build measured each word again. :::paragraphs{style="colophon"} Set in Ysabeau Office, Noto Serif Display and IBM Plex Mono (SIL OFL 1.1) · Text: original, CC BY 4.0 · Pellow Lane Press is imaginary. :::`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design may ask for (page 3 lists them), each declared from two files. const FONTS = { 'Ysabeau Office': ['400', '400i', '700', '700i'], // text, waterfall, pangrams, lead 'Noto Serif Display': ['900', '900i'], // the glyphs, the title, the subheads 'IBM Plex Mono': ['400', '400i', '700', '700i'], // labels, chips, the table, captions }; const facesOf = (fonts) => Object.entries(fonts).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); // #region declare: one FontFace per file, as a stylesheet has one @font-face rule per file const SUBSETS = { // the characters each file covers, copied from the family's @font-face CSS 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 FILES = new Map(); // FontFace → the subset its file covers, for the audit function declareFaces(fonts) { // Fontsource's static files stand in for your own /fonts/ folder for (const { family, weight, style } of facesOf(fonts)) { const id = family.toLowerCase().replaceAll(' ', '-'); for (const [subset, unicodeRange] of Object.entries(SUBSETS)) { const file = `${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; // Adding a face fetches nothing: the file downloads when a load or a line needs it. const face = new FontFace(family, `url(https://cdn.jsdelivr.net/npm/@fontsource/${file})`, { weight: `${weight}`, style, unicodeRange }); FILES.set(face, subset); document.fonts.add(face); } } } // #endregion // #region audit: page 3's table: every face declared or set, the report's word, its files const SET = { loaded: 'yes', synthesized: 'faked', missing: 'in a fallback' }; // the report const nameOf = (f) => `${f.family.replaceAll('"', '')} ${f.weight} ${f.style}`; // or a FontFace function auditOf(doc, report) { const found = Object.keys(SET).flatMap((kind) => report[kind].map((f) => [nameOf(f), kind])); const inPages = new Map(found); const loaded = [...FILES].filter(([face]) => face.status === 'loaded'); const faces = new Map([...facesOf(FONTS), ...Object.keys(SET).flatMap((kind) => report[kind])] .map((f) => [nameOf(f), f])); // the declared faces, then any the pages set without a file const rows = [...faces].map(([name, f], i, all) => [ i && all[i - 1][1].family === f.family ? '' : f.family, // each family named once `${f.weight}${f.style === 'italic' ? ' italic' : ''}`, SET[inPages.get(name)] ?? 'no', loaded.filter(([face]) => nameOf(face) === name).map(([, subset]) => subset).join(' · ') || 'none'].map((cell) => ({ content: cell }))); // What else to read in a finished layout: its content warnings, its passes, its warnings. return { rows, note: `${report.loaded.length} of ${rows.length} faces set in the pages · ` + `${loaded.length} of ${FILES.size} files fetched · fontFallback warnings: ` + `${fallbacksIn(doc) || 'none'} · ${doc.converged ? '' : 'not '}converged · layout warnings: ` + `${doc.warnings?.length || 'none'}` }; } // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region answer: one call loads every face the config and the text ask for, then builds // buildDocumentWithFonts starts with prepareFonts: it reads the config and the text for every // face they ask for (text, headings, chips, table, captions, opener, running heads), in each // weight and slant, and loads them for the characters the document sets. The page declares its // files (declareFaces), so they load through document.fonts; a face nothing declares would be // asked of a resolve(family, weight, style) option. Then it builds, reads the faces the pages // are set in, loads any that the config did not name and builds again. let report; // { loaded, missing, synthesized }: lists of { family, weight, style } const build = () => buildDocumentWithFonts(content(), config(), { onFonts: (found) => { report = found; } }); // what the last look at the pages found kitStatus('Loading fonts…'); // the kit's bar: it also reports any error thrown below const early = buildDocument(content(), config()); // on purpose: the page has no font yet (page 4) declareFaces(FONTS); const fitted = await build(); // every face loaded before a line is measured audit = auditOf(fitted, report); // page 3's table drawProof(early, fitted); // page 4's picture const doc = await build(); // nothing left to load: the same pages, table and picture filled in showPages(doc, { title: 'Load every font before layout' }); // #endregionKit · core, fonts, viewer: uguale in ogni ricetta · 224 righe
// ─── 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 ───────────────────────────────────────────────────────────────────────
Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)
Varianti
#Passa i file con resolve
kitFonts(FONTS), in da una stringa Markdown a una pagina progettata, non dichiara nulla: Postext chiede al suo resolve i file di ogni carattere e li aggiunge da sé a document.fonts.
#Dimentica un carattere
Togli il grassetto di IBM Plex Mono, che compone l’occhiello, l’intestazione della tabella e le etichette delle didascalie: il browser ispessisce il regolare, la tabella segna il carattere come faked e la nota conta un avviso fontFallback, che nomina IBM Plex Mono 700 come synthesized.
- 'IBM Plex Mono': ['400', '400i', '700', '700i'], // labels, chips, the table, captions
+ 'IBM Plex Mono': ['400', '400i', '700i'], // labels, chips, the table, captions#Incorpora gli stessi caratteri in un PDF
renderToPdf incorpora i byte che il suo fontProvider restituisce per ogni carattere, e il provider di un vero PDF con gli stessi font incorporati serve i file latin di Fontsource, che si fermano al Latin-1: per le ż, ł, ř e ů di questo libretto, fagli restituire il file di font completo di ogni carattere da una tua cartella.
Errori frequenti
Errore frequente
I file di Fontsource coprono le lettere, non i simboli
Il kit incorpora il file latin di Fontsource di un carattere, più il suo file latin-ext per č, ł, † e il suo file greek per α, χ (nelle famiglie che ne hanno uno: Lora e Gelasio no), quando il testo li compone, a schermo e nel PDF. Simboli come →, ≈, ✓ e ★ non sono in nessuno di quei file e spariscono nel PDF: disegnali, componili come matematica o scegli un carattere i cui file li contengano. Font incorporati nel PDF →
Avviso di impaginazione · fontFallback
Font di ripiego nell’impaginazione
Perché. Una variante con cui era composto il testo non era nel set di font quando il documento è stato impaginato (`reason: 'missing'`), oppure la famiglia non ha un file per quel peso o quell’inclinazione e il browser la disegna partendo da un’altra variante (`'synthesized'`), quindi gli a capo sono stati misurati con una variante diversa da quella che il PDF incorpora.
Soluzione. Carica le varianti prima della prima build: `prepareFonts(content, config, { resolve })`, oppure componi con `buildDocumentWithFonts`, che carica anche ogni variante usata dalle pagine e ricompone. Per `synthesized`, carica quel peso o quell’inclinazione, oppure imposta un peso che la famiglia include. Documentazione →
Verifica della Sandbox · missingFont
Font mancante
Perché. Una famiglia indicata nella configurazione non si è mai caricata nel browser, quindi il testo è stato misurato e disegnato con un font di sistema di ripiego.
Soluzione. Correggi il nome della famiglia (una sola famiglia, non un elenco CSS) e carica ogni variante prima della prima build; in un pen, elencala in FONTS. Documentazione →
Verifica della Sandbox · missingFontVariant
Variante di font mancante
Perché. Una famiglia personalizzata non ha un file per un peso e uno stile usati dal documento, come il suo corsivo o il suo grassetto.
Soluzione. Carica o dichiara la variante mancante, oppure smetti di usare quel peso o quello stile. Documentazione →
- Se carichi un carattere da solo,
document.fonts.load(font)senza testo di esempio carica solo il file che copre lo spazio, quello latin, e ż e ř ricadono su un carattere di sistema.prepareFontspassa i caratteri del documento stesso. renderPageToCanvasepostext-pdfritagliano ogni colonna 2 pt oltre il suo bordo, quindi una riga misurata con un ripiego più stretto perde le ultime lettere. La costruzione che l’ha misurata nomina il carattere indoc.contentWarningscomefontFallback: leggi quell’elenco prima di mostrare o esportare una pagina.
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- Testo originale, CC BY 4.0
- Font
- Ysabeau Office (SIL OFL 1.1) · Noto Serif Display (SIL OFL 1.1) · IBM Plex Mono (SIL OFL 1.1)
- Codice
- MIT, come Postext
Modifica questo testo ↗ (si apre in una nuova scheda)Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)


