Kurz gesagt
Ein vierseitiges Programm für ein Abendkonzert. Es zeigt, wie man es als PDF speichert, das genau dem Bildschirm entspricht, mit den Schriften darin und jeder Überschrift in einer Liste zum schnellen Springen.
Was Sie setzen
Das Programm für ein Konzert in der Dämmerung, Home from Sea: vier A5-Seiten für ein Ensemble aus Sopran, Violoncello und Harfe. Der Umschlag ist nachtpetrol, mit dem Titel in goldener Fraunces Kursiv und Reihen goldener Wellenschuppen, die vom Fuß aufsteigen. Innen ist die Programmfolge eine Tabelle mit petrolfarbenem Kopf in Versalien aus der Tenor Sans und einer getönten Summenzeile. Die Werkeinführung steht im Blocksatz aus der Crimson Text. Die Texte der drei Lieder sind Gedichte von Longfellow, Stevenson und Tennyson, gesetzt mit den Einzügen ihrer gedruckten Ausgaben. Die Schaltfläche Build the PDF erzeugt die Datei, die Sie dem Publikum per E-Mail schicken oder auf die Website des Spielorts stellen würden. Ihre vier Seiten entsprechen dem Bildschirm Zeile für Zeile. Sie bettet nur die fünf Schriftdateien ein, die der Browser geladen hat, und jede Überschrift ist ein Lesezeichen. Für den kommerziellen Druck siehe das druckfertige PDF.
Dieses Rezept beantwortet
- Wie exportiere ich im Browser ein echtes PDF mit eingebetteten Schriften?
- Warum ändern sich meine Zeilenumbrüche oder überlappen Wörter im PDF, und wie lade ich Schriften richtig?
Die kurze Antwort
// Hook-up: `await registerFaces()` before the first build; `renderToPdf(doc, { fontProvider })`.
const files = new Map(); // 'crimson-text-latin-600-normal' → its WOFF2 (gotcha: latin-subset)
function fontFile(family, weight, style) {
const id = family.toLowerCase().replaceAll(' ', '-'), file = `${id}-latin-${weight}-${style}`;
if (!files.has(file)) {
files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
.then((res) => {
if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
return res.arrayBuffer();
}));
}
return files.get(file);
}
const facesOf = (family) => (FONTS[family] ?? []).map((spec) =>
({ spec, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }));
// The screen: a FontFace per face, from those bytes, before the first build.
const registerFaces = () => Promise.all(Object.keys(FONTS).flatMap((family) =>
facesOf(family).map(async ({ weight, style }) => {
const face = new FontFace(family, await fontFile(family, weight, style),
{ weight: `${weight}`, style });
document.fonts.add(await face.load());
})));
// The PDF: the same bytes as TrueType. renderToPdf asks for each face the pages paint and no
// other. A face FONTS lacks gets the closest one it has, and is logged as a stand-in: the
// status line should list none.
const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready
async function fontProvider(family, weight, style) {
if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`);
const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a));
const asked = `${weight}${style === 'italic' ? 'i' : ''}`;
embedded.add(`${family} ${best.spec}`);
if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`);
return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style)));
}
Zutaten
- Funktionen
- Im PDF eingebettete SchriftenPDF-ExportPDF-LesezeichenSchriften vor dem LayoutGrundschriftDokument-MetadatenUmschläge, Titelseiten und ImpressumGestaltete AuftakteÜberschriftenformateÜberschriftenattributeKolumnentitel je AbschnittZeilenumbrüche in TitelnKolumnentitel und SeitenzahlenBilder in SeitengestaltungenPapierfarbeEigene RessourcentypenAbbildungen genau hierTabellenformatAbsatzformateSeiten auf einem Canvas
- Nutzt außerdem
- Seiten- und SpaltenumbrücheAbbildungen und Tabellen als RessourcenExpliziter vertikaler Abstand
- Schrift
- Crimson Text, Fraunces, Tenor Sans (SIL OFL 1.1)
- Dateien
- The seigaiha waves on the cover, drawn in code in the page’s palette (Ignacio Ferro, CC BY 4.0)
Zubereitung
#1 · Ein Download pro Schrift, für die Seite und für das PDF
Der Code steht oben in der kurzen Antwort. Postext misst jedes Wort mit den Schriften, die der Browser beim Build geladen hat, und das PDF zeichnet jede Zeile dorthin, wo dieses Layout sie gesetzt hat. Bettet das PDF eine andere Datei ein, etwa die Standardinstanz einer variablen Schrift oder einen Systemersatz, beginnt jedes Wort zwar an seiner gemessenen Position, doch seine Buchstaben haben andere Breiten, sodass Wörter sich überlappen oder Lücken lassen. Das Rezept lädt deshalb jede Schrift einmal. Ihre Bytes werden vor dem ersten Build zu einem FontFace, und der Font-Provider reicht dieselben Bytes für das PDF durch decompressWoff2, sodass der Export keine eigenen Schriften herunterlädt.
#2 · Erst die Schriften, dann das Layout
await registerFaces(); // the answer: every face in FONTS, from its own bytes
await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES));
// Every face is declared by now; one FONTS forgot would come from Fontsource, for the screen only.
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: 'Home from Sea · a recital programme' });
registerFaces() wird erst aufgelöst, wenn jede Schrift in FONTS geladen ist, sodass der erste Build mit den Schriften misst, die das PDF einbetten wird. Es ist auch der einzige Build, weil FONTS jede Schrift auflistet, die die Seiten setzen, und buildDocumentWithFonts jede gesuchte Schrift bereits deklariert vorfindet (Schriften vor dem Layout laden). Diese Prüfung hilft nur dem Bildschirm. Fehlt in FONTS eine Schrift, fragt die Engine das Kit danach, lädt die Fontsource-Datei und setzt das Layout ohne Warnung in der Konsole erneut, doch das PDF bekommt trotzdem die nächstliegende Schrift, die der Provider hat.
#3 · Der Export und eine Liste dessen, was er eingebettet hat
const bar = Object.assign(document.createElement('progress'), { max: 1, value: 0 });
const list = (faces) => [...faces].join(', ') || 'none';
offerPdf(() => {
document.getElementById('pt-actions').prepend(bar);
return renderToPdf(doc, {
fontProvider, // the answer: the page's own font files
resourceBytes: imageBytes, // the cover drawing, as vector paths
outlines: true, // the default, spelled out: each heading becomes a bookmark
onProgress: ({ phase, pages, totalPages }) => {
bar.value = pages / totalPages;
const says = { prepare: 'fonts and cover embedded', pages: `page ${pages} of ${totalPages}`,
save: `embedded: ${list(embedded)} · stand-ins: ${list(standIns)}` };
kitStatus(`PDF · ${says[phase]}`);
},
});
}, `${RECIPE}.pdf`);
renderToPdf ruft onProgress in drei Phasen auf: prepare, sobald die Schriften und der Umschlag eingebettet sind, pages nach jeder Seite und save, bevor es die Datei schreibt. Es fragt den Provider nur nach den Schriften, die die Seiten zeichnen, hier fünf. Die Statuszeile listet die fünf Dateien, die der Provider geliefert hat, genau die aus FONTS, und keinen Ersatz. Ein Ersatz verweist auf eine Schrift, die in FONTS fehlt. Entfernen Sie Crimson Text 600, meldet die Zeile Crimson Text 600 → 400, und das fette The Harbour Consort auf Seite 4 erscheint in normaler Schriftstärke. Die Vorschau von CodePen kann kein PDF zeigen, deshalb ersetzt offerPdf aus dem Kit die Schaltfläche durch zwei Links: einen, der das PDF in einem neuen Tab öffnet, und einen, der es herunterlädt (PDFs erzeugen).
#4 · Der Überschriftenbaum ist der Lesezeichenbaum
const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'),
marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above
levels: [ // a section opens on the next page, left or right: a programme has no blank pages
{ level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
{ level: 2, ...H2 },
] };
// The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break).
const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
...H2, marginTop: pt(LEAD) };
Im PDF zeigt das Lesezeichenfenster den Titel des Umschlags, Programme mit About the music darunter, The texts mit seinen drei Liedern und The performers. Die Gliederung folgt den Überschriftenebenen, wählen Sie sie also mit Blick auf die Lesezeichen: hier eine H1 pro Abschnitt und eine H2 pro Lied. The performers hat ein eigenes Überschriftenformat, eine H1 ohne Seitenumbruch und ohne Auftakt, sodass sie sich Seite 4 mit Tennysons Gedicht teilt und trotzdem ein Lesezeichen auf oberster Ebene bekommt. Das \\, das den Umschlagtitel in zwei Zeilen bricht, wird im Lesezeichen zu einem Leerzeichen.
#5 · Titel und Autor aus dem Frontmatter
const cover = {
id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] },
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'night', resourceId: 'cover',
placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
text('consort', '{author}', at('page', 'top', 18), label(8.5, 'gilt')),
text('title', '{titleText}', at('page', 'top', 26, 120), // the \\ in the heading breaks it
{ ...title(68), lineHeight: 0.95, color: col('gilt') }),
text('subtitle', '{subtitle}', at('page', 'top', 75, 66), { fontFamily: 'Crimson Text',
italic: true, fontSize: pt(12.5), lineHeight: 1.25, color: col('foam') }),
text('when', '{attr.when}', at('page', 'top', 91), label(LABEL, 'foam')),
text('where', '{attr.where}', at('page', 'top', 96), label(LABEL, 'foam')),
] } },
};
Der Umschlag druckt zwei Werte aus dem Frontmatter über {author} und {subtitle}, und die Dokumenteigenschaften des PDF nehmen den Titel Home from Sea und den Autor The Harbour Consort aus demselben Block. Datum und Spielort sind Attribute der Umschlagüberschrift. Die Zeichnung dahinter ist ein SVG in Seitenbreite, und das PDF behält es als Vektorpfade.
Das ganze Rezept
// ═══ Postext Cookbook · Nº 025 · A real PDF with the same fonts embedded ═══════════ // https://postext.dev/en/cookbook/pdf-with-embedded-fonts // Code: MIT · Text: notes original (CC BY 4.0), poems in the public domain · Cover: drawn in code // Fonts: Crimson Text, Fraunces, Tenor Sans (SIL OFL 1.1) · Needs postext ≥ 1.25.0 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 = 'pdf-with-embedded-fonts'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region answer: one download per face: the layout measures it, the PDF embeds it // Hook-up: `await registerFaces()` before the first build; `renderToPdf(doc, { fontProvider })`. const files = new Map(); // 'crimson-text-latin-600-normal' → its WOFF2 (gotcha: latin-subset) function fontFile(family, weight, style) { const id = family.toLowerCase().replaceAll(' ', '-'), file = `${id}-latin-${weight}-${style}`; if (!files.has(file)) { files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`) .then((res) => { if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); return res.arrayBuffer(); })); } return files.get(file); } const facesOf = (family) => (FONTS[family] ?? []).map((spec) => ({ spec, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })); // The screen: a FontFace per face, from those bytes, before the first build. const registerFaces = () => Promise.all(Object.keys(FONTS).flatMap((family) => facesOf(family).map(async ({ weight, style }) => { const face = new FontFace(family, await fontFile(family, weight, style), { weight: `${weight}`, style }); document.fonts.add(await face.load()); }))); // The PDF: the same bytes as TrueType. renderToPdf asks for each face the pages paint and no // other. A face FONTS lacks gets the closest one it has, and is logged as a stand-in: the // status line should list none. const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready async function fontProvider(family, weight, style) { if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`); const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight); const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a)); const asked = `${weight}${style === 'italic' ? 'i' : ''}`; embedded.add(`${family} ${best.spec}`); if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`); return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style))); } // #endregion const palette = { // eight named colours; every colour in the config links to one of them ink: '#1a2326', band: '#0f2a33', // text, a sea-green near-black; night teal: cover and titles gilt: '#c9a227', bronze: '#806414', // the accent; deepened to 5.4:1 for small type on paper foam: '#e3ebe8', rule: '#b9c6c2', // cover small type and the table's total; hairlines muted: '#5c6b70', paper: '#fbfaf6' }; // feet and colophon; the page // A colour is its hex and the palette entry it links to. const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [...Object.entries(palette), ['main-color', palette.band]] // the defaults' .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // id: teal, never blue const PAGE = { width: 148, height: 210 }; // mm: an A5 programme const MARGIN = { top: 22, bottom: 20, inner: 18, outer: 28 }; // mm, mirrored: a 102 mm measure const LEAD = 13.3; // pt: the leading of text and verse, 1.33 × the 10 pt body const LABEL = 7.5, TRACK = 0.2; // pt: kickers, feet, table head, date; em: capitals' tracking const H2 = { italic: true, fontSize: pt(13.5), lineHeight: pt(2 * LEAD) }; // two lines of text const label = (size, ink) => ({ fontFamily: 'Tenor Sans', fontSize: pt(size), letterSpacing: pt(size * TRACK), textTransform: 'uppercase', color: col(ink) }); const title = (size) => ({ fontFamily: 'Fraunces', fontWeight: 300, italic: true, fontSize: pt(size), lineHeight: 1 }); // set solid const text = (id, content, placement, style) => ({ kind: 'text', id, content, placement, ...style }); // in a heading design a long text wraps const at = (to, edge, y, width) => ({ anchor: { to, edge }, offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) }); // #region cover: the drawing fills the page; the frontmatter and the heading set the type const cover = { id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'night', resourceId: 'cover', placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } }, text('consort', '{author}', at('page', 'top', 18), label(8.5, 'gilt')), text('title', '{titleText}', at('page', 'top', 26, 120), // the \\ in the heading breaks it { ...title(68), lineHeight: 0.95, color: col('gilt') }), text('subtitle', '{subtitle}', at('page', 'top', 75, 66), { fontFamily: 'Crimson Text', italic: true, fontSize: pt(12.5), lineHeight: 1.25, color: col('foam') }), text('when', '{attr.when}', at('page', 'top', 91), label(LABEL, 'foam')), text('where', '{attr.where}', at('page', 'top', 96), label(LABEL, 'foam')), ] } }, }; // #endregion const opener = { enabled: true, minHeight: pt(4 * LEAD), slot: { elements: [ // kicker, title, rule text('kicker', '{attr.kicker}', at('container', 'top-left', 0), label(LABEL, 'bronze')), text('title', '{titleText}', at('#kicker', 'below', 1.5), { ...title(26), color: col('band') }), { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('gilt'), placement: { ...at('#title', 'below', 2.5), size: { width: mm(14) } } }, ] } }; const foot = (parity, edge, x, content) => ({ kind: 'text', id: parity, content, parity, ...label(LABEL, 'muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-MARGIN.bottom / 2) } } }); // #region headings: the heading tree is the bookmark tree const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'), marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above levels: [ // a section opens on the next page, left or right: a programme has no blank pages { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener }, { level: 2, ...H2 }, ] }; // The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break). const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false }, ...H2, marginTop: pt(LEAD) }; // #endregion const config = () => ({ locale: 'en-us', // the sample is in English colorPalette, resourceTypes: [plain], headings, headingStyles: [cover, aside], page: { width: mm(PAGE.width), height: mm(PAGE.height), backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, bodyText: { fontFamily: 'Crimson Text', fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), boldFontWeight: 600, firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6 }, layout: { layoutType: 'single', inlineResourceGap: 'above' }, // no line under the table paragraphStyles: [ // verse: a paragraph per line, never stretched if a line ever turns over { id: 'verse', textAlign: 'left', firstLineIndent: pt(0) }, { id: 'verse-in', textAlign: 'left' }, // a line the poet indented: the body's 4.5 mm { id: 'colophon', fontFamily: 'Tenor Sans', fontSize: pt(6.5), lineHeight: pt(9.3), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ], tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('band'), headerColor: col('paper'), headerFontFamily: 'Tenor Sans', headerFontSize: pt(LABEL), headerBold: false, bodyFontSize: pt(9), cellPadding: mm(1.2) }, header: { elements: [] }, footer: { elements: [ // no running heads: folios in the feet, 10 mm up foot('even', 'bottom-left', MARGIN.outer, '{pageNumber} · {title}'), // verso: the programme foot('odd', 'bottom-right', -MARGIN.outer, '{chapterTitle} · {pageNumber}')] }, // recto }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown-Beispiel · 162 Zeilen · content.en.md
title: "Home from Sea" subtitle: "Three new songs and older water music for soprano, cello and harp" author: "The Harbour Consort" --- # Home \\ from Sea {style="cover" when="Saturday 17 October 2026 · 6 pm" where="The Sail Loft, Kellan Harbour"} # Programme {kicker="Twilight recital · 17 October 2026"} ::resource{id="order"} ## About the music Hester Vane wrote *Home from Sea* for the Harbour Consort last winter, and tonight is its first performance. Its three songs set poems written within ten years of one another, and between them the consort plays older water music in its own arrangements, so that each new song follows a piece the room may already know. The poems follow on the next pages, in the order in which they are sung. Mendelssohn’s boat song rocks in six-eight; then, in Longfellow’s *The Tide Rises, the Tide Falls*, the harp keeps the tide turning and the cello takes the curlew’s call. Fauré wrote his *Élégie* in 1880 as the slow movement of a cello sonata he never finished; its lament leads into Stevenson’s *Requiem*, set almost as a folk song. Debussy’s *La cathédrale engloutie*, a piano prelude that Lior Bensaid has arranged for harp, follows the Breton legend of a church that rises from the sea on clear mornings and sinks again. Last comes Tennyson’s *Crossing the Bar*, which the poet asked to have placed at the end of every collection of his poems. Vane gives it the same place in her cycle. # The texts {kicker="Home from Sea · three songs"} ## The Tide Rises, the Tide Falls :::paragraphs{style="verse"} The tide rises, the tide falls, The twilight darkens, the curlew calls; Along the sea-sands damp and brown The traveller hastens toward the town, :::paragraphs{style="verse-in"} And the tide rises, the tide falls. ::: :::space Darkness settles on roofs and walls, But the sea in the darkness calls and calls; The little waves, with their soft, white hands, Efface the footprints in the sands, :::paragraphs{style="verse-in"} And the tide rises, the tide falls. ::: :::space The morning breaks; the steeds in their stalls Stamp and neigh, as the hostler calls; The day returns, but nevermore Returns the traveller to the shore, :::paragraphs{style="verse-in"} And the tide rises, the tide falls. ::: ::: :::space ## Requiem :::paragraphs{style="verse"} Under the wide and starry sky, Dig the grave and let me lie. Glad did I live and gladly die, :::paragraphs{style="verse-in"} And I laid me down with a will. ::: :::space This be the verse you grave for me: *Here he lies where he longed to be*; *Home is the sailor*, *home from sea*, :::paragraphs{style="verse-in"} *And the hunter home from the hill*. ::: ::: :::pagebreak ## Crossing the Bar :::paragraphs{style="verse"} Sunset and evening star, :::paragraphs{style="verse-in"} And one clear call for me! ::: And may there be no moaning of the bar, :::paragraphs{style="verse-in"} When I put out to sea, ::: :::space But such a tide as moving seems asleep, :::paragraphs{style="verse-in"} Too full for sound and foam, ::: When that which drew from out the boundless deep :::paragraphs{style="verse-in"} Turns again home. ::: :::space Twilight and evening bell, :::paragraphs{style="verse-in"} And after that the dark! ::: And may there be no sadness of farewell, :::paragraphs{style="verse-in"} When I embark; ::: :::space For tho’ from out our bourne of Time and Place :::paragraphs{style="verse-in"} The flood may bear me far, ::: I hope to see my Pilot face to face :::paragraphs{style="verse-in"} When I have crost the bar. ::: ::: # The performers {style="aside"} **The Harbour Consort** was formed in 2019 by three musicians who had played together at the town’s lifeboat-day concerts for years: Morwenna Hale, soprano, Ada Pryor, cello, and Lior Bensaid, harp. Its twilight recitals in the Sail Loft run from October to March. Hester Vane, the consort’s composer this season, writes mostly for voices and small ensembles, and *Home from Sea* is her second song cycle. :::paragraphs{style="colophon"} Set in Crimson Text, Fraunces and Tenor Sans (SIL Open Font License), from the same font files on screen and in this PDF. Poems by Longfellow (1880), Stevenson (1887) and Tennyson (1889), public domain; notes CC BY 4.0. Town, consort and composer are imagined. :::`; // content.<lang>.md, inlined by the Cookbook const plain = { id: 'plain', name: 'Programme', shortLabel: '', captionPrefix: '', // no "Table 1" numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }; const row = (who, what, time, more) => [who, what, time].map((content, i) => ({ content, align: i === 2 ? 'right' : 'left', ...more })); // durations flush right const resources = [ { id: 'order', typeId: 'plain', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'here' }, // where ::resource sets it, not floated to the foot table: { model: { headerRowCount: 1, columnWidths: [28, 55, 17], rows: [ row('COMPOSER', 'WORK', 'DURATION', { isHeader: true }), // capitals: the head is a label row('Felix Mendelssohn', '*Venetian Boat Song*, op. 30 no. 6', '3′05″'), row('Hester Vane', '*The Tide Rises, the Tide Falls* · Longfellow', '4′20″'), row('Gabriel Fauré', '*Élégie*, op. 24', '6′50″'), row('Hester Vane', '*Requiem* · Stevenson', '3′10″'), row('Claude Debussy', '*La cathédrale engloutie*', '6′15″'), row('Hester Vane', '*Crossing the Bar* · Tennyson', '5′40″'), row('', 'About half an hour, without an interval', '29′20″', { background: col('foam') }), ] } } }, { id: 'cover', typeId: 'plain', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'cover.svg', width: PAGE.width * 10, height: PAGE.height * 10 }, altText: 'Night-teal cover whose lower half is rows of gilt wave scales, fading upward.' }, ]; // #region art: seigaiha, the blue-sea-wave pattern, as gilt rings on night teal const WAVES = 115; // mm from the top edge: where the waves begin, under the venue function coverArt(w, h, top) { // mm: the page, and where the waves begin const R = 12.5; // mm: the radius of one scale const f = (n) => +n.toFixed(2); const rows = Math.ceil((h - top) / (R / 2)) + 1; let out = `<rect width="${w}" height="${h}" fill="${palette.band}"/>`; for (let i = 0; i <= rows; i++) { // top row first: each row hides the lower half of the last const y = top + (i * R) / 2; const glow = f(0.5 + 0.5 * (i / rows) ** 1.3); // half-lit at the top, full gilt at the foot for (let x = (i % 2) * R; x <= w + R; x += 2 * R) { out += `<circle cx="${f(x)}" cy="${f(y)}" r="${R}" fill="${palette.band}"/>`; for (const k of [0.9, 0.64, 0.38]) { out += `<circle cx="${f(x)}" cy="${f(y)}" r="${f(k * R)}" fill="none" ` + `stroke="${palette.gilt}" stroke-width="${f(0.09 * R)}" stroke-opacity="${glow}"/>`; } out += `<circle cx="${f(x)}" cy="${f(y)}" r="${f(0.12 * R)}" fill="${palette.gilt}" ` + `fill-opacity="${glow}"/>`; } } return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" height="${h * 10}" ` + `viewBox="0 0 ${w} ${h}"><clipPath id="page"><rect width="${w}" height="${h}"/></clipPath>` + `<g clip-path="url(#page)">${out}</g></svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // The faces the pages set, and no other. Tenor Sans has 400 only (gotcha: faked-font-styles). const FONTS = { 'Crimson Text': ['400', '400i', '600'], Fraunces: ['300i'], 'Tenor Sans': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: the faces first, then the layout, then a check that nothing was missed await registerFaces(); // the answer: every face in FONTS, from its own bytes await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES)); // Every face is declared by now; one FONTS forgot would come from Fontsource, for the screen only. const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); showPages(doc, { title: 'Home from Sea · a recital programme' }); // #endregion // #region pdf: the export: bookmarks from the headings, a progress bar, the faces it embedded const bar = Object.assign(document.createElement('progress'), { max: 1, value: 0 }); const list = (faces) => [...faces].join(', ') || 'none'; offerPdf(() => { document.getElementById('pt-actions').prepend(bar); return renderToPdf(doc, { fontProvider, // the answer: the page's own font files resourceBytes: imageBytes, // the cover drawing, as vector paths outlines: true, // the default, spelled out: each heading becomes a bookmark onProgress: ({ phase, pages, totalPages }) => { bar.value = pages / totalPages; const says = { prepare: 'fonts and cover embedded', pages: `page ${pages} of ${totalPages}`, save: `embedded: ${list(embedded)} · stand-ins: ${list(standIns)}` }; kitStatus(`PDF · ${says[phase]}`); }, }); }, `${RECIPE}.pdf`); // #endregionKit · 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
#Die Lesezeichen weglassen
Ein einseitiger Flyer oder ein Plakat hat nichts, was Lesezeichen bräuchte. Mit outlines: false hat das PDF keine Gliederung und öffnet sich mit geschlossener Seitenleiste.
- outlines: true, // the default, spelled out: each heading becomes a bookmark
+ outlines: false,#Die Schriften selbst ausliefern
Richten Sie den Fetch auf Ihre eigenen Kopien derselben statischen WOFF2-Dateien, eine pro Schriftstärke und Stil, unter den Dateinamen von Fontsource. Seite und PDF teilen sich weiterhin jeden Download.
- files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
+ files.set(file, fetch(`/fonts/${file}.woff2`)
.then((res) => {
- if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
+ if (!res.ok) throw new Error(`No font file ${file}.woff2`);Häufige Fehler
Häufiger Fehler
Ein fehlender Fett- oder Kursivschnitt wird am Bildschirm nachgeahmt, im PDF nicht
Verlangt ein Text eine Schriftstärke oder einen Stil, den die Schriftfamilie nicht mitbringt (ein fetter Tabellenkopf in einer Auszeichnungsschrift mit nur einem Schnitt, kursiv in einer Grotesk ohne Kursive), erzeugt der Browser ihn auf dem Canvas und in HTML synthetisch und verdickt oder neigt den aufrechten Schnitt bei gleichen Breiten. Ein PDF bettet nur echte Schnitte ein, dort druckt also der nächstliegende Schnitt des Providers normal. Nehmen Sie nur Schnitte in Ihre Schriftliste auf, die die Familie mitbringt, und stellen Sie jeden Stil passend ein, etwa tableStyle.headerBold: false. Im PDF eingebettete Schriften →
Häufiger Fehler
Fontsource-Dateien decken Buchstaben ab, keine Symbole
Das Kit bettet die latin-Datei einer Schrift von Fontsource ein, dazu ihre latin-ext-Datei für č, ł, † und ihre greek-Datei für α, χ (bei einer Familie, die eine mitliefert: Lora und Gelasio tun es nicht), wenn der Text diese Zeichen setzt, auf dem Bildschirm und im PDF. Symbole wie →, ≈, ✓ und ★ stehen in keiner dieser Dateien und fehlen im PDF: zeichnen Sie sie, setzen Sie sie als Mathematik oder wählen Sie eine Schrift, deren Dateien sie enthalten. Im PDF eingebettete Schriften →
Häufiger Fehler
Ein Überschriftenformat erbt den Seitenumbruch seiner Ebene
Ein Eintrag in headingStyles übernimmt jedes Feld, das er auslässt, von seiner Überschriftenebene, breakBefore eingeschlossen. Ein Inhaltsverzeichnis oder Impressum, das nach einem :::pagebreak über eine H1 gestaltet wird, erbt die Parität 'odd' und landet hinter einer Vakatseite. Geben Sie einem solchen Format breakBefore: { enabled: false }. Überschriftenformate →
Listen Sie in FONTS jede Schrift auf, die Ihre Seiten setzen, darunter Fett und Kursiv des Textes. Fehlt eine, sieht der Bildschirm trotzdem richtig aus, weil das Kit die Schrift ohne Warnung lädt, doch das PDF druckt die nächstliegende Schrift des Providers, und nur die Ersatzschriften in der Statuszeile verraten den Tausch.
Verwenden Sie statische Dateien, eine pro Schriftstärke und Stil. Aus einer variablen WOFF2 bettet das PDF nur die Standardinstanz ein, sodass ein fetter Abschnitt in normaler Schriftstärke erschiene (Warum ein Font-Provider?).
Nachweise
- Rezept
- Ignacio Ferro
- Text
- “The Tide Rises, the Tide Falls”, with its indents, as printed in The Complete Poetical Works of Henry Wadsworth Longfellow · Henry Wadsworth Longfellow · gemeinfrei
- “Requiem”, with the indents and italics of the first edition of Underwoods (1887) · Robert Louis Stevenson · gemeinfrei
- “Crossing the Bar”, with its indented short lines, from the first edition of Demeter and Other Poems (1889), in the proofread Wikisource transcription · Alfred Tennyson · gemeinfrei
- The programme, the notes on the music, the performers and the colophon · Ignacio Ferro · CC BY 4.0
- Bilder
- The seigaiha waves on the cover, drawn in code in the page’s palette · Ignacio Ferro · CC BY 4.0
- Schriften
- Crimson Text (SIL OFL 1.1) · Fraunces (SIL OFL 1.1) · Tenor Sans (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


