Zum Hauptinhalt springen
Rezept Nummer 38

Rezepte · Kapitel 9 · Vollständige Publikationen

Taschenbuch: tiefe Auftakte und Kapitel rechts

Beate und Mareile als Taschenbuch von 140 × 216 mm: gemalter Umschlag, Impressum ohne Kolumnentitel, dann tief gesetzte Kapitel rechts unter kursiver Ziffer.

Auf dieser Seite

S. 1 von 9

  • Endformat 140 × 216 mm
  • 1 Spalte
  • Crimson Pro 10,5/14
  • Cormorant Garamond
  • Cormorant SC
  • 9 Seiten
  • Stufe
  • Postext 1.33.7
  • In 109 ms gesetzt
  • 196 Zeilen Code

Kurz gesagt

Die ersten beiden Kapitel eines deutschen Romans als Taschenbuch. Jedes Kapitel beginnt auf einer rechten Seite, weiter unten, unter einer großen Kapitelnummer.

Was Sie setzen

Neun Seiten eines Taschenbuchs von 140 × 216 mm enthalten die ersten beiden Kapitel von Eduard von Keyserlings Beate und Mareile (1903), das zweite gekürzt, in der Schreibung der Ausgabe bei S. Fischer. Der Umschlag, ein mit Diffusionsmodellen erzeugtes und als JPEG geladenes Gemälde, zeigt das Meer zwischen den Stämmen zweier Bäume: ein Segelboot am Horizont und einen weißen Sonnenschirm, der vom Strand einen Weg zwischen gelben und weißen Blumen heraufkommt. Das Impressum steht am Fuß seiner linken Seite. Jedes Kapitel beginnt auf einer rechten Seite mit seinem Text in 81 mm Tiefe, unter einer kursiven Ziffer zu 48 pt, einer rostroten Haarlinie von 18 mm und dem ersten Satz des Kapitels in Kapitälchen, nach dem Sinn in zwei zentrierte Zeilen umbrochen. Kapitel I endet auf Seite 6, einer linken Seite, mit einer Schlussvignette aus Kamille, und Kapitel II beginnt ohne Vakatseite auf Seite 7 und endet auf Seite 9. In der englischen Ausgabe endet Kapitel I auf Seite 5, einer rechten Seite, und Seite 6 bleibt leer, ohne Kolumnentitel oder Pagina. Die Kolumnentitel tragen auf den linken Seiten den Autor und auf den rechten den Titel in Kursiv.

Dieses Rezept beantwortet

  • Wie erzwinge ich einen Seiten- oder Spaltenumbruch und lasse jedes Kapitel auf einer rechten Seite beginnen?
  • Wie füge ich einem Auftakt eine Autorenzeile, einen Vorspann oder einen Einstieg mit Initiale hinzu?
  • Wie füge ich gezielt Vakatseiten ein oder beginne einen Abschnitt auf einer neuen Doppelseite?
  • Wie setze ich Kolumnentitel: Buchtitel auf der linken Seite, Kapiteltitel auf der rechten, Seitenzahl außen?
  • Wie erstelle ich einen Umschlag, einen Schmutztitel, eine Titelseite und ein Impressum?
  • Wie füge ich zusätzlichen vertikalen Abstand zwischen zwei Blöcken ein, wenn Leerzeilen nichts bewirken?
  • Wie setze ich nicht nummerierte Grafiken: Ornamente, Vignetten, Logos?

Die kurze Antwort

script.js · Zeilen 47–77im vollständigen Code
// # I {lead="A green and yellow parrot, … kept repeating over and over:"}
// The heading's text is the numeral, printed by {titleText}; the chapter's first sentence
// travels as the heading's lead attribute, printed by {attr.lead} in small capitals: a
// centred display, which a \n in the attribute breaks by sense.
const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts
const NUMERAL = 48; // pt
const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead
const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } });
const opener = {
  enabled: true,
  // The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and
  // starts the text on line BODY_AT (a one-line lead would leave two).
  minHeight: line(BODY_AT),
  slot: { elements: [
    { kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'),
      align: 'center', placement: centred(mm(NUMERAL_Y)) },
    { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center',
      lineHeight: LEAD / 9.5, // one grid line to each line of the lead
      placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } },
  ] },
};
// Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on
// a recto, where the default 'always-odd' leaves a blank page after every chapter.
// marginBottom 0: the level's default 0.5 em would start the text a line lower.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
  marginBottom: pt(0), italic: true, advancedDesign: opener };

Zutaten

Schrift
Crimson Pro, Cormorant Garamond, Cormorant SC (SIL OFL 1.1)
Dateien
  • cover-1190.jpg
  • cover-it-1190.jpg
  • Der Blick aufs Meer auf dem Umschlag, ein Gemälde (Generated With Diffusion Models, eigener Text)
  • The Italian edition’s cover: a detail of Settignano (c. 1880), oil on canvas; scan from Wikimedia Commons, a 1,744 × 1,669 px cut reduced to 1,190 px wide (Telemaco Signorini, gemeinfrei)
  • Die Kamillenvignette, in Code mit der Farbpalette der Seite gezeichnet (Ignacio Ferro, MIT)

Zubereitung

#1 · Der Auftakt stammt aus der Überschrift und ihrem Attribut

Der Code steht oben in der kurzen Antwort. Keyserlings Kapitel tragen keine Titel, nur ihre Zählung; jede Überschrift enthält also nur ihre Ziffer (# I), die {titleText} druckt. {attr.lead} druckt das Attribut lead der Überschrift, das den ersten Satz des Kapitels enthält; der Markdown-Absatz unter der Überschrift beginnt mit dem Satz danach (Attribute von Überschriften). Der Anfangssatz ist eine zentrierte Auszeichnungszeile, und das \n in jedem Attribut bricht ihn nach dem Sinn um, nach erscholl und nach Tarniffs,. Der Kasten des Anfangssatzes beginnt neun Rasterzeilen unter der Oberkante des Satzspiegels, und ein minHeight von 12 Zeilen lässt darunter eine Leerzeile; ohne minHeight beginnt der Text direkt unter dem Anfangssatz (Span und erweitertes Design). marginBottom: pt(0) ersetzt den voreingestellten Abstand von einem halben Geviert der Ebene, der den Text eine Zeile tiefer schieben würde.

breakBefore mit der Parität 'odd' schickt jedes Kapitel auf eine rechte Seite, mit einer leeren linken Seite nur nach einem Kapitel, das auf einer rechten Seite endet (Umbruch davor). Um dort eine rechte Seite zu erzwingen, wo keine Überschrift steht, schreiben Sie :::pagebreak{parity="odd"} ins Markdown; in einem Layout mit zwei oder mehr Spalten schiebt :::columnbreak den Text stattdessen in die nächste Spalte (:::pagebreak).

#2 · Eine Taschenbuchseite aus ganzen Zeilen

script.js · Zeilen 33–43im vollständigen Code
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in
const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto
const LINES = 35; // every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * MM_PER_PT),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};

Der Fußsteg ist, was das Endformat von 216 mm nach Kopfsteg und 35 Zeilen zu 14 pt übrig lässt, sodass jede volle Seite auf derselben Zeile endet, wie die Seiten 3, 4, 7 und 8. Seite 5 endet eine Zeile höher, damit der folgende Absatz nicht eine einzelne Zeile am Fuß der Seite zurücklässt. Mit mirror: true liegt der Bundsteg von 20 mm auf rechten wie linken Seiten an der Seite des Bunds (gespiegelte Ränder). Die Satzbreite von 104,5 mm fasst etwa 69 Zeichen Crimson Pro zu 10,5 pt. Der Grundtext hält die Wortzwischenräume zwischen dem 0,7- und dem 1,9-Fachen der normalen Breite; beim voreingestellten Minimum von 0,6 schließt sich eine Zeile der englischen Ausgabe auf 0,62.

#3 · Kolumnentitel, die Auftaktseiten und leere Seiten überspringen

script.js · Zeilen 81–101im vollständigen Code
// {author} and {title} come from the frontmatter.
const HEAD_Y = 12; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontSize: pt(9), color: col('muted'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } },
});
const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) };
const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) };
const folio = { fontFamily: TEXT, color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-title', '{title}', 'odd', 'top', SHIFT, italic),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] };

parity: 'even' setzt die Autorin in Kapitälchen der Cormorant SC auf die linken Seiten, und 'odd' setzt den Titel kursiv auf die rechten; die Paginas stehen an der Außenkante des Satzspiegels. pages: 'body' hält alle vier Elemente von den Auftaktseiten fern und von einer leeren linken Seite, die der Paritätsumbruch hinzufügt, wie Seite 6 der englischen Ausgabe; mit pages: 'all' würde diese Seite „6“ und „Kate Chopin“ drucken. In der deutschen Ausgabe ist Seite 6 eine Textseite mit „Eduard von Keyserling“ im Kolumnentitel, weil Kapitel I dort endet. Die Auftaktseiten erhalten stattdessen eine Pagina am Fuß, ein Fußzeilenelement, das pages: 'opener' auf sie beschränkt (Textelemente).

#4 · Der Umschlag ist ein Abschnitt ohne Kolumnentitel

script.js · Zeilen 105–128im vollständigen Code
// Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and
// chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it.
// The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines.
const COLOPHON_LINES = 6;
const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down
const cover = {
  id: 'cover',
  header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2
  margins: { top: mm(COLOPHON_TOP) },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center',
      placement: onPage(155) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } },
    { kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12),
      letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) },
  ] } },
};
const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'),
  textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) };

Der Umschlag ist eine Überschrift, # Beate und Mareile {style="cover"}. Ihr Format setzt das Gemälde über die oberen 134 mm, den Titel aus dem Frontmatter zu 50 pt und den Autor in Kapitälchen. Das Format gilt auch für den Abschnitt, den die Überschrift eröffnet, die Seiten 1 und 2 bis zu Kapitel I. Seine leeren header und footer halten die Pagina am Fuß vom Umschlag und die Kolumnentitel vom Impressum fern. Sein Kopfsteg setzt das Impressum auf die letzten sechs Rasterzeilen des Satzspiegels, sodass die letzte Zeile des Impressums auf der Höhe der letzten Zeile von Seite 3 steht (Überschriftenformate). Die Spalte des Abschnitts beginnt 165 mm tief, und das Design, an Seite und Anschnitt verankert, zeichnet trotzdem darüber: Nur der Fuß einer Spalte beschneidet ein Design, das in ihr steht.

Seite 2: das Impressum am Fuß der Rückseite des Umschlags, ohne Kolumnentitel oder Pagina.

#5 · Eine Schlussvignette schließt jedes Kapitel ab

script.js · Zeilen 132–164im vollständigen Code
// At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha
// resource-double-quotes). The space adds a line to the gap the embed keeps above it.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.17, align: 'center' } };
const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg',
  svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 });
// The cover is a painting, a JPEG in assets/ cut to the art's 140 × 134 mm, declared at its pixels;
// the it edition's is a detail of Signorini's Settignano.
const painting = { id: 'cover', typeId: 'ornament', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: t({ en: 'cover-1190.jpg', it: 'cover-it-1190.jpg' }), format: 'jpeg',
    width: 1190, height: 1139 },
  altText: t({ en: 'The gulf seen between the trunks of water-oaks, a lugger on the horizon; a '
    + 'white sunshade comes up from the beach through the camomile.',
  pt: 'O mar visto entre os troncos de duas árvores, um veleiro no horizonte; uma sombrinha '
    + 'branca sobe da praia por um caminho entre flores amarelas e brancas.',
  fr: 'La mer vue entre les troncs de deux arbres, un voilier à l’horizon ; une ombrelle '
    + 'blanche monte de la plage par un chemin entre des fleurs jaunes et blanches.',
  de: 'Das Meer zwischen den Stämmen zweier Bäume, ein Segelboot am Horizont; ein weißer '
    + 'Sonnenschirm kommt vom Strand einen Weg zwischen gelben und weißen Blumen herauf.',
  it: 'Una strada di Settignano: la bottega del pane e del vino, una donna seduta sulla '
    + 'soglia, un gatto sul selciato al sole.' }) };
const resources = [
  painting,
  // ::resource places each ornament once, so each chapter end has its own id.
  ...t({ en: ['I', 'II'], pt: ['I', 'II', 'III'], fr: ['I', 'II'], de: ['I', 'II'], it: ['I', 'II'] })
    .map((n, i) => svg(`tailpiece-${i + 1}`, 'fleuron.svg', 40, 12,
      t({ en: `A camomile flower between two sprigs closes chapter ${n}.`,
        pt: `Uma flor de camomila entre dois ramos fecha o capítulo ${n}.`,
        fr: `Une fleur de camomille entre deux brins clôt le chapitre ${n}.`,
        de: `Eine Kamillenblüte zwischen zwei Zweigen schließt das Kapitel ${n}.`,
        it: `Un fiore di camomilla tra due rametti chiude il capitolo ${n}.` }))),
];

Der Ornamenttyp hat kein Präfix für die Beschriftung, deshalb druckt die Kamillenblüte ohne Label, 17 % der Satzbreite breit, dort, wo ::resource{id="tailpiece-1"} steht (Ressourcentypen). Eine eingebettete Ressource im Fluss hält eine Rasterzeile über sich frei, und das :::space davor fügt zwischen der letzten Zeile des Kapitels und der Blüte eine zweite hinzu (:::space). Beide Schlussvignetten zeichnen dieselbe Datei, fleuron.svg, unter zwei IDs, weil eine Ressource nur einmal platziert wird.

Das ganze Rezept

Sandbox
// ═══ Postext Cookbook · Nº 038 · Trade paperback: sunk openers and recto chapters ═══
// https://postext.dev/en/cookbook/trade-paperback-novel
// Code: MIT · Text: Chopin, Machado, Goncourt, Collodi, Keyserling (PD) · Covers: diffusion models, Signorini
// Fonts: Crimson Pro, Cormorant Garamond, Cormorant SC (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'de'; // @lang: 'en' Chopin|'pt' Machado|'fr' Goncourt|'de' Keyserling|'it' Collodi
const RECIPE = 'trade-paperback-novel';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#231d18', // the text: a warm near-black
  rubric: '#8e3b22', // the one accent: the opener's hairline, the author, the tailpiece
  muted: '#6e655b', // running heads, folios on openers, the colophon
  paper: '#fbf7ef', // a cream book paper
};
// Each colour names its palette entry and carries its hex.
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 (headings, lists, callouts, captions) link to 'main-color', the rubric.
  { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } },
];
const TEXT = 'Crimson Pro'; // the text face
const DISPLAY = 'Cormorant Garamond'; // italic numerals and the cover's title
const LABEL = 'Cormorant SC'; // small capitals: the lead, the author's name
const [BODY, LEAD] = [10.5, 14]; // pt: the body size and its leading, the grid's pitch
const line = (n) => pt(n * LEAD); // n grid lines

// #region page: a 140 × 216 mm trade page whose text block holds 35 whole lines
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in
const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto
const LINES = 35; // every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * MM_PER_PT),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};
// #endregion

// #region answer: a sunk opener: the numeral, a hairline, and the first sentence in small caps
// # I {lead="A green and yellow parrot, … kept repeating over and over:"}
// The heading's text is the numeral, printed by {titleText}; the chapter's first sentence
// travels as the heading's lead attribute, printed by {attr.lead} in small capitals: a
// centred display, which a \n in the attribute breaks by sense.
const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts
const NUMERAL = 48; // pt
const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead
const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } });
const opener = {
  enabled: true,
  // The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and
  // starts the text on line BODY_AT (a one-line lead would leave two).
  minHeight: line(BODY_AT),
  slot: { elements: [
    { kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'),
      align: 'center', placement: centred(mm(NUMERAL_Y)) },
    { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center',
      lineHeight: LEAD / 9.5, // one grid line to each line of the lead
      placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } },
  ] },
};
// Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on
// a recto, where the default 'always-odd' leaves a blank page after every chapter.
// marginBottom 0: the level's default 0.5 em would start the text a line lower.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
  marginBottom: pt(0), italic: true, advancedDesign: opener };
// #endregion

// #region heads: the author on the verso, the title on the recto, folios outside
// {author} and {title} come from the frontmatter.
const HEAD_Y = 12; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontSize: pt(9), color: col('muted'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } },
});
const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) };
const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) };
const folio = { fontFamily: TEXT, color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-title', '{title}', 'odd', 'top', SHIFT, italic),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] };
// #endregion

// #region cover: a section of its own: art, title, author, no heads, a colophon on its verso
// Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and
// chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it.
// The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines.
const COLOPHON_LINES = 6;
const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down
const cover = {
  id: 'cover',
  header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2
  margins: { top: mm(COLOPHON_TOP) },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center',
      placement: onPage(155) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } },
    { kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12),
      letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) },
  ] } },
};
const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'),
  textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) };
// #endregion

// #region tailpiece: an ornament type that prints no caption, where ::resource stands
// At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha
// resource-double-quotes). The space adds a line to the gap the embed keeps above it.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.17, align: 'center' } };
const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg',
  svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 });
// The cover is a painting, a JPEG in assets/ cut to the art's 140 × 134 mm, declared at its pixels;
// the it edition's is a detail of Signorini's Settignano.
const painting = { id: 'cover', typeId: 'ornament', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: t({ en: 'cover-1190.jpg', it: 'cover-it-1190.jpg' }), format: 'jpeg',
    width: 1190, height: 1139 },
  altText: t({ en: 'The gulf seen between the trunks of water-oaks, a lugger on the horizon; a '
    + 'white sunshade comes up from the beach through the camomile.',
  pt: 'O mar visto entre os troncos de duas árvores, um veleiro no horizonte; uma sombrinha '
    + 'branca sobe da praia por um caminho entre flores amarelas e brancas.',
  fr: 'La mer vue entre les troncs de deux arbres, un voilier à l’horizon ; une ombrelle '
    + 'blanche monte de la plage par un chemin entre des fleurs jaunes et blanches.',
  de: 'Das Meer zwischen den Stämmen zweier Bäume, ein Segelboot am Horizont; ein weißer '
    + 'Sonnenschirm kommt vom Strand einen Weg zwischen gelben und weißen Blumen herauf.',
  it: 'Una strada di Settignano: la bottega del pane e del vino, una donna seduta sulla '
    + 'soglia, un gatto sul selciato al sole.' }) };
const resources = [
  painting,
  // ::resource places each ornament once, so each chapter end has its own id.
  ...t({ en: ['I', 'II'], pt: ['I', 'II', 'III'], fr: ['I', 'II'], de: ['I', 'II'], it: ['I', 'II'] })
    .map((n, i) => svg(`tailpiece-${i + 1}`, 'fleuron.svg', 40, 12,
      t({ en: `A camomile flower between two sprigs closes chapter ${n}.`,
        pt: `Uma flor de camomila entre dois ramos fecha o capítulo ${n}.`,
        fr: `Une fleur de camomille entre deux brins clôt le chapitre ${n}.`,
        de: `Eine Kamillenblüte zwischen zwei Zweigen schließt das Kapitel ${n}.`,
        it: `Un fiore di camomilla tra due rametti chiude il capitolo ${n}.` }))),
];
// #endregion

const card = { id: 'card', fontFamily: LABEL, fontWeight: 600, // the fr edition's visiting card
  textAlign: 'center', firstLineIndent: pt(0) };

const config = () => ({
  locale: t({ en: 'en-us', pt: 'pt', fr: 'fr', de: 'de', it: 'it' }), // each edition's hyphenation patterns
  colorPalette,
  resourceTypes: [ornament],
  page,
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    minWordSpacing: 0.7, maxWordSpacing: 1.9, // at the default 0.6 a line closes to 0.62
  },
  // The designs paint every heading; the level's own face is the numeral's (500 italic), so the
  // kit has no unused face to load.
  headings: { fontFamily: DISPLAY, fontWeight: 500, levels: [chapter] },
  headingStyles: [cover],
  paragraphStyles: [colophon, card],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown-Beispiel · 123 Zeilen · content.de.mdtitle: "Beate und Mareile" author: "Eduard von Keyserling" --- # Beate und Mareile {style="cover"} :::paragraphs{style="colophon"} Zuerst 1903 bei S. Fischer in Berlin erschienen. Kapitel 1 und 2 (dieses gekürzt) folgen dem Druck des Verlags, wie ihn Wikisource transkribiert hat, in dessen Schreibung. Gesetzt aus der Crimson Pro, der Cormorant Garamond und der Cormorant SC (SIL Open Font License). Der Umschlag, ein Gemälde, zeigt einen Strand zwischen Bäumen. Eine Ausgabe der Postext-Rezepte. Der Text ist gemeinfrei. ::: # I {lead="Aus dem Badezimmer erscholl\nein gleichmäßiges Plätschern."} Günther von Tarniff saß in seinem rotgelben Badebassin. Die lauwarme Dusche wurde in der Morgensonne ganz blank – fließendes Kristall. Das war so hübsch und angenehm, daß Günther sich nicht davon trennen konnte. Er saß da schon geraume Zeit und registrierte die behaglichen Empfindungen, die über seinen Körper hinglitten … wachsam und aufmerksam, wie er jedes angenehme Gefühl in sich zu verfolgen pflegte, als müßte aus dieser Addition sich ein Glück herausrechnen lassen. „Ziehen Herr Graf die neuen Weißen an?“ fragte Peter aus dem Nebenzimmer. „Ja. Gefallen sie dir nicht?“ rief Günther zurück. „’ne neue Mode. Wird man sehn,“ meinte Peter. Nun mußte Günther heraus. Peter rieb ihn behutsam mit einem weichen Tuche ab. Günther pflegte seinen Körper wie ein Brahmane. Er bewunderte ihn und achtete ihn, als die Tafel, auf der das Leben viele, wichtige Genüsse zu verzeichnen hat. „Frau Gräfin waren schon auf, bei der Morgenandacht,“ berichtete Peter. „Ja, bei den alten Herrschaften im Flügel ist Morgenandacht mit den Leuten vom Alten Testament, wie die Amalie sagt.“ „Teufel. Dann sind wir hier das Neue Testament – was? Bedeutend freche Jungfrau, die Amalie. Und du?“ „Gott, ich!“ Peter zog die Augenbrauen über den kleinen Lithauer Augen empor: „Heute bin ich dabei gewesen. So ’n mal. Sonst, der Beckmann geht nich –“ „– So – der Beckmann ist dein Dienerideal? – Gott! mit dem dummen Gesicht!“ Als Peter seinem Herrn das Beinkleid reichte, nahm er ein anderes Thema auf: „Schön is hier! Das Haus, der Garten. Alles gehört uns!“ „Ja,“ meinte Günther und hielt im Ankleiden inne, um seine Bemerkung Peter eindringlich mitzuteilen: „Wie dieser Anzug. Alles weich – lose. Nicht? Und die Uniform war steif – und eng. Nun also. Wenn man den Dienst aufgibt und nach Kaltin zieht, dann zieht man eben die Uniform aus und dies hier an!“ Peter war voller Bewunderung: „Wie spitzig der Herr Graf das sagen! Ja, so ’n Kopf, wie unser Graf! Aber so stramm war unser Dienst nicht.“ „Ach was, Dienst! Das Leben, verstehst du? Die Zeit vergeht und noch zu wenig, zu wenig …“ „Weiber,“ half Peter ein. „Ja, auch das. Das ist vorüber. Hier ist Ruhe.“ „Gott sei Dank,“ schloß Peter die Unterhaltung. Günther war fertig und stellte sich vor den Spiegel. Er sah gut aus, er konnte zufrieden sein: die matte Gesichtsfarbe, das schwarze Haar seiner italienischen Mutter, die braunen, blanken Frauenaugen mit den langen Wimpern, die Lippen so rot wie bei Knaben, in denen die Jugend noch wie ein Fieber brennt. „Heute wieder wunderbar,“ meinte Peter. „Sie hat auf mich gewartet,“ dachte Günther, als er in den Gartensaal trat und die zwei Gedecke auf dem Frühstückstische sah. Eine behagliche Rührung ergriff ihn bei diesem Anblick: „Angenehm ist das – wie – wie – reine Wäsche nach der Reise!“ Er trat auf die Veranda hinaus und blickte über die Kieswege und Blumenbeete hin. Die heiße Luft zitterte und flimmerte. Der Buchsbaum glänzte wie grünes Leder. Hinter dem Garten dehnte sich Wiesenland aus, dann niedrige Hügel, an denen die Äcker wie regelmäßige Seidenstreifen niederhingen. Unten, von der Buchsbaumhecke sah Günther seine Frau auf das Haus zulaufen. Die eine Hand hielt die Schleppe des weißen Kleides, die andere einen bunten Strauß Erbsenblüten. Ein wenig atemlos blieb Beate vor Günther stehen und lächelte. Die Gestalt schwankte leicht, wie zu biegsam. „Riech mal,“ sagte sie und hielt ihm den Strauß hin. „Das riecht wie Sommerferien, nicht?“ „Du kannst ja laufen wie ein Jöhr,“ meinte Günther. „Ja, ja!“ Beate lachte: „Hier ist man wieder jung; weil alles umher so schön alt ist, so alt wie – wie Kinderfrauen.“ Sie gingen in den Gartensaal. Günther streckte sich in einem Sessel aus und ließ sich Tee einschenken. „Gewiß! Gut ist’s hier,“ begann er, die Worte langsam vor sich hinschnarrend. „Wie’s so aussieht, müßte der schon ein umgewandter Monsieur sein, der hier nicht auf seine Rechnung kommt, wie, Beating?“ Beate schlug die Augen zu ihm auf, für das schmale, weiße Gesicht sehr große Augen, durchsichtig und graublau, mit ein wenig feuchtem Golde auf dem Grunde. Eine freundliche, ruhige Ironie lag in ihrem Blick. Das machte Günther befangen. Er begann im Zimmer auf und ab zu gehen und angeregt zu sprechen: „So wie hier, das lieb ich; ruhige, königlich preußische Schönheit. Die ewigen Großartigkeiten fallen mir auf die Nerven. Na – ja du – du bist anders. Sorrent – Luzern – das ist dir wie dein Deputat.“ „Ja, Kaltin ist gut,“ meinte Beate. „Hier läßt man sich also nieder,“ setzte Günther seine Betrachtung fort. „Das ist das Definitive – Ruhe – Abschluß.“ Beate zog die Augenbrauen empor. „Womit schließt du denn ab? Jetzt fängt’s doch gerade an – unser Leben.“ „Für euch Frauen,“ dozierte Günther mit klingender Stimme, „für euch ist die Ehe ein Anfang – *der* Anfang. Für uns Männer ist die Ehe auch ein Ende. Das Frühere ist zu Ende – aus; verstehst du? – Frauen unserer Gesellschaft haben kein Früher. Sie haben Gouvernanten, aber keine Vergangenheit gehabt.“ „Dieses ‚Früher‘ klingt ziemlich unsympathisch,“ warf Beate ein wenig gereizt ein. Günther lachte: „Ja, das könnt ihr nun mal nicht ändern. Ihr Ehefrauen seid immer ’ne Art Hafen. Du, Beating, bist ein hübscher, glatter, tiefer Hafen, gut ausgebaggert, man sieht bis auf den Grund.“ Beate schaute in der stillverschlossenen Art vor sich hin, die sie anzunehmen pflegte, wenn sie etwas gleichsam nicht zu sich hereinlassen wollte, es ihr zuwider war. Günther sprach schon von anderem: „Müssen wir nicht zu unseren alten Damen hinüber?“ „Ja, wenn du willst.“ „Sag, ist’s dort noch so – so – düster?“ „Düster – dort?“ „Na ja, für dich – natürlich – da sind’s die Kinderzimmer und so. Die Zimmer sind’s auch nicht. Ich glaube, es ist die Tante Seneïde.“ „Tante?“ rief Beate. „Aber Tante Seneïde ist doch wie – wie Mondschein im Ahnensaal.“ „So! ist das nicht unheimlich, wenn man so ist?“ „Ach nein!“ erklärte Beate. „Weißt du, wenn der Mond durch die oberen Fenster des Ahnensaals scheint, dann ist der Fußboden ganz voll von Lichtkringel. Als Kinder setzten Mareile und ich uns da mitten hinein. Tante Seneïde ging im Saale auf und ab und sagte ihre geistlichen Lieder her. Das war so echt Kaltinsch, und das gehört Tante.“ „So,“ meinte Günther, „als Knabe habe ich mich gefürchtet, wenn die Leute von der kranken Komtesse sprachen. Na, jetzt soll sie mir wie Mondschein im Ahnensaal sein. Komm!“ :::space ::resource{id="tailpiece-1"} # II {lead="Lantin, das Stammgut der Tarniffs,\ngrenzte an Kaltin, den Sitz der Losnitz’."} Beate und Günther waren Nachbarskinder und verwandt. Die Tarniffs und die Losnitz’ gehörten zu dem alteingesessenen Landadel, zu den „braungebrannten Herren,“ von denen Bismarck spricht: „Die man morgens früh um fünf auf ihren Feldern einhergehen oder reiten sieht.“ Starke Leute, die das Leben und die Arbeit lieben, roh mit den Weibern und andächtig mit ihren Frauen umgehen und einen angeerbten Glauben und angeerbte Grundsätze haben. Der Lantiner Zweig der Tarniffs jedoch hatte durch mehrere Generationen dem Staat gute Diplomaten geliefert. Der Aufenthalt in der Fremde entrückte sie ihrem Landsitz. Die Schüler der Grumbkow, Hardenberg, Bismarck brachten etwas Fremdes in das Gleichgewicht und die ein wenig hochmütige Beschränkung der Landjunker; neue Gedanken und Appetite komplizierten ihr Seelenleben. Dazu schlossen die Herren auf ihren diplomatischen Posten Ehen mit Ausländerinnen. Das exotische Blut nagte an den starken Nerven der märkischen Race, erhitzte und schwächte sie mit seiner Erbschaft fremder Geschlechter. Graf Botho, Günthers Vater, war mit einer italienischen Prinzessin vermählt gewesen; ein herrliches Geschöpf, wie Fra Sebastiano sie gerne malte: Königliche, edelsteinharte Augen, eine bleiche Gesichtsfarbe, in die sich etwas wie grünliches Gold mischt. Die schöne Römerin konnte deutsche Luft und deutsche Menschen nicht vertragen. Getrennt von ihrem Gatten lebte sie mit ihrem einzigen Kinde, dem kleinen Günther, in ihrer Heimat. Noch jung erlag sie einem Brustleiden. Lantin hatte von seiner Herrschaft wenig gesehen. Jetzt langte Graf Botho in Lantin an mit seinem Kinde, dem Sarg seiner Frau und Komtesse Benigne, seiner alten Schwester. Der Sarg wurde in der Familiengruft beigesetzt, Benigne mit dem Kinde im Schloß eingerichtet und dann reiste Graf Botho wieder ab. Hier verbrachte Günther seine Kindheit. Damals war es, daß er seine ersten Spiele mit Beate und Mareile, der braunen Inspektorstochter, zwischen den Levkojen und Lilienbeeten des Kaltiner Gartens spielte. Die Baronin von Losnitz, früh verwitwet, lebte mit ihrer einzigen Tochter in Kaltin. Komtesse Seneïde Sallen, ihre Schwester, wohnte bei ihr. Irgendeine brutale Liebesgeschichte war in das stille Leben des Landfräuleins eingeschlagen und hatte es seelisch und geistig gebrochen. Jetzt lebte sie hier. Friedliche Beschäftigungen, die freundliche Narkose der Religion erhielten das Gleichgewicht dieses kranken Geistes. […] Günther erwuchs zu einem sehr glänzenden Ulanenoffizier. Er durchspähte das Leben mit leidenschaftlicher Hast nach Genüssen, als fürchtete er beständig, irgendein Genuß, ein seltenes Glück könnte ihm unterschlagen werden. Nach einigen Jahren hieß es, seiner Gesundheit halber müsse er den Dienst verlassen. Andere erzählten, seine Beziehungen zu einer hochstehenden Dame hätten seine Entfernung aus Berlin wünschenswert gemacht. Er ging nach Athen, bei der Gesandtschaft diplomatische Kenntnisse zu sammeln. Einige Winter später trafen die Jugendgespielen sich in Berlin. Frau von Losnitz wollte Beate in die Gesellschaft einführen. Günther befand sich gerade in einer Krisis, die bei solchen nervösen, allzu gierigen Lebenstrinkern gegen Ende der zwanziger Jahre einzutreten pflegt. Er war satt. Von jeher hatte er das Weib für die Verschleißerin der wichtigsten Genüsse des Lebens angesehen. Für jede Stimmung das richtige Weib zu finden erschien ihm als die bedeutsamste Kunst; und urplötzlich war er der Weiber so müde: „Es ist doch in der ganzen Welt immer wieder dieselbe kleine Schauspielerin mit den gemalten Augenbrauen und den geldgierigen Taubenaugen,“ meinte er. „Ich kann Dir sagen,“ schrieb er an den Maler Hans Berkow, seinen Freund, „ich gehe den Weibern wie einer Drehorgel, die eine zu oft gehörte Melodie spielt, aus dem Wege. Ich kann nur noch mit den stillen, kühlen Marmordamen im Museum verkehren.“ In dieser Gemütslage mußte Beate stark auf Günther wirken. Dieses Mädchen, mit einer stilvollen Reinheit, schien ihm ein Glück zu versprechen, das ihm wirklich bisher unterschlagen worden war. „Sie ist ja die adelige Poesie in Person,“ sagte er, denn er liebte die geschmückten Redewendungen. Einen schwungvolleren Bewerber hatte die kühle Berliner Gesellschaft noch nicht gesehen: „Je nun!“ sagte der Fürst Kornowitz, „wir haben bei unseren Damen schon alle möglichen Manieren versucht, Jockeymanieren, Künstlermanieren, Dekadenzmanieren. Der Tarniff scheint die Troubadourmanier aufbringen zu wollen. Keine bequeme Manier das.“ Beate nahm Günthers Werbung in ihrer wohlerzogenen Art hin. In den Schlössern unseres Landadels wachsen noch, unter feiner berechneter Obhut, solche Mädchen von wunderbar naiver Reinheit heran. Das Gute und Schöne erwarten sie von dem Leben, wie das Selbstverständliche, und Günther erschien Beate als dieses Schöne und Gute. Im Winter verlobten sie sich, im April wurden sie getraut und im Juli des nächsten Jahres zog Günther nach Kaltin, entschlossen, dort ein glückliches Familienleben zu führen nach wohlbewährtem, altadeligem Rezepte. :::space ::resource{id="tailpiece-2"}
`; // content.<lang>.md, inlined by the Cookbook // #region art: the fleuron, drawn in millimetres in the page's colour function drawFleuron(color) { // a camomile head between two leafy sprigs const petals = Array.from({ length: 10 }, (_, i) => `<ellipse cx="22.1" cy="6" rx="1.55" ` + `ry="0.62" transform="rotate(${i * 36} 20 6)" fill="${color}"/>`).join(''); const sprig = `<path d="M17.2 6.3C14 7.4 10 7.2 6.4 5.6C5.2 5.1 4.2 5.2 3.4 5.9" fill="none" ` + `stroke="${color}" stroke-width="0.55" stroke-linecap="round"/>` + `<path d="M13.4 6.9C12.6 4.9 10.8 4 9 4.2C10 5.6 11.4 6.6 13.4 6.9Z" fill="${color}"/>` + `<path d="M9.2 6.5C8.7 7.9 7.4 8.7 5.9 8.8C6.6 7.5 7.7 6.7 9.2 6.5Z" fill="${color}"/>` + `<circle cx="2.8" cy="6.3" r="0.7" fill="${color}"/>`; return '<svg xmlns="http://www.w3.org/2000/svg" width="40mm" height="12mm" ' + `viewBox="0 0 40 12">${petals}<circle cx="20" cy="6" r="1.05" fill="${color}"/>${sprig}` + `<g transform="translate(40 0) scale(-1 1)">${sprig}</g></svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build 'Crimson Pro': ['400', '400i'], // text, colophon, folios 'Cormorant Garamond': ['500i'], // numerals, cover title, the recto's running head 'Cormorant SC': ['600'], // the leads, the author on the cover and the verso }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadImage(painting.bitmap.fileId, asset(painting.bitmap.fileId)); await loadSvg('fleuron.svg', drawFleuron(palette.rubric)); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'Trade paperback: sunk openers and recto chapters', pt: 'Romance em brochura: aberturas rebaixadas em página ímpar', fr: 'Roman broché : ouvertures basses et chapitres au recto', de: 'Taschenbuch: tiefe Auftakte und Kapitel rechts', it: 'Romanzo in brossura: aperture abbassate e capitoli sul recto' }) });
Kit · core, fonts, viewer, images: in jedem Rezept gleich · 270 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 · 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

#Kapitel II auf der nächsten Seite folgen lassen

Mit der Parität 'any' beginnt Kapitel II der englischen Ausgabe auf Seite 6, einer linken Seite, und das Buch endet auf Seite 7; die Pagina am Fuß ist auf den Satzspiegel zentriert, nicht auf die Seite, und steht daher auch auf dieser linken Seite unter der Mitte des Texts. In der deutschen Ausgabe ändert sich nichts, weil Kapitel I schon auf einer linken Seite endet.

-const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
+const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },

#Einen Szenenwechsel innerhalb eines Kapitels markieren

Zwischen zwei :::space-Zeilen gesetzt, markiert dieselbe Blüte einen Szenenwechsel, und der Absatz danach beginnt stumpf, weil indentAfterHeading aus ist. In der englischen Ausgabe nach seemed long to him. platziert, fällt die Blüte an den Fuß von Seite 4; der zweite Abstand passt dort nicht mehr hin und entfällt. Der Wechsel fügt vier Zeilen hinzu, die die Schlussvignette von Kapitel I allein auf Seite 6 schieben, unter einen Kolumnentitel; passen Sie das Kapitel also neu ein, nachdem Sie einen hinzugefügt haben.

 const resources = [
+  svg('break-1', 'fleuron.svg', 40, 12, 'A scene break.'),
That was why the morning seemed long to him.
 
:::space
 
::resource{id="break-1"}
 
:::space
 
“You are burnt beyond recognition,” he added, …

Häufige Fehler

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

::resource{id="…"} nimmt nur doppelte Anführungszeichen

Eine Block-Einbettung wird nur als ::resource{id="…"} mit doppelten Anführungszeichen erkannt; jede andere Form bleibt als sichtbare Zeile im Text. Abbildungen genau hier →

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 →

  • Mit 'always-odd' folgt auf jeden Abschnitt vor jedem Paritätsausgleich eine leere Trennseite; in der englischen Ausgabe würde Kapitel I also nach zwei leeren Seiten auf Seite 5 rücken, und das Buch liefe auf zehn Seiten.
  • Die Seiten der englischen Ausgabe sind eingepasst. Mit einem Außensteg von 16 statt 15,5 mm trennt Seite 7 after-din-ner an einem zweiten Trennstrich und endet eine Zeile zu früh, Seite 8 beendet einen Absatz mit dem Rest spondent., und die zweite Schlussvignette fällt auf eine neunte Seite. Prüfen Sie den Fuß jeder Seite, nachdem Sie Satzbreite, Schriftgrad oder Vorschlag geändert haben.
  • In der englischen Ausgabe sind Mr. und Mrs. mit einem geschützten Leerzeichen (U+00A0) an den Namen gebunden. Der Absatz, der mit seemed long to him. endet, behält ein gewöhnliches Leerzeichen, weil das Binden des Namens dort seine erste Zeile auf das 2,2-Fache des normalen Wortzwischenraums öffnen würde.

Nachweise

Text
  • The Awakening (Das Erwachen, 1899), Kapitel I und II, in der englischen Ausgabe · Kate Chopin · gemeinfrei
  • Quincas Borba (1891), Kapitel I bis III, in der portugiesischen Ausgabe, in modernisierter Rechtschreibung · Machado de Assis · gemeinfrei
  • Madame Gervaisais (1869), Kapitel I und II, in der französischen Ausgabe, in der Schreibung der Erstausgabe · Edmond et Jules de Goncourt · gemeinfrei
  • Beate und Mareile (1903), Kapitel 1 und 2 (das zweite gekürzt), in der Schreibung der Ausgabe bei S. Fischer · Eduard von Keyserling · gemeinfrei
  • Le avventure di Pinocchio (The Adventures of Pinocchio, Felice Paggi, Florence, 1883), chapters I and II, the Italian edition’s text, in the first edition’s spelling, without the chapter summaries · Carlo Collodi · gemeinfrei
  • Das Impressum · Postext Cookbook · eigener Text
Bilder
  • Der Blick aufs Meer auf dem Umschlag, ein Gemälde · Generated With Diffusion Models · eigener Text
  • The Italian edition’s cover: a detail of Settignano (c. 1880), oil on canvas; scan from Wikimedia Commons, a 1,744 × 1,669 px cut reduced to 1,190 px wide · Telemaco Signorini · gemeinfrei
  • Die Kamillenvignette, in Code mit der Farbpalette der Seite gezeichnet · Ignacio Ferro · MIT
Schriften
Crimson Pro (SIL OFL 1.1) · Cormorant Garamond (SIL OFL 1.1) · Cormorant SC (SIL OFL 1.1)
Sandbox