In breve
Attestati di un corso per un laboratorio di stampa, una pagina per allievo, con un bordo decorato e un sigillo. Scrivere una volta il nome di un allievo dà un attestato completo.
Che cosa comporrai
Attestati di fine corso per il Quoin Room, un laboratorio di stampa a caratteri mobili immaginario a Providence: una pagina US Letter per allievo. L’edizione spagnola li compone come diplomi A4 per La Cuña, un laboratorio di Saragozza. Una fascia verde petrolio piena di lenti guilloché color crema incornicia il foglio, con un rosone crema in ogni angolo; entrambi sono disegnati via codice a partire da sinusoidi e ipotrocoidi. Sotto un titolo in maiuscole spaziate, il nome dell’allievo è composto in Pinyon Script da 48 pt sopra un filetto dorato, e la motivazione è centrata in Rosarivo. Un sigillo rosso con due code di nastro sta tra le righe per le firme, con una riga di sigla editoriale sotto. Poiché un solo stile di titolo disegna tutta la pagina, ogni allievo è un titolo Markdown, e il PDF contiene la classe con un segnalibro per nome.
Questa ricetta risponde a
- Come aggiungo una filigrana, una tinta di fondo o un’immagine decorativa su ogni pagina?
- Come compongo illustrazioni non numerate: fregi, vignette, loghi?
- Come fisso un badge o un adesivo in una posizione fissa della pagina?
- Come esporto nel browser un vero PDF con i font incorporati?
La risposta breve
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"}
Ingredienti
- Funzionalità
- Testatine per sezioneImmagini nei progetti di paginaAperture progettateAncoraggio degli elementi del progettoTesto, filetti e riquadri nei progetti di paginaStili di titoloGeometria della sezioneAttributi dei titoliColore della cartaTavolozza di colori semanticaFigure e tabelle come risorseMetadati del documentoPagine su un canvasEsportazione in PDFFont incorporati nel PDFSegnalibri del PDF
- Tipo
- Rosarivo, Pinyon Script, Aboreto (SIL OFL 1.1)
- Risorse
- Nessuna: ogni immagine è disegnata nel codice
Preparazione
#1 · Lascia che un solo stile di titolo disegni la pagina
Lo stile è la risposta breve qui sopra. Uno stile di titolo governa le pagine della sua sezione (stili dei titoli): il suo header e il suo footer sostituiscono quelli del documento, che qui sono vuoti, i suoi margins stabiliscono l’area del testo e il suo advancedDesign disegna l’apertura. L’altezza di un’apertura si arrotonda per eccesso a righe intere della griglia del testo da 17 pt. Una minHeight di 106 mm diventa 18 righe, quindi la motivazione comincia a 140 mm dal bordo superiore; in A4, y() la porta a 112,7 mm, cioè 19 righe, e la motivazione comincia a 146 mm. Il solo blocco del titolo farebbe cominciare la motivazione a 128 mm (134 mm in A4). Senza marginBottom: 0, i 0,5 em predefiniti sotto un titolo porterebbero gli stessi 106 mm a 19 righe.
#2 · Aggancia la cornice alla pagina nell’intestazione
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 elemento immagine disegna una risorsa alle dimensioni della sua collocazione (elementi immagine): ancorato all’angolo superiore sinistro della pagina con entrambi i lati 'fill', il guilloché copre il riquadro del formato rifilato, e l’SVG, dichiarato con le proporzioni della pagina, lo riempie senza deformarsi. L’intestazione lo dipinge sopra page.backgroundColor e non riserva spazio, quindi sono i soli margini a tenere il testo ad almeno 32 mm dal rifilo, dentro il filetto interno a 26 mm. Una filigrana collocata nell’intestazione si stamperebbe sopra le parole, perché l’intestazione viene dipinta dopo il testo. In un’intestazione, {attr.serial} legge l’attributo del titolo alla cui sezione appartiene la pagina, così ogni attestato stampa il proprio numero.
#3 · Tieni nell’apertura solo il blocco del titolo
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) } } },
];
Ogni riga è ancorata al bordo 'top' della pagina, il che la centra, con una y misurata dal rifilo, così il blocco resta al suo posto qualunque siano i margini; y() allunga quelle posizioni da Letter ad A4. Il nome è il testo stesso del titolo, letto tramite {titleText}, quindi i segnalibri e i tag del PDF lo riportano. Un nome lungo va su una seconda riga, perché un progetto grafico di titolo manda a capo il suo testo (overflow: 'wrap'). Le righe spaziate non richiedono correzioni: la spaziatura dopo l’ultima lettera resta fuori dalla misura, quindi sono le lettere stesse a essere centrate.
#4 · Aggancia il sigillo e le firme a un solo bordo
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) },
];
Tutto ciò che sta nel piè di pagina è misurato da SIGN, il bordo superiore del sigillo, e il margine inferiore dello stile chiude l’area del testo sullo stesso bordo, così una motivazione troppo lunga per la pagina passa alla successiva invece di scorrere sotto il sigillo. Spostate nell’apertura, le righe e i nomi conterebbero per la sua altezza (span e progetto grafico avanzato); poiché arrivano sotto l’area del testo, l’apertura reclamerebbe l’intera pagina e manderebbe la motivazione alla successiva. LINE prende la larghezza tra i margini della pagina, sottrae il disco del sigillo da 34 mm, dimezza il resto e toglie 3 mm, così ogni riga si ferma 3 mm prima del sigillo in entrambi i formati (lunga 56 mm in Letter, 53 mm in A4). Ogni nome e ruolo è un elemento di testo largo quanto la sua riga, il che lo centra sotto di essa.
#5 · Unisci l’elenco della classe ai titoli
// 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' }) };
Ogni riga diventa un titolo # che nomina lo stile e porta il numero di serie come attributo (attributi dei titoli), seguito dal testo di content.en.md, quindi tre righe danno tre pagine. Il breakBefore del livello 1 con parità 'any' fa cominciare ogni titolo su una nuova pagina senza pagine bianche tra gli attestati; lo stile lo eredita.
#6 · Costruisci un solo PDF per tutta 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 è un unico documento, quindi renderToPdf scrive un solo file di tre pagine con un segnalibro per titolo, ciascuno intitolato a un allievo. renderToPdf accetta anche un array di documenti, come i capitoli di un libro, ma un documento per allievo significa una chiamata a buildDocument per ciascuno. Sotto forma di titoli, l’intera classe viene impaginata in una sola build, e il visualizzatore mostra i tre attestati di quella build. Il PDF incorpora i quattro caratteri che le pagine stampano, che il font provider del kit scarica da Fontsource (perché un font provider?).
La ricetta 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.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.Esempio Markdown · 2 righe · 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: uguale in ogni ricetta · 315 righe
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · 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 ───────────────────────────────────────────────────────────────────────
Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)
Varianti
#Stampa un PDF per allievo
Un pulsante per allievo costruisce quell’attestato come documento a sé, con buildDocumentWithFonts come build propria del pen, e dà al file il nome del numero di serie; ogni file pesa circa 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) {
+ 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;
+}#Stampa la cornice e il titolo in blu marino
La grafica SVG e la configurazione leggono lo stesso oggetto palette, quindi cambiando una voce si ricolorano la fascia, i motivi dei rosoni e le righe del titolo.
- teal: '#1f5f5b', // the frame, the title lines
+ teal: '#2b3a67', // the frame, the title linesErrori frequenti
Errore frequente
Un’apertura riserva altezza fino al suo elemento ancorato alla pagina più basso
Un’apertura con progetto avanzato riserva l’altezza del suo elemento più basso, e contano anche gli elementi ancorati alla pagina o all’abbondanza sotto il titolo, quindi una decorazione al piede della pagina spinge il testo alla pagina successiva. Dai a un elemento del genere reserve: false (da postext 1.5): viene dipinto dove è ancorato e non riserva nulla. Gli elementi che seguono la fascia stessa (ancorati al suo piede, o alti 'fill') non ne determinano mai l’altezza. Aperture progettate →
Errore frequente
Gli elementi di intestazione e piè di pagina dipingono sopra il testo
Gli elementi di intestazione e piè di pagina vengono dipinti sopra la pagina e l’area del testo non fa loro spazio. Tienili entro i margini, che sono ciò che ne riserva lo spazio. Testatine e folio →
Errore frequente
Valori degli attributi: niente { o }; virgolette semplici per un valore con "
Il valore di un attributo finisce alla graffa di chiusura, quindi non può contenere { o }. Un valore che contiene una virgoletta doppia va tra virgolette semplici; il simbolo del dollaro va bene. Attributi dei titoli →
Errore frequente
Niente <marker> né filtri nelle illustrazioni SVG (ripiego raster)
Una figura SVG resta vettoriale nel PDF solo senza <marker>, filtri e maschere; altrimenti ripiega su un raster, e filtri annidati in profondità possono farla sparire in Chrome. Disegna le punte delle frecce come tracciati. Figure e tabelle come risorse →
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- Testo originale, CC BY 4.0
- Font
- Rosarivo (SIL OFL 1.1) · Pinyon Script (SIL OFL 1.1) · Aboreto (SIL OFL 1.1)
- Codice
- MIT, come Postext
Modifica questo testo ↗ (si apre in una nuova scheda)Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)


