Em poucas palavras
Certificados de um curso de oficina gráfica, uma página por aluno, com uma moldura decorada e um selo. Basta escrever o nome de cada aluno para ter o certificado inteiro.
O que você vai compor
Certificados de conclusão de curso para o Quoin Room, uma oficina fictícia de impressão tipográfica em Providence: uma página Carta americana por aluno. A edição em espanhol os compõe como diplomas A4 para La Cuña, uma oficina de Zaragoza. Uma faixa verde-azulada cheia de lentes de guilhochê cor de creme emoldura a folha, com uma roseta creme em cada canto; faixa e rosetas são desenhadas em código a partir de senoides e hipotrocoides. Sob um título em maiúsculas espacejadas, o nome do aluno vai em Pinyon Script de 48 pt sobre um fio dourado, e o texto do certificado, centrado, em Rosarivo. Um selo vermelho com duas fitas fica entre as linhas de assinatura, com uma linha de imprenta embaixo. Como um único estilo de título desenha a página inteira, cada aluno é um título de Markdown, e o PDF reúne a turma com um marcador por nome.
Esta receita responde a
- Como acrescento uma marca-d'água, um fundo de cor ou uma imagem decorativa em todas as páginas?
- Como componho ilustrações sem numeração: ornamentos, vinhetas, logotipos?
- Como fixo um selo ou um adesivo em uma posição exata da página?
- Como faço para exportar um PDF de verdade no navegador, com as fontes incorporadas?
A resposta curta
const certificate = () => ({ // a function: it uses the elements defined below
id: 'certificate',
// Section geometry, for this style's pages only: a narrower measure, and a text area that
// ends at the seal, so a citation too long for the page moves on instead of running under it.
margins: { left: mm(SIDE), right: mm(SIDE), bottom: mm(H - SIGN) },
// Header and footer elements paint over the page and reserve nothing (gotcha:
// header-paints-over-text), so the frame, the seal and the signatures go there. In the
// opener the signature lines would count towards its height, and a design that reaches
// below the text area loses the whole reservation (gotcha: opener-reserves-anchored).
header: { elements: [frame, serial] },
footer: { elements: signatures },
// The citation starts 18 lines of the 17 pt grid under the top margin, 140 mm down (19
// lines, 146 mm, on A4): an opener's height rounds up to whole lines, and without
// marginBottom: 0 the level's 0.5 em under a heading would add a line. minHeight is a
// floor: the title block alone would start the citation at 128 mm (134 mm on A4).
advancedDesign: { enabled: true, minHeight: mm(y(106)), slot: { elements: title } },
marginBottom: mm(0),
});
// Hook-up: headingStyles: [certificate()], and in the Markdown one heading per student:
// # Imogen Achterberg {style="certificate" serial="26-031"}
Ingredientes
- Funcionalidades
- Cabeços por seçãoImagens nos designs de páginaAberturas desenhadasAncoragem de elementos de designTextos, fios e caixas nos designs de páginaEstilos de títuloGeometria por seçãoAtributos de títuloCor do papelPaleta de cores semânticaFiguras e tabelas como recursosMetadados do documentoPáginas em um canvasExportação para PDFFontes incorporadas ao PDFMarcadores do PDF
- Tipografia
- Rosarivo, Pinyon Script, Aboreto (SIL OFL 1.1)
- Materiais
- Nenhum: todas as imagens são desenhadas em código
Preparo
#1 · Deixar um estilo de título desenhar a página
O estilo completo é a resposta curta lá em cima. Um estilo de título governa as páginas da sua seção (estilos de título): o seu header e o seu footer substituem os do documento, que aqui estão vazios, as suas margins definem a área de texto e o seu advancedDesign desenha a abertura. A altura de uma abertura é arredondada para cima em linhas inteiras da grade de 17 pt do texto corrido. Um minHeight de 106 mm vira 18 linhas, então o texto começa a 140 mm da borda superior; em A4, y() o transforma em 112,7 mm, ou 19 linhas, e o texto começa a 146 mm. Só o bloco do título faria o texto começar a 128 mm (134 mm em A4). Sem marginBottom: 0, o 0,5 em que fica por padrão sob um título levaria os mesmos 106 mm a 19 linhas.
#2 · Pendurar a moldura na página, pelo cabeçalho
const frame = { kind: 'image', id: 'frame', resourceId: 'guilloche',
placement: { anchor: { to: 'page', edge: 'top-left' }, // the trim box
size: { width: 'fill', height: 'fill' } } }; // the SVG has the page's proportions
const serial = { kind: 'text', id: 'serial', ...caps, fontSize: pt(8.5), letterSpacing: pt(1.6),
content: t({ en: 'No. {attr.serial}', es: 'N.º {attr.serial}' }), // from the heading line
color: col('seal'), align: 'right', placement: at('top-right', space(1.6) - MARGIN, 29) };
const PX = 10; // declared pixels per mm: an SVG resource only needs the right proportions
const svg = (id, w, h, altText) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX } });
const resources = [
svg('guilloche', W, H, t({ en: 'A teal guilloche border with cream rosettes at the corners.',
es: 'Una orla de guilloché verde azulado con rosetas color crema en las esquinas.' })),
svg('seal', SEAL.w, SEAL.h, t({ en: 'A red seal with a serrated edge and two ribbon tails.',
es: 'Un sello rojo de borde dentado con dos cintas.' })),
];
Um elemento de imagem desenha um recurso no tamanho da sua colocação (elementos de imagem): ancorado no canto superior esquerdo da página e com os dois lados em 'fill', o guilhochê cobre a área de refile, e o SVG, declarado com as proporções da página, a preenche sem deformar. O cabeçalho o pinta sobre page.backgroundColor e não reserva espaço, então são só as margens que mantêm o texto a pelo menos 32 mm do refile, dentro do fio interno, que está a 26 mm. Uma marca d’água posta no cabeçalho ficaria por cima das palavras, porque o cabeçalho é pintado depois do texto. Num cabeçalho, {attr.serial} lê o atributo do título a cuja seção a página pertence, e assim cada certificado imprime o seu próprio número.
#3 · Deixar na abertura só o bloco do título
const centred = (top, tracking = 0) => at('top', space(tracking) / 2, y(top));
const title = [
{ kind: 'text', id: 'kicker', ...caps, fontSize: pt(9.5), letterSpacing: pt(2.4),
content: t({ en: 'The Quoin Room · Letterpress workshop',
es: 'La Cuña · Taller de tipografía' }),
placement: centred(39, 2.4) },
{ kind: 'text', id: 'title', content: t({ en: 'Certificate', es: 'Diploma' }), ...caps,
fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3), placement: centred(48, 3) },
{ kind: 'text', id: 'of', content: t({ en: 'of completion', es: 'de aprovechamiento' }),
...caps, fontSize: pt(11), letterSpacing: pt(4), placement: centred(68, 4) },
{ kind: 'text', id: 'lead', content: t({ en: 'This certifies that', es: 'Se otorga a' }),
fontFamily: 'Rosarivo', italic: true, fontSize: pt(13), color: col('muted'),
placement: centred(90) },
{ kind: 'text', id: 'name', content: '{titleText}', fontFamily: 'Pinyon Script',
fontSize: pt(48), lineHeight: 1.25, // a multiple (gotcha: design-lineheight-multiple)
color: col('ink'), align: 'center', overflow: 'wrap', // a long name wraps, never ends in '…'
placement: { ...centred(97), size: { width: mm(W - 2 * MARGIN) } } },
{ kind: 'rule', id: 'underline', direction: 'horizontal', thickness: pt(0.75),
color: col('gold'), placement: { anchor: { to: '#name', edge: 'below' },
offset: { x: mm(18), y: mm(5) }, size: { width: mm(W - 2 * MARGIN - 36) } } },
];
Cada linha é ancorada na borda 'top' da página, o que a centra, com um y medido a partir do refile, de modo que o bloco não sai do lugar, sejam quais forem as margens; y() estica essas posições do formato Carta para o A4. O nome é o próprio texto do título, lido com {titleText}, e por isso aparece nos marcadores e nas tags do PDF. Com overflow: 'wrap', um nome longo passa para uma segunda linha; o valor padrão o cortaria com reticências. centred() desloca cada linha espacejada para a direita metade do seu tracking. Sem esse ajuste, cada uma fica essa mesma distância à esquerda do centro da página: 0,4 mm o sobretítulo, 0,5 mm Certificate e 0,7 mm of completion.
#4 · Pendurar o selo e as assinaturas da mesma borda
const SIGNED = t({ en: [['Harriet Colfax', 'Master printer'], ['Samuel Okoro', 'Course tutor']],
es: [['Pilar Ansón', 'Directora del taller'], ['Julián Oteo', 'Profesor del curso']] });
// mm: each signature line stops 3 mm short of the seal (56 on Letter, 53 on A4)
const LINE = (W - 2 * MARGIN - 2 * SEAL.r) / 2 - 3;
const under = (id, gap, x = 0) => ({ anchor: { to: `#${id}`, edge: 'below' },
offset: { x: mm(x), y: mm(gap) }, size: { width: mm(LINE) } });
const signatures = [
{ kind: 'image', id: 'seal', resourceId: 'seal',
placement: { ...at('top', 0, SIGN), size: { width: mm(SEAL.w) } } },
...SIGNED.flatMap(([who, role], i) => [
{ kind: 'rule', id: `line${i}`, direction: 'horizontal', thickness: pt(0.6), color: col('rule'),
placement: { ...at('top-left', i ? W - MARGIN - LINE : MARGIN, SIGN + 23),
size: { width: mm(LINE) } } },
{ kind: 'text', id: `who${i}`, content: who, fontFamily: 'Rosarivo', fontSize: pt(10.5),
color: col('ink'), align: 'center', placement: under(`line${i}`, 1.6) },
{ kind: 'text', id: `role${i}`, content: role, ...caps, fontSize: pt(7.5),
letterSpacing: pt(1.4), color: col('muted'), align: 'center',
placement: under(`who${i}`, 0.6, space(1.4) / 2) },
]),
{ kind: 'text', id: 'imprint', fontFamily: 'Rosarivo', italic: true, fontSize: pt(7),
content: t({ en: 'Printed at the Quoin Room · set in Rosarivo, Pinyon Script and Aboreto',
es: 'Impreso en La Cuña · compuesto en Rosarivo, Pinyon Script y Aboreto' }),
color: col('muted'), placement: at('top', 0, SIGN + 50) },
];
Tudo o que vai no rodapé se mede a partir de SIGN, a borda superior do selo, e a margem inferior do estilo fecha a área de texto nessa mesma borda, de modo que um texto comprido demais para a página passa para a seguinte em vez de correr por baixo do selo. Se fossem para a abertura, as linhas e os nomes contariam na altura dela (span e design avançado); como descem abaixo da área de texto, a 1.4.1 descartaria a reserva inteira e o texto começaria 7,6 mm abaixo da margem superior, por cima do título. LINE pega a largura entre as margens da página, subtrai o disco de 34 mm do selo, divide o resto ao meio e tira 3 mm, então cada linha para a 3 mm do selo nos dois formatos (56 mm de comprimento no Carta, 53 mm no A4). Cada nome e cada função é um elemento de texto da largura da sua linha, e assim fica centrado sob ela.
#5 · Transformar a lista da turma em títulos
// Name and serial: {titleText} and {attr.serial} in the designs (gotcha: attr-values).
const CLASS = t({
en: [['Imogen Achterberg', '26-031'], ['Tomás Okafor', '26-032'], ['Ruth Adair', '26-033']],
es: [['Lucía Beltrán Ochoa', '26-017'], ['Íñigo Sarasola', '26-018'], ['Ana Rius', '26-019']],
});
const merged = CLASS.map(([name, serial]) =>
`# ${name} {style="certificate" serial="${serial}"}\n\n${markdown}`).join('\n\n');
const metadata = { // the PDF's title and author
title: t({ en: 'Certificates of completion, fall 2026',
es: 'Diplomas del curso de otoño de 2026' }),
author: t({ en: 'The Quoin Room', es: 'La Cuña' }) };
Cada linha da lista vira um título # que nomeia o estilo e leva o número de série como atributo (atributos de título), seguido do texto de content.en.md, então três linhas dão três páginas. O breakBefore do nível 1, com paridade 'any', começa cada título numa página nova, sem página em branco entre os certificados; o estilo o herda.
#6 · Gerar um único PDF para a turma toda
// The PDF also asks for bold and bold italic Rosarivo, which the family does not ship: the
// kit's provider snaps each request to the nearest face (gotcha: pdf-provider-all-styles).
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
`${RECIPE}.pdf`); // bookmarks: one per heading, so one per student
document.querySelector('[data-postext-pdf]').textContent = t({ en: 'Class list → one PDF',
es: 'Toda la clase en un PDF' });
A turma é um único documento, então renderToPdf grava um só arquivo de três páginas com um marcador por título, cada um com o nome de um aluno. renderToPdf também aceita uma lista de documentos, como os capítulos de um livro, mas um documento por aluno exigiria uma chamada a buildDocument para cada um. Com títulos, a turma inteira é composta de uma vez, e o visualizador mostra os três certificados dessa mesma composição. Além das quatro variantes que as páginas imprimem, o PDF pede o negrito e o negrito itálico da Rosarivo, que a Fontsource não oferece: o provedor de fontes do kit ajusta cada pedido à variante mais próxima da família (por que um provedor de fontes?).
A receita completa
// ═══ Postext Cookbook · Nº 063 · Certificate with a guilloche border ═══════════════ // https://postext.dev/en/cookbook/certificate-single-page // Code: MIT · Text: original (CC BY 4.0) · Guilloche and seal: generated in code (CC BY 4.0) // Fonts: Rosarivo, Pinyon Script, Aboreto (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // End-of-course certificates, one page per student, drawn by one heading style, in one PDF. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } 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 = 'certificate-single-page'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#1f2a2e', // text and the student's name teal: '#1f5f5b', // the frame, the title lines gold: '#b08d57', // hairlines and the rule under the name; never text (2.9:1 on paper) seal: '#8d2c2c', // the seal and the serial number rule: '#8f8878', // signature lines muted: '#6d6a60', // the lead-in, the signatories' roles, the imprint paper: '#fbf8ef', // the sheet }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries(palette) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [W, H] = t({ en: [215.9, 279.4], es: [210, 297] }); // US Letter; A4 for the Spanish diploma const MARGIN = 32; // mm on every side, inside the frame's inner rule (26 mm in) const SIDE = 50; // mm: the certificate's side margins, a 116 mm measure (110 on A4) // Positions are drawn on US Letter and stretched to A4: y(90) is 90 mm down on Letter. const y = (mm) => (mm * H) / 279.4; const SIGN = y(194); // mm from the top: the seal's top edge, where the text area ends const SEAL = { w: 40, h: 45, r: 17 }; // mm: the seal with its ribbon tails, and its disc's radius const at = (edge, x, top) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(top) } }); // A tracked line is measured with a letter space after its last letter, so a centred one // sits half a space left and a right-set one stops a space short: space() gives it in mm. const space = (tracking) => (tracking * 25.4) / 72; const caps = { fontFamily: 'Aboreto', textTransform: 'uppercase', color: col('teal') }; // #region answer: one heading style draws the whole certificate const certificate = () => ({ // a function: it uses the elements defined below id: 'certificate', // Section geometry, for this style's pages only: a narrower measure, and a text area that // ends at the seal, so a citation too long for the page moves on instead of running under it. margins: { left: mm(SIDE), right: mm(SIDE), bottom: mm(H - SIGN) }, // Header and footer elements paint over the page and reserve nothing (gotcha: // header-paints-over-text), so the frame, the seal and the signatures go there. In the // opener the signature lines would count towards its height, and a design that reaches // below the text area loses the whole reservation (gotcha: opener-reserves-anchored). header: { elements: [frame, serial] }, footer: { elements: signatures }, // The citation starts 18 lines of the 17 pt grid under the top margin, 140 mm down (19 // lines, 146 mm, on A4): an opener's height rounds up to whole lines, and without // marginBottom: 0 the level's 0.5 em under a heading would add a line. minHeight is a // floor: the title block alone would start the citation at 128 mm (134 mm on A4). advancedDesign: { enabled: true, minHeight: mm(y(106)), slot: { elements: title } }, marginBottom: mm(0), }); // Hook-up: headingStyles: [certificate()], and in the Markdown one heading per student: // # Imogen Achterberg {style="certificate" serial="26-031"} // #endregion // #region frame: the guilloche is an SVG resource, drawn by an image element in the header const frame = { kind: 'image', id: 'frame', resourceId: 'guilloche', placement: { anchor: { to: 'page', edge: 'top-left' }, // the trim box size: { width: 'fill', height: 'fill' } } }; // the SVG has the page's proportions const serial = { kind: 'text', id: 'serial', ...caps, fontSize: pt(8.5), letterSpacing: pt(1.6), content: t({ en: 'No. {attr.serial}', es: 'N.º {attr.serial}' }), // from the heading line color: col('seal'), align: 'right', placement: at('top-right', space(1.6) - MARGIN, 29) }; const PX = 10; // declared pixels per mm: an SVG resource only needs the right proportions const svg = (id, w, h, altText) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX } }); const resources = [ svg('guilloche', W, H, t({ en: 'A teal guilloche border with cream rosettes at the corners.', es: 'Una orla de guilloché verde azulado con rosetas color crema en las esquinas.' })), svg('seal', SEAL.w, SEAL.h, t({ en: 'A red seal with a serrated edge and two ribbon tails.', es: 'Un sello rojo de borde dentado con dos cintas.' })), ]; // #endregion // #region title: the opener holds the title block; the name is the heading's own text const centred = (top, tracking = 0) => at('top', space(tracking) / 2, y(top)); const title = [ { kind: 'text', id: 'kicker', ...caps, fontSize: pt(9.5), letterSpacing: pt(2.4), content: t({ en: 'The Quoin Room · Letterpress workshop', es: 'La Cuña · Taller de tipografía' }), placement: centred(39, 2.4) }, { kind: 'text', id: 'title', content: t({ en: 'Certificate', es: 'Diploma' }), ...caps, fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3), placement: centred(48, 3) }, { kind: 'text', id: 'of', content: t({ en: 'of completion', es: 'de aprovechamiento' }), ...caps, fontSize: pt(11), letterSpacing: pt(4), placement: centred(68, 4) }, { kind: 'text', id: 'lead', content: t({ en: 'This certifies that', es: 'Se otorga a' }), fontFamily: 'Rosarivo', italic: true, fontSize: pt(13), color: col('muted'), placement: centred(90) }, { kind: 'text', id: 'name', content: '{titleText}', fontFamily: 'Pinyon Script', fontSize: pt(48), lineHeight: 1.25, // a multiple (gotcha: design-lineheight-multiple) color: col('ink'), align: 'center', overflow: 'wrap', // a long name wraps, never ends in '…' placement: { ...centred(97), size: { width: mm(W - 2 * MARGIN) } } }, { kind: 'rule', id: 'underline', direction: 'horizontal', thickness: pt(0.75), color: col('gold'), placement: { anchor: { to: '#name', edge: 'below' }, offset: { x: mm(18), y: mm(5) }, size: { width: mm(W - 2 * MARGIN - 36) } } }, ]; // #endregion // #region signatures: seal, signature lines, names and roles, hung from the seal's top edge const SIGNED = t({ en: [['Harriet Colfax', 'Master printer'], ['Samuel Okoro', 'Course tutor']], es: [['Pilar Ansón', 'Directora del taller'], ['Julián Oteo', 'Profesor del curso']] }); // mm: each signature line stops 3 mm short of the seal (56 on Letter, 53 on A4) const LINE = (W - 2 * MARGIN - 2 * SEAL.r) / 2 - 3; const under = (id, gap, x = 0) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { x: mm(x), y: mm(gap) }, size: { width: mm(LINE) } }); const signatures = [ { kind: 'image', id: 'seal', resourceId: 'seal', placement: { ...at('top', 0, SIGN), size: { width: mm(SEAL.w) } } }, ...SIGNED.flatMap(([who, role], i) => [ { kind: 'rule', id: `line${i}`, direction: 'horizontal', thickness: pt(0.6), color: col('rule'), placement: { ...at('top-left', i ? W - MARGIN - LINE : MARGIN, SIGN + 23), size: { width: mm(LINE) } } }, { kind: 'text', id: `who${i}`, content: who, fontFamily: 'Rosarivo', fontSize: pt(10.5), color: col('ink'), align: 'center', placement: under(`line${i}`, 1.6) }, { kind: 'text', id: `role${i}`, content: role, ...caps, fontSize: pt(7.5), letterSpacing: pt(1.4), color: col('muted'), align: 'center', placement: under(`who${i}`, 0.6, space(1.4) / 2) }, ]), { kind: 'text', id: 'imprint', fontFamily: 'Rosarivo', italic: true, fontSize: pt(7), content: t({ en: 'Printed at the Quoin Room · set in Rosarivo, Pinyon Script and Aboreto', es: 'Impreso en La Cuña · compuesto en Rosarivo, Pinyon Script y Aboreto' }), color: col('muted'), placement: at('top', 0, SIGN + 50) }, ]; // #endregion const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity) locale: t({ en: 'en-us', es: 'es' }), // the PDF's /Lang; centred text is never hyphenated colorPalette, page: { width: mm(W), height: mm(H), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MARGIN), bottom: mm(MARGIN), left: mm(MARGIN), right: mm(MARGIN) } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: 'Rosarivo', fontSize: pt(11.5), lineHeight: pt(17), color: col('ink'), // Ink for the italic (the course title, the date line) and for any bold you add to the // wording; referenceColor falls back to boldColor. boldColor: col('ink'), italicColor: col('ink'), // Centred lines get no runt check (gotcha: ragged-runts): the wording was fitted by hand. textAlign: 'center', firstLineIndent: pt(0), paragraphSpacing: true, }, // The design replaces the heading's own text, which the PDF still tags and bookmarks: set it // in a face the pages load, regular, since Rosarivo has no bold. headings: { fontFamily: 'Rosarivo', fontWeight: 400, // One page per student, no blank backs; the style inherits it (gotcha: headings-drop-h1-break). levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }] }, headingStyles: [certificate()], header: { elements: [] }, // no page-wide furniture: the certificate style sets its own footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`has completed *Hand Composition and Platen Printing*, a course of forty-eight hours in the fall term of 2026, and as a final piece has set by hand and printed a broadside of their own design in an edition of forty copies.Amostra em Markdown · 2 linhas · content.en.md
*Given at Providence, Rhode Island, on December 4, 2026.*`; // content.<lang>.md: the wording every certificate shares // #region merge: the class list becomes one styled heading per student // Name and serial: {titleText} and {attr.serial} in the designs (gotcha: attr-values). const CLASS = t({ en: [['Imogen Achterberg', '26-031'], ['Tomás Okafor', '26-032'], ['Ruth Adair', '26-033']], es: [['Lucía Beltrán Ochoa', '26-017'], ['Íñigo Sarasola', '26-018'], ['Ana Rius', '26-019']], }); const merged = CLASS.map(([name, serial]) => `# ${name} {style="certificate" serial="${serial}"}\n\n${markdown}`).join('\n\n'); const metadata = { // the PDF's title and author title: t({ en: 'Certificates of completion, fall 2026', es: 'Diplomas del curso de otoño de 2026' }), author: t({ en: 'The Quoin Room', es: 'La Cuña' }) }; // #endregion // #region art: the guilloche frame and the seal, drawn as SVG paths // Strokes and fills only: no <marker>, filter or mask, so the PDF keeps both drawings vector // (gotcha: svg-no-marker-filters), and no text, which could not see the web fonts (gotcha: // svg-no-webfonts). No random numbers, so every build draws the same curves. const TAU = 2 * Math.PI; const r2 = (v) => Math.round(v * 100) / 100; // A polyline in relative moves, about a tenth smaller than in absolute coordinates. const polyline = (pts, close = false) => `M${r2(pts[0][0])} ${r2(pts[0][1])}l${pts.slice(1) .map(([x, y], i) => `${r2(x - pts[i][0])} ${r2(y - pts[i][1])}`).join(' ')}${close ? 'z' : ''}`; const gcd = (a, b) => (b ? gcd(b, a % b) : a); // A hypotrochoid: the path of a pen at distance d from the centre of a circle of radius r // rolling inside one of radius R, scaled to `radius` mm. It closes after r / gcd(R, r) turns. function spiro(R, r, d, radius, steps = 1400) { const k = radius / (R - r + d); return polyline(Array.from({ length: steps + 1 }, (_, i) => { const a = (i / steps) * TAU * (r / gcd(R, r)); return [((R - r) * Math.cos(a) + d * Math.cos(((R - r) / r) * a)) * k, ((R - r) * Math.sin(a) - d * Math.sin(((R - r) / r) * a)) * k]; }), true); } // One lens of the border, `len` mm long and up to `half` mm either side of its axis: strands // that cross at a steady rate, under an envelope that pinches them together at both ends. const lens = (len, half, count, color) => Array.from({ length: count }, (_, k) => `<path stroke="${color}" d="${polyline(Array.from({ length: 97 }, (_, i) => { const s = (i / 96) * len; const envelope = 0.56 - 0.44 * Math.cos((TAU * s) / len); return [s, half * envelope * Math.sin((2 * TAU * s) / len + (TAU * k) / count)]; }))}"/>`).join(''); const BAND = [11, 23]; // the teal band, mm in from the trim; the lenses run along its middle const MID = (BAND[0] + BAND[1]) / 2; function guillocheSvg() { // Each side holds a whole number of lenses about 24 mm long: one tile per side length. const tiles = [['h', W - 2 * MID], ['v', H - 2 * MID]].map(([id, run]) => { const n = Math.round(run / 24); return { id, n, len: run / n, def: `<g id="${id}" stroke-width="0.2">` + `${lens(run / n, 5.4, 11, palette.paper)}${lens(run / n, 2.6, 3, palette.gold)}</g>` }; }); const use = (id, x, y, turn) => `<use href="#${id}" transform="translate(${r2(x)} ${r2(y)})` + ` rotate(${turn})"/>`; const uses = tiles.flatMap(({ id, n, len }) => Array.from({ length: n }, (_, i) => MID + i * len) .flatMap((p) => (id === 'h' ? [use(id, p, MID, 0), use(id, p, H - MID, 0)] : [use(id, MID, p, 90), use(id, W - MID, p, 90)]))); const ring = (inset, color, width) => `<rect x="${inset}" y="${inset}"` + ` width="${r2(W - 2 * inset)}" height="${r2(H - 2 * inset)}" stroke="${color}"` + ` stroke-width="${width}"/>`; const box = (inset) => `M${inset} ${inset}h${r2(W - 2 * inset)}v${r2(H - 2 * inset)}` + `h${r2(2 * inset - W)}z`; // A cream medallion on the teal band, so each corner reads as a disc at thumbnail size. const rosette = `<g id="rosette"><circle r="10" fill="${palette.paper}" stroke="${palette.gold}"` + ` stroke-width="0.6"/><circle r="9.1" stroke="${palette.teal}" stroke-width="0.25"/>` + `<path d="${spiro(30, 13, 9, 8.2)}" stroke="${palette.teal}" stroke-width="0.16"/>` + `<circle r="1.4" fill="${palette.gold}"/></g>`; const corners = [[MID, MID], [W - MID, MID], [MID, H - MID], [W - MID, H - MID]] .map(([x, y]) => use('rosette', x, y, 0)); return `<svg xmlns="http://www.w3.org/2000/svg" width="${r2(W * PX)}" height="${r2(H * PX)}"` + ` viewBox="0 0 ${W} ${H}"><defs>${tiles.map((tile) => tile.def).join('')}${rosette}</defs>` + `<g fill="none" stroke-linecap="round" stroke-linejoin="round">${ring(9, palette.gold, 0.35)}` + `<path fill="${palette.teal}" fill-rule="evenodd" d="${box(BAND[0])}${box(BAND[1])}"/>` + `${uses.join('')}${ring(25, palette.gold, 0.6)}${ring(26.2, palette.teal, 0.2)}` + `${corners.join('')}</g></svg>`; } function sealSvg() { const [cx, cy, R] = [SEAL.w / 2, 19, SEAL.r]; const teeth = Array.from({ length: 144 }, (_, i) => [ cx + (i % 2 ? R : R - 1.3) * Math.cos((i / 144) * TAU), cy + (i % 2 ? R : R - 1.3) * Math.sin((i / 144) * TAU)]); // Two ribbon tails behind the seal, notched at the ends; the far one a shade darker. const tail = (s) => polyline([[cx + s * 3, cy], [cx + s * 12, cy + 24], [cx + s * 9, cy + 21.6], [cx + s * 6, cy + 25.5], [cx - s * 3, cy + 3]], true); return `<svg xmlns="http://www.w3.org/2000/svg" width="${SEAL.w * PX}" height="${SEAL.h * PX}"` + ` viewBox="0 0 ${SEAL.w} ${SEAL.h}"><path d="${tail(-1)}" fill="#6f2323"/>` + `<path d="${tail(1)}" fill="${palette.seal}"/>` + `<path d="${polyline(teeth, true)}" fill="${palette.seal}"/>` + `<g fill="none" stroke="${palette.paper}" stroke-linecap="round">` + `<circle cx="${cx}" cy="${cy}" r="${R - 3}" stroke-width="0.35"/>` + `<circle cx="${cx}" cy="${cy}" r="${R - 3.8}" stroke-width="0.15"/>` + `<path transform="translate(${cx} ${cy})" d="${spiro(24, 11, 9, R - 5)}"` + ' stroke-width="0.14"/>' + `</g><circle cx="${cx}" cy="${cy}" r="1.3" fill="${palette.paper}"/></svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Loaded from Fontsource before the first build: layout measures with them (gotcha: fonts-first) const FONTS = { Rosarivo: ['400', '400i'], 'Pinyon Script': ['400'], Aboreto: ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, merged); await loadSvg('guilloche.svg', guillocheSvg()); await loadSvg('seal.svg', sealSvg()); const doc = await buildWithFonts(() => buildDocument({ markdown: merged, metadata, resources }, config()), merged); showPages(doc, { title: t({ en: 'Certificate with a guilloche border', es: 'Diploma con orla de guilloché' }) }); // #region pdf: one document with a page per student, so one PDF holds the whole class // The PDF also asks for bold and bold italic Rosarivo, which the family does not ship: the // kit's provider snaps each request to the nearest face (gotcha: pdf-provider-all-styles). offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // bookmarks: one per heading, so one per student document.querySelector('[data-postext-pdf]').textContent = t({ en: 'Class list → one PDF', es: 'Toda la clase en un PDF' }); // #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
#Gerar um PDF por aluno
Um botão por aluno compõe aquele certificado como documento à parte, com um config() novo, e dá ao arquivo o nome do número de série; cada arquivo pesa cerca de 245 KB.
-offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
- `${RECIPE}.pdf`); // bookmarks: one per heading, so one per student
-document.querySelector('[data-postext-pdf]').textContent = t({ en: 'Class list → one PDF',
- es: 'Toda la clase en un PDF' });
+for (const [name, serial] of CLASS) {
+ offerPdf(() => renderToPdf(buildDocument({ markdown:
+ `# ${name} {style="certificate" serial="${serial}"}\n\n${markdown}`, metadata, resources },
+ config()), { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${serial}.pdf`);
+ document.querySelector(`[data-postext-pdf="${serial}.pdf"]`).textContent = name;
+}#Imprimir a moldura e o título em azul-marinho
O desenho SVG e a configuração leem o mesmo objeto palette, então trocar uma única entrada muda a cor da faixa, do desenho das rosetas e das linhas do título.
- teal: '#1f5f5b', // the frame, the title lines
+ teal: '#2b3a67', // the frame, the title linesErros comuns
Erro comum
Uma abertura reserva altura até o seu elemento ancorado mais baixo
Uma abertura com design avançado reserva a altura do seu elemento mais baixo, e os elementos ancorados à página ou à sangria abaixo do título também contam, então um ornamento no pé da página empurra o texto para a página seguinte. Mantenha esses ornamentos acima do título, passe-os para uma posição do cabeçalho ou do rodapé, ou defina a reserva com minHeight. Aberturas desenhadas →
Erro comum
Os elementos de cabeçalho e rodapé são pintados sobre o texto
Os elementos de cabeçalho e rodapé são pintados por cima da página, e a área de texto não abre espaço para eles. Mantenha esses elementos dentro das margens, que são o que reserva o espaço deles. Cabeços e fólios →
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
Valores de atributo: sem { ou }; aspas simples se tiver "
Um valor de atributo termina na chave de fechamento, por isso não pode conter { nem }. Um valor que contém aspas duplas vai entre aspas simples; o cifrão não causa problema. Atributos de título →
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
Texto em bandeira nunca é verificado contra linhas curtas
optimalLineBreaking, avoidRunts, runtPenalty e runtMinCharacters agem sobre o algoritmo de quebra de linha de Knuth–Plass, que o postext 1.4.1 só aplica a texto justificado. Um parágrafo em bandeira é quebrado linha a linha e pode terminar numa única palavra curta, digam o que disserem essas configurações. Revise as últimas linhas do texto em bandeira e reescreva o parágrafo que terminar numa linha curta. Viúvas, órfãs e linhas curtas →
Erro comum
O texto dentro de um SVG <img> não pode usar fontes web
Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda. Figuras e tabelas como recursos →
Erro comum
Sem <marker> nem filtros na arte SVG (vira bitmap)
Uma figura SVG só continua vetorial no PDF sem <marker>, filtros e máscaras; caso contrário, é rasterizada, e filtros muito aninhados podem deixá-la em branco no Chrome. Desenhe as pontas de seta como caminhos (paths). Figuras e tabelas como recursos →
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
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 →
- Na 1.4.1, um texto de design centrado com
letterSpacingfica meio espaço de letra à esquerda da sua âncora, e um alinhado à direita termina um espaço inteiro antes, porque a sua largura inclui o tracking depois da última letra.space()devolve as linhas espacejadas ao lugar. - O postext-pdf 1.4.1 grava um SVG no fluxo de conteúdo de cada página que o desenha, sem uma cópia compartilhada: a moldura acrescenta cerca de 210 KB a cada página, então o PDF de três páginas pesa 694 KB (58 KB sem a moldura) e o de uma turma de trinta pesaria cerca de 6,6 MB.
Créditos
- Receita
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Fontes
- Rosarivo (SIL OFL 1.1) · Pinyon Script (SIL OFL 1.1) · Aboreto (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)


