Em poucas palavras
Um manual de identidade visual de um metrô inventado. Mostra como baixar uma única vez as fontes próprias de uma marca e usá-las na visualização da página, no PDF e no arquivo salvo.
O que você vai compor
Quatro páginas do manual de identidade do Metro de Alba, um metrô fictício de cinco linhas. Na capa, as cinco linhas saem juntas do canto inferior esquerdo num trilho comum e se separam para a direita depois de cruzar um rio cinza, sob o nome em Big Shoulders Display. Cada seção abre sob uma faixa preta, com um número grande de dois algarismos na cor de uma das linhas. Em 01 Colour, a primeira coluna da tabela é a própria cor; 02 Type traz linhas de amostra e uma placa de plataforma, e 03 Usage fecha com dois desenhos de mapa emoldurados, um feito segundo as regras (Do) e outro contra elas (Don't). Todo o texto é composto em seis arquivos de fonte que o script baixa antes da primeira diagramação. O PDF é desenhado com os mesmos seis arquivos, e o arquivo .postext leva cinco, porque a fonte de títulos está marcada como não redistribuível.
Esta receita responde a
- Como uso as fontes da minha marca ou fontes licenciadas na composição e as incorporo ao PDF?
- Como faço para exportar um PDF de verdade no navegador, com as fontes incorporadas?
- Como evito que títulos, negritos e marcadores de lista saiam em azul?
- Como acrescento amostras de cor, como chave de cores, ao texto, às legendas ou às notas de tabela?
- Como crio um pacote .postext a partir do código, para passar um documento ao Sandbox ou a outro programa?
A resposta curta
// Your licensed files, one per face in FONTS. Fontsource's copies stand in for them here:
// point FONT_URL at your own server (same origin, or one that lets this page in by CORS).
const slug = (family) => family.toLowerCase().replaceAll(' ', '-');
const FONT_URL = ({ family, weight, style }) => `https://cdn.jsdelivr.net/npm/@fontsource/`
+ `${slug(family)}@5/files/${slug(family)}-latin-${weight}-${style}.woff2`;
const brandFaces = () => Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => {
const weight = parseInt(spec, 10), style = spec.endsWith('i') ? 'italic' : 'normal';
return { family, weight, style, fileId: `${slug(family)}-${weight}-${style}.woff2` };
}));
const fontFiles = new Map(); // fileId → the WOFF2 bytes, fetched once
// 1 · Layout measures with document.fonts: register every face before the first build.
async function loadBrandFonts() {
await Promise.all(brandFaces().map(async ({ family, weight, style, fileId }) => {
const res = await fetch(FONT_URL({ family, weight, style }));
if (!res.ok) throw new Error(`No file for ${family} ${weight} ${style}`);
fontFiles.set(fileId, new Uint8Array(await res.arrayBuffer()));
const face = new FontFace(family, fontFiles.get(fileId), { weight: `${weight}`, style });
document.fonts.add(await face.load());
}));
}
// 2 · The PDF embeds the same bytes as TrueType. It also asks for faces no text uses (the
// display face's italic, the monospace's SemiBold): answer with the family's closest file.
// 1.4.1 writes an unused copy of that file for each of them (gotcha: pdf-font-copies).
async function brandFontProvider(family, weight, style) {
const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
const own = brandFaces().filter((f) => f.family === family);
if (!own.length) throw new Error(`${family} is not one of the brand's fonts`);
return decompressWoff2(fontFiles.get(own.reduce((a, b) => (cost(b) < cost(a) ? b : a)).fileId));
}
// 3 · The bundle: customFonts names each face's file by its fileId; createBundle packs the bytes
// of every family it may hand on. Layout never reads it (gotcha: custom-fonts-declarative).
const customFonts = () => Object.keys(FONTS).map((name) => ({
name, redistributable: name !== DISPLAY, // the display face reaches suppliers another way
variants: brandFaces().filter((f) => f.family === name)
.map(({ weight, style, fileId }) => ({ weight, style, fileId, format: 'woff2' })),
}));
Ingredientes
- Funcionalidades
- Suas próprias fontesPaleta de cores semânticaPreenchimento de célulasAmostras de corAberturas desenhadasTítulos numeradosFaixa de capítulo em largura totalEstilos de tabela com nomeEstilos de parágrafoTipos de recurso personalizadosEstilo de legendaPosicionamento de figurasTextos, fios e caixas nos designs de páginaTipografia do textoUma ou duas colunasFontes antes da diagramaçãoExportação para PDFPacotes .postextPáginas em um canvasFiguras e tabelas como recursos
- Também usa
- BoxesCitações que posicionam as figurasAtributos de títuloEstilos de títuloFigura e Tabela no seu idiomaCores por parteFontes incorporadas ao PDFCabeços por seçãoCapítulos sem número
- Tipografia
- Public Sans, Big Shoulders Display, Spline Sans Mono (SIL OFL 1.1)
- Materiais
- The network drawing on the cover and the two map examples, drawn in code (Ignacio Ferro, CC BY 4.0)
Preparo
#1 · Registrar as fontes da marca, compor e dar ao PDF os mesmos bytes
await loadBrandFonts(); // the answer, step 1 (gotcha: fonts-first)
for (const [fileId, markup] of Object.entries(ART)) await loadSvg(fileId, markup);
const doc = buildDocument({ markdown, resources }, config());
showPages(doc, { title: 'Metro de Alba · Identity manual' });
offerPdf(() => renderToPdf(doc, { fontProvider: brandFontProvider, resourceBytes: imageBytes }),
`${RECIPE}.pdf`); // step 2: the provider hands renderToPdf the same files
A diagramação mede cada palavra com as fontes de document.fonts, então loadBrandFonts(), na resposta curta, baixa cada arquivo uma vez, guarda os bytes e registra com eles um FontFace antes de buildDocument rodar. O loadFonts do kit só baixa da Fontsource, e uma marca serve os arquivos do próprio servidor. No postext-pdf 1.4.1, renderToPdf pede dez variantes, quatro das quais nenhum texto usa (o itálico da fonte de títulos e o itálico, o seminegrito e o seminegrito itálico da monoespaçada), e brandFontProvider responde a cada pedido com o arquivo mais próximo da mesma família (Por que um provedor de fontes?). As páginas usam só os seis arquivos que o canvas mediu, mas a 1.4.1 também grava uma cópia não usada do arquivo substituto para cada um desses quatro pedidos.
#2 · O sistema de cores é uma lista só
const COLOURS = [ // id, name, screen, print (coated stock)
['line-1', 'Line 1 · Tile red', '#e4572e', '0 75 85 0'],
['line-2', 'Line 2 · Harbour teal', '#17bebb', '75 0 32 0'],
['line-3', 'Line 3 · Broom yellow', '#ffc914', '0 22 95 0'],
['line-4', 'Line 4 · Heather', '#6c4f9e', '65 75 0 0'],
['line-5', 'Line 5 · Pine', '#3f9b4a', '76 12 90 2'],
['signal-red', 'Signal red', '#c0391b', '10 88 100 2'],
['signal-green', 'Signal green', '#2b7d3c', '84 25 95 10'],
['ink', 'Ink', '#1f2124', '72 62 55 78'],
['rule', 'Rule grey', '#d9d9d4', '14 10 14 0'],
];
const palette = { ...Object.fromEntries(COLOURS.map(([id, , hex]) => [id, hex])),
paper: '#ffffff', section: '#e4572e' }; // section: the line colour of the current section
// col() writes the hex too: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [...Object.entries(palette), ['main-color', palette.ink]]
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(' ');
const swatchTable = () => COLOURS.reduce( // an empty first cell, filled with the colour itself
(model, [id], i) => setCellBackground(model, { row: i + 1, col: 0 }, col(id)),
{ headerRowCount: 1, columnWidths: [22, 34, 14, 15, 15], rows: [
['COLOUR', 'NAME', 'HEX', 'RGB', 'CMYK'].map((content) => ({ content, isHeader: true })),
...COLOURS.map(([, name, hex, cmyk]) =>
['', name, hex.toUpperCase(), rgb(hex), cmyk].map((content) => ({ content }))),
] });
Como a paleta e a Tabela 1.1 são montadas com as mesmas nove linhas, a tabela não tem como listar um valor que as páginas não usam. setCellBackground preenche a primeira célula vazia de cada linha com a cor daquela linha, vinculada à paleta. No texto, :swatch{color="line-2"} recebe o id de uma cor da paleta e desenha um quadrado contornado na cor do texto. main-color tem o hex de Ink, de modo que os títulos e os trechos em negrito e itálico, vinculados a ela por padrão, saem em Ink e não no azul do motor. Na 1.4.1, as referências mantêm esse azul seja qual for o valor de main-color; bodyText.referenceColor: col('ink') as põe em Ink também (Paleta de cores).
#3 · Uma abertura, três cores de linha
// Texts wrap (gotcha: overflow-ellipsis-default); lineHeight is a multiple (gotcha:
// design-lineheight-multiple). The body starts ten grid lines down, in both columns.
const opener = { enabled: true, minHeight: pt(10 * LEAD), slot: { elements: [
{ kind: 'text', id: 'number', content: '{number}', fontFamily: DISPLAY, fontWeight: 800,
fontSize: pt(130), lineHeight: 1, color: col('section'), align: 'left', overflow: 'wrap',
// Nudged by eye on the capture: the cap tops of number and title on one line.
placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { x: mm(-1.5), y: mm(1.3) } } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 800,
fontSize: pt(34), lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { x: mm(COL + GUTTER), y: mm(1) }, size: { width: mm(COL) } } },
{ kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(12.5),
lineHeight: 1.36, color: col('ink'), align: 'left', overflow: 'wrap',
placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(3) },
size: { width: mm(COL) } } },
] } };
// Each section sets `section` to its line's colour; the number and the head square use it.
const section = (id) => ({ id, palette: { section: palette[id] } });
numberingTemplate: '{1:01}' escreve o contador com dois algarismos, e {number} imprime 01, 02 e 03 na abertura; o título da capa é numbered: false e fica sem número. Os três estilos de seção só diferem na entrada section da paleta, à qual cada um dá a cor da sua linha. O número e o quadrado da faixa estão vinculados a section, então um único design de abertura serve às três seções. minHeight reserva dez linhas da grade de 14 pt para a abertura, e o texto de cada seção começa na mesma linha da grade na sua página.
#4 · Do e Don't são tipos de figura
const example = (id, name, colour) => ({ id, name, shortLabel: name, captionPrefix: name,
numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
captionStyle: { labelColor: col(colour) } });
const resourceTypes = [...defaultResourceTypes(LANG),
example('do', 'Do', 'signal-green'), example('dont', 'Don’t', 'signal-red')];
const FOOT = { position: 'bottom' }; // cited in one paragraph: its page's foot, one per column
Um tipo de recurso pode ter um captionStyle parcial, e estes dois definem só a cor do rótulo, Signal green para Do e Signal red para Don't (Tipos de recurso). As cores de sinalização chegam a 5,1:1 e 5,5:1 sobre branco, enquanto as linhas 1, 2, 3 e 5 ficam abaixo dos 4,5:1 que o texto pequeno exige. Os dois desenhos flutuam com position: 'bottom' e são citados no mesmo parágrafo, o primeiro sob Drawing the map, então dividem o pé da página 4, Do na coluna da esquerda e Don't na da direita.
#5 · O pacote leva as fontes que pode compartilhar
const pack = Object.assign(document.createElement('button'), { type: 'button',
textContent: 'Build the .postext' });
pack.addEventListener('click', async () => {
const { bytes, manifest, warnings } = await createBundle({
name: 'Metro de Alba identity manual', locale: LANG, markdown, config: config(), resources,
files: new Map([...Object.entries(ART), ...fontFiles]), // fileId → SVG markup or font bytes
});
const size = `${Math.round(bytes.length / 1024)} KB`;
const packed = `fonts inside: ${manifest.fonts.map((font) => font.name).join(', ')}`;
kitStatus(['.postext', size, packed, ...warnings].join(' · ')); // warnings: what stayed out
pack.replaceWith(Object.assign(document.createElement('a'), { download: `${RECIPE}.postext`,
href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })),
textContent: `Download ${RECIPE}.postext · ${size}` }));
});
document.getElementById('pt-actions').append(pack);
createBundle lê config.customFonts, procura em files os bytes de cada variante pelo fileId dela e os grava em fonts/, com uma entrada fonts no manifesto. A família de títulos tem redistributable: false, fazendo as vezes de uma licença que proíbe repassar os arquivos (a Big Shoulders Display em si está sob a OFL). O arquivo dela fica de fora, e warnings nomeia a família. O arquivo de 81 KB contém o capítulo, a configuração, os três desenhos e cinco arquivos de fonte. A diagramação nunca lê customFonts; sem ele as páginas saem iguais (Criar um pacote).
A receita completa
// ═══ Postext Cookbook · Nº 040 · Brand fonts in layout, PDF and bundle ════════════ // https://postext.dev/en/cookbook/brand-fonts-identity-manual // Code: MIT · Text and drawings: original (CC BY 4.0) · Metro de Alba is a fictional network // Fonts: Public Sans, Big Shoulders Display, Spline Sans Mono (OFL 1.1) · Needs postext ≥ 1.19.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, setCellBackground, createBundle, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'brand-fonts-identity-manual'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region answer: the brand's font files, fetched once for the layout, the PDF and the bundle // Your licensed files, one per face in FONTS. Fontsource's copies stand in for them here: // point FONT_URL at your own server (same origin, or one that lets this page in by CORS). const slug = (family) => family.toLowerCase().replaceAll(' ', '-'); const FONT_URL = ({ family, weight, style }) => `https://cdn.jsdelivr.net/npm/@fontsource/` + `${slug(family)}@5/files/${slug(family)}-latin-${weight}-${style}.woff2`; const brandFaces = () => Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => { const weight = parseInt(spec, 10), style = spec.endsWith('i') ? 'italic' : 'normal'; return { family, weight, style, fileId: `${slug(family)}-${weight}-${style}.woff2` }; })); const fontFiles = new Map(); // fileId → the WOFF2 bytes, fetched once // 1 · Layout measures with document.fonts: register every face before the first build. async function loadBrandFonts() { await Promise.all(brandFaces().map(async ({ family, weight, style, fileId }) => { const res = await fetch(FONT_URL({ family, weight, style })); if (!res.ok) throw new Error(`No file for ${family} ${weight} ${style}`); fontFiles.set(fileId, new Uint8Array(await res.arrayBuffer())); const face = new FontFace(family, fontFiles.get(fileId), { weight: `${weight}`, style }); document.fonts.add(await face.load()); })); } // 2 · The PDF embeds the same bytes as TrueType. It also asks for faces no text uses (the // display face's italic, the monospace's SemiBold): answer with the family's closest file. // 1.4.1 writes an unused copy of that file for each of them (gotcha: pdf-font-copies). async function brandFontProvider(family, weight, style) { const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight); const own = brandFaces().filter((f) => f.family === family); if (!own.length) throw new Error(`${family} is not one of the brand's fonts`); return decompressWoff2(fontFiles.get(own.reduce((a, b) => (cost(b) < cost(a) ? b : a)).fileId)); } // 3 · The bundle: customFonts names each face's file by its fileId; createBundle packs the bytes // of every family it may hand on. Layout never reads it (gotcha: custom-fonts-declarative). const customFonts = () => Object.keys(FONTS).map((name) => ({ name, redistributable: name !== DISPLAY, // the display face reaches suppliers another way variants: brandFaces().filter((f) => f.family === name) .map(({ weight, style, fileId }) => ({ weight, style, fileId, format: 'woff2' })), })); // #endregion const TEXT = 'Public Sans', DISPLAY = 'Big Shoulders Display', MONO = 'Spline Sans Mono'; // #region colours: the colour system, from which the palette and Table 1.1 are both built const COLOURS = [ // id, name, screen, print (coated stock) ['line-1', 'Line 1 · Tile red', '#e4572e', '0 75 85 0'], ['line-2', 'Line 2 · Harbour teal', '#17bebb', '75 0 32 0'], ['line-3', 'Line 3 · Broom yellow', '#ffc914', '0 22 95 0'], ['line-4', 'Line 4 · Heather', '#6c4f9e', '65 75 0 0'], ['line-5', 'Line 5 · Pine', '#3f9b4a', '76 12 90 2'], ['signal-red', 'Signal red', '#c0391b', '10 88 100 2'], ['signal-green', 'Signal green', '#2b7d3c', '84 25 95 10'], ['ink', 'Ink', '#1f2124', '72 62 55 78'], ['rule', 'Rule grey', '#d9d9d4', '14 10 14 0'], ]; const palette = { ...Object.fromEntries(COLOURS.map(([id, , hex]) => [id, hex])), paper: '#ffffff', section: '#e4572e' }; // section: the line colour of the current section // col() writes the hex too: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [...Object.entries(palette), ['main-color', palette.ink]] .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(' '); const swatchTable = () => COLOURS.reduce( // an empty first cell, filled with the colour itself (model, [id], i) => setCellBackground(model, { row: i + 1, col: 0 }, col(id)), { headerRowCount: 1, columnWidths: [22, 34, 14, 15, 15], rows: [ ['COLOUR', 'NAME', 'HEX', 'RGB', 'CMYK'].map((content) => ({ content, isHeader: true })), ...COLOURS.map(([, name, hex, cmyk]) => ['', name, hex.toUpperCase(), rgb(hex), cmyk].map((content) => ({ content }))), ] }); // #endregion const PAGE = { width: 210, height: 280 }; // mm const MARGIN = { top: 24, bottom: 22, inner: 18, outer: 18 }; const GUTTER = 8, COL = (PAGE.width - MARGIN.inner - MARGIN.outer - GUTTER) / 2; // 83 mm const BAND = 13, LEAD = 14; // mm: the ink band at the head of each page; pt: the leading // #region sections: a giant zero-padded number in the section's line colour // Texts wrap (gotcha: overflow-ellipsis-default); lineHeight is a multiple (gotcha: // design-lineheight-multiple). The body starts ten grid lines down, in both columns. const opener = { enabled: true, minHeight: pt(10 * LEAD), slot: { elements: [ { kind: 'text', id: 'number', content: '{number}', fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(130), lineHeight: 1, color: col('section'), align: 'left', overflow: 'wrap', // Nudged by eye on the capture: the cap tops of number and title on one line. placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(-1.5), y: mm(1.3) } } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(34), lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(COL + GUTTER), y: mm(1) }, size: { width: mm(COL) } } }, { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(12.5), lineHeight: 1.36, color: col('ink'), align: 'left', overflow: 'wrap', placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(3) }, size: { width: mm(COL) } } }, ] } }; // Each section sets `section` to its line's colour; the number and the head square use it. const section = (id) => ({ id, palette: { section: palette[id] } }); // #endregion // Running heads: an ink band across the head of the page, as on the platform signs. const PT = 25.4 / 72; // mm in a point const HEAD = 7.5, SQUARE = 4.5, STEP = 8; // pt: head size; mm: the square, and the spacing const label = (size, colour) => ({ fontFamily: TEXT, fontSize: pt(size), fontWeight: 600, letterSpacing: pt(size * 0.16), textTransform: 'uppercase', color: col(colour) }); const inBand = (edge, x, height) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm((BAND - height) / 2) } }); // centred in the band const head = (id, content, parity, edge, x) => ({ kind: 'text', id, content, parity, lineHeight: 1, ...label(HEAD, 'paper'), placement: inBand(edge, x, HEAD * PT) }); const square = (id, parity, edge, x) => ({ kind: 'box', id, parity, style: { backgroundColor: col('section') }, // the section's line, as on its signs placement: { ...inBand(edge, x, SQUARE), size: { width: mm(SQUARE), height: mm(SQUARE) } } }); const header = { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('ink') }, placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } }, square('verso-line', 'even', 'top-left', MARGIN.outer), head('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer + STEP), head('verso-title', '{title} · {subtitle}', 'even', 'top-left', MARGIN.outer + 2 * STEP), square('recto-line', 'odd', 'top-right', -MARGIN.outer), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -(MARGIN.outer + STEP)), head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(MARGIN.outer + 2 * STEP)), ] }; // The cover: the network drawing fills the page; the heading and frontmatter give the texts. const coverText = (id, content, placement, style) => ({ kind: 'text', id, content, placement, overflow: 'wrap', align: 'left', ...style }); const under = (id, gap) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(gap) }, size: { width: mm(PAGE.width - 2 * MARGIN.outer) } }); const cover = { id: 'cover', numbered: false, span: 'page', header: { elements: [] }, footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'map', resourceId: 'network', placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } }, coverText('kicker', '{attr.kicker}', { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(MARGIN.outer), y: mm(MARGIN.top - 2) } }, label(8, 'ink')), // At lineHeight 0.86 the capitals rise about 3 mm above the title's box: hence 6.5 mm. coverText('title', '{titleText}', under('kicker', 6.5), { fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(88), lineHeight: 0.86, color: col('ink') }), coverText('subtitle', '{subtitle}', under('title', 3), { fontFamily: TEXT, fontWeight: 600, fontSize: pt(20), lineHeight: 1.2, color: col('ink') }), coverText('edition', '{attr.edition}', under('subtitle', 1.5), { fontFamily: MONO, fontSize: pt(9), lineHeight: 1.3, color: col('ink') }), ] } } }; // #region do-dont: two figure types whose captions carry their own label colour const example = (id, name, colour) => ({ id, name, shortLabel: name, captionPrefix: name, numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', captionStyle: { labelColor: col(colour) } }); const resourceTypes = [...defaultResourceTypes(LANG), example('do', 'Do', 'signal-green'), example('dont', 'Don’t', 'signal-red')]; const FOOT = { position: 'bottom' }; // cited in one paragraph: its page's foot, one per column // #endregion const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity) colorPalette, resourceTypes, customFonts: customFonts(), page: { width: mm(PAGE.width), height: mm(PAGE.height), dpi: 150, margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(GUTTER) }, bodyText: { fontFamily: TEXT, fontSize: pt(9.8), lineHeight: pt(LEAD), color: col('ink'), referenceColor: col('ink'), // references skip the palette (gotcha: palette-skips-designs) boldFontWeight: 600, // the brand has no Bold: its SemiBold sets **emphasis** textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true }, headings: { fontFamily: DISPLAY, fontWeight: 800, levels: [ // ink: main-color // span: 'page' breaks already; restated in case it goes (gotcha: headings-drop-h1-break) { level: 1, span: 'page', numberingTemplate: '{1:01}', marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener }, { level: 2, fontSize: pt(17), lineHeight: pt(2 * LEAD), marginTop: pt(0), // two lines, marginBottom: pt(0) }, // so a column that opens with one starts on the same line ] }, headingStyles: [cover, section('line-1'), section('line-4'), section('line-5')], paragraphStyles: [ { id: 'specimen', fontSize: pt(15), lineHeight: pt(LEAD * 1.5), marginBottom: pt(LEAD) }, { id: 'specimen-mono', fontFamily: MONO, fontSize: pt(11), lineHeight: pt(LEAD * 1.5), marginBottom: pt(LEAD) }, { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10.5) }, ], calloutStyles: [ { id: 'sign', span: 'page', placement: 'bottom', background: col('ink'), padding: { top: mm(6), right: mm(8), bottom: mm(6), left: mm(8) }, titleStyle: { fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(60), gap: mm(2), color: col('paper') }, body: { fontSize: pt(16), lineHeight: pt(LEAD * 1.5), color: col('paper'), boldColor: col('paper') } }, ], tableStyles: [ { id: 'swatches', rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('ink'), headerColor: col('paper'), headerFontSize: pt(7.5), bodyFontFamily: MONO, bodyFontSize: pt(8.5), cellPadding: mm(2) }, ], captionStyle: { fontSize: pt(8.3) }, // face and ink from bodyText; the label in SemiBold header, footer: { elements: [] }, // the folio sits in the band }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Amostra em Markdown · 90 linhas · content.en.md
title: "Metro de Alba" subtitle: "Identity manual" author: "Metro de Alba brand office" --- # Metro de Alba {style="cover" kicker="Brand office · Lines and stations" edition="Edition 3 · September 2026"} # Colour {style="line-1" lead="Riders learn the colour of a line before its number. These values are fixed for print, screen and enamel."} ## Five lines, five colours The network has five lines, and each owns one colour, listed in :ref{id="colours" style="full"}. The colour marks the line wherever it appears: the stripe on a platform wall, the band along a train, the line’s badge and its path on the map. It marks nothing else, so a leaflet about fares is printed in ink on paper and red always means line 1. Take the values from the table, never from a screenshot, an old sign or a colour picker. Screens use the HEX value, and print uses the CMYK recipe on coated stock; for uncoated paper and newsprint, ask the brand office for the matching recipe. Enamel panels and vinyl are matched to the printed swatch card that the brand office keeps. ## Colour and contrast Line colours are for fills. A line badge is a square in the line colour with the number inside it: white on lines :swatch{color="line-1"} 1, :swatch{color="line-4"} 4 and :swatch{color="line-5"} 5, ink on lines :swatch{color="line-2"} 2 and :swatch{color="line-3"} 3, whose teal and yellow measure 2.3:1 and 1.5:1 against white, below the 3:1 that large type needs. Small text is never set in a line colour. Warnings and confirmations use :swatch{color="signal-red"} Signal red and :swatch{color="signal-green"} Signal green, which reach 5.5:1 and 5.1:1 on white. ## Neutrals Ink, a blue-black, sets text and outlines. Paper is plain white, since a tint would dull the yellow of line 3. Rule grey draws rules and the river. # Type {style="line-4" lead="Names and headings are set in a condensed display face. Running text is set in a sans, and a monospace takes the figures that have to line up."} ## Big Shoulders Display Station names, line names and headings are set in Big Shoulders Display ExtraBold. It is narrow, so a long name such as Puerta del Mercado still fits a platform sign at a size that reads from the far end of the platform. Set it in capitals and lowercase, never in capitals alone, and never below 14 pt, where its narrow counters start to fill in. A platform sign is an ink band 400 mm deep. The station name has a cap height of 150 mm and sits on a 100 mm margin. Under it, a square in the colour of each line that stops there comes before the line numbers, set in Public Sans SemiBold. :::callout{type="sign" title="Puerta del Mercado"} :swatch{color="line-2"} :swatch{color="line-3"} :swatch{color="line-5"} **Lines 2, 3 and 5** ::: ## Public Sans Anything people read in sentences, such as a notice or this manual, is set in Public Sans. Text is 9.8 pt on a 14 pt line, ragged right, with no indents and a space between paragraphs. Captions in print are 8.3 pt, and nothing a rider needs to read is set smaller. On screen, text starts at 16 px and never drops below 14 px. ## Weights Public Sans is used in four faces. There is no Bold, and the SemiBold takes its place: :::paragraphs{style="specimen"} Regular and *Italic* **SemiBold** and ***SemiBold Italic*** ::: Emphasis is **SemiBold, in ink**, as in this sentence; a heavier weight would compete with the station names. Italic marks the titles of documents and words in another language, such as *andén* on a bilingual sign. ## Spline Sans Mono Departure times, platform codes and colour values are set in Spline Sans Mono, so that the figures on a departure board line up in columns without tabs: :::paragraphs{style="specimen-mono"} 07:42 · 07:46 · 07:51 · #E4572E ::: Use it for values only: at the same size, a sentence in it runs about 30% wider than in Public Sans. # Usage {style="line-5" lead="The network map is the drawing we print most. The rules below apply to every copy of it, printed or on screen."} ## Drawing the map Lines run horizontally, vertically or at 45 degrees, and change direction on a curve three line widths in radius, as in :ref{id="do-grid"}. Lines that share track run side by side at an equal spacing, in the order of their numbers, and bend round one centre. A station is a white dot with an ink rim, and an interchange is a single white capsule drawn across every line that stops there. :ref{id="dont-grid"} breaks each of these rules: free angles, curves of any radius, uneven spacing and stations in the line colour. ## Names on the map Station names are set in Public Sans SemiBold, always horizontal, on the side of the line that has no other line. An interchange carries its name once, beside the capsule. ## Geography Stations are evenly spaced. The one geographical feature on the map is the river, a band of Rule grey. ## Line width and scale The line width sets the scale of the whole drawing: 6 mm on the platform map, 1.5 mm on the pocket map and 4 px on screen at the default zoom. A station dot is 1.4 line widths across with a rim of a quarter of a line width, and a capsule is a dot stretched across the lines it serves. ## Files for suppliers Suppliers receive this manual as a PDF with its typefaces embedded, and as an editable source file that carries Public Sans and Spline Sans Mono. The display face is sent separately from the brand office’s type folder, so that every supplier sets names from the same version of it. Map artwork keeps station names as live text, never as outlines, so that a station renamed by the city can be corrected in every file. :::paragraphs{style="colophon"} Metro de Alba is a fictional network. Set in Public Sans, Big Shoulders Display and Spline Sans Mono, under the SIL Open Font License 1.1. Text and drawings: CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook // Its frontmatter fills {title} · {subtitle} in the band and the PDF's title and author: // every value is quoted (gotcha: quote-frontmatter). const ART = {}; // fileId → SVG markup: registered for the pages, packed into the bundle const drawing = (id, typeId, fileId, [w, h], caption, altText, placement) => ({ id, typeId, kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId, width: w * 10, height: h * 10 }, caption, altText, placement }); const resources = [ { id: 'colours', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: { span: 'page' }, caption: 'The colour system. Line colours are fills; only Ink and the two signals set text.', note: 'HEX and RGB for screens; CMYK recipes for coated stock.', table: { styleId: 'swatches', model: swatchTable() } }, drawing('network', 'figure', 'network.svg', [PAGE.width, PAGE.height], '', 'Five coloured metro lines share a track from the lower left, then fan out to the right.'), drawing('do-grid', 'do', 'do-grid.svg', [COL, 43.5], 'Lines at 0°, 45° and 90°, bent together at an even spacing; one capsule for the interchange.', 'Three parallel lines bend at 45 degrees together; white stations with ink rims.', FOOT), drawing('dont-grid', 'dont', 'dont-grid.svg', [COL, 43.5], 'Free angles, mixed radii, uneven spacing and stations drawn in the line colour.', 'The same three lines drawn at free angles with coloured station dots.', FOOT), ]; // #region art: the drawings, made by the rules of section 03 const f = (n) => +n.toFixed(2); const P = ([x, y]) => `${f(x)} ${f(y)}`; const sub = (a, b) => [a[0] - b[0], a[1] - b[1]]; const add = (a, b, k = 1) => [a[0] + b[0] * k, a[1] + b[1] * k]; const unit = (v) => { const l = Math.hypot(v[0], v[1]); return [v[0] / l, v[1] / l]; }; const dotp = (a, b) => a[0] * b[0] + a[1] * b[1]; const left = ([x, y]) => [y, -x]; // the normal on the left of a direction (y runs down) const HEADING = { E: [1, 0], NE: [1, -1], SE: [1, 1], S: [0, 1] }; /** From a point, a run of moves such as ['NE', 40]: 0°, 45° and 90° only. */ const walk = (from, moves) => moves.reduce((pts, [h, len]) => [...pts, add(pts[pts.length - 1], unit(HEADING[h]), len)], [from]); const dirs = (pts, i) => { const a = unit(sub(pts[i], pts[i - 1] ?? pts[i])), b = unit(sub(pts[i + 1] ?? pts[i], pts[i])); return [Number.isNaN(a[0]) ? b : a, Number.isNaN(b[0]) ? a : b]; }; /** A polyline moved d mm to its left, mitred at each bend, so parallel lines stay parallel. */ const shift = (pts, d) => pts.map((p, i) => { const [n1, n2] = dirs(pts, i).map(left); return add(p, add(n1, n2), d / (1 + dotp(n1, n2))); }); /** A path through the points, each bend rounded: radius R, or R ± d for a line d mm off a * shared centre line, so that the bends of parallel lines stay concentric. */ function track(pts, R, offsets = []) { let out = `M${P(pts[0])}`; for (let i = 1; i < pts.length - 1; i++) { const [a, b] = dirs(pts, i); const turn = Math.acos(Math.max(-1, Math.min(1, dotp(a, b)))); const r = R + (offsets[i] ?? 0) * Math.sign(a[0] * b[1] - a[1] * b[0]); const cut = r * Math.tan(turn / 2); out += ` L${P(add(pts[i], a, -cut))} Q${P(pts[i])} ${P(add(pts[i], b, cut))}`; } return `${out} L${P(pts[pts.length - 1])}`; } /** The point `dist` mm along a polyline. */ function along(pts, dist) { for (let i = 1; i < pts.length; i++) { const len = Math.hypot(...sub(pts[i], pts[i - 1])); if (dist <= len) return add(pts[i - 1], unit(sub(pts[i], pts[i - 1])), dist); dist -= len; } return pts[pts.length - 1]; } const stroke = (d, colour, w, cap = 'butt') => `<path d="${d}" fill="none" stroke="${colour}" ` + `stroke-width="${f(w)}" stroke-linecap="${cap}" stroke-linejoin="round"/>`; /** A station: a white dot with an ink rim. An interchange: one capsule from a to b. */ const station = (p, w) => `<circle cx="${f(p[0])}" cy="${f(p[1])}" r="${f(0.7 * w)}" ` + `fill="${palette.paper}" stroke="${palette.ink}" stroke-width="${f(w / 4)}"/>`; const capsule = (a, b, w) => stroke(`M${P(a)} L${P(b)}`, palette.ink, 1.65 * w, 'round') + stroke(`M${P(a)} L${P(b)}`, palette.paper, 1.15 * w, 'round'); // a dot's cross-section const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}"><clipPath id="frame"><rect width="${w}" ` + `height="${h}"/></clipPath><g clip-path="url(#frame)">${body}</g></svg>`; const LINES = ['line-1', 'line-2', 'line-3', 'line-4', 'line-5']; function networkArt(W, H) { const w = 6.5, gap = 9.5, R = 3 * w; // line width, spacing and corner radius, in mm const trunk = walk([-20, 272], [['NE', 100], ['E', 72]]); // shared track from the lower left const offsets = LINES.map((_, i) => (2 - i) * gap); // line 1 on the left, line 5 on the right const branches = [[['E', 8], ['NE', 95], ['E', 60]], [['E', 34], ['NE', 38], ['E', 60]], [['E', 110]], [['E', 26], ['SE', 36], ['E', 60]], [['E', 4], ['SE', 44], ['S', 60]]]; const river = walk([-10, 148], [['E', 40], ['SE', 52], ['S', 120]]); let out = `<rect width="${W}" height="${H}" fill="${palette.paper}"/>` + stroke(track(river, 28), palette.rule, 15); const lines = LINES.map((id, i) => { const own = shift(trunk, offsets[i]); const branch = walk(own[own.length - 1], branches[i]); const d = [...own.map(() => offsets[i]), ...branches[i].map(() => 0)]; out += stroke(track([...own, ...branch.slice(1)], R, d), palette[id], w); return branch; }); const across = (p, dir) => [add(p, left(dir), 2 * gap + 0.05 * w), add(p, left(dir), -2 * gap - 0.05 * w)]; out += capsule(...across(along(trunk, 62), unit(HEADING.NE)), w) + capsule(...across(along(trunk, 158), HEADING.E), w); const stops = [[45, 80], [12, 90], [34, 68], [44, 82], [32, 88]]; // on straights only lines.forEach((branch, i) => { for (const s of stops[i]) out += station(along(branch, s), w); }); return svg(W, H, out); } /** Three lines and their stops, drawn by the rules (good) or against every one of them. */ function gridArt(W, H, good) { const w = 3.6, gap = 5.4, R = 3 * w; const ids = ['line-1', 'line-4', 'line-5']; const dot = (p, id) => `<circle cx="${f(p[0])}" cy="${f(p[1])}" r="${f(0.75 * w)}" ` + `fill="${palette[id]}"/>`; let out = `<rect width="${W}" height="${H}" fill="${palette.paper}"/>`; if (good) { const trunk = walk([-4, 11], [['E', 30], ['SE', 20]]); const lines = [['E', 70], ['E', 70], ['S', 40]].map((move, i) => shift([...trunk, ...walk(trunk[2], [move]).slice(1)], (1 - i) * gap)); lines.forEach((pts, i) => { const shared = (k) => k === 1 || (k === 2 && i < 2); // bends two or three lines share out += stroke(track(pts, R, pts.map((_, k) => (shared(k) ? (1 - i) * gap : 0))), palette[ids[i]], w); }); const hub = along(trunk, 12); // the interchange, on the shared straight out += capsule(add(hub, [0, -gap - 0.05 * w]), add(hub, [0, gap + 0.05 * w]), w); for (const [i, s] of [[0, 72], [1, 84], [2, 52]]) out += station(along(lines[i], s), w); } else { // free angles, radii and spacing; stops as coloured dots, three dots for the hub const lines = [[[-4, 7], [22, 7], [44, 19], [90, 17]], [[-4, 15], [30, 14], [46, 31], [90, 31]], [[-4, 20], [24, 21], [40, 35], [45, 60]]]; lines.forEach((pts, i) => { out += stroke(track(pts, [2, 15, 6][i]), palette[ids[i]], w); }); lines.forEach((pts, i) => { out += dot(along(pts, 12), ids[i]); }); for (const [i, s] of [[0, 72], [1, 84], [2, 58]]) out += dot(along(lines[i], s), ids[i]); } const frame = `<rect x="0.15" y="0.15" width="${W - 0.3}" height="${H - 0.3}" fill="none" ` + `stroke="${palette.rule}" stroke-width="0.3"/>`; // a hairline in Rule grey return svg(W, H, out + frame); } ART['network.svg'] = networkArt(PAGE.width, PAGE.height); ART['do-grid.svg'] = gridArt(COL, 43.5, true); ART['dont-grid.svg'] = gridArt(COL, 43.5, false); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // The brand's files and no others: no Bold (the SemiBold stands in), the display face in // ExtraBold only. The PDF shows Fontsource's names ('PublicSansThin-SemiBold'); yours show theirs. const FONTS = { 'Public Sans': ['400', '400i', '600', '600i'], 'Big Shoulders Display': ['800'], 'Spline Sans Mono': ['400'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: the brand's faces first, then the pages, then a PDF from the same bytes await loadBrandFonts(); // the answer, step 1 (gotcha: fonts-first) for (const [fileId, markup] of Object.entries(ART)) await loadSvg(fileId, markup); const doc = buildDocument({ markdown, resources }, config()); showPages(doc, { title: 'Metro de Alba · Identity manual' }); offerPdf(() => renderToPdf(doc, { fontProvider: brandFontProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // step 2: the provider hands renderToPdf the same files // #endregion // #region bundle: one .postext file with the text, the design, the drawings and the fonts const pack = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the .postext' }); pack.addEventListener('click', async () => { const { bytes, manifest, warnings } = await createBundle({ name: 'Metro de Alba identity manual', locale: LANG, markdown, config: config(), resources, files: new Map([...Object.entries(ART), ...fontFiles]), // fileId → SVG markup or font bytes }); const size = `${Math.round(bytes.length / 1024)} KB`; const packed = `fonts inside: ${manifest.fonts.map((font) => font.name).join(', ')}`; kitStatus(['.postext', size, packed, ...warnings].join(' · ')); // warnings: what stayed out pack.replaceWith(Object.assign(document.createElement('a'), { download: `${RECIPE}.postext`, href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })), textContent: `Download ${RECIPE}.postext · ${size}` })); }); document.getElementById('pt-actions').append(pack); // #endregionKit · core, fonts, viewer, pdf, images: igual em todas as receitas · 316 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 · pdf v2 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType: the nearest weight the * family ships, upright if it has no italic; latin, then what the face's * letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const more = kitSubsetsFor(text, meta); const files = await Promise.all(['latin', ...more].map(async (subset) => { const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
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
#Incluir também a fonte de títulos
Se a licença permite repassar os arquivos, tire o sinalizador, e o pacote leva as três famílias em 96 KB.
- name, redistributable: name !== DISPLAY, // the display face reaches suppliers another way
+ name,#Usar um negrito de verdade
boldFontWeight: 600 compõe os trechos em negrito, os cabeçalhos de tabela e os rótulos de legenda no seminegrito, porque a marca não tem negrito; se a sua família tiver, acrescente os arquivos dele a FONTS, apague a configuração e reescreva a linha de amostra do texto de exemplo e o “There is no Bold” dela, que deixaria de ser verdade.
- boldFontWeight: 600, // the brand has no Bold: its SemiBold sets **emphasis**
- 'Public Sans': ['400', '400i', '600', '600i'],
+ 'Public Sans': ['400', '400i', '600', '600i', '700', '700i'],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
customFonts não carrega nenhuma fonte
config.customFonts só nomeia arquivos de fonte pelo fileId: buildDocument e os renderizadores de canvas, HTML e PDF nunca o leem, então uma família listada ali e não registrada é medida e pintada com uma fonte substituta. Registre você mesmo cada estilo (um FontFace a partir dos bytes) antes da primeira composição e passe esses mesmos bytes ao provedor de fontes do PDF. createBundle lê a lista para empacotar os arquivos num .postext, e o Sandbox a lê para registrar as fontes. Suas próprias fontes →
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
O negrito ou itálico que falta na família é simulado na tela, não no PDF
Quando um texto pede um peso ou estilo que a família não traz (um cabeçalho de tabela em negrito numa fonte de títulos de um estilo só, itálico numa sem serifa sem itálicos), o navegador o sintetiza no canvas e no HTML, engrossando ou inclinando o redondo com as mesmas larguras. Um PDF só incorpora fontes reais, então nele sai a fonte mais próxima que o provedor entregar, sem engrossar nem inclinar. Ponha na sua lista de fontes só as que a família traz e ajuste cada estilo a elas, por exemplo com tableStyle.headerBold: false. Fontes incorporadas ao PDF →
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 guarda uma cópia de fonte para cada estilo que pede
postext-pdf 1.4.1 grava um programa de fonte para cada família, peso e estilo que pede ao provedor de fontes, mesmo quando o provedor responde a vários deles com o mesmo arquivo e nenhuma página desenha com esse estilo. Por isso, cada substituto de um itálico ou de um peso que falta acrescenta outra cópia do arquivo que toma emprestado, sem uso quando nenhum texto é composto nesse estilo. pdffonts só lista as fontes que as páginas usam, mas as cópias estão no arquivo do mesmo jeito, e nada que um pen faça as remove. Fontes incorporadas ao PDF →
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 →
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
Coloque entre aspas cada valor do frontmatter
O YAML lê title: 1984 como número e uma data como objeto Date, e valores que não são strings saem vazios nos placeholders e deixam o PDF sem título. Coloque cada valor entre aspas: title: "1984". Metadados do documento →
Passe a createBundle os bytes de cada fonte sob o fileId que customFonts dá a ela. Uma variante sem bytes fica de fora com o aviso “missing file, skipped”, e uma família que perde todas as variantes sai do manifesto.
Créditos
- Receita
- Ignacio Ferro
- Texto
- The Metro de Alba identity manual, a fictional network · Ignacio Ferro · CC BY 4.0
- Imagens
- The network drawing on the cover and the two map examples, drawn in code · Ignacio Ferro · CC BY 4.0
- Fontes
- Public Sans (SIL OFL 1.1) · Big Shoulders Display (SIL OFL 1.1) · Spline Sans 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)


