En bref
Des certificats de stage pour un atelier d’imprimerie, une page par élève, avec une bordure à motifs et un sceau. Écrire une seule fois le nom d’un élève donne un certificat entier.
Ce que vous allez composer
Des certificats de fin de stage pour le Quoin Room, un atelier de typographie au plomb fictif de Providence : une page US Letter par élève. L’édition espagnole les compose en diplômes A4 pour La Cuña, un atelier de Saragosse. Une bande bleu canard garnie de lentilles guillochées crème encadre la feuille, avec une rosace crème à chaque coin ; les deux sont dessinées en code à partir de sinusoïdes et d’hypotrochoïdes. Sous un titre en capitales interlettrées, le nom de l’élève est composé en Pinyon Script de 48 pt au-dessus d’un filet doré, et la mention est centrée en Rosarivo. Un sceau rouge à deux rubans se tient entre les lignes de signature, avec une ligne d’adresse d’imprimeur dessous. Comme un seul style de titre dessine toute la page, chaque élève est un titre Markdown, et le PDF contient la classe avec un signet par nom.
Cette recette répond à
- Comment ajouter un filigrane, une teinte de fond ou une image décorative sur chaque page ?
- Comment composer des illustrations non numérotées : ornements, vignettes, logos ?
- Comment fixer un badge ou une vignette à une position fixe sur la page ?
- Comment exporter un vrai PDF dans le navigateur avec les polices incorporées ?
La réponse courte
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 (gotcha:
// opener-reserves-anchored), and a design that reaches below the text area takes the page.
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"}
Ingrédients
- Fonctionnalités
- Titres courants par sectionImages dans les maquettes de pageOuvertures composéesAncrer les éléments de maquetteTexte, filets et boîtes dans les maquettes de pageStyles de titreGéométrie de sectionAttributs de titreCouleur du papierPalette de couleurs sémantiqueFigures et tableaux comme ressourcesMétadonnées du documentPages sur un canvasExport PDFPolices intégrées au PDFSignets PDF
- Typographie
- Rosarivo, Pinyon Script, Aboreto (SIL OFL 1.1)
- Ressources
- Aucune : chaque image est dessinée en code
Préparation
#1 · Laisser un style de titre dessiner la page
Le style lui-même est la réponse courte ci-dessus. Un style de titre gouverne les pages de sa section (styles de titre) : son header et son footer remplacent ceux du document, vides ici, ses margins fixent la zone de texte et son advancedDesign dessine l’ouverture. La hauteur d’une ouverture s’arrondit au nombre supérieur de lignes entières de la grille de 17 pt du texte courant. Un minHeight de 106 mm devient 18 lignes, si bien que la mention commence à 140 mm du bord supérieur ; en A4, y() le porte à 112,7 mm, soit 19 lignes, et la mention commence à 146 mm. Le bloc de titre seul ferait commencer la mention à 128 mm (134 mm en A4). Sans marginBottom: 0, les 0,5 em par défaut sous un titre porteraient les mêmes 106 mm à 19 lignes.
#2 · Accrocher le cadre à la page dans l’en-tête
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', -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.' })),
];
Un élément image dessine une ressource à la taille de son placement (éléments image) : ancré au coin supérieur gauche de la page avec ses deux côtés à 'fill', le guilloché couvre la zone de coupe, et le SVG, déclaré aux proportions de la page, la remplit sans se déformer. L’en-tête le peint par-dessus page.backgroundColor et ne réserve aucune place, si bien que les marges suffisent à tenir le texte à 32 mm au moins de la coupe, à l’intérieur du filet intérieur placé à 26 mm. Un filigrane placé dans l’en-tête s’imprimerait par-dessus les mots, car l’en-tête est peint après le texte. Dans un en-tête, {attr.serial} lit l’attribut du titre dont relève la section de la page, si bien que chaque certificat imprime son propre numéro.
#3 · Ne garder que le bloc de titre dans l’ouverture
const centred = (top) => at('top', 0, y(top)); // tracked or not, the letters are centred
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) },
{ kind: 'text', id: 'title', content: t({ en: 'Certificate', es: 'Diploma' }), ...caps,
fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3), placement: centred(48) },
{ kind: 'text', id: 'of', content: t({ en: 'of completion', es: 'de aprovechamiento' }),
...caps, fontSize: pt(11), letterSpacing: pt(4), placement: centred(68) },
{ 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, // 60 pt from line to line of a long name
color: col('ink'), align: 'center', overflow: 'wrap', // a long name takes a second line
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) } } },
];
Chaque ligne est ancrée au bord 'top' de la page, ce qui la centre, avec un y mesuré depuis la coupe, si bien que le bloc garde sa place quelles que soient les marges ; y() étire ces positions du format Letter vers l’A4. Le nom est le texte même du titre, lu par {titleText}, si bien que les signets et les balises du PDF le portent. Un nom long prend une deuxième ligne, puisqu’une maquette de titre renvoie son texte à la ligne (overflow: 'wrap'). Les lignes interlettrées n’ont besoin d’aucune correction : l’approche qui suit la dernière lettre n’entre pas dans la mesure, si bien que ce sont les lettres elles-mêmes qui sont centrées.
#4 · Accrocher le sceau et les signatures à un même bord
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) },
]),
{ 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) },
];
Tout ce que contient le pied de page se mesure depuis SIGN, le bord supérieur du sceau, et la marge inférieure du style termine la zone de texte sur ce même bord, si bien qu’une mention trop longue pour la page passe à la suivante au lieu de courir sous le sceau. Placés dans l’ouverture, les lignes et les noms compteraient dans sa hauteur (largeur et maquette avancée) ; comme ils descendent sous la zone de texte, l’ouverture réclamerait toute la page et renverrait la mention à la suivante. LINE prend la largeur entre les marges de la page, en retire le disque de 34 mm du sceau, divise le reste par deux et ôte 3 mm, si bien que chaque ligne s’arrête à 3 mm du sceau dans les deux formats (56 mm de long en Letter, 53 mm en A4). Chaque nom et chaque fonction est un élément texte aussi large que sa ligne, ce qui le centre sous la ligne.
#5 · Fusionner la liste de la classe en titres
// 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' }) };
Chaque rangée devient un titre # qui nomme le style et porte le numéro de série en attribut (attributs de titre), suivi du texte de content.en.md, si bien que trois rangées donnent trois pages. Le breakBefore du niveau 1, avec la parité 'any', fait commencer chaque titre sur une nouvelle page, sans page blanche entre les certificats ; le style en hérite.
#6 · Composer un seul PDF pour toute la classe
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' });
La classe forme un seul document, si bien que renderToPdf écrit un unique fichier de trois pages avec un signet par titre, chacun au nom d’un élève. renderToPdf accepte aussi un tableau de documents, comme les chapitres d’un livre, mais un document par élève signifie un appel à buildDocument pour chacun. Sous forme de titres, toute la classe est mise en page en une seule composition, et la visionneuse montre les trois certificats issus de cette composition. Le PDF incorpore les quatre polices qu’impriment les pages, que le fournisseur de polices du kit va chercher sur Fontsource (pourquoi un fournisseur de polices ?).
La recette complète
// ═══ 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.25.0 // End-of-course certificates, one page per student, drawn by one heading style, in one PDF. import { buildDocumentWithFonts, renderPageToCanvas, 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) } }); 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 (gotcha: // opener-reserves-anchored), and a design that reaches below the text area takes the page. 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', -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) => at('top', 0, y(top)); // tracked or not, the letters are centred 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) }, { kind: 'text', id: 'title', content: t({ en: 'Certificate', es: 'Diploma' }), ...caps, fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3), placement: centred(48) }, { kind: 'text', id: 'of', content: t({ en: 'of completion', es: 'de aprovechamiento' }), ...caps, fontSize: pt(11), letterSpacing: pt(4), placement: centred(68) }, { 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, // 60 pt from line to line of a long name color: col('ink'), align: 'center', overflow: 'wrap', // a long name takes a second line 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) }, ]), { 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 = () => ({ 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'), 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 certificate style inherits it. 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.Exemple Markdown · 2 lignes · 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. 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 const FONTS = { Rosarivo: ['400', '400i'], 'Pinyon Script': ['400'], Aboreto: ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadSvg('guilloche.svg', guillocheSvg()); await loadSvg('seal.svg', sealSvg()); const doc = await buildDocumentWithFonts({ markdown: merged, metadata, resources }, config(), kitFonts(FONTS)); 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 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 : identique dans toutes les recettes · 315 lignes
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)
Variantes
#Imprimer un PDF par élève
Un bouton par élève compose ce certificat comme un document à part, avec buildDocumentWithFonts comme composition propre du pen, et nomme le fichier d’après le numéro de série ; chaque fichier pèse environ 245 Ko.
-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) {
+ const one = `# ${name} {style="certificate" serial="${serial}"}\n\n${markdown}`;
+ offerPdf(async () => renderToPdf(await buildDocumentWithFonts({ markdown: one, metadata,
+ resources }, config(), kitFonts(FONTS)), { fontProvider: fontsourceProvider,
+ resourceBytes: imageBytes }), `${serial}.pdf`);
+ document.querySelector(`[data-postext-pdf="${serial}.pdf"]`).textContent = name;
+}#Imprimer le cadre et le titre en bleu marine
Les dessins SVG et la configuration lisent le même objet palette, si bien que changer une entrée recolore la bande, les motifs des rosaces et les lignes du titre.
- teal: '#1f5f5b', // the frame, the title lines
+ teal: '#2b3a67', // the frame, the title linesErreurs fréquentes
Erreur fréquente
Une ouverture réserve la hauteur jusqu’à son élément ancré à la page le plus bas
Une ouverture en maquette avancée réserve la hauteur de son élément le plus bas, et les éléments ancrés à la page ou au fond perdu sous le titre comptent aussi : un décor au pied de la page repousse donc le texte à la page suivante. Donnez à un tel élément reserve: false (depuis postext 1.5) : il est peint là où il est ancré et ne réserve rien. Les éléments qui suivent le bandeau lui-même (ancrés à son pied, ou de hauteur 'fill') ne fixent jamais sa hauteur. Ouvertures composées →
Erreur fréquente
Les éléments d’en-tête et de pied de page se peignent par-dessus le texte
Les éléments d’en-tête et de pied de page sont peints par-dessus la page et la zone de texte ne leur fait pas de place. Gardez-les dans les marges, qui sont ce qui leur réserve de l’espace. Titres courants et folios →
Erreur fréquente
Valeurs d’attribut : pas de { ni de } ; une valeur avec " entre apostrophes simples
Une valeur d’attribut s’arrête à l’accolade fermante : elle ne peut donc contenir ni { ni }. Une valeur qui contient un guillemet double se met entre apostrophes simples ; un signe dollar ne pose pas de problème. Attributs de titre →
Erreur fréquente
Pas de <marker> ni de filtres dans les dessins SVG (repli en matriciel)
Une figure SVG ne reste vectorielle dans le PDF que sans <marker>, filtres ni masques ; sinon elle se replie sur une image matricielle, et des filtres profondément imbriqués peuvent la rendre blanche dans Chrome. Dessinez les pointes de flèche comme des tracés. Figures et tableaux comme ressources →
Crédits
- Recette
- Ignacio Ferro
- Texte
- Texte original, CC BY 4.0
- Polices
- Rosarivo (SIL OFL 1.1) · Pinyon Script (SIL OFL 1.1) · Aboreto (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


