Kurz gesagt
Die ersten Seiten einer Taschenbuchausgabe von Frankenstein. Sie zeigen, wie die Seiten vor der Geschichte i, ii, iii gezählt werden und die Zählung dort, wo die Geschichte beginnt, wieder bei Seite 1 anfängt.
Was Sie setzen
Die ersten Blätter von Frankenstein als Taschenbuchklassiker, 129 × 198 mm: ein Umschlag mit Blitzen über dem Mont Blanc, dessen Rückseite leer bleibt, ein Schmutztitel, ein Frontispiz mit Waltons im Eis eingeschlossenem Schiff gegenüber dem Titelblatt, das Impressum tief auf dessen Rückseite, die Widmung an Godwin über Miltons Motto, eine Inhaltsseite und das Vorwort von 1818. Diese zehn Seiten sind i bis x nummeriert, doch nur das Inhaltsverzeichnis und das Vorwort drucken ihre Zahlen. „Letter I“ eröffnet den Roman auf einer rechten Seite unter dem Nordlicht, und seine Pagina lautet 1, an derselben Stelle wie das x auf der gegenüberliegenden Seite. Der Brief läuft bis zu Waltons Unterschrift. Das ix neben dem Vorwort im Inhaltsverzeichnis und die 1 neben dem Brief stammen aus den fertigen Seiten, und ein PDF-Betrachter beschriftet die Seiten genauso. Umschlag, Frontispiz und Nordlicht sind mit Diffusionsmodellen erzeugte Gemälde, als JPEG-Bitmaps geladen; das Verlagssignet und der Eiskristall sind im Code gezeichnet.
Dieses Rezept beantwortet
- Wie nummeriere ich die Titelei mit römischen Ziffern und beginne mit Kapitel 1 wieder bei 1?
- Wie erstelle ich einen Umschlag, einen Schmutztitel, eine Titelseite und ein Impressum?
- Wie füge ich ein Inhaltsverzeichnis hinzu, das sich selbst aktualisiert (Führungspunkte, Seitenzahlen, Autoren, Teilzeilen)?
- Wie füge ich gezielt Vakatseiten ein oder beginne einen Abschnitt auf einer neuen Doppelseite?
- Wie setze ich ein Motto, eine Widmung, eine Unterschrift oder ein herausgestelltes Zitat mit großem Anführungszeichen?
Die kurze Antwort
// The page counter starts in lower-case roman: the cover is page i. After the Preface, two
// directives in the Markdown count again from 1 on the next recto:
// :::pagebreak{parity="odd"}
// :::numbering{format="decimal" startAt=1}
// :::numbering takes effect on the next page that starts (gotcha: numbering-next-page). Level 1
// breaks to 'odd', so here the pagebreak changes nothing; it finds the recto when a level breaks
// with parity 'any' or not at all. Never with 'always-odd': the blank pages would stack up.
const pageNumbering = { format: 'lower-roman', startAt: 1 }; // startAt spelled out: cover = i
// One folio design, centred under the text block. {pageNumber} prints the page's own label,
// so the same design sets "x" in the front matter and "1" on the first page of the novel.
const folio = (pages) => text(`folio-${pages}`, '{pageNumber}', 'Playfair Display SC', 8.5,
at('top', 0, 9), { letterSpacing: pt(0.8), pages }); // lower case sets as small capitals
// The front matter is a heading style, unnumbered and left out of the contents (a heading
// can opt back in with {toc="true"}). It prints no running heads and puts a folio on every
// page of the section. Parity 'any', because the contents fall on a verso.
const front = {
id: 'front', numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' },
header: { elements: [] }, footer: { elements: [folio('all')] }, ...frontLook,
};
// The novel keeps the document's furniture: heads on its body pages, the folio on openers.
const footer = { elements: [folio('opener')] };
// Hook-up in config(): page: { pageNumbering }, headingStyles: [front] and footer at the top
// level. frontLook, set above, is the look of the prelims' openers.
Zutaten
- Funktionen
- Römisch paginierte TiteleiInhaltsverzeichnisKolumnentitel je AbschnittUmschläge, Titelseiten und ImpressumSeiten- und SpaltenumbrücheÜberschriftenformateKapitel ohne NummerAbschnittsgeometrieBilder in SeitengestaltungenKapitel, die rechts beginnenAbsatzformateGestaltete AuftakteÜberschriftenattributeDokument-MetadatenKolumnentitel und SeitenzahlenKopfzeilen nach SeitenrolleGespiegelte RänderPDF-ExportPDF-LesezeichenAbbildungen und Tabellen als Ressourcen
- Schrift
- Fanwood Text, Playfair Display SC, Cinzel (SIL OFL 1.1)
- Dateien
band-1400.jpgcover-1000.jpgplate-1000.jpg- The cover: lightning over Mont Blanc and the lake of Geneva (Generated With Diffusion Models, eigener Text)
- The frontispiece: a brig beset in pack ice, a dog sledge far off (Generated With Diffusion Models, eigener Text)
- Letter I’s opener: northern lights over the frozen Neva (Generated With Diffusion Models, eigener Text)
Zubereitung
#1 · Jedes Blatt ist eine Überschrift mit einer Gestaltung
// Each leaf is a heading, so it opens its page, gets a PDF bookmark and takes a design. The
// leaves are unnumbered, left out of the contents and print no heads or folio (blind folios).
// Parity 'any', or each would inherit level 1's odd break (gotcha: style-inherits-break).
const blind = { numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' },
header: { elements: [] }, footer: { elements: [] } };
const PLATE_H = 126; // mm: the frontispiece, as wide as the text block; its caption hangs below
// mm from the trim: the imprint's eleven lines end on the text block's last baseline.
const IMPRINT_TOP = 126.4; // re-tune it whenever the imprint changes
const leaves = [
// i: bled to the trim. The painting is as tall as the page, so the cover takes it whole.
{ id: 'cover', ...blind, advancedDesign: design([
image('cover', at('top-left', 0, 0, { width: 'fill', to: 'bleed' })),
label('author', '{author}', 9, onPage(22), 'bone', 2.6),
text('title', '{title}', 'Fanwood Text', 56, onPage(31), { color: col('bone'), lineHeight: 1 }),
text('subtitle', '{subtitle}', 'Fanwood Text', 13, onPage(53), { ...on('ice'), italic: true }),
label('series', '{attr.publisher}', 7, onPage(168), 'ice', 2),
]) },
// iii: the half title waits for a recto, so the back of the cover, ii, is left blank.
{ id: 'half', ...blind, breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: design([
text('title', '{title}', 'Playfair Display SC', 17, at('top', 0, 34), { letterSpacing: pt(3) }),
image('crystal', at('top', 0, 45, { width: 6 })),
]) },
{ id: 'plate', ...blind, advancedDesign: design([ // iv: faces the title page
image('plate', at('top-left', 0, 0, { width: 'fill' })),
text('caption', '{attr.caption}', 'Fanwood Text', 8.8,
at('top', 0, PLATE_H + 5, { width: 80 }), { italic: true, lineHeight: 1.3 }),
label('source', '{attr.source}', 7.5, at('top', 0, PLATE_H + 16), 'muted', 1.3),
]) },
{ id: 'title', ...blind, advancedDesign: design([ // v: four frontmatter fields, one attribute
label('edition', 'The text of {publishDate}', 7.5, at('top', 0, 22), 'accent', 1.8),
text('title', '{title}', 'Playfair Display SC', 33, at('top', 0, 30),
{ fontWeight: 700, letterSpacing: pt(0.4), lineHeight: 1 }),
text('subtitle', '{subtitle}', 'Fanwood Text', 14, at('top', 0, 44), { italic: true }),
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.8), color: col('accent'),
placement: at('top', 0, 60, { width: 14 }) },
label('author', '{author}', 10, at('top', 0, 66), 'ink', 2.4),
image('mark', at('top', 0, 126, { width: 11 })),
label('publisher', '{attr.publisher}', 7.5, at('top', 0, 140), 'muted', 1.5),
]) },
// vi: the section's margins set the small print low, in a 70 mm measure against the outer
// margin. hidden: the heading opens the page and names its bookmark, and prints nothing.
{ id: 'imprint', ...blind, hidden: true,
margins: { top: mm(IMPRINT_TOP), left: mm(TRIM_W - OUTER - 70) } },
// vii: dedication and epigraph, under a heading whose design is an ornament and no title.
{ id: 'quiet', ...blind,
advancedDesign: design([image('crystal', at('top', 0, 24, { width: 6 }))], 34) },
];
// :::paragraphs blocks set the small print, the dedication, the epigraph and Walton's close;
// the dedication and the verse take one paragraph per line, so no line of them can reflow.
const VERSE_IN = 22; // mm: the indent that centres the verse as a block
const SOURCE_IN = 55; // mm: 'Paradise Lost' ends under the end of the longest line of verse
const prelimStyles = [
{ id: 'small-print', fontSize: pt(7.6), lineHeight: pt(10.4), textAlign: 'left',
firstLineIndent: pt(0), spaceBetween: pt(5.2) },
// Small capitals, with the italic lines of the 1818 page, leaded as one inscription.
{ id: 'dedication', fontFamily: 'Playfair Display SC', fontSize: pt(10), lineHeight: pt(15),
textAlign: 'center', firstLineIndent: pt(0) },
{ id: 'verse', fontSize: pt(10), textAlign: 'left', firstLineIndent: mm(VERSE_IN),
marginTop: pt(LEAD * 4) }, // on the body's 14 pt leading, which a style inherits
{ id: 'source', fontFamily: 'Cinzel', fontSize: pt(7.5), textAlign: 'left',
firstLineIndent: mm(SOURCE_IN), marginTop: pt(4), color: col('muted') },
{ id: 'signature', textAlign: 'right', firstLineIndent: pt(0) }, // the letter's close
];
Eine Überschrift eröffnet eine eigene Seite und wird zu einem PDF-Lesezeichen; die Gestaltung in ihrem Format platziert Schrift und Bilder des Blattes. Mit numbered: false und toc: false bleiben die Blätter aus der Kapitelzählung und dem Inhaltsverzeichnis heraus. Eine leere Kopf- und Fußzeile macht sie zu mitgezählten Seiten ohne gedruckte Pagina, englisch „blind folios“: Das Titelblatt druckt keine Zahl, ist aber trotzdem Seite v, deshalb kann das Vorwort ix sein. Nur der Schmutztitel behält einen Umbruch auf eine ungerade Seite, und der lässt die Rückseite des Umschlags leer. Impressum, Widmung, Motto und Waltons Briefschluss sind Blöcke :::paragraphs, gesetzt in den Formaten am Ende des Ausschnitts.
#2 · Das Inhaltsverzeichnis nimmt seine Zahlen aus dem Layout
// Entries take the body's face and ink, and start on the Preface's first baseline, facing it.
const contents = {
levels: [{ level: 1, fontSize: pt(12), lineHeight: pt(PREFACE.lead) }],
// The folio's face: "ix" in small capitals, then a plain 1. The leader takes it too.
pageNumber: { fontFamily: 'Playfair Display SC', fontSize: pt(10), color: col('accent'),
width: mm(8) },
leader: { char: '. ', gap: mm(2.5) },
// A second line from the heading's to="…" attribute: whom each letter is written to.
subtitle: { enabled: true, attr: 'to', fontSize: pt(9.5), color: col('muted') },
};
:::toc listet jede Überschrift, die ihr Format zulässt, mit der Beschriftung der Seite, auf der sie landet (Inhaltsverzeichnis). Inhaltsverzeichnis und Vorwort teilen sich das Format front, das toc: false setzt, und das Vorwort überschreibt es mit {toc="true"}. Weder das Vorwort noch „Letter I“ ist nummeriert, deshalb drucken ihre Einträge keine Kapitelnummer. Die zweite Zeile des Eintrags für den Brief ist sein Attribut to="…". Die Seitenzahlen stehen in der Schrift der Pagina. Die Einträge beginnen auf derselben Grundlinie wie die erste Zeile des Vorworts, sodass die beiden gegenüberliegenden Seiten Register halten.
#3 · Der Roman beginnt rechts, unter dem Nordlicht
const BAND = 104; // mm from the trim to the foot of the drawing, which fades into the paper
// minHeight ends AIR mm below it and the first line takes the next grid line. to: 'bleed' is the
// trim until page.cutLines adds a bleed; 3 mm drop the foot 1.8 mm, still above that line.
const AIR = 2;
const opener = design([
image('band', at('top-left', 0, 0, { width: 'fill', to: 'bleed' })),
label('to', '{attr.to}', 7.5, onPage(19), 'ice', 2),
text('title', '{titleText}', 'Playfair Display SC', 46, onPage(25),
{ color: col('bone'), lineHeight: 1 }),
text('dateline', '{attr.dateline}', 'Fanwood Text', 10.5, onPage(44),
{ italic: true, color: col('bone') }),
// The drawing reserves down to its foot. minHeight, counted from the text block (TOP below
// the trim), is a floor AIR mm lower, which sends the first line one grid line further down.
], BAND - TOP + AIR);
// Walton's letters come before Chapter I: they take the novel's opener but no number.
const letter = { id: 'letter', numbered: false };
Das Gemälde läuft über den Kopf der Seite hinaus und verblasst an seinem Fuß ins Papier, wo der Brief beginnt. Der Auftakt reserviert die Seite bis zum Fuß des Gemäldes, 104 mm unter der Oberkante der Seite, und minHeight setzt eine Untergrenze 2 mm tiefer, wodurch der Brief eine Rasterzeile weiter unten beginnt, frei vom Verlauf (Seitenbreite und erweiterte Gestaltung). Dachzeile und Ortszeile kommen aus den Attributen to und dateline der Überschrift. „Letter I“ trägt außerdem {style="letter"}, ein Format, dessen einzige Einstellung numbered: false ist, weil Waltons Briefe vor „Chapter I“ stehen und keine Kapitelnummer haben.
#4 · Mit dem Roman kehren die Kolumnentitel zurück
const HEAD_Y = 12.5; // mm from the trim to the heads' line
const HEAD_GAP = 8; // mm from the outer margin, where the folio hangs, to the words beside it
const head = (id, content, parity, edge, x, extra = {}) => ({ ...label(id, content, 7.5,
at(edge, x, HEAD_Y, { to: 'page' }), 'muted', 1.4), align: 'left', parity, pages: 'body',
...extra }); // pages: 'body' keeps them off the opener
const numeral = { fontFamily: 'Playfair Display SC', fontSize: pt(8.5), fontWeight: 400,
letterSpacing: pt(0.6), color: col('ink') }; // folios in the folio face, as on the openers
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, numeral),
head('verso-title', '{title}', 'even', 'top-left', OUTER + HEAD_GAP),
head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + HEAD_GAP)),
head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, numeral),
] };
Ein Abschnitt behält Kopf- und Fußzeile des Dokuments, es sei denn, sein Format ersetzt sie. Die Titelei ersetzt beide, das Format letter nicht, deshalb druckt der Roman ab Seite 1 diese Kolumnentitel auf seinen Textseiten und auf seinem Auftakt die in der kurzen Antwort definierte Pagina am Fuß. {pageNumber} druckt die Beschriftung der Seite, auf der es steht, sodass der Helfer folio() mit derselben Gestaltung x auf die eine Seite des Bundes setzt und 1 auf die andere.
#5 · Das PDF trägt dieselben Seitenzahlen
// :::toc lists each heading with the label of the page it lands on; buildDocument lays the
// document out again until those labels stop moving (ix for the Preface, 1 for Letter I).
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: 'Frankenstein · the opening leaves' });
// The PDF gets /PageLabels from the same labels: a viewer numbers its pages i–x, then 1, 2.
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
`${RECIPE}.pdf`);
buildDocument wiederholt das Layout, bis sich die Seitenbeschriftungen im Inhaltsverzeichnis nicht mehr ändern, ein Aufruf genügt also. renderToPdf schreibt diese Beschriftungen in das PDF: In einem Betrachter, der Seitenbeschriftungen liest, läuft das Feld für die Seitenzahl von i bis x und dann ab 1, und „gehe zu Seite 1“ öffnet „Letter I“. Jede Überschrift wird zu einem Lesezeichen, das nach ihrem Text benannt ist, deshalb haben die Blätter schlichte Titel: „Cover“, „Half title“, „Frontispiece“, „Title page“, „Imprint“, „Dedication“.
Das ganze Rezept
// ═══ Postext Cookbook · Nº 006 · Front matter: roman folios, then page 1 ═══════════ // https://postext.dev/en/cookbook/front-matter-roman-to-arabic // Code: MIT · Text: M. & P. B. Shelley, 1818 (PD) · Pictures: diffusion models; marks drawn in code // Fonts: Fanwood Text, Playfair Display SC, Cinzel (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 = 'front-matter-roman-to-arabic'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // every colour in the config links to one of these ink: '#1c1d21', // text: a cold near-black accent: '#3d5a70', // steel blue, the one accent on paper: contents folios, the rule, the marks muted: '#6a665f', // running heads, sources, the second lines of the contents paper: '#fbf8f2', // the page night: '#131a24', ice: '#a9c6d6', bone: '#efe9dc', // the drawings' inks, and the type on them }; 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' } })), // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue. { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } }, ]; const TRIM_W = 129, TRIM_H = 198; // mm: a pocket classic const TOP = 21, BOTTOM = 23, INNER = 17, OUTER = 15; // margins, mm; heads align to OUTER const MEASURE = TRIM_W - INNER - OUTER; // 97 mm: the text block, and the frontispiece's width const LEAD = 14; // body leading in pt: 31 lines fill the text block const PREFACE = { size: 9.8, lead: 13.3 }; // pt: the preface, a size under the novel's 10.5/14 // Design-slot shorthands. at(): a placement from an anchor's edge; edge 'top' centres it. const wide = (width) => (width === 'fill' ? 'fill' : mm(width)); const at = (edge, x, y, { width, to = 'container' } = {}) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(width !== undefined && { size: { width: wide(width) } }) }); const onPage = (y) => at('top', 0, y, { to: 'page' }); // centred on the page, y mm from the trim const on = (id) => ({ color: col(id) }); // a design text's ink // overflow 'wrap' for the running heads too, where a text too wide would end in '…'. const text = (id, content, font, size, placement, extra = {}) => ({ kind: 'text', id, content, fontFamily: font, fontSize: pt(size), color: col('ink'), align: 'center', overflow: 'wrap', placement, ...extra }); const label = (id, content, size, placement, ink, tracking) => text(id, content, 'Cinzel', size, placement, { fontWeight: 600, letterSpacing: pt(tracking), textTransform: 'uppercase', ...on(ink) }); // the label voice: Cinzel capitals, tracked const image = (id, placement) => ({ kind: 'image', id, resourceId: id, placement }); // A heading's design. minHeight (mm) where the text must start lower than the elements reach. const design = (elements, minHeight) => ({ enabled: true, slot: { elements }, ...(minHeight !== undefined && { minHeight: mm(minHeight) }) }); // The prelims' openers: a centred title in small capitals on white space, and the preface's // text a size smaller than the novel's, on its own leading. const FRONT_DROP = 46; // mm from the text block's top to the first line of text const frontLook = { advancedDesign: design([text('title', '{titleText}', 'Playfair Display SC', 19, at('top', 0, 14), { letterSpacing: pt(1.6) })], FRONT_DROP), bodyStyle: { fontSize: pt(PREFACE.size), lineHeight: pt(PREFACE.lead) }, }; // #region answer: roman folios for the front matter and arabic from Letter I, in one design // The page counter starts in lower-case roman: the cover is page i. After the Preface, two // directives in the Markdown count again from 1 on the next recto: // :::pagebreak{parity="odd"} // :::numbering{format="decimal" startAt=1} // :::numbering takes effect on the next page that starts (gotcha: numbering-next-page). Level 1 // breaks to 'odd', so here the pagebreak changes nothing; it finds the recto when a level breaks // with parity 'any' or not at all. Never with 'always-odd': the blank pages would stack up. const pageNumbering = { format: 'lower-roman', startAt: 1 }; // startAt spelled out: cover = i // One folio design, centred under the text block. {pageNumber} prints the page's own label, // so the same design sets "x" in the front matter and "1" on the first page of the novel. const folio = (pages) => text(`folio-${pages}`, '{pageNumber}', 'Playfair Display SC', 8.5, at('top', 0, 9), { letterSpacing: pt(0.8), pages }); // lower case sets as small capitals // The front matter is a heading style, unnumbered and left out of the contents (a heading // can opt back in with {toc="true"}). It prints no running heads and puts a folio on every // page of the section. Parity 'any', because the contents fall on a verso. const front = { id: 'front', numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' }, header: { elements: [] }, footer: { elements: [folio('all')] }, ...frontLook, }; // The novel keeps the document's furniture: heads on its body pages, the folio on openers. const footer = { elements: [folio('opener')] }; // Hook-up in config(): page: { pageNumbering }, headingStyles: [front] and footer at the top // level. frontLook, set above, is the look of the prelims' openers. // #endregion // #region leaves: the cover and the preliminary leaves, headings that print a design only // Each leaf is a heading, so it opens its page, gets a PDF bookmark and takes a design. The // leaves are unnumbered, left out of the contents and print no heads or folio (blind folios). // Parity 'any', or each would inherit level 1's odd break (gotcha: style-inherits-break). const blind = { numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' }, header: { elements: [] }, footer: { elements: [] } }; const PLATE_H = 126; // mm: the frontispiece, as wide as the text block; its caption hangs below // mm from the trim: the imprint's eleven lines end on the text block's last baseline. const IMPRINT_TOP = 126.4; // re-tune it whenever the imprint changes const leaves = [ // i: bled to the trim. The painting is as tall as the page, so the cover takes it whole. { id: 'cover', ...blind, advancedDesign: design([ image('cover', at('top-left', 0, 0, { width: 'fill', to: 'bleed' })), label('author', '{author}', 9, onPage(22), 'bone', 2.6), text('title', '{title}', 'Fanwood Text', 56, onPage(31), { color: col('bone'), lineHeight: 1 }), text('subtitle', '{subtitle}', 'Fanwood Text', 13, onPage(53), { ...on('ice'), italic: true }), label('series', '{attr.publisher}', 7, onPage(168), 'ice', 2), ]) }, // iii: the half title waits for a recto, so the back of the cover, ii, is left blank. { id: 'half', ...blind, breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: design([ text('title', '{title}', 'Playfair Display SC', 17, at('top', 0, 34), { letterSpacing: pt(3) }), image('crystal', at('top', 0, 45, { width: 6 })), ]) }, { id: 'plate', ...blind, advancedDesign: design([ // iv: faces the title page image('plate', at('top-left', 0, 0, { width: 'fill' })), text('caption', '{attr.caption}', 'Fanwood Text', 8.8, at('top', 0, PLATE_H + 5, { width: 80 }), { italic: true, lineHeight: 1.3 }), label('source', '{attr.source}', 7.5, at('top', 0, PLATE_H + 16), 'muted', 1.3), ]) }, { id: 'title', ...blind, advancedDesign: design([ // v: four frontmatter fields, one attribute label('edition', 'The text of {publishDate}', 7.5, at('top', 0, 22), 'accent', 1.8), text('title', '{title}', 'Playfair Display SC', 33, at('top', 0, 30), { fontWeight: 700, letterSpacing: pt(0.4), lineHeight: 1 }), text('subtitle', '{subtitle}', 'Fanwood Text', 14, at('top', 0, 44), { italic: true }), { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.8), color: col('accent'), placement: at('top', 0, 60, { width: 14 }) }, label('author', '{author}', 10, at('top', 0, 66), 'ink', 2.4), image('mark', at('top', 0, 126, { width: 11 })), label('publisher', '{attr.publisher}', 7.5, at('top', 0, 140), 'muted', 1.5), ]) }, // vi: the section's margins set the small print low, in a 70 mm measure against the outer // margin. hidden: the heading opens the page and names its bookmark, and prints nothing. { id: 'imprint', ...blind, hidden: true, margins: { top: mm(IMPRINT_TOP), left: mm(TRIM_W - OUTER - 70) } }, // vii: dedication and epigraph, under a heading whose design is an ornament and no title. { id: 'quiet', ...blind, advancedDesign: design([image('crystal', at('top', 0, 24, { width: 6 }))], 34) }, ]; // :::paragraphs blocks set the small print, the dedication, the epigraph and Walton's close; // the dedication and the verse take one paragraph per line, so no line of them can reflow. const VERSE_IN = 22; // mm: the indent that centres the verse as a block const SOURCE_IN = 55; // mm: 'Paradise Lost' ends under the end of the longest line of verse const prelimStyles = [ { id: 'small-print', fontSize: pt(7.6), lineHeight: pt(10.4), textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(5.2) }, // Small capitals, with the italic lines of the 1818 page, leaded as one inscription. { id: 'dedication', fontFamily: 'Playfair Display SC', fontSize: pt(10), lineHeight: pt(15), textAlign: 'center', firstLineIndent: pt(0) }, { id: 'verse', fontSize: pt(10), textAlign: 'left', firstLineIndent: mm(VERSE_IN), marginTop: pt(LEAD * 4) }, // on the body's 14 pt leading, which a style inherits { id: 'source', fontFamily: 'Cinzel', fontSize: pt(7.5), textAlign: 'left', firstLineIndent: mm(SOURCE_IN), marginTop: pt(4), color: col('muted') }, { id: 'signature', textAlign: 'right', firstLineIndent: pt(0) }, // the letter's close ]; // #endregion // #region contents: what :::toc prints: titles, dotted leaders, the labels each page prints // Entries take the body's face and ink, and start on the Preface's first baseline, facing it. const contents = { levels: [{ level: 1, fontSize: pt(12), lineHeight: pt(PREFACE.lead) }], // The folio's face: "ix" in small capitals, then a plain 1. The leader takes it too. pageNumber: { fontFamily: 'Playfair Display SC', fontSize: pt(10), color: col('accent'), width: mm(8) }, leader: { char: '. ', gap: mm(2.5) }, // A second line from the heading's to="…" attribute: whom each letter is written to. subtitle: { enabled: true, attr: 'to', fontSize: pt(9.5), color: col('muted') }, }; // #endregion // #region letter: the novel's opener: the northern lights, bled off the head of the recto const BAND = 104; // mm from the trim to the foot of the drawing, which fades into the paper // minHeight ends AIR mm below it and the first line takes the next grid line. to: 'bleed' is the // trim until page.cutLines adds a bleed; 3 mm drop the foot 1.8 mm, still above that line. const AIR = 2; const opener = design([ image('band', at('top-left', 0, 0, { width: 'fill', to: 'bleed' })), label('to', '{attr.to}', 7.5, onPage(19), 'ice', 2), text('title', '{titleText}', 'Playfair Display SC', 46, onPage(25), { color: col('bone'), lineHeight: 1 }), text('dateline', '{attr.dateline}', 'Fanwood Text', 10.5, onPage(44), { italic: true, color: col('bone') }), // The drawing reserves down to its foot. minHeight, counted from the text block (TOP below // the trim), is a floor AIR mm lower, which sends the first line one grid line further down. ], BAND - TOP + AIR); // Walton's letters come before Chapter I: they take the novel's opener but no number. const letter = { id: 'letter', numbered: false }; // #endregion // #region heads: running heads on the novel's body pages; the book on versos, letter on rectos const HEAD_Y = 12.5; // mm from the trim to the heads' line const HEAD_GAP = 8; // mm from the outer margin, where the folio hangs, to the words beside it const head = (id, content, parity, edge, x, extra = {}) => ({ ...label(id, content, 7.5, at(edge, x, HEAD_Y, { to: 'page' }), 'muted', 1.4), align: 'left', parity, pages: 'body', ...extra }); // pages: 'body' keeps them off the opener const numeral = { fontFamily: 'Playfair Display SC', fontSize: pt(8.5), fontWeight: 400, letterSpacing: pt(0.6), color: col('ink') }; // folios in the folio face, as on the openers const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, numeral), head('verso-title', '{title}', 'even', 'top-left', OUTER + HEAD_GAP), head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + HEAD_GAP)), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, numeral), ] }; // #endregion const config = () => ({ locale: 'en-us', // the hyphenation patterns colorPalette, layout: { layoutType: 'single' }, page: { // mirror: left is the inner margin; 150 dpi is for the screen sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, backgroundColor: col('paper'), pageNumbering, margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true }, }, bodyText: { // justified, hyphenated, optimal line breaking, widow control: the defaults fontFamily: 'Fanwood Text', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, // An even grey: spaces 0.75–1.75 of their width; a last line under 36 spaces' width (about // 18 letters: a lone 'occurrence.') is a runt, and costs three times the default. minWordSpacing: 0.75, maxWordSpacing: 1.75, runtMinCharacters: 36, runtPenalty: 3000, }, headings: { fontFamily: 'Playfair Display SC', fontWeight: 400, color: col('ink'), // 'odd': the novel opens on a recto. span 'page': a design kept in the column is cut at // the foot of the text block, and the cover's painting, a style of this level, runs on. levels: [{ level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener }], }, headingStyles: [...leaves, front, letter], toc: contents, paragraphStyles: prelimStyles, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown-Beispiel · 105 Zeilen · content.en.md
title: "Frankenstein" subtitle: "or, The Modern Prometheus" author: "Mary Shelley" publishDate: "1818" --- # Cover {style="cover" publisher="Postext Classics"} # Half title {style="half"} # Frontispiece {style="plate" caption="We perceived a low carriage, fixed on a sledge and drawn by dogs, pass on towards the north, at the distance of half a mile." source="Letter IV"} # Title page {style="title" publisher="Postext Classics · MMXXVI"} # Imprint {style="imprint"} :::paragraphs{style="small-print"} *Frankenstein; or, The Modern Prometheus* was first published anonymously, in three volumes, by Lackington, Hughes, Harding, Mavor & Jones of Finsbury Square, London, on 1 January 1818. This edition follows that first text, transcribed by Project Gutenberg from a photographic reprint (eBook 41445), and keeps its spelling: phænomena, their’s, St. Petersburgh. Set in Fanwood Text, Playfair Display SC and Cinzel (SIL Open Font License). Cover and paintings generated with diffusion models; publisher’s mark drawn in code; pages laid out by Postext. Text: public domain. Design and editorial matter: CC BY 4.0. Postext Cookbook · No. 6 ::: # Dedication {style="quiet"} :::paragraphs{style="dedication"} *To* William Godwin, *Author of Political Justice,* *Caleb Williams, &c.* These volumes *Are respectfully inscribed* By The Author. ::: :::paragraphs{style="verse"} *Did I request thee, Maker, from my clay* *To mould me man? Did I solicit thee* *From darkness to promote me?——* ::: :::paragraphs{style="source"} Paradise Lost ::: # Contents {style="front"} :::toc # Preface {style="front" toc="true"} The event on which this fiction is founded has been supposed, by Dr. Darwin, and some of the physiological writers of Germany, as not of impossible occurrence. I shall not be supposed as according the remotest degree of serious faith to such an imagination; yet, in assuming it as the basis of a work of fancy, I have not considered myself as merely weaving a series of supernatural terrors. The event on which the interest of the story depends is exempt from the disadvantages of a mere tale of spectres or enchantment. It was recommended by the novelty of the situations which it developes; and, however impossible as a physical fact, affords a point of view to the imagination for the delineating of human passions more comprehensive and commanding than any which the ordinary relations of existing events can yield. I have thus endeavoured to preserve the truth of the elementary principles of human nature, while I have not scrupled to innovate upon their combinations. The *Iliad*, the tragic poetry of Greece,—Shakespeare, in the *Tempest* and *Midsummer Night’s Dream*,—and most especially Milton, in *Paradise Lost*, conform to this rule; and the most humble novelist, who seeks to confer or receive amusement from his labours, may, without presumption, apply to prose fiction a licence, or rather a rule, from the adoption of which so many exquisite combinations of human feeling have resulted in the highest specimens of poetry. The circumstance on which my story rests was suggested in casual conversation. It was commenced, partly as a source of amusement, and partly as an expedient for exercising any untried resources of mind. Other motives were mingled with these, as the work proceeded. I am by no means indifferent to the manner in which whatever moral tendencies exist in the sentiments or characters it contains shall affect the reader; yet my chief concern in this respect has been limited to the avoiding of the enervating effects of the novels of the present day, and to the exhibitions of the amiableness of domestic affection, and the excellence of universal virtue. The opinions which naturally spring from the character and situation of the hero are by no means to be conceived as existing always in my own conviction; nor is any inference justly to be drawn from the following pages as prejudicing any philosophical doctrine of whatever kind. It is a subject also of additional interest to the author, that this story was begun in the majestic region where the scene is principally laid, and in society which cannot cease to be regretted. I passed the summer of 1816 in the environs of Geneva. The season was cold and rainy, and in the evenings we crowded around a blazing wood fire, and occasionally amused ourselves with some German stories of ghosts, which happened to fall into our hands. These tales excited in us a playful desire of imitation. Two other friends (a tale from the pen of one of whom would be far more acceptable to the public than any thing I can ever hope to produce) and myself agreed to write each a story, founded on some supernatural occurrence. The weather, however, suddenly became serene; and my two friends left me on a journey among the Alps, and lost, in the magnificent scenes which they present, all memory of their ghostly visions. The following tale is the only one which has been completed. :::pagebreak{parity="odd"} :::numbering{format="decimal" startAt=1} # Letter I {style="letter" to="To Mrs. Saville, England" dateline="St. Petersburgh, Dec. 11th, 17—"} You will rejoice to hear that no disaster has accompanied the commencement of an enterprise which you have regarded with such evil forebodings. I arrived here yesterday; and my first task is to assure my dear sister of my welfare, and increasing confidence in the success of my undertaking. I am already far north of London; and as I walk in the streets of Petersburgh, I feel a cold northern breeze play upon my cheeks, which braces my nerves, and fills me with delight. Do you understand this feeling? This breeze, which has travelled from the regions towards which I am advancing, gives me a foretaste of those icy climes. Inspirited by this wind of promise, my day dreams become more fervent and vivid. I try in vain to be persuaded that the pole is the seat of frost and desolation; it ever presents itself to my imagination as the region of beauty and delight. There, Margaret, the sun is for ever visible; its broad disk just skirting the horizon, and diffusing a perpetual splendour. There—for with your leave, my sister, I will put some trust in preceding navigators—there snow and frost are banished; and, sailing over a calm sea, we may be wafted to a land surpassing in wonders and in beauty every region hitherto discovered on the habitable globe. Its productions and features may be without example, as the phænomena of the heavenly bodies undoubtedly are in those undiscovered solitudes. What may not be expected in a country of eternal light? I may there discover the wondrous power which attracts the needle; and may regulate a thousand celestial observations, that require only this voyage to render their seeming eccentricities consistent for ever. I shall satiate my ardent curiosity with the sight of a part of the world never before visited, and may tread a land never before imprinted by the foot of man. These are my enticements, and they are sufficient to conquer all fear of danger or death, and to induce me to commence this laborious voyage with the joy a child feels when he embarks in a little boat, with his holiday mates, on an expedition of discovery up his native river. But, supposing all these conjectures to be false, you cannot contest the inestimable benefit which I shall confer on all mankind to the last generation, by discovering a passage near the pole to those countries, to reach which at present so many months are requisite; or by ascertaining the secret of the magnet, which, if at all possible, can only be effected by an undertaking such as mine. These reflections have dispelled the agitation with which I began my letter, and I feel my heart glow with an enthusiasm which elevates me to heaven; for nothing contributes so much to tranquillize the mind as a steady purpose,—a point on which the soul may fix its intellectual eye. This expedition has been the favourite dream of my early years. I have read with ardour the accounts of the various voyages which have been made in the prospect of arriving at the North Pacific Ocean through the seas which surround the pole. You may remember, that a history of all the voyages made for purposes of discovery composed the whole of our good uncle Thomas’s library. My education was neglected, yet I was passionately fond of reading. These volumes were my study day and night, and my familiarity with them increased that regret which I had felt, as a child, on learning that my father’s dying injunction had forbidden my uncle to allow me to embark in a sea-faring life. These visions faded when I perused, for the first time, those poets whose effusions entranced my soul, and lifted it to heaven. I also became a poet, and for one year lived in a Paradise of my own creation; I imagined that I also might obtain a niche in the temple where the names of Homer and Shakespeare are consecrated. You are well acquainted with my failure, and how heavily I bore the disappointment. But just at that time I inherited the fortune of my cousin, and my thoughts were turned into the channel of their earlier bent. Six years have passed since I resolved on my present undertaking. I can, even now, remember the hour from which I dedicated myself to this great enterprise. I commenced by inuring my body to hardship. I accompanied the whale-fishers on several expeditions to the North Sea; I voluntarily endured cold, famine, thirst, and want of sleep; I often worked harder than the common sailors during the day, and devoted my nights to the study of mathematics, the theory of medicine, and those branches of physical science from which a naval adventurer might derive the greatest practical advantage. Twice I actually hired myself as an under-mate in a Greenland whaler, and acquitted myself to admiration. I must own I felt a little proud, when my captain offered me the second dignity in the vessel, and entreated me to remain with the greatest earnestness; so valuable did he consider my services. And now, dear Margaret, do I not deserve to accomplish some great purpose. My life might have been passed in ease and luxury; but I preferred glory to every enticement that wealth placed in my path. Oh, that some encouraging voice would answer in the affirmative! My courage and my resolution is firm; but my hopes fluctuate, and my spirits are often depressed. I am about to proceed on a long and difficult voyage; the emergencies of which will demand all my fortitude: I am required not only to raise the spirits of others, but sometimes to sustain my own, when their’s are failing. This is the most favourable period for travelling in Russia. They fly quickly over the snow in their sledges; the motion is pleasant, and, in my opinion, far more agreeable than that of an English stage-coach. The cold is not excessive, if you are wrapt in furs, a dress which I have already adopted; for there is a great difference between walking the deck and remaining seated motionless for hours, when no exercise prevents the blood from actually freezing in your veins. I have no ambition to lose my life on the post-road between St. Petersburgh and Archangel. I shall depart for the latter town in a fortnight or three weeks; and my intention is to hire a ship there, which can easily be done by paying the insurance for the owner, and to engage as many sailors as I think necessary among those who are accustomed to the whale-fishing. I do not intend to sail until the month of June: and when shall I return? Ah, dear sister, how can I answer this question? If I succeed, many, many months, perhaps years, will pass before you and I may meet. If I fail, you will see me again soon, or never. Farewell, my dear, excellent, Margaret. Heaven shower down blessings on you, and save me, that I may again and again testify my gratitude for all your love and kindness. :::paragraphs{style="signature"} *Your affectionate brother,* R. Walton. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the publisher's mark and an ice crystal, drawn in the palette const f = (n) => n.toFixed(2); const poly = (pts, fill, a = 1) => `<path d="M${pts.map(([x, y]) => `${f(x)} ${f(y)}`).join('L')}Z"` + ` fill="${fill}" fill-opacity="${a}"/>`; const disk = (x, y, r, fill, a = 1) => `<circle cx="${f(x)}" cy="${f(y)}" r="${f(r)}" ` + `fill="${fill}" fill-opacity="${a}"/>`; const PX = 10; // each drawing is w × h mm in its viewBox and declares w·PX × h·PX pixels const svgOf = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" ` + `height="${h * PX}" viewBox="0 0 ${w} ${h}">${body}</svg>`; // The publisher's mark: a polar star in a double ring. The ornament: an ice crystal. const EMBLEM = 40; // mm: both are drawn on a 40 × 40 viewBox function mark() { const star = []; for (let i = 0; i < 16; i++) { const a = (i * Math.PI) / 8; const rr = i % 4 === 0 ? 13 : i % 2 === 0 ? 7 : 2.6; star.push([20 + rr * Math.sin(a), 20 - rr * Math.cos(a)]); } const rings = [[19, 'accent'], [18, 'paper'], [16.2, 'accent'], [15.6, 'paper']]; return svgOf(EMBLEM, EMBLEM, rings.map(([rr, c]) => disk(20, 20, rr, palette[c])).join('') + poly(star, palette.ink)); } function crystal() { const arm = poly([[19.5, 20], [19.5, 3], [20, 1.5], [20.5, 3], [20.5, 20]], palette.accent) + poly([[20, 9], [15, 5], [15.5, 4.4], [20, 7.8], [24.5, 4.4], [25, 5]], palette.accent); return svgOf(EMBLEM, EMBLEM, [0, 60, 120, 180, 240, 300].map((a) => `<g transform="rotate(${a} 20 20)">${arm}</g>`).join('') + disk(20, 20, 2.4, palette.accent)); } for (const [id, svg] of Object.entries({ mark: mark(), crystal: crystal() })) { await loadSvg(`${id}.svg`, svg); } // Each declares svgOf's pixel size. const svgResource = (id, w, h, altText) => ({ id, typeId: 'figure', kind: 'svg', altText, createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX } }); const emblems = [ svgResource('mark', EMBLEM, EMBLEM, 'Publisher’s mark: a polar star in a double ring.'), svgResource('crystal', EMBLEM, EMBLEM, 'Ornament: an ice crystal.'), ]; // #endregion const painting = (id, w, h, altText) => ({ id, typeId: 'figure', kind: 'bitmap', altText, createdAt: 0, updatedAt: 0, bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h } }); const paintings = [ // JPEGs in assets/; only the designs use them, nothing cites them painting('cover', 1000, 1535, 'Lightning strikes Mont Blanc over the lake of Geneva.'), painting('plate', 1000, 1299, 'A brig beset in pack ice, a sledge drawn by dogs far off.'), painting('band', 1400, 1129, 'Northern lights over the frozen Neva and St Petersburgh.'), ]; await Promise.all(paintings.map(({ bitmap: b }) => loadImage(b.fileId, asset(b.fileId)))); const resources = [...paintings, ...emblems]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages set, loaded before the first build. const FONTS = { 'Fanwood Text': ['400', '400i'], Cinzel: ['400', '600'], 'Playfair Display SC': ['400', '400i', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: lay out once (buildDocument settles the contents), show, offer a PDF // :::toc lists each heading with the label of the page it lands on; buildDocument lays the // document out again until those labels stop moving (ix for the Preface, 1 for Letter I). const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); showPages(doc, { title: 'Frankenstein · the opening leaves' }); // The PDF gets /PageLabels from the same labels: a viewer numbers its pages i–x, then 1, 2. offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${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 Titelei in Großbuchstaben zählen
Römische Ziffern in Großbuchstaben erscheinen in der Schrift der Pagina als volle Versalien, höher als die Kapitälchen, die römische Ziffern in Kleinbuchstaben ergeben.
-const pageNumbering = { format: 'lower-roman', startAt: 1 }; // startAt spelled out: cover = i
+const pageNumbering = { format: 'upper-roman', startAt: 1 }; // startAt spelled out: cover = I#Das Buch durchgehend nummerieren
Lassen Sie startAt weg, und die Zählung läuft in arabischen Ziffern weiter, sodass „Letter I“ zu Seite 11 wird, wie in Büchern, die ihre Titelei mit dem Text zählen.
-:::numbering{format="decimal" startAt=1}
+:::numbering{format="decimal"}#Das Frontispiz weglassen
Löschen Sie die Überschrift # Frontispiece und schicken Sie das Titelblatt auf eine rechte Seite: Seite iv bleibt leer, das Vorwort ist weiterhin ix und „Letter I“ weiterhin 1.
- { id: 'title', ...blind, advancedDesign: design([ // v: four frontmatter fields, one attribute
+ { id: 'title', ...blind, breakBefore: { enabled: true, parity: 'odd' },
+ advancedDesign: design([ // v: four frontmatter fields, one attribute#Anschnitt und Schnittmarken für den Druck hinzufügen
Mit cutLines laufen Umschlag und Auftakt, beide am Anschnitt verankert, 3 mm über das Endformat hinaus; Paginas und Seitenzahl ändern sich nicht (Schnittmarken).
backgroundColor: col('paper'), pageNumbering,
+ cutLines: { enabled: true }, // a 3 mm bleed and crop marksHäufige Fehler
Häufiger Fehler
:::numbering wirkt ab der nächsten Seite
:::numbering ändert die Zählung ab der nächsten beginnenden Seite, nicht ab der aktuellen. Setzen Sie es direkt nach ein :::pagebreak (Parität odd vor dem ersten Kapitel), damit die neue Zählung dort beginnt, wo Sie es wollen. Römisch paginierte Titelei →
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 →
Häufiger Fehler
Seite 1 ist eine rechte Seite: Seiten mit physischen Nummern planen
Seite 1 ist eine rechte Seite und Seite 2 die erste linke, planen Sie Doppelseiten also mit physischen Seitenzahlen: ein Auftakt auf einer geraden Seite steht der ungeraden Seite danach gegenüber. Seiten- und Spaltenumbrüche →
Häufiger Fehler
Eine Seite übernimmt die Kopfzeile des letzten Abschnitts, der auf ihr beginnt
Beginnen zwei Überschriftenformate auf einer Seite, übernimmt die Seite Kopfzeile, Fußzeile und Palette des letzten. Eine trennende Vakatseite gehört zum Kapitel davor, eine Paritäts-Vakatseite zum Kapitel danach. Kolumnentitel je Abschnitt →
Sandbox-Prüfung · parityCascade
Paritätskaskade
Ursache. Paritätsumbrüche addieren sich und erzeugen mehr als zwei Vakatseiten hintereinander.
Lösung. Überprüfen Sie Umbrüche mit always-odd und die Paritäten von breakBefore; bei kurzen Kapiteln vermeidet parity any die Vakatseiten meist. Doku →
Sandbox-Prüfung · numberingInvalidFormat
Ungültiges Nummerierungsformat
Ursache. Ein Format von :::numbering ist keines von decimal, lower-roman, upper-roman, lower-alpha oder upper-alpha.
Lösung. Verwenden Sie eines der fünf Seitenzahlformate; Listen und Ressourcen schreiben ihre anders. Doku →
- Die Blätter erben den Umbruch
'odd'der Ebene 1. Ohne Eingriff wartet jedes auf eine rechte Seite. Die Titelei läuft dann auf vierzehn statt zehn Seiten, mit vier weiteren leeren linken Seiten, und das Frontispiz steht einer leeren Seite statt dem Titelblatt gegenüber. Geben Sie den Umbruch mitparity: 'any'neu an, wie dieses Rezept es tut, oder schalten Sie ihn mitbreakBefore: { enabled: false }ab; eine Überschrift über die ganze Seitenbreite eröffnet ohnehin ihre eigene Seite. - Entfernen Sie den
:::pagebreakvor:::numbering, wenn Ebene 1 mit'always-odd'umbricht, dem eigenen Standard der Engine für H1. Endet der Text davor auf einer rechten Seite, füllt der Seitenumbruch bis zur nächsten rechten Seite auf, dann fügt die Überschrift ihre Trennseite hinzu und füllt erneut auf, was drei leere Seiten hintereinander statt einer hinterlässt. Nach einem Umbruch'odd', wie hier, ändert der Seitenumbruch nichts; nach'any'ist er es, der Seite 1 auf eine rechte Seite bringt. - Ein Vorwort, das um eine Seite wächst, schiebt „Letter I“ um zwei Seiten weiter, weil der Umbruch auf eine ungerade Seite eine leere linke Seite davor einfügt. Prüfen Sie die Seitenzahl, wann immer Sie die Titelei bearbeiten.
- Die Punktlinien des Inhaltsverzeichnisses stehen in Schrift und Farbe der Seitenzahlen, und
toc.leaderhat keine eigene Farbe, deshalb erscheinen sie hier in Stahlblau.
Nachweise
- Rezept
- Ignacio Ferro
- Text
- Frankenstein; or, The Modern Prometheus (1818): the dedication, Letter I, a line of Letter IV (the frontispiece caption) and one of volume I, chapter VI (quoted in a code comment) · Mary Shelley · gemeinfrei
- The unsigned Preface of the 1818 edition (by common attribution) · Percy Bysshe Shelley · gemeinfrei
- Paradise Lost (1667), book X, lines 743–745: the epigraph of the 1818 title leaves · John Milton · gemeinfrei
- Bilder
- The cover: lightning over Mont Blanc and the lake of Geneva · Generated With Diffusion Models · eigener Text
- The frontispiece: a brig beset in pack ice, a dog sledge far off · Generated With Diffusion Models · eigener Text
- Letter I’s opener: northern lights over the frozen Neva · Generated With Diffusion Models · eigener Text
- Schriften
- Fanwood Text (SIL OFL 1.1) · Playfair Display SC (SIL OFL 1.1) · Cinzel (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


