Zum Hauptinhalt springen
Rezept Nummer 63

Rezepte · Kapitel 3 · Überschriften und Auftakte

Urkunde mit Guillochenrahmen

Kursurkunden aus einem Überschriftenformat: ein Guillochenrahmen in der Kopfzeile, Siegel und Unterschriftslinien in der Fußzeile, der Name im Auftakt.

Auf dieser Seite
Ausgabe
Canvas · PDF
Stufe
Mittel
Postext
Getestet mit Postext 1.31.0
Erfordert ≥ 1.25.0 · postext-pdf ≥ 1.25.0
Lizenz
Aktualisiert am 10. Okt. 2026
Code MIT · Text CC BY 4.0

S. 1 von 3

  • Englisches Beispiel: noch keine deutsche Ausgabe
  • Endformat 215,9 × 279,4 mm
  • 1 Spalte
  • Rosarivo 11,5/17
  • Aboreto
  • Pinyon Script
  • 3 Seiten
  • Stufe
  • Postext 1.30.0
  • In 57 ms gesetzt
  • 174 Zeilen Code

Kurz gesagt

Teilnahmeurkunden für eine Druckwerkstatt, eine Seite je Teilnehmer, mit gemustertem Rahmen und Siegel. Wer den Namen eines Teilnehmers einmal schreibt, bekommt eine ganze Urkunde.

Was Sie setzen

Abschlussurkunden für den Quoin Room, eine erfundene Buchdruckwerkstatt in Providence: eine Seite im Format US Letter je Teilnehmer. Die spanische Ausgabe setzt sie als A4-Diplome für La Cuña, eine Werkstatt in Saragossa. Ein petrolfarbenes Band voller cremefarbener Guillochen-Linsen rahmt das Blatt, mit einer cremefarbenen Rosette in jeder Ecke; beides ist im Code aus Sinuskurven und Hypotrochoiden gezeichnet. Unter einem Titel in gesperrten Versalien steht der Name des Teilnehmers in der Pinyon Script zu 48 pt über einer goldenen Linie, und die Würdigung ist zentriert in der Rosarivo gesetzt. Ein rotes Siegel mit zwei Bandenden sitzt zwischen den Unterschriftslinien, darunter ein einzeiliges Impressum. Weil ein einziges Überschriftenformat die ganze Seite zeichnet, ist jeder Teilnehmer eine Markdown-Überschrift, und das PDF enthält den ganzen Kurs mit einem Lesezeichen je Name.

Dieses Rezept beantwortet

  • Wie füge ich auf jeder Seite ein Wasserzeichen, eine Hintergrundtönung oder ein Schmuckbild ein?
  • Wie setze ich nicht nummerierte Grafiken: Ornamente, Vignetten, Logos?
  • Wie hefte ich ein Abzeichen oder einen Aufkleber an eine feste Position auf der Seite?
  • Wie exportiere ich im Browser ein echtes PDF mit eingebetteten Schriften?

Die kurze Antwort

script.js · Zeilen 38–57im vollständigen Code
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"}

Zutaten

Schrift
Rosarivo, Pinyon Script, Aboreto (SIL OFL 1.1)
Dateien
Keine: Jedes Bild wird im Code gezeichnet

Zubereitung

#1 · Ein Überschriftenformat die Seite zeichnen lassen

Das Format selbst steht oben in der kurzen Antwort. Ein Überschriftenformat bestimmt die Seiten seines Abschnitts (Überschriftenformate): Sein header und sein footer ersetzen die des Dokuments, die hier leer sind, seine margins legen den Textbereich fest, und sein advancedDesign zeichnet den Auftakt. Die Höhe eines Auftakts wird auf ganze Zeilen des Grundrasters von 17 pt aufgerundet. Eine minHeight von 106 mm wird zu 18 Zeilen, die Würdigung beginnt also 140 mm unter der Oberkante; auf A4 macht y() daraus 112,7 mm oder 19 Zeilen, und die Würdigung beginnt bei 146 mm. Der Titelblock allein ließe die Würdigung bei 128 mm beginnen (134 mm auf A4). Ohne marginBottom: 0 würde der Standardabstand von 0,5 em unter einer Überschrift dieselben 106 mm auf 19 Zeilen bringen.

#2 · Den Rahmen in der Kopfzeile an der Seite aufhängen

script.js · Zeilen 61–75im vollständigen Code
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.' })),
];

Ein Bildelement zeichnet eine Ressource in der Größe ihrer Platzierung (Bildelemente): an der linken oberen Ecke der Seite verankert und mit 'fill' auf beiden Seiten, bedeckt die Guilloche das Endformat, und das SVG, mit den Proportionen der Seite angelegt, füllt es, ohne sich zu verzerren. Die Kopfzeile malt es über page.backgroundColor und reserviert keinen Platz, sodass allein die Ränder den Text mindestens 32 mm vom Beschnitt fernhalten, innerhalb der inneren Linie bei 26 mm. Ein Wasserzeichen in der Kopfzeile würde über den Wörtern drucken, weil die Kopfzeile nach dem Text gemalt wird. In einer Kopfzeile liest {attr.serial} das Attribut der Überschrift, zu deren Abschnitt die Seite gehört, sodass jede Urkunde ihre eigene Nummer druckt.

#3 · Nur den Titelblock im Auftakt behalten

script.js · Zeilen 79–99im vollständigen Code
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) } } },
];

Jede Zeile ist an der Kante 'top' der Seite verankert, was sie zentriert, mit einem y, das vom Beschnitt aus gemessen wird, sodass der Block unabhängig von den Rändern seinen Platz behält; y() dehnt diese Positionen von Letter auf A4. Der Name ist der eigene Text der Überschrift, gelesen über {titleText}, sodass PDF-Lesezeichen und Tags ihn tragen. Ein langer Name nimmt eine zweite Zeile, da eine Überschriftengestaltung ihren Text umbricht (overflow: 'wrap'). Die gesperrten Zeilen brauchen keine Korrektur: Die Sperrung nach dem letzten Buchstaben bleibt aus der Messung heraus, sodass die Buchstaben selbst zentriert stehen.

#4 · Siegel und Unterschriften an einer Kante aufhängen

script.js · Zeilen 103–126im vollständigen Code
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) },
];

Alles in der Fußzeile wird von SIGN aus gemessen, der Oberkante des Siegels, und der untere Rand des Formats beendet den Textbereich an derselben Kante, sodass eine für die Seite zu lange Würdigung auf die nächste Seite weiterläuft, statt unter das Siegel zu laufen. In den Auftakt verschoben, zählten die Linien und die Namen zu seiner Höhe (Span und erweiterte Gestaltung); da sie unter den Textbereich reichen, beanspruchte der Auftakt die ganze Seite und schickte die Würdigung auf die nächste. LINE nimmt die Breite zwischen den Seitenrändern, zieht die 34 mm der Siegelscheibe ab, halbiert den Rest und zieht 3 mm ab, sodass jede Linie in beiden Formaten 3 mm vor dem Siegel endet (56 mm lang auf Letter, 53 mm auf A4). Jeder Name und jede Funktion ist ein Textelement so breit wie seine Linie, was ihn unter der Linie zentriert.

#5 · Die Teilnehmerliste in Überschriften einfügen

script.js · Zeilen 159–169im vollständigen Code
// 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' }) };

Jede Zeile wird zu einer Überschrift #, die das Format nennt und die laufende Nummer als Attribut trägt (Überschriftenattribute), gefolgt vom Wortlaut aus content.en.md, sodass drei Zeilen drei Seiten ergeben. Das breakBefore der Ebene 1 mit der Parität 'any' beginnt jede Überschrift auf einer neuen Seite ohne Vakatseite zwischen den Urkunden; das Format erbt es.

#6 · Ein PDF für den ganzen Kurs bauen

script.js · Zeilen 267–270im vollständigen Code
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' });

Der Kurs ist ein einziges Dokument, deshalb schreibt renderToPdf eine dreiseitige Datei mit einem Lesezeichen je Überschrift, jedes nach einem Teilnehmer benannt. renderToPdf nimmt auch ein Array von Dokumenten an, etwa die Kapitel eines Buches, aber ein Dokument je Teilnehmer bedeutet einen Aufruf von buildDocument für jeden. Als Überschriften wird der ganze Kurs in einem Build gesetzt, und der Betrachter zeigt alle drei Urkunden aus diesem Build. Das PDF bettet die vier Schriften ein, die die Seiten drucken und die der Schriften-Provider des Kits von Fontsource holt (Warum ein Schriften-Provider?).

Das ganze Rezept

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.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.
Markdown-Beispiel · 2 Zeilen · 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' }); // #endregion
Kit · core, fonts, viewer, pdf, images: in jedem Rezept gleich · 315 Zeilen// ─── 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 ───────────────────────────────────────────────────────────────────────

Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)

Varianten

#Ein PDF je Teilnehmer drucken

Eine Schaltfläche je Teilnehmer baut dessen Urkunde als eigenes Dokument, mit buildDocumentWithFonts als eigenem Build des Pens, und benennt die Datei nach der laufenden Nummer; jede Datei wiegt etwa 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;
+}

#Rahmen und Titel in Marineblau drucken

Die SVG-Grafik und die Konfiguration lesen dasselbe Objekt palette, sodass die Änderung eines Eintrags das Band, die Rosettenmuster und die Titelzeilen umfärbt.

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

Häufige Fehler

Häufiger Fehler

Ein Auftakt reserviert Höhe bis zu seinem tiefsten an der Seite verankerten Element

Ein Auftakt mit erweiterter Gestaltung reserviert die Höhe seines tiefsten Elements, und an der Seite oder am Anschnitt verankerte Elemente unterhalb der Überschrift zählen mit, sodass Schmuck am Seitenfuß den Text auf die nächste Seite schiebt. Geben Sie einem solchen Element reserve: false (seit postext 1.5): es wird dort gemalt, wo es verankert ist, und reserviert nichts. Elemente, die dem Band selbst folgen (an seinem Fuß verankert oder 'fill' hoch), bestimmen nie seine Höhe. Gestaltete Auftakte →

Häufiger Fehler

Elemente von Kopf- und Fußzeile malen über den Text

Elemente von Kopf- und Fußzeile werden über die Seite gemalt, und der Textbereich macht ihnen keinen Platz. Halten Sie sie innerhalb der Ränder, denn die Ränder reservieren ihren Raum. Kolumnentitel und Seitenzahlen →

Häufiger Fehler

Attributwerte: kein { oder }; einen Wert mit " in einfache Anführungszeichen setzen

Ein Attributwert endet an der schließenden geschweiften Klammer, kann also kein { oder } enthalten. Ein Wert, der ein doppeltes Anführungszeichen enthält, kommt in einfache Anführungszeichen; ein Dollarzeichen ist unproblematisch. Überschriftenattribute →

Häufiger Fehler

Kein <marker> und keine Filter in SVG-Grafiken (Raster-Ersatz)

Eine SVG-Abbildung bleibt im PDF nur ohne <marker>, Filter und Masken ein Vektor; andernfalls fällt sie auf ein Raster zurück, und tief verschachtelte Filter können sie in Chrome leer erscheinen lassen. Zeichnen Sie Pfeilspitzen als Pfade. Abbildungen und Tabellen als Ressourcen →

Nachweise

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