Saltar al contingut principal
Recepta número 63

Receptari · Capítol 3 · Títols i obertures

Diploma amb orla de guilloché

Diplomes de final de curs dibuixats per un estil de títol: l'orla de guilloché a la capçalera, el segell i les signatures al peu i el nom a l'obertura.

En aquesta pàgina
Sortida
Canvas · PDF
Nivell
Intermedi
Postext
Provada amb Postext 1.4.1
Requereix ≥ 1.4.1 · postext-pdf ≥ 1.4.1
Llicència
Actualitzada el 26 de set. del 2026
Codi MIT · Text CC BY 4.0
  • Mostra en castellà: encara no hi ha edició en català
  • Format 210 × 297 mm
  • 1 columna
  • Rosarivo 11,5/17
  • Aboreto
  • Pinyon Script
  • 3 pàgines
  • Nivell
  • Postext 1.4.1
  • Compost en 17 ms
  • 180 línies de codi

En poques paraules

Diplomes de final de curs d'un taller d'impremta, una pàgina per alumne, amb una orla decorada i un segell. N'hi ha prou d'escriure el nom de cada alumne per obtenir-ne el diploma complet.

Què compondràs

Diplomes de final de curs per a La Cuña, un taller de tipografia fictici de Saragossa: un A4 per alumne. L'edició anglesa els compon en mida carta per al Quoin Room, un taller de Providence. Una banda verd blavós plena de lents de guilloché de color crema emmarca el full, amb una roseta crema a cada cantonada; la banda i les rosetes es dibuixen en codi amb sinusoides i hipotrocoides. Sota un títol en versals espaiades, el nom de l'alumne va en Pinyon Script de 48 pt sobre un filet daurat, i el text del diploma, centrat, en Rosarivo. Entre les dues línies de signatura hi ha un segell vermell amb dues cintes i, a sota, un peu d'impremta d'una línia. Com que tota la pàgina la dibuixa un estil de títol, cada alumne és un títol de Markdown, i el PDF recull la classe completa amb un marcador per nom.

Aquesta recepta respon a

  • Com afegeixo una marca d'aigua, un fons de color o una imatge decorativa a totes les pàgines?
  • Com componc il·lustracions sense numerar: ornaments, vinyetes, logotips?
  • Com fixo una insígnia o un adhesiu en una posició concreta de la pàgina?
  • Com exporto un PDF de debò al navegador, amb les fonts incrustades?

La resposta curta

script.js · línies 41–60al codi complet
const certificate = () => ({ // a function: it uses the elements defined below
  id: 'certificate',
  // Section geometry, for this style's pages only: a narrower measure, and a text area that
  // ends at the seal, so a citation too long for the page moves on instead of running under it.
  margins: { left: mm(SIDE), right: mm(SIDE), bottom: mm(H - SIGN) },
  // Header and footer elements paint over the page and reserve nothing (gotcha:
  // header-paints-over-text), so the frame, the seal and the signatures go there. In the
  // opener the signature lines would count towards its height, and a design that reaches
  // below the text area loses the whole reservation (gotcha: opener-reserves-anchored).
  header: { elements: [frame, serial] },
  footer: { elements: signatures },
  // The citation starts 18 lines of the 17 pt grid under the top margin, 140 mm down (19
  // lines, 146 mm, on A4): an opener's height rounds up to whole lines, and without
  // marginBottom: 0 the level's 0.5 em under a heading would add a line. minHeight is a
  // floor: the title block alone would start the citation at 128 mm (134 mm on A4).
  advancedDesign: { enabled: true, minHeight: mm(y(106)), slot: { elements: title } },
  marginBottom: mm(0),
});
// Hook-up: headingStyles: [certificate()], and in the Markdown one heading per student:
// # Imogen Achterberg {style="certificate" serial="26-031"}

Ingredients

Tipografia
Rosarivo, Pinyon Script, Aboreto (SIL OFL 1.1)
Recursos
Cap: totes les imatges es dibuixen en codi

Elaboració

#1 · Deixa que un estil de títol dibuixi la pàgina

L'estil complet és a la resposta curta de més amunt. Un estil de títol («estil d'encapçalament» a la documentació) governa les pàgines de la seva secció: el seu header i el seu footer substitueixen els del document, que aquí són buits, els seus margins fixen la caixa de text i el seu advancedDesign dibuixa l'obertura. L'alçada d'una obertura s'arrodoneix cap amunt a línies senceres de la retícula de base de 17 pt. En A4, y(106) dona un minHeight de 112,7 mm, que es converteix en 19 línies, de manera que el text comença a 146 mm de la vora superior (a 140 mm a l'edició en mida carta); si només comptés el bloc del títol, començaria a 134 mm. Sense marginBottom: 0, el mig quadratí que es deixa per defecte sota un títol duria aquesta mateixa alçada a 20 línies.

#2 · Penja l'orla de la pàgina, a la capçalera

script.js · línies 64–78al codi complet
const frame = { kind: 'image', id: 'frame', resourceId: 'guilloche',
  placement: { anchor: { to: 'page', edge: 'top-left' }, // the trim box
    size: { width: 'fill', height: 'fill' } } }; // the SVG has the page's proportions
const serial = { kind: 'text', id: 'serial', ...caps, fontSize: pt(8.5), letterSpacing: pt(1.6),
  content: t({ en: 'No. {attr.serial}', es: 'N.º {attr.serial}' }), // from the heading line
  color: col('seal'), align: 'right', placement: at('top-right', space(1.6) - MARGIN, 29) };
const PX = 10; // declared pixels per mm: an SVG resource only needs the right proportions
const svg = (id, w, h, altText) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
  updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX } });
const resources = [
  svg('guilloche', W, H, t({ en: 'A teal guilloche border with cream rosettes at the corners.',
    es: 'Una orla de guilloché verde azulado con rosetas color crema en las esquinas.' })),
  svg('seal', SEAL.w, SEAL.h, t({ en: 'A red seal with a serrated edge and two ribbon tails.',
    es: 'Un sello rojo de borde dentado con dos cintas.' })),
];

Un element d'imatge dibuixa un recurs a la mida que li dona la seva col·locació (elements d'imatge): ancorat a la cantonada superior esquerra de la pàgina i amb els dos costats en 'fill', el guilloché cobreix la caixa de tall, i l'SVG, declarat amb les proporcions de la pàgina, l'omple sense deformar-se. La capçalera el pinta sobre page.backgroundColor a cada diploma sense treure lloc al text, de manera que són els marges els que mantenen el text a 32 mm del tall com a mínim, dins del filet interior, que és a 26 mm. Una marca d'aigua posada a la capçalera quedaria damunt de les paraules, perquè la capçalera es pinta després del text. En una capçalera, {attr.serial} llegeix l'atribut del títol a la secció del qual pertany la pàgina, i així cada diploma porta el seu número.

#3 · Deixa a l'obertura només el bloc del títol

script.js · línies 82–102al codi complet
const centred = (top, tracking = 0) => at('top', space(tracking) / 2, y(top));
const title = [
  { kind: 'text', id: 'kicker', ...caps, fontSize: pt(9.5), letterSpacing: pt(2.4),
    content: t({ en: 'The Quoin Room · Letterpress workshop',
      es: 'La Cuña · Taller de tipografía' }),
    placement: centred(39, 2.4) },
  { kind: 'text', id: 'title', content: t({ en: 'Certificate', es: 'Diploma' }), ...caps,
    fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3), placement: centred(48, 3) },
  { kind: 'text', id: 'of', content: t({ en: 'of completion', es: 'de aprovechamiento' }),
    ...caps, fontSize: pt(11), letterSpacing: pt(4), placement: centred(68, 4) },
  { kind: 'text', id: 'lead', content: t({ en: 'This certifies that', es: 'Se otorga a' }),
    fontFamily: 'Rosarivo', italic: true, fontSize: pt(13), color: col('muted'),
    placement: centred(90) },
  { kind: 'text', id: 'name', content: '{titleText}', fontFamily: 'Pinyon Script',
    fontSize: pt(48), lineHeight: 1.25, // a multiple (gotcha: design-lineheight-multiple)
    color: col('ink'), align: 'center', overflow: 'wrap', // a long name wraps, never ends in '…'
    placement: { ...centred(97), size: { width: mm(W - 2 * MARGIN) } } },
  { kind: 'rule', id: 'underline', direction: 'horizontal', thickness: pt(0.75),
    color: col('gold'), placement: { anchor: { to: '#name', edge: 'below' },
      offset: { x: mm(18), y: mm(5) }, size: { width: mm(W - 2 * MARGIN - 36) } } },
];

Cada línia s'ancora a la vora 'top' de la pàgina, que la centra, amb una y mesurada des del tall, de manera que el bloc no es mou encara que canviïn els marges; y() estira aquestes posicions de la mida carta a l'A4. El nom és el mateix text del títol, que entra amb {titleText}, així que apareix als marcadors i a les etiquetes del PDF. Amb overflow: 'wrap', un nom llarg passa a una segona línia; amb el valor per defecte es tallaria amb punts suspensius. centred() desplaça cada línia espaiada cap a la dreta la meitat del seu espaiat. Sense aquest ajust, cadascuna queda aquesta mateixa distància a l'esquerra del centre de la pàgina: 0,4 mm l'avanttítol, 0,5 mm Diploma i 0,7 mm d'aprofitament.

#4 · Penja el segell i les signatures d'una mateixa vora

script.js · línies 106–129al codi complet
const SIGNED = t({ en: [['Harriet Colfax', 'Master printer'], ['Samuel Okoro', 'Course tutor']],
  es: [['Pilar Ansón', 'Directora del taller'], ['Julián Oteo', 'Profesor del curso']] });
// mm: each signature line stops 3 mm short of the seal (56 on Letter, 53 on A4)
const LINE = (W - 2 * MARGIN - 2 * SEAL.r) / 2 - 3;
const under = (id, gap, x = 0) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { x: mm(x), y: mm(gap) }, size: { width: mm(LINE) } });
const signatures = [
  { kind: 'image', id: 'seal', resourceId: 'seal',
    placement: { ...at('top', 0, SIGN), size: { width: mm(SEAL.w) } } },
  ...SIGNED.flatMap(([who, role], i) => [
    { kind: 'rule', id: `line${i}`, direction: 'horizontal', thickness: pt(0.6), color: col('rule'),
      placement: { ...at('top-left', i ? W - MARGIN - LINE : MARGIN, SIGN + 23),
        size: { width: mm(LINE) } } },
    { kind: 'text', id: `who${i}`, content: who, fontFamily: 'Rosarivo', fontSize: pt(10.5),
      color: col('ink'), align: 'center', placement: under(`line${i}`, 1.6) },
    { kind: 'text', id: `role${i}`, content: role, ...caps, fontSize: pt(7.5),
      letterSpacing: pt(1.4), color: col('muted'), align: 'center',
      placement: under(`who${i}`, 0.6, space(1.4) / 2) },
  ]),
  { kind: 'text', id: 'imprint', fontFamily: 'Rosarivo', italic: true, fontSize: pt(7),
    content: t({ en: 'Printed at the Quoin Room · set in Rosarivo, Pinyon Script and Aboreto',
      es: 'Impreso en La Cuña · compuesto en Rosarivo, Pinyon Script y Aboreto' }),
    color: col('muted'), placement: at('top', 0, SIGN + 50) },
];

Tot el que va al peu es mesura des de SIGN, la vora superior del segell, i el marge inferior de l'estil tanca la caixa de text en aquesta mateixa vora, de manera que un text massa llarg per a la pàgina passa a la següent en lloc de ficar-se sota el segell. Si fossin a l'obertura, les línies i els noms comptarien per a la seva alçada (span i disseny avançat), i com que arriben per sota de la caixa de text, la 1.4.1 descartaria tota la reserva: el text començaria 7,6 mm per sota del marge superior, muntat sobre el títol. LINE pren l'amplada que queda entre els marges de la pàgina, li resta el disc del segell (34 mm), la reparteix entre les dues signatures i treu 3 mm a cada meitat, de manera que cada línia s'atura a 3 mm del segell en els dos formats (53 mm de llarg en A4 i 56 mm en mida carta). Cada nom i cada càrrec és un text tan ample com la seva línia, i així queda centrat a sota.

#5 · Converteix la llista de classe en títols

script.js · línies 163–173al codi complet
// Name and serial: {titleText} and {attr.serial} in the designs (gotcha: attr-values).
const CLASS = t({
  en: [['Imogen Achterberg', '26-031'], ['Tomás Okafor', '26-032'], ['Ruth Adair', '26-033']],
  es: [['Lucía Beltrán Ochoa', '26-017'], ['Íñigo Sarasola', '26-018'], ['Ana Rius', '26-019']],
});
const merged = CLASS.map(([name, serial]) =>
  `# ${name} {style="certificate" serial="${serial}"}\n\n${markdown}`).join('\n\n');
const metadata = { // the PDF's title and author
  title: t({ en: 'Certificates of completion, fall 2026',
    es: 'Diplomas del curso de otoño de 2026' }),
  author: t({ en: 'The Quoin Room', es: 'La Cuña' }) };

Cada fila es converteix en un títol # que anomena l'estil i porta el número de sèrie com a atribut (atributs d'encapçalament), seguit del text de content.es.md, de manera que tres files donen tres pàgines. El breakBefore del nivell 1, amb paritat 'any', comença cada títol en una pàgina nova sense pàgines en blanc entre diplomes; l'estil l'hereta.

#6 · Genera un sol PDF per a tota la classe

script.js · línies 271–276al codi complet
// The PDF also asks for bold and bold italic Rosarivo, which the family does not ship: the
// kit's provider snaps each request to the nearest face (gotcha: pdf-provider-all-styles).
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`); // bookmarks: one per heading, so one per student
document.querySelector('[data-postext-pdf]').textContent = t({ en: 'Class list → one PDF',
  es: 'Toda la clase en un PDF' });

La classe és un únic document, així que renderToPdf escriu un sol fitxer de tres pàgines, amb un marcador per títol que porta el nom de l'alumne. renderToPdf també accepta una llista de documents, com els capítols d'un llibre, però amb un document per alumne caldria cridar buildDocument per a cadascun. Amb títols, tota la classe es compon d'una vegada, i el visor mostra els tres diplomes d'aquesta mateixa composició. A més de les quatre variants que imprimeixen les pàgines, el PDF demana la negreta i la negreta cursiva de Rosarivo, que Fontsource no té: el proveïdor de fonts del kit ajusta cada petició a la variant més propera de la família (per què un proveïdor de fonts?).

La recepta completa

Sandbox
// ═══ Postext Cookbook · Nº 063 · Certificate with a guilloche border ═══════════════
// https://postext.dev/en/cookbook/certificate-single-page
// Code: MIT · Text: original (CC BY 4.0) · Guilloche and seal: generated in code (CC BY 4.0)
// Fonts: Rosarivo, Pinyon Script, Aboreto (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// End-of-course certificates, one page per student, drawn by one heading style, in one PDF.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'certificate-single-page';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1f2a2e', // text and the student's name
  teal: '#1f5f5b', // the frame, the title lines
  gold: '#b08d57', // hairlines and the rule under the name; never text (2.9:1 on paper)
  seal: '#8d2c2c', // the seal and the serial number
  rule: '#8f8878', // signature lines
  muted: '#6d6a60', // the lead-in, the signatories' roles, the imprint
  paper: '#fbf8ef', // the sheet
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries(palette)
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [W, H] = t({ en: [215.9, 279.4], es: [210, 297] }); // US Letter; A4 for the Spanish diploma
const MARGIN = 32; // mm on every side, inside the frame's inner rule (26 mm in)
const SIDE = 50; // mm: the certificate's side margins, a 116 mm measure (110 on A4)
// Positions are drawn on US Letter and stretched to A4: y(90) is 90 mm down on Letter.
const y = (mm) => (mm * H) / 279.4;
const SIGN = y(194); // mm from the top: the seal's top edge, where the text area ends
const SEAL = { w: 40, h: 45, r: 17 }; // mm: the seal with its ribbon tails, and its disc's radius
const at = (edge, x, top) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(top) } });
// A tracked line is measured with a letter space after its last letter, so a centred one
// sits half a space left and a right-set one stops a space short: space() gives it in mm.
const space = (tracking) => (tracking * 25.4) / 72;
const caps = { fontFamily: 'Aboreto', textTransform: 'uppercase', color: col('teal') };

// #region answer: one heading style draws the whole certificate
const certificate = () => ({ // a function: it uses the elements defined below
  id: 'certificate',
  // Section geometry, for this style's pages only: a narrower measure, and a text area that
  // ends at the seal, so a citation too long for the page moves on instead of running under it.
  margins: { left: mm(SIDE), right: mm(SIDE), bottom: mm(H - SIGN) },
  // Header and footer elements paint over the page and reserve nothing (gotcha:
  // header-paints-over-text), so the frame, the seal and the signatures go there. In the
  // opener the signature lines would count towards its height, and a design that reaches
  // below the text area loses the whole reservation (gotcha: opener-reserves-anchored).
  header: { elements: [frame, serial] },
  footer: { elements: signatures },
  // The citation starts 18 lines of the 17 pt grid under the top margin, 140 mm down (19
  // lines, 146 mm, on A4): an opener's height rounds up to whole lines, and without
  // marginBottom: 0 the level's 0.5 em under a heading would add a line. minHeight is a
  // floor: the title block alone would start the citation at 128 mm (134 mm on A4).
  advancedDesign: { enabled: true, minHeight: mm(y(106)), slot: { elements: title } },
  marginBottom: mm(0),
});
// Hook-up: headingStyles: [certificate()], and in the Markdown one heading per student:
// # Imogen Achterberg {style="certificate" serial="26-031"}
// #endregion

// #region frame: the guilloche is an SVG resource, drawn by an image element in the header
const frame = { kind: 'image', id: 'frame', resourceId: 'guilloche',
  placement: { anchor: { to: 'page', edge: 'top-left' }, // the trim box
    size: { width: 'fill', height: 'fill' } } }; // the SVG has the page's proportions
const serial = { kind: 'text', id: 'serial', ...caps, fontSize: pt(8.5), letterSpacing: pt(1.6),
  content: t({ en: 'No. {attr.serial}', es: 'N.º {attr.serial}' }), // from the heading line
  color: col('seal'), align: 'right', placement: at('top-right', space(1.6) - MARGIN, 29) };
const PX = 10; // declared pixels per mm: an SVG resource only needs the right proportions
const svg = (id, w, h, altText) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
  updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX } });
const resources = [
  svg('guilloche', W, H, t({ en: 'A teal guilloche border with cream rosettes at the corners.',
    es: 'Una orla de guilloché verde azulado con rosetas color crema en las esquinas.' })),
  svg('seal', SEAL.w, SEAL.h, t({ en: 'A red seal with a serrated edge and two ribbon tails.',
    es: 'Un sello rojo de borde dentado con dos cintas.' })),
];
// #endregion

// #region title: the opener holds the title block; the name is the heading's own text
const centred = (top, tracking = 0) => at('top', space(tracking) / 2, y(top));
const title = [
  { kind: 'text', id: 'kicker', ...caps, fontSize: pt(9.5), letterSpacing: pt(2.4),
    content: t({ en: 'The Quoin Room · Letterpress workshop',
      es: 'La Cuña · Taller de tipografía' }),
    placement: centred(39, 2.4) },
  { kind: 'text', id: 'title', content: t({ en: 'Certificate', es: 'Diploma' }), ...caps,
    fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3), placement: centred(48, 3) },
  { kind: 'text', id: 'of', content: t({ en: 'of completion', es: 'de aprovechamiento' }),
    ...caps, fontSize: pt(11), letterSpacing: pt(4), placement: centred(68, 4) },
  { kind: 'text', id: 'lead', content: t({ en: 'This certifies that', es: 'Se otorga a' }),
    fontFamily: 'Rosarivo', italic: true, fontSize: pt(13), color: col('muted'),
    placement: centred(90) },
  { kind: 'text', id: 'name', content: '{titleText}', fontFamily: 'Pinyon Script',
    fontSize: pt(48), lineHeight: 1.25, // a multiple (gotcha: design-lineheight-multiple)
    color: col('ink'), align: 'center', overflow: 'wrap', // a long name wraps, never ends in '…'
    placement: { ...centred(97), size: { width: mm(W - 2 * MARGIN) } } },
  { kind: 'rule', id: 'underline', direction: 'horizontal', thickness: pt(0.75),
    color: col('gold'), placement: { anchor: { to: '#name', edge: 'below' },
      offset: { x: mm(18), y: mm(5) }, size: { width: mm(W - 2 * MARGIN - 36) } } },
];
// #endregion

// #region signatures: seal, signature lines, names and roles, hung from the seal's top edge
const SIGNED = t({ en: [['Harriet Colfax', 'Master printer'], ['Samuel Okoro', 'Course tutor']],
  es: [['Pilar Ansón', 'Directora del taller'], ['Julián Oteo', 'Profesor del curso']] });
// mm: each signature line stops 3 mm short of the seal (56 on Letter, 53 on A4)
const LINE = (W - 2 * MARGIN - 2 * SEAL.r) / 2 - 3;
const under = (id, gap, x = 0) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { x: mm(x), y: mm(gap) }, size: { width: mm(LINE) } });
const signatures = [
  { kind: 'image', id: 'seal', resourceId: 'seal',
    placement: { ...at('top', 0, SIGN), size: { width: mm(SEAL.w) } } },
  ...SIGNED.flatMap(([who, role], i) => [
    { kind: 'rule', id: `line${i}`, direction: 'horizontal', thickness: pt(0.6), color: col('rule'),
      placement: { ...at('top-left', i ? W - MARGIN - LINE : MARGIN, SIGN + 23),
        size: { width: mm(LINE) } } },
    { kind: 'text', id: `who${i}`, content: who, fontFamily: 'Rosarivo', fontSize: pt(10.5),
      color: col('ink'), align: 'center', placement: under(`line${i}`, 1.6) },
    { kind: 'text', id: `role${i}`, content: role, ...caps, fontSize: pt(7.5),
      letterSpacing: pt(1.4), color: col('muted'), align: 'center',
      placement: under(`who${i}`, 0.6, space(1.4) / 2) },
  ]),
  { kind: 'text', id: 'imprint', fontFamily: 'Rosarivo', italic: true, fontSize: pt(7),
    content: t({ en: 'Printed at the Quoin Room · set in Rosarivo, Pinyon Script and Aboreto',
      es: 'Impreso en La Cuña · compuesto en Rosarivo, Pinyon Script y Aboreto' }),
    color: col('muted'), placement: at('top', 0, SIGN + 50) },
];
// #endregion

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // the PDF's /Lang; centred text is never hyphenated
  colorPalette,
  page: { width: mm(W), height: mm(H), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(MARGIN), bottom: mm(MARGIN), left: mm(MARGIN), right: mm(MARGIN) } },
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: 'Rosarivo', fontSize: pt(11.5), lineHeight: pt(17), color: col('ink'),
    // Ink for the italic (the course title, the date line) and for any bold you add to the
    // wording; referenceColor falls back to boldColor.
    boldColor: col('ink'), italicColor: col('ink'),
    // Centred lines get no runt check (gotcha: ragged-runts): the wording was fitted by hand.
    textAlign: 'center', firstLineIndent: pt(0), paragraphSpacing: true,
  },
  // The design replaces the heading's own text, which the PDF still tags and bookmarks: set it
  // in a face the pages load, regular, since Rosarivo has no bold.
  headings: { fontFamily: 'Rosarivo', fontWeight: 400,
    // One page per student, no blank backs; the style inherits it (gotcha: headings-drop-h1-break).
    levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }] },
  headingStyles: [certificate()],
  header: { elements: [] }, // no page-wide furniture: the certificate style sets its own
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`por haber completado las cuarenta y ocho horas lectivas del curso *Composición manual e impresión en minerva*, impartido en otoño de 2026, y haber compuesto a mano, como trabajo final, un cartel de diseño propio, impreso en una tirada de cuarenta ejemplares.
Mostra en Markdown · 2 línies · content.es.md *Expedido en Zaragoza, a 4 de diciembre de 2026.*
`; // content.<lang>.md: the wording every certificate shares // #region merge: the class list becomes one styled heading per student // Name and serial: {titleText} and {attr.serial} in the designs (gotcha: attr-values). const CLASS = t({ en: [['Imogen Achterberg', '26-031'], ['Tomás Okafor', '26-032'], ['Ruth Adair', '26-033']], es: [['Lucía Beltrán Ochoa', '26-017'], ['Íñigo Sarasola', '26-018'], ['Ana Rius', '26-019']], }); const merged = CLASS.map(([name, serial]) => `# ${name} {style="certificate" serial="${serial}"}\n\n${markdown}`).join('\n\n'); const metadata = { // the PDF's title and author title: t({ en: 'Certificates of completion, fall 2026', es: 'Diplomas del curso de otoño de 2026' }), author: t({ en: 'The Quoin Room', es: 'La Cuña' }) }; // #endregion // #region art: the guilloche frame and the seal, drawn as SVG paths // Strokes and fills only: no <marker>, filter or mask, so the PDF keeps both drawings vector // (gotcha: svg-no-marker-filters), and no text, which could not see the web fonts (gotcha: // svg-no-webfonts). No random numbers, so every build draws the same curves. const TAU = 2 * Math.PI; const r2 = (v) => Math.round(v * 100) / 100; // A polyline in relative moves, about a tenth smaller than in absolute coordinates. const polyline = (pts, close = false) => `M${r2(pts[0][0])} ${r2(pts[0][1])}l${pts.slice(1) .map(([x, y], i) => `${r2(x - pts[i][0])} ${r2(y - pts[i][1])}`).join(' ')}${close ? 'z' : ''}`; const gcd = (a, b) => (b ? gcd(b, a % b) : a); // A hypotrochoid: the path of a pen at distance d from the centre of a circle of radius r // rolling inside one of radius R, scaled to `radius` mm. It closes after r / gcd(R, r) turns. function spiro(R, r, d, radius, steps = 1400) { const k = radius / (R - r + d); return polyline(Array.from({ length: steps + 1 }, (_, i) => { const a = (i / steps) * TAU * (r / gcd(R, r)); return [((R - r) * Math.cos(a) + d * Math.cos(((R - r) / r) * a)) * k, ((R - r) * Math.sin(a) - d * Math.sin(((R - r) / r) * a)) * k]; }), true); } // One lens of the border, `len` mm long and up to `half` mm either side of its axis: strands // that cross at a steady rate, under an envelope that pinches them together at both ends. const lens = (len, half, count, color) => Array.from({ length: count }, (_, k) => `<path stroke="${color}" d="${polyline(Array.from({ length: 97 }, (_, i) => { const s = (i / 96) * len; const envelope = 0.56 - 0.44 * Math.cos((TAU * s) / len); return [s, half * envelope * Math.sin((2 * TAU * s) / len + (TAU * k) / count)]; }))}"/>`).join(''); const BAND = [11, 23]; // the teal band, mm in from the trim; the lenses run along its middle const MID = (BAND[0] + BAND[1]) / 2; function guillocheSvg() { // Each side holds a whole number of lenses about 24 mm long: one tile per side length. const tiles = [['h', W - 2 * MID], ['v', H - 2 * MID]].map(([id, run]) => { const n = Math.round(run / 24); return { id, n, len: run / n, def: `<g id="${id}" stroke-width="0.2">` + `${lens(run / n, 5.4, 11, palette.paper)}${lens(run / n, 2.6, 3, palette.gold)}</g>` }; }); const use = (id, x, y, turn) => `<use href="#${id}" transform="translate(${r2(x)} ${r2(y)})` + ` rotate(${turn})"/>`; const uses = tiles.flatMap(({ id, n, len }) => Array.from({ length: n }, (_, i) => MID + i * len) .flatMap((p) => (id === 'h' ? [use(id, p, MID, 0), use(id, p, H - MID, 0)] : [use(id, MID, p, 90), use(id, W - MID, p, 90)]))); const ring = (inset, color, width) => `<rect x="${inset}" y="${inset}"` + ` width="${r2(W - 2 * inset)}" height="${r2(H - 2 * inset)}" stroke="${color}"` + ` stroke-width="${width}"/>`; const box = (inset) => `M${inset} ${inset}h${r2(W - 2 * inset)}v${r2(H - 2 * inset)}` + `h${r2(2 * inset - W)}z`; // A cream medallion on the teal band, so each corner reads as a disc at thumbnail size. const rosette = `<g id="rosette"><circle r="10" fill="${palette.paper}" stroke="${palette.gold}"` + ` stroke-width="0.6"/><circle r="9.1" stroke="${palette.teal}" stroke-width="0.25"/>` + `<path d="${spiro(30, 13, 9, 8.2)}" stroke="${palette.teal}" stroke-width="0.16"/>` + `<circle r="1.4" fill="${palette.gold}"/></g>`; const corners = [[MID, MID], [W - MID, MID], [MID, H - MID], [W - MID, H - MID]] .map(([x, y]) => use('rosette', x, y, 0)); return `<svg xmlns="http://www.w3.org/2000/svg" width="${r2(W * PX)}" height="${r2(H * PX)}"` + ` viewBox="0 0 ${W} ${H}"><defs>${tiles.map((tile) => tile.def).join('')}${rosette}</defs>` + `<g fill="none" stroke-linecap="round" stroke-linejoin="round">${ring(9, palette.gold, 0.35)}` + `<path fill="${palette.teal}" fill-rule="evenodd" d="${box(BAND[0])}${box(BAND[1])}"/>` + `${uses.join('')}${ring(25, palette.gold, 0.6)}${ring(26.2, palette.teal, 0.2)}` + `${corners.join('')}</g></svg>`; } function sealSvg() { const [cx, cy, R] = [SEAL.w / 2, 19, SEAL.r]; const teeth = Array.from({ length: 144 }, (_, i) => [ cx + (i % 2 ? R : R - 1.3) * Math.cos((i / 144) * TAU), cy + (i % 2 ? R : R - 1.3) * Math.sin((i / 144) * TAU)]); // Two ribbon tails behind the seal, notched at the ends; the far one a shade darker. const tail = (s) => polyline([[cx + s * 3, cy], [cx + s * 12, cy + 24], [cx + s * 9, cy + 21.6], [cx + s * 6, cy + 25.5], [cx - s * 3, cy + 3]], true); return `<svg xmlns="http://www.w3.org/2000/svg" width="${SEAL.w * PX}" height="${SEAL.h * PX}"` + ` viewBox="0 0 ${SEAL.w} ${SEAL.h}"><path d="${tail(-1)}" fill="#6f2323"/>` + `<path d="${tail(1)}" fill="${palette.seal}"/>` + `<path d="${polyline(teeth, true)}" fill="${palette.seal}"/>` + `<g fill="none" stroke="${palette.paper}" stroke-linecap="round">` + `<circle cx="${cx}" cy="${cy}" r="${R - 3}" stroke-width="0.35"/>` + `<circle cx="${cx}" cy="${cy}" r="${R - 3.8}" stroke-width="0.15"/>` + `<path transform="translate(${cx} ${cy})" d="${spiro(24, 11, 9, R - 5)}"` + ' stroke-width="0.14"/>' + `</g><circle cx="${cx}" cy="${cy}" r="1.3" fill="${palette.paper}"/></svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Loaded from Fontsource before the first build: layout measures with them (gotcha: fonts-first) const FONTS = { Rosarivo: ['400', '400i'], 'Pinyon Script': ['400'], Aboreto: ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, merged); await loadSvg('guilloche.svg', guillocheSvg()); await loadSvg('seal.svg', sealSvg()); const doc = await buildWithFonts(() => buildDocument({ markdown: merged, metadata, resources }, config()), merged); showPages(doc, { title: t({ en: 'Certificate with a guilloche border', es: 'Diploma con orla de guilloché' }) }); // #region pdf: one document with a page per student, so one PDF holds the whole class // The PDF also asks for bold and bold italic Rosarivo, which the family does not ship: the // kit's provider snaps each request to the nearest face (gotcha: pdf-provider-all-styles). offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // bookmarks: one per heading, so one per student document.querySelector('[data-postext-pdf]').textContent = t({ en: 'Class list → one PDF', es: 'Toda la clase en un PDF' }); // #endregion
Kit · core, fonts, viewer, pdf, images: igual a totes les receptes · 310 línies// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true 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; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { 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 res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)

Variants

#Genera un PDF per alumne

Un botó per alumne compon el seu diploma com a document a part, amb un config() nou, i fa servir el número de sèrie com a nom del fitxer; cadascun pesa uns 238 KB.

-offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
-  `${RECIPE}.pdf`); // bookmarks: one per heading, so one per student
-document.querySelector('[data-postext-pdf]').textContent = t({ en: 'Class list → one PDF',
-  es: 'Toda la clase en un PDF' });
+for (const [name, serial] of CLASS) {
+  offerPdf(() => renderToPdf(buildDocument({ markdown:
+    `# ${name} {style="certificate" serial="${serial}"}\n\n${markdown}`, metadata, resources },
+    config()), { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${serial}.pdf`);
+  document.querySelector(`[data-postext-pdf="${serial}.pdf"]`).textContent = name;
+}

#Imprimeix l'orla i el títol en blau marí

El dibuix SVG i la configuració llegeixen el mateix objecte palette, de manera que, en canviar una sola entrada, canvien de color la banda, el traçat de les rosetes i les línies del títol.

-  teal: '#1f5f5b', // the frame, the title lines
+  teal: '#2b3a67', // the frame, the title lines

Errors freqüents

Error freqüent

Una obertura reserva alçada fins al seu element ancorat més baix

Una obertura de disseny avançat reserva l'alçada del seu element més baix, i també hi compten els ancorats a la pàgina o a la sang que queden per sota del títol, així que un ornament al peu de la pàgina empeny el text a la següent. Deixa aquests ornaments per sobre del títol, passa'ls a una ranura de capçalera o de peu, o fixa la reserva amb minHeight. Obertures dissenyades →

Error freqüent

Els elements de capçalera i peu es pinten damunt del text

Els elements de capçalera i peu es pinten sobre la pàgina i l'àrea de text no els deixa lloc. Mantén-los dins dels marges, que són els que els reserven l'espai. Capçaleres i folis →

Error freqüent

Qualsevol objecte headings desactiva el salt de pàgina de l'H1

Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →

Error freqüent

Valors d'atribut: sense { ni }; cometes simples si porten "

Un valor d'atribut acaba a la clau de tancament, així que no pot contenir { ni }. Un valor que porti cometes dobles va entre cometes simples; el signe de dòlar no dona problemes. Atributs de títol →

Error freqüent

El lineHeight d'un text de disseny és un múltiple, mai una mesura

En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol. Textos, filets i caixes als dissenys de pàgina →

Error freqüent

En el text en bandera no s'eviten les línies curtes

optimalLineBreaking, avoidRunts, runtPenalty i runtMinCharacters actuen sobre l'algorisme de Knuth–Plass, que postext 1.4.1 només aplica al text justificat. Un paràgraf en bandera es talla línia a línia i pot acabar en una sola paraula curta, diguin el que diguin aquestes opcions. Revisa les últimes línies del text en bandera i reescriu el paràgraf que acabi en una línia curta. Vídues, òrfenes i línies curtes →

Error freqüent

El text dins d'un SVG <img> no pot fer servir fonts web

Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu. Figures i taules com a recursos →

Error freqüent

Sense <marker> ni filtres als SVG, o passen a mapa de bits

Una figura SVG només continua sent vectorial al PDF sense <marker>, filtres ni màscares; si no, passa a mapa de bits, i els filtres molt imbricats la poden deixar en blanc a Chrome. Dibuixa les puntes de fletxa com a traçats. Figures i taules com a recursos →

Error freqüent

El PDF demana tots els gruixos i estils de cada família

renderToPdf demana al proveïdor de fonts la negreta, la cursiva i la negreta cursiva de cada família que un bloc podria fer servir, encara que no s'imprimeixi mai, i un sol rebuig atura l'exportació. El proveïdor s'ha d'ajustar al gruix més proper que tingui la família i tornar a la rodona quan no hi hagi cursiva. Fonts incrustades al PDF →

Error freqüent

Una configuració es desa a la memòria cau per identitat: crea un objecte nou

El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config(). Pàgines en un canvas →

Error freqüent

Carrega totes les fonts abans de compondre

La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →

  • A la 1.4.1, un text de disseny centrat amb letterSpacing queda mig espai a l'esquerra de la seva àncora, i un d'alineat a la dreta acaba un espai abans de la seva àncora, perquè la seva amplada inclou l'espaiat que segueix l'última lletra. space() torna les línies espaiades al seu lloc.
  • postext-pdf 1.4.1 escriu un SVG al contingut de cada pàgina que el dibuixa, sense una còpia compartida: l'orla suma uns 205 KB a cada pàgina, de manera que el PDF de tres pàgines pesa 670 KB (58 KB sense l'orla) i el d'una classe de trenta en pesaria uns 6,4 MB.

Crèdits

Text
Text original, CC BY 4.0
Fonts
Rosarivo (SIL OFL 1.1) · Pinyon Script (SIL OFL 1.1) · Aboreto (SIL OFL 1.1)
SandboxPDF