Em poucas palavras
Um livreto de quatro páginas que exibe três famílias tipográficas. Mostra por que todas as fontes precisam terminar de carregar antes de diagramar as páginas, senão as linhas quebram no lugar errado.
O que você vai compor
House Specimen Nº 3, da imaginária Pellow Lane Press, é um espécime tipográfico de quatro páginas para três fontes. Na capa, um campo azul-ultramar traz um Ag branco em Noto Serif Display itálico de 240 pt e duas linhas em monoespaçada com o nome das três fontes; o título fica abaixo do campo. A página 2 desce a Ysabeau Office numa cascata de 7 a 14 pt e depois compõe pangramas em espanhol, polonês e tcheco cujos ż, ř e ů precisam de um segundo arquivo de fonte. A página 3 é a auditoria: uma tabela em IBM Plex Mono, preenchida pelo pen, com as dez fontes que a diagramação pediu e seus tamanhos. A página 4 compõe duas vezes o primeiro parágrafo da página 1, conforme a primeira composição e conforme a última. A primeira rodou antes de as fontes chegarem, por isso suas linhas terminam em outros pontos e algumas passam da medida.
Esta receita responde a
- Por que as minhas quebras de linha mudam ou as palavras se sobrepõem no PDF, e como carrego as fontes corretamente?
- Como uso as fontes da minha marca ou fontes licenciadas na composição e as incorporo ao PDF?
- Como descubro o que está errado no meu documento (avisos, transbordamento, layout que não converge)?
A resposta curta
// Every block, table, caption, chip, opener and running head keeps the font string it is set in
// (fontString, headerFontString…) and those of the bold and italics it may use (boldFontString…).
function fontStringsIn(doc) {
const found = new Map(); // font string → true when something is set in it
const walk = (node) => {
if (!node || typeof node !== 'object') return;
for (const [key, value] of Object.entries(node)) {
if (typeof value !== 'string' || !/fontString$/i.test(key)) walk(value);
else found.set(value, found.get(value) || !/(bold|italic)FontString$/i.test(key));
}
};
walk(doc.pages); walk(doc.blocks); // not doc.config: it is large and holds no font strings
return found;
}
function faceOf(font) { // 'italic 700 22.9px "Source Serif 4"' → { family, weight, style, px }
const [, italic, weight = '400', px, family] = /^(italic )?(\d+ )?([\d.]+)px (.+)$/.exec(font);
return { family: family.replaceAll('"', ''), weight: weight.trim(), px: Number(px),
style: italic ? 'italic' : 'normal' };
}
const nameOf = (face) => `${face.family} ${face.weight} ${face.style}`; // a FontFace works too
async function buildWithLoadedFonts(build, sample) { // → every build, first to last
const builds = [];
while (builds.length < 4) {
builds.push(build()); // the first one measures with whatever faces the browser has
// fonts.check() says yes to an undeclared family and to a face it can fake, so each face that
// something is set in needs a FontFace of its own; a bold or italic that is only named loads
// if declared (a family with no italic has none). load() fetches the files the sample needs.
const declared = new Set([...document.fonts].map(nameOf)), missing = new Set(), pending = [];
for (const [font, set] of fontStringsIn(builds.at(-1))) {
const name = nameOf(faceOf(font));
if (!declared.has(name)) { if (set) missing.add(name); }
else if (!document.fonts.check(font, sample)) pending.push(font);
}
if (missing.size) throw new Error(`No FontFace for ${[...missing].join(', ')}`);
if (!pending.length) return builds;
await Promise.all(pending.map((font) => document.fonts.load(font, sample)));
clearMeasurementCache(); // the widths measured with a fallback stay cached until cleared
}
throw new Error(`The fonts had not settled after ${builds.length} builds.`);
}
Ingredientes
- Funcionalidades
- Fontes antes da diagramaçãoTipografia do textoPáginas em um canvasFiguras e tabelas como recursosFiguras exatamente aquiTabelas a partir de dadosEstilo de tabelaEstilo de legendaTipos de recurso personalizadosAberturas desenhadasAtributos de títuloAncoragem de elementos de designEstilos de parágrafoChips no textoFormato da páginaMargens espelhadasPaleta de cores semânticaCabeços e fóliosCabeços por tipo de página
- Também usa
- Figura e Tabela no seu idiomaFaixa de capítulo em largura totalQuebras de página e de coluna
- Tipografia
- Ysabeau Office, Noto Serif Display, IBM Plex Mono (SIL OFL 1.1)
- Materiais
- Nenhum: todas as imagens são desenhadas em código
Preparo
#1 · Declare cada arquivo sem baixá-lo
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' };
function declareFaces(fonts) { // Fontsource's static files stand in for your own /fonts/ folder
for (const [family, specs] of Object.entries(fonts)) {
const id = family.toLowerCase().replaceAll(' ', '-');
for (const spec of specs) {
const [weight, style] = [spec.slice(0, 3), spec.endsWith('i') ? 'italic' : 'normal'];
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 url = `https://cdn.jsdelivr.net/npm/@fontsource/${file}`;
document.fonts.add(new FontFace(family, `url(${url})`, { weight, style, unicodeRange }));
}
}
}
}
Um FontFace é um arquivo, como uma regra @font-face, então uma fonte com um arquivo latin e outro latin-ext é declarada com dois objetos FontFace que compartilham família, peso e estilo e diferem no unicodeRange, copiado do CSS da família. Adicioná-los a document.fonts não baixa nada: o navegador busca um arquivo quando uma chamada a load() ou uma linha de texto precisa dos caracteres dele. buildDocument não carrega fontes (só loadBundleFonts carrega, para os arquivos dentro de um pacote .postext), e customFonts numa configuração apenas descreve esses arquivos, então uma página composta a partir de Markdown precisa declarar os seus.
#2 · Componha, carregue, limpe, componha de novo
kitStatus('Loading fonts…'); // the kit's bar: it also reports any error thrown below
declareFaces(FONTS);
const build = () => buildDocument({ markdown, resources: resources() }, config());
const builds = await buildWithLoadedFonts(build, markdown);
audit = auditOf(builds); // page 3's table
drawProof(builds[0], builds.at(-1)); // page 4's picture
const doc = (await buildWithLoadedFonts(build, markdown)).at(-1); // nothing is left to load
showPages(doc, { title: 'Load every font before layout' });
A primeira composição mede com o que o navegador tiver, uma fonte substituta, mas suas páginas já nomeiam todas as fontes de que precisam. buildWithLoadedFonts, a resposta curta lá em cima, recolhe esses nomes de cada composição, carrega as fontes que ainda faltam usando o texto do livreto como amostra e compõe de novo, até que uma composição não encontre mais nada para carregar. Essa amostra também baixa os arquivos latin-ext da fonte de títulos e da monoespaçada, que não compõem nenhuma dessas letras; se o tamanho do download importa, carregue cada fonte só com o próprio texto. clearMeasurementCache() não recebe argumento e esvazia as larguras que a primeira composição guardou em cache; sem essa chamada, a segunda composição mantém todas as quebras de linha da primeira (veja “Variações”).
#3 · Monte a auditoria a partir da diagramação
function auditOf(builds) {
const doc = builds.at(-1), faces = new Map(), declared = new Set([...document.fonts].map(nameOf));
for (const face of [...fontStringsIn(doc).keys()].map(faceOf)) {
const name = `${face.weight}${face.style === 'italic' ? ' italic' : ''}`; // '400 italic'
const key = `${Object.keys(FONTS).indexOf(face.family)} ${name}`; // FONTS order, upright first
// A face with no file is only named, never set: the browser fakes it if a line asks for it.
if (!faces.has(key)) faces.set(key, { family: face.family, sizes: new Set(),
face: declared.has(nameOf(face)) ? name : `${name} · no file` });
faces.get(key).sizes.add(Math.round((face.px * 72 * 10) / DPI) / 10); // px back to pt
}
const rows = [...faces].sort(([a], [b]) => a.localeCompare(b)).map(([, f], i, all) => [
i && all[i - 1][1].family === f.family ? '' : f.family, // each family named once
f.face, [...f.sizes].sort((a, b) => a - b).join(' · ')].map((content) => ({ content })));
const files = [...document.fonts].filter((face) => face.status === 'loaded').length;
const warnings = doc.warnings?.length || 'no'; // what else to read in a finished layout
return { rows, note: `Build ${builds.length}: ${rows.length} faces · ${files} files loaded · `
+ `${doc.converged ? 'converged' : 'not converged'} · ${warnings} layout warnings` };
}
A tabela é o mesmo percurso agrupado por fonte, com cada tamanho convertido dos pixels da diagramação de volta para pontos. A nota dela lê mais dois campos da composição que descreve: converged, verdadeiro quando as passadas da diagramação se estabilizaram, e warnings, que na 1.4.1 lista cada caixa que não cabe em nenhuma coluna. Confira os dois antes de mostrar ou exportar uma página. A tabela tem mais fontes do que as páginas mostram, porque cada bloco de texto, tabela e legenda nomeia um negrito, um itálico e um negrito itálico ao lado da própria fonte, e renderToPdf pede todos eles. A resposta curta exige um FontFace próprio para cada fonte em que algo é composto, porque document.fonts.check() devolve verdadeiro para uma família não declarada e para um negrito que o navegador consegue simular. Uma fonte que só é nomeada carrega quando está declarada: uma família sem arquivo de itálico passa, e a tabela marcaria seus itálicos com no file.
#4 · Guarde a primeira composição como 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 }; // lines of text the first build broke elsewhere, of all
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 first build set it, measured before the fonts '
+ 'had arrived (above), and as the last build set it (below). The first build broke '
+ `${proof.moved} of its ${proof.total} lines of text elsewhere. 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.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 first 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
['first', '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);
}
buildWithLoadedFonts devolve todas as composições, então o pen pode pintar a primeira de novo quando as fontes reais já chegaram e compará-la linha a linha com a última; a contagem da legenda sai dessa comparação. Uma linha simples medida numa fonte substituta mais estreita passa da medida até que o recorte da coluna, 2 pt além da borda, corta as últimas letras, por isso o pen pinta a página 1 com a coluna alargada para mostrar todo o excesso. O canvas e o postext-pdf desenham cada trecho de uma linha justificada ou com estilos misturados onde a diagramação o mediu, então uma fonte real mais larga que a substituta sai impressa por cima da palavra seguinte, e as palavras de um PDF composto antes de as fontes chegarem se sobrepõem.
#5 · Uma cascata no ritmo do texto
const bodyText = () => ({ // one family name, never a CSS stack (gotcha: font-family-one-name)
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) }];
A Ysabeau Office compõe o texto em 11 sobre 15,5 pt, com uma linha em branco entre parágrafos em vez de recuo. O texto é alinhado à esquerda, então cada espaço entre palavras mantém a largura natural. Negrito, itálico e referências saem na cor da tinta; sem ajuste, tomariam a cor principal. Cada tamanho da cascata e cada pangrama é um estilo de parágrafo com duas entrelinhas (31 pt) de altura, de modo que a página mantém o ritmo de 15,5 pt do texto. Os rótulos de tamanho são chips em monoespaçada, e os tamanhos de um algarismo ganham meia letra de espaçamento interno de cada lado, para que todas as amostras comecem na mesma borda.
#6 · Três fontes numa capa
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 }); // design text wraps instead of ending in an ellipsis
const cover = () => ({ level: 1, fontSize: pt(30), italic: true, // headings.levels[0]
breakBefore: { enabled: true, parity: 'odd' }, // restated (gotcha: headings-drop-h1-break)
span: 'page', // lets the field reach the top edge: in the column it stops at the top margin
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)),
// lineHeight multiplies the size (gotcha: design-lineheight-multiple)
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) } }),
] } } });
A capa é o design do nível H1: as letras do atributo glyphs do título na fonte de títulos a 240 pt e, embaixo, duas linhas em monoespaçada que nomeiam essa fonte e as de texto e de rótulos, como na folha de espécime de uma fundição de tipos. span: 'page' deixa o campo ancorado à sangria sair pela borda de cima; mantido na coluna, o design é recortado na margem superior, o que corta o alto do campo e o chapéu. minHeight conta a partir da margem superior e reserva o espaço até Y.end, 42 mm abaixo do campo; sem ele, o texto começa uma linha acima, colado na linha fina.
A receita 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 first and last builds // Fonts: Ysabeau Office, Noto Serif Display, IBM Plex Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, 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; // font strings carry px at this resolution; the audit turns them back into pt 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 keeps its palette id beside its hex, because 1.4.1 paints design slots from the // hex (gotcha: palette-skips-designs); 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 = () => ({ // one family name, never a CSS stack (gotcha: font-family-one-name) 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 }); // design text wraps instead of ending in an ellipsis const cover = () => ({ level: 1, fontSize: pt(30), italic: true, // headings.levels[0] breakBefore: { enabled: true, parity: 'odd' }, // restated (gotcha: headings-drop-h1-break) span: 'page', // lets the field reach the top edge: in the column it stops at the top margin 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)), // lineHeight multiplies the size (gotcha: design-lineheight-multiple) 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 = () => ({ // a new object per build (gotcha: config-cache-identity) // "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 here = { position: 'here' }; // both sit where ::resource puts them const resources = () => [ { id: 'faces', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: here, caption: 'Faces this document asked for, read from its own layout.', note: audit.note, table: { model: { headerRowCount: 1, columnWidths: [3, 2, 5], rows: [ ['Family', 'Face', 'Sizes (pt)'].map((content) => ({ content, isHeader: true })), ...audit.rows] } } }, proofFigure(), // drawn from the builds just below ]; // #region art-proof: page 1's first paragraph from the first 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 }; // lines of text the first build broke elsewhere, of all 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 first build set it, measured before the fonts ' + 'had arrived (above), and as the last build set it (below). The first build broke ' + `${proof.moved} of its ${proof.total} lines of text elsewhere. 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.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 first 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 ['first', '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`---Amostra em Markdown · 77 linhas · 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 and keeps the widths, so a face that arrives a second late leaves the page broken for a fallback, with no warning. These pages were built three times: once to learn which faces the layout asks for, again once all of them had loaded, and a last time to print their list on page 3 and, on page 4, what the first 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 script compiled the table below from the layout. After the first build, it walked the finished pages for every font they had asked for, in the text, headings, chips, tables, captions, opener and running heads. It loaded each face that had not arrived, emptied the measurement cache and built the pages again, then wrote down what it had found. ::resource{id="faces"} Some faces in the table set nothing in this booklet. Beside the face of every block of text, table and caption, Postext names a bold, an italic and a bold italic, whether the text uses them or not, and a PDF export asks for all of them. The script loads each one it has a file for. Before loading anything, the script also checked that every face some text is set in had a file of its own. It could not rely on the browser’s check, which answers yes for a family nobody declared and for any bold it can fake by thickening the regular. :::pagebreak ## What the first build got wrong The first build ran before any of these files had arrived, so the browser measured its words in a fallback face. Drawn in the real faces, its lines no longer fit the measure. ::resource{id="proof"} The fallback widths stay in the measurement cache, and a second build made without emptying it breaks every line where the first one did. :::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: every frontmatter value is quoted // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the layout asks 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 }; // #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' }; function declareFaces(fonts) { // Fontsource's static files stand in for your own /fonts/ folder for (const [family, specs] of Object.entries(fonts)) { const id = family.toLowerCase().replaceAll(' ', '-'); for (const spec of specs) { const [weight, style] = [spec.slice(0, 3), spec.endsWith('i') ? 'italic' : 'normal']; 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 url = `https://cdn.jsdelivr.net/npm/@fontsource/${file}`; document.fonts.add(new FontFace(family, `url(${url})`, { weight, style, unicodeRange })); } } } } // #endregion // #region answer: build, collect every font the layout asked for, load it, clear, build again // Every block, table, caption, chip, opener and running head keeps the font string it is set in // (fontString, headerFontString…) and those of the bold and italics it may use (boldFontString…). function fontStringsIn(doc) { const found = new Map(); // font string → true when something is set in it const walk = (node) => { if (!node || typeof node !== 'object') return; for (const [key, value] of Object.entries(node)) { if (typeof value !== 'string' || !/fontString$/i.test(key)) walk(value); else found.set(value, found.get(value) || !/(bold|italic)FontString$/i.test(key)); } }; walk(doc.pages); walk(doc.blocks); // not doc.config: it is large and holds no font strings return found; } function faceOf(font) { // 'italic 700 22.9px "Source Serif 4"' → { family, weight, style, px } const [, italic, weight = '400', px, family] = /^(italic )?(\d+ )?([\d.]+)px (.+)$/.exec(font); return { family: family.replaceAll('"', ''), weight: weight.trim(), px: Number(px), style: italic ? 'italic' : 'normal' }; } const nameOf = (face) => `${face.family} ${face.weight} ${face.style}`; // a FontFace works too async function buildWithLoadedFonts(build, sample) { // → every build, first to last const builds = []; while (builds.length < 4) { builds.push(build()); // the first one measures with whatever faces the browser has // fonts.check() says yes to an undeclared family and to a face it can fake, so each face that // something is set in needs a FontFace of its own; a bold or italic that is only named loads // if declared (a family with no italic has none). load() fetches the files the sample needs. const declared = new Set([...document.fonts].map(nameOf)), missing = new Set(), pending = []; for (const [font, set] of fontStringsIn(builds.at(-1))) { const name = nameOf(faceOf(font)); if (!declared.has(name)) { if (set) missing.add(name); } else if (!document.fonts.check(font, sample)) pending.push(font); } if (missing.size) throw new Error(`No FontFace for ${[...missing].join(', ')}`); if (!pending.length) return builds; await Promise.all(pending.map((font) => document.fonts.load(font, sample))); clearMeasurementCache(); // the widths measured with a fallback stay cached until cleared } throw new Error(`The fonts had not settled after ${builds.length} builds.`); } // #endregion // #region audit: page 3's table, one row per face the walk found, with every size it set function auditOf(builds) { const doc = builds.at(-1), faces = new Map(), declared = new Set([...document.fonts].map(nameOf)); for (const face of [...fontStringsIn(doc).keys()].map(faceOf)) { const name = `${face.weight}${face.style === 'italic' ? ' italic' : ''}`; // '400 italic' const key = `${Object.keys(FONTS).indexOf(face.family)} ${name}`; // FONTS order, upright first // A face with no file is only named, never set: the browser fakes it if a line asks for it. if (!faces.has(key)) faces.set(key, { family: face.family, sizes: new Set(), face: declared.has(nameOf(face)) ? name : `${name} · no file` }); faces.get(key).sizes.add(Math.round((face.px * 72 * 10) / DPI) / 10); // px back to pt } const rows = [...faces].sort(([a], [b]) => a.localeCompare(b)).map(([, f], i, all) => [ i && all[i - 1][1].family === f.family ? '' : f.family, // each family named once f.face, [...f.sizes].sort((a, b) => a - b).join(' · ')].map((content) => ({ content }))); const files = [...document.fonts].filter((face) => face.status === 'loaded').length; const warnings = doc.warnings?.length || 'no'; // what else to read in a finished layout return { rows, note: `Build ${builds.length}: ${rows.length} faces · ${files} files loaded · ` + `${doc.converged ? 'converged' : 'not converged'} · ${warnings} layout warnings` }; } // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: declare the files, build until the fonts settle, audit, build the last time kitStatus('Loading fonts…'); // the kit's bar: it also reports any error thrown below declareFaces(FONTS); const build = () => buildDocument({ markdown, resources: resources() }, config()); const builds = await buildWithLoadedFonts(build, markdown); audit = auditOf(builds); // page 3's table drawProof(builds[0], builds.at(-1)); // page 4's picture const doc = (await buildWithLoadedFonts(build, markdown)).at(-1); // nothing is left to load showPages(doc, { title: 'Load every font before layout' }); // #endregionKit · core, fonts, viewer: igual em todas as receitas · 236 linhas
// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** 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. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; 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 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 = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout; `base` marks a block's own face. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** 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, '-'); } /** 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 v1 ── 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. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)
Variações
#Não limpe o cache
O pen continua carregando todas as fontes, mas a segunda composição mantém todas as quebras de linha da primeira: as duas faixas da página 4 passam do fio, a legenda conta 0 linhas quebradas em outro ponto e as palavras das legendas em monoespaçada saem umas por cima das outras, cada trecho desenhado onde a fonte substituta o mediu.
- clearMeasurementCache(); // the widths measured with a fallback stay cached until cleared
+ // clearMeasurementCache();#Esqueça uma fonte
Deixe de fora o negrito da IBM Plex Mono, que compõe o chapéu e o cabeçalho da tabela, e o pen lança No FontFace for IBM Plex Mono 700 normal antes de mostrar qualquer página; só com document.fonts.check(), o navegador teria engrossado o redondo.
- 'IBM Plex Mono': ['400', '400i', '700', '700i'], // labels, chips, the table, captions
+ 'IBM Plex Mono': ['400', '400i', '700i'], // labels, chips, the table, captions#Incorpore as mesmas fontes num PDF
renderToPdf incorpora os bytes que o seu fontProvider devolve para cada fonte, e o provedor de um PDF de verdade com as mesmas fontes incorporadas serve os arquivos latin do Fontsource, que param no Latin-1: para os ż, ł, ř e ů deste livreto, faça-o devolver o arquivo inteiro de cada fonte a partir da sua própria pasta.
Erros comuns
Erro comum
Carregue todas as fontes antes do layout
O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada. Fontes antes da diagramação →
Erro comum
fontFamily é um nome de família, nunca uma pilha CSS
Uma pilha como 'Lora, serif' é lida como uma única família que não existe, então o texto é medido sem aviso com uma fonte substituta, e canvas, HTML e PDF não coincidem. Informe uma única família. Fontes antes da diagramação →
Erro comum
Os arquivos do Fontsource cobrem letras, não símbolos
O kit incorpora o arquivo latin do Fontsource de cada fonte e, quando o texto usa esses caracteres, o arquivo latin-ext para č, ł, † e o arquivo greek para α, χ (em uma família que o tenha: Lora e Gelasio não têm), na tela e no PDF. Símbolos como →, ≈, ✓ e ★ não estão em nenhum desses arquivos e somem no PDF: desenhe-os, componha-os como matemática ou escolha uma fonte cujos arquivos os tenham. Fontes incorporadas ao PDF →
Erro comum
O PDF pede todos os pesos e estilos de cada família
renderToPdf pede ao provedor de fontes o negrito, o itálico e o negrito itálico de cada família que um bloco poderia usar, mesmo os que nunca são impressos, e uma única recusa interrompe a exportação. O provedor deve se ajustar ao peso mais próximo que a família oferece e voltar ao romano quando não houver itálico. Fontes incorporadas ao PDF →
Erro comum
A configuração fica em cache pela identidade: crie um objeto novo
O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config(). Páginas em um canvas →
Erro comum
Qualquer objeto headings desativa a quebra de página do H1
Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →
Erro comum
Uma paleta trocada não chega aos elementos de design nem à cor das referências
postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor. Paleta de cores semântica →
Erro comum
O lineHeight de um texto de design é um múltiplo, nunca uma medida
Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título. Textos, fios e caixas nos designs de página →
Erro comum
O excesso de texto de design é 'ellipsis-end' por padrão
Um elemento de texto de design que não cabe na sua largura termina em reticências por padrão. Use overflow: 'wrap' nos títulos que devem passar para mais linhas. Textos, fios e caixas nos designs de página →
Verificação do Sandbox · missingFont
Fonte não carregada
Por quê. Uma família citada na configuração não chegou a carregar no navegador, então o texto foi medido e desenhado com uma fonte substituta do sistema.
Correção. Corrija o nome da família (uma só família, sem pilha CSS) e carregue todas as fontes antes da primeira composição; num pen, inclua a família em FONTS. Docs →
Verificação do Sandbox · missingFontVariant
Variante de fonte ausente
Por quê. Uma família personalizada não tem arquivo para um peso e estilo que o documento usa, como o itálico ou o negrito.
Correção. Envie ou declare a variante que falta, ou deixe de usar esse peso ou estilo. Docs →
document.fonts.load(font)sem texto de amostra carrega só o arquivo que cobre o espaço, o latin; passe o texto, ou ż e ř caem numa fonte do sistema sem nenhum aviso.renderPageToCanvasepostext-pdfrecortam cada coluna 2 pt além da borda, então uma linha medida com uma fonte substituta mais estreita perde as últimas letras sem nenhum aviso.
Créditos
- Receita
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Fontes
- Ysabeau Office (SIL OFL 1.1) · Noto Serif Display (SIL OFL 1.1) · IBM Plex Mono (SIL OFL 1.1)
- Código
- MIT, como o Postext
Editar este texto ↗ (abre em uma nova aba)Pasta da receita no GitHub ↗ (abre em uma nova aba)


