Kurz gesagt
Der Anfang einer berühmten Komödie von Oscar Wilde, für Schauspieler gedruckt. Die Namen der Sprecher stechen in fetter Farbe hervor, und die Regieanweisungen sind grau, damit man sie leicht überspringen kann.
Was Sie setzen
Der Anfang von Oscar Wildes The Importance of Being Earnest, gesetzt als fünfseitige Bühnenfassung in 140 × 216 mm. Die Titelseite ist ein pflaumenroter Theaterzettel in einem doppelten goldenen Rahmen, mit Importance und Earnest in fetten Versalien einer Fat Face und einer goldenen Nelke unter dem Titel. Gegenüber dem ersten Akt nennt das Personenverzeichnis den Darsteller, der jede Rolle 1895 am St James’s Theatre schuf. Jede Replik beginnt mit dem Namen des Sprechers in fettem Pflaumenrot und zieht ihre Folgezeilen 5 mm hängend ein, sodass ein Schauspieler das nächste Stichwort auf einen Blick findet. Regieanweisungen, innerhalb einer Replik oder zwischen zweien, stehen grau kursiv, und das Auge kann sie beim Textlernen überspringen. Die spanische Ausgabe ist eine neue Übersetzung, die der spanischen Bühnenpraxis folgt, mit einem Gedankenstrich nach jedem Namen und den Regieanweisungen in Klammern.
Dieses Rezept beantwortet
- Wie setze ich ein Drehbuch oder ein Theaterstück: Szenenüberschriften, Figurennamen und beidseitig eingerückte Dialoge?
- Wie färbe ich Schlüsselbegriffe (fett oder kursiv) im Text oder in Kästen ein?
- Wie setze ich nicht nummerierte Grafiken: Ornamente, Vignetten, Logos?
Die kurze Antwort
// Speeches are plain paragraphs, the speaker's name in bold and each stage direction in
// italic; a direction between two speeches is a paragraph of its own, in a container:
// **LANE.** Yes, sir. *[Hands them on a salver.]*
// :::paragraphs{style="direction"}
// *[Enter Lane.]*
// :::
// The body's two emphasis colours then mark who speaks (plum) and what is done (grey).
const dialogue = {
fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('plum'), // **ALGERNON.**
italicColor: col('muted'), // *[Languidly.]*, in a speech or a paragraph of its own
minWordSpacing: 0.65, maxWordSpacing: 1.8, // justified (the default); limits inside 0.6–2
// A runt fix tracks by 5/1000 em at most (default 10). At 10, “that?” went up beside “as”
// and the speech stood darker than the ones around it; at 5 the word turns over.
maxRuntTracking: 5,
firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung
};
// Directions: smaller and centred, on the same grid. They are written *…* like the ones
// inside a speech, so both take the body's italic grey.
const direction = { id: 'direction', fontSize: pt(8.6),
textAlign: 'center', firstLineIndent: pt(0) };
Zutaten
- Funktionen
- AbsatzformateFett, kursiv und ihre FarbenTabulatoren und FüllzeichenEinzüge, Ausrichtung und AbsatzabständeÜberschriftenebenenKapitel, die rechts beginnenEigene RessourcentypenAbbildungen genau hierÜberschriftenformateUmschläge, Titelseiten und ImpressumÜberschriftenattributeBilder in SeitengestaltungenText, Linien und Kästen in SeitengestaltungenKolumnentitel und SeitenzahlenKopfzeilen nach SeitenrolleDokument-MetadatenPapierfarbeSemantische Farbpalette
- Nutzt außerdem
- Gestaltete AuftakteAbbildungen und Tabellen als RessourcenKolumnentitel je AbschnittExpliziter vertikaler Abstand
- Schrift
- Libre Baskerville, Abril Fatface, Playfair Display SC (SIL OFL 1.1)
- Dateien
- The title page’s frame and the carnation fleuron, drawn in code in the page’s palette (Ignacio Ferro, MIT)
Zubereitung
#1 · Ein Akt ist eine schlichte Überschrift
const headings = {
fontFamily: LABEL, fontWeight: 400, color: col('ink'),
textAlign: 'center', // every level: the act, the cast list's heads, the scene
lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // whole lines of the grid
levels: [
{ level: 1, fontFamily: DISPLAY, fontSize: pt(24), lineHeight: pt(3 * LEAD),
color: col('plum'), textTransform: 'uppercase',
breakBefore: { enabled: true, parity: 'odd' } }, // a recto, facing the cast list
{ level: 2, fontSize: pt(13), lineHeight: pt(2 * LEAD) },
{ level: 3, fontSize: pt(9.5), color: col('plum') },
],
};
Der Akttitel ist eine Überschrift der Ebene 1 in der Abril Fatface in 24 pt, ohne Gestaltungsfeld. textTransform setzt ihn in Versalien, und seine drei Rasterzeilen zu 13,6 pt stellen die Versalien 5,5 mm unter die Oberkante des Satzspiegels (Überschriften). textAlign: 'center' lässt sich nicht pro Ebene setzen, deshalb zentriert es auch die Überschriften des Personenverzeichnisses und die Bezeichnung Scene. breakBefore mit der Parität 'odd' beginnt den Akt auf einer rechten Seite, gegenüber dem Personenverzeichnis. Der Standardwert 'always-odd' würde zwei leere Seiten zwischen sie setzen (Umbruch davor).
#2 · Das Personenverzeichnis ist eine Punktlinie zu jedem Darsteller
// A tab sends the actor to a stop at the end of the measure that ends the text there; the stop's
// leader fills the room before it with dots, from half an em after the person to half an em
// before the actor. The bill's head is a centred line in the face's own small capitals.
const person = { id: 'person', fontSize: pt(8.8), firstLineIndent: pt(0), textAlign: 'left',
tabStops: [{ position: 'end', align: 'end', leader: '.' }],
spaceBetween: pt(LEAD / 2) }; // a person every line and a half
const billHead = { id: 'bill-head', fontFamily: LABEL, fontSize: pt(8.5), color: col('plum'),
textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD / 2),
marginBottom: pt(LEAD / 2) };
// The scenes: two stops, so `:tab Act I :tab Algernon’s flat` ends the act on the first
// (15 mm in) and starts the place on the second, 2 mm after it, as a two-column list would.
const scene = { id: 'scene', textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD / 2),
tabStops: [{ position: mm(15), align: 'end' }, { position: mm(17) }] };
Jede Person ist ein Absatz im Stil person, der Darsteller nach :tab (Tabulatoren und Tabstopps). Der Stil hat einen Tabstopp bei 'end', dem Ende der Satzbreite, und mit align: 'end' endet der Name des Darstellers dort. Sein leader: '.' füllt den Raum vor dem Tabstopp mit Punkten, ein halbes Geviert von Person und Darsteller entfernt, und spaceBetween setzt eine Person auf jede anderthalbte Zeile. Die Namen der Personen sind fett und erscheinen deshalb in Pflaumenrot, wie die Namen der Sprecher im Dialog. Originalbesetzung, Theater und Datum stehen im Stil bill-head über der Liste, zentriert in der Playfair Display SC, deren Kleinbuchstaben Kapitälchen sind. Die Schauplätze nehmen zwei Tabstopps: :tab Act I :tab … lässt Act I am ersten enden, 15 mm eingerückt, mit align: 'end', sodass die drei Akte rechtsbündig auf einer Spalte stehen, und beginnt den Ort am zweiten, 2 mm danach. Die Zeit der Handlung ist eine zentrierte Zeile im Stil der Regieanweisungen.
#3 · Ornamente ohne Nummer und ohne Bildunterschrift
// ::resource{id="fleuron"} sets it where it stands (gotcha: resource-double-quotes).
const unnumbered = (id, name, defaultPlacement) => ({ id, name, shortLabel: '', captionPrefix: '',
numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement });
const resourceTypes = [
unnumbered('ornament', 'Ornament', { position: 'here', width: 0.24, align: 'center' }),
];
Ein Ressourcentyp mit leerem captionPrefix und leerem shortLabel druckt seine Bilder ohne alles (Ressourcentypen). Sein defaultPlacement setzt die Nelke dorthin, wo ::resource{id="fleuron"} steht, zentriert und 0,24 der 107 mm breiten Satzbreite breit, etwa 26 mm.
#4 · Die Titelseite ist ein Überschriftenstil
// # The Importance of Being Earnest {style="playbill" small="The" big="Importance" …}
// Each attribute is a playbill line in its own face and size. The ground is an image that
// fills the sheet: the heading reserves down to its foot, so the cast list starts on page 2.
const line = (id, content, font, size, y, color, extra = {}) => ({ kind: 'text', id, content,
fontFamily: font, fontSize: pt(size), lineHeight: 1, color: col(color), align: 'center',
placement: { anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }, ...extra });
const tracked = (track) => ({ textTransform: 'uppercase', letterSpacing: pt(track) });
const playbill = {
id: 'playbill', span: 'page', // kept in the column, its foot would cut the ground
header: { elements: [] }, footer: { elements: [] }, // no heads on p. 2, no folio on p. 1
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'ground', resourceId: 'playbill',
placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
line('kicker', '{subtitle}', LABEL, 8.5, 36, 'gilt', tracked(1.7)),
line('small', '{attr.small}', TEXT, 17, 51, 'paper', { italic: true }),
line('big', '{attr.big}', DISPLAY, 40, 60, 'paper', { textTransform: 'uppercase' }),
line('link', '{attr.link}', TEXT, 17, 78, 'gilt', { italic: true }),
line('name', '{attr.name}', DISPLAY, 60, 87, 'paper', { textTransform: 'uppercase' }),
line('author', '{author}', LABEL, 13, 133, 'paper', tracked(3)),
line('theatre', '{attr.theatre}', LABEL, 8, 170, 'gilt', tracked(1.6)),
line('premiere', '{attr.premiere}', TEXT, 8.5, 176, 'paper', { italic: true }),
] } },
};
# The Importance of Being Earnest {style="playbill" …} trägt den Titel in vier Attributen, die die Gestaltung in zwei Schriften setzt: Abril Fatface in 40 und 60 pt für die Substantive, Libre Baskerville kursiv in 17 pt für The und of Being (Attribute von Überschriften). Der pflaumenrote Grund, der Rahmen und die Nelke sind ein einziges, in Code gezeichnetes SVG, am Anschnitt verankert (Bildelemente). Der Stil setzt span: 'page', obwohl das Buch einspaltig ist. In der Spalte gehalten, würde die Gestaltung am Fuß des Satzspiegels abgeschnitten, 23,3 mm über dem Fuß des Blatts. Der leere header des Stils hält die Kolumnentitel vom Personenverzeichnis fern, das zum selben Abschnitt gehört, und sein leerer footer hält die Seitenzahl am Fuß von der Titelseite fern, wo sie pflaumenrot auf Pflaumenrot stünde (Überschriftenstile).
Der Grund ist ein Bildelement, und ein Auftakt reserviert Platz bis zum Fuß seiner Bilder, sodass die Überschrift das ganze Blatt einnimmt: The Persons of the Play beginnt auf Seite 2 ohne :::pagebreak nach dem Titel, wo auch immer die Zeilen der Titelseite stehen. Auch eine zentrierte Zeile mit letterSpacing braucht keine Korrektur. Die Sperrung nach ihrem letzten Buchstaben bleibt beim Zentrieren außen vor, und OSCAR WILDE steht auf der Mitte der Seite.
#5 · Kolumnentitel, die die erste Seite des Akts auslassen
const head = (id, content, parity, edge, x, style) => ({
kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
fontFamily: LABEL, fontSize: pt(8.5), letterSpacing: pt(0.8), color: col('muted'),
textTransform: 'uppercase', ...style,
placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(11) } },
});
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('plum') };
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
head('recto-act', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)),
head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// The act's first page carries a drop folio instead, centred under the text block.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'top', 0, folio),
pages: 'opener', align: 'center',
placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(8) } } }] };
Die linke Seite trägt den Titel des Stücks aus dem Frontmatter, die rechte den Akt aus {chapterTitle}, beide in Versalien der Playfair Display SC, mit der Seitenzahl in Pflaumenrot am Außenrand (Textelemente). Die erste Seite des Akts gilt als Auftaktseite, weil ihr erster Block eine Überschrift ist, die die Seite umbricht; deshalb hält pages: 'body' die Kolumnentitel von ihr fern, und die Fußzeile gibt ihr stattdessen eine Seitenzahl am Fuß.
Das ganze Rezept
// ═══ Postext Cookbook · Nº 070 · Play script: cast list, speakers and stage directions ═══ // https://postext.dev/en/cookbook/stage-play // Code: MIT · Text: Oscar Wilde, 1895 (PD, Gutenberg #844), Spanish: the Cookbook · Art: in code // Fonts: Libre Baskerville, Abril Fatface, Playfair Display SC (OFL 1.1) · Needs postext ≥ 1.25.0 import { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'stage-play'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // every colour in the config links to one of these ink: '#221b1f', // the dialogue: a near-black with a little plum in it plum: '#5b2349', // the one accent: speakers' names, act titles, the title page's ground gold: '#b48a45', // rules and ornaments gilt: '#c9aa77', // gold lightened for small type on the plum ground (5.3:1) muted: '#6c6168', // stage directions and running heads (5.5:1 on the paper) paper: '#fbf6ec', // a cream stock }; // A colour is its hex and the palette entry it follows. 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: 'plum (defaults)', value: { hex: palette.plum, model: 'hex' } }, ]; const [TEXT, DISPLAY, LABEL] = ['Libre Baskerville', 'Abril Fatface', 'Playfair Display SC']; const [BODY, LEAD] = [9.4, 13.6]; // pt: the dialogue and its leading, the pitch of the grid const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in, the acting-edition size const [TOP, INNER, OUTER, LINES] = [20, 18, 15, 36]; // mm, and 36 lines to a full page // #region answer: a speech is a paragraph: the name in bold, the directions in italic // Speeches are plain paragraphs, the speaker's name in bold and each stage direction in // italic; a direction between two speeches is a paragraph of its own, in a container: // **LANE.** Yes, sir. *[Hands them on a salver.]* // :::paragraphs{style="direction"} // *[Enter Lane.]* // ::: // The body's two emphasis colours then mark who speaks (plum) and what is done (grey). const dialogue = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('plum'), // **ALGERNON.** italicColor: col('muted'), // *[Languidly.]*, in a speech or a paragraph of its own minWordSpacing: 0.65, maxWordSpacing: 1.8, // justified (the default); limits inside 0.6–2 // A runt fix tracks by 5/1000 em at most (default 10). At 10, “that?” went up beside “as” // and the speech stood darker than the ones around it; at 5 the word turns over. maxRuntTracking: 5, firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung }; // Directions: smaller and centred, on the same grid. They are written *…* like the ones // inside a speech, so both take the body's italic grey. const direction = { id: 'direction', fontSize: pt(8.6), textAlign: 'center', firstLineIndent: pt(0) }; // #endregion // #region acts: an act title is a plain first-level heading, centred and set in capitals const headings = { fontFamily: LABEL, fontWeight: 400, color: col('ink'), textAlign: 'center', // every level: the act, the cast list's heads, the scene lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // whole lines of the grid levels: [ { level: 1, fontFamily: DISPLAY, fontSize: pt(24), lineHeight: pt(3 * LEAD), color: col('plum'), textTransform: 'uppercase', breakBefore: { enabled: true, parity: 'odd' } }, // a recto, facing the cast list { level: 2, fontSize: pt(13), lineHeight: pt(2 * LEAD) }, { level: 3, fontSize: pt(9.5), color: col('plum') }, ], }; // #endregion // #region cast: the cast list: each person a paragraph, the actor after a dot leader // A tab sends the actor to a stop at the end of the measure that ends the text there; the stop's // leader fills the room before it with dots, from half an em after the person to half an em // before the actor. The bill's head is a centred line in the face's own small capitals. const person = { id: 'person', fontSize: pt(8.8), firstLineIndent: pt(0), textAlign: 'left', tabStops: [{ position: 'end', align: 'end', leader: '.' }], spaceBetween: pt(LEAD / 2) }; // a person every line and a half const billHead = { id: 'bill-head', fontFamily: LABEL, fontSize: pt(8.5), color: col('plum'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 2) }; // The scenes: two stops, so `:tab Act I :tab Algernon’s flat` ends the act on the first // (15 mm in) and starts the place on the second, 2 mm after it, as a two-column list would. const scene = { id: 'scene', textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD / 2), tabStops: [{ position: mm(15), align: 'end' }, { position: mm(17) }] }; // #endregion // #region ornament: a resource type for artwork with no number and no caption // ::resource{id="fleuron"} sets it where it stands (gotcha: resource-double-quotes). const unnumbered = (id, name, defaultPlacement) => ({ id, name, shortLabel: '', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement }); const resourceTypes = [ unnumbered('ornament', 'Ornament', { position: 'here', width: 0.24, align: 'center' }), ]; // #endregion // #region playbill: the title page, a heading style whose design fills the sheet // # The Importance of Being Earnest {style="playbill" small="The" big="Importance" …} // Each attribute is a playbill line in its own face and size. The ground is an image that // fills the sheet: the heading reserves down to its foot, so the cast list starts on page 2. const line = (id, content, font, size, y, color, extra = {}) => ({ kind: 'text', id, content, fontFamily: font, fontSize: pt(size), lineHeight: 1, color: col(color), align: 'center', placement: { anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }, ...extra }); const tracked = (track) => ({ textTransform: 'uppercase', letterSpacing: pt(track) }); const playbill = { id: 'playbill', span: 'page', // kept in the column, its foot would cut the ground header: { elements: [] }, footer: { elements: [] }, // no heads on p. 2, no folio on p. 1 advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'ground', resourceId: 'playbill', placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } }, line('kicker', '{subtitle}', LABEL, 8.5, 36, 'gilt', tracked(1.7)), line('small', '{attr.small}', TEXT, 17, 51, 'paper', { italic: true }), line('big', '{attr.big}', DISPLAY, 40, 60, 'paper', { textTransform: 'uppercase' }), line('link', '{attr.link}', TEXT, 17, 78, 'gilt', { italic: true }), line('name', '{attr.name}', DISPLAY, 60, 87, 'paper', { textTransform: 'uppercase' }), line('author', '{author}', LABEL, 13, 133, 'paper', tracked(3)), line('theatre', '{attr.theatre}', LABEL, 8, 170, 'gilt', tracked(1.6)), line('premiere', '{attr.premiere}', TEXT, 8.5, 176, 'paper', { italic: true }), ] } }, }; // #endregion // #region heads: the play on the verso, the act on the recto, folios outside const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages fontFamily: LABEL, fontSize: pt(8.5), letterSpacing: pt(0.8), color: col('muted'), textTransform: 'uppercase', ...style, placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(11) } }, }); const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('plum') }; const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio), head('verso-title', '{title}', 'even', 'top-left', OUTER + 8), head('recto-act', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio), ] }; // The act's first page carries a drop folio instead, centred under the text block. const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'top', 0, folio), pages: 'opener', align: 'center', placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(8) } } }] }; // #endregion const config = () => ({ locale: t({ en: 'en-us', es: 'es' }), // each edition's hyphenation patterns colorPalette, resourceTypes, page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72), left: mm(INNER), right: mm(OUTER), mirror: true }, }, layout: { layoutType: 'single' }, bodyText: dialogue, headings, headingStyles: [playbill], paragraphStyles: [direction, person, billHead, scene, { id: 'colophon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD) }], header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown-Beispiel · 156 Zeilen · content.en.md
title: "The Importance of Being Earnest" subtitle: "A Trivial Comedy for Serious People" author: "Oscar Wilde" --- # The Importance of Being Earnest {style="playbill" small="The" big="Importance" link="of Being" name="Earnest" theatre="St James’s Theatre · London" premiere="First performed on 14 February 1895"} ## The Persons of the Play :::paragraphs{style="bill-head"} The original cast · St James’s Theatre, 14 February 1895 ::: :::paragraphs{style="person"} **John Worthing**, J.P. :tab Mr. George Alexander **Algernon Moncrieff** :tab Mr. Allen Aynesworth **Rev. Canon Chasuble**, D.D. :tab Mr. H. H. Vincent **Merriman**, butler :tab Mr. Frank Dyall **Lane**, manservant :tab Mr. F. Kinsey Peile **Lady Bracknell** :tab Miss Rose Leclercq **Hon. Gwendolen Fairfax** :tab Miss Irene Vanbrugh **Cecily Cardew** :tab Miss Evelyn Millard **Miss Prism**, governess :tab Mrs. George Canninge ::: ## The Scenes of the Play :::paragraphs{style="scene"} :tab Act I :tab Algernon Moncrieff’s Flat in Half-Moon Street, W. :tab Act II :tab The Garden at the Manor House, Woolton. :tab Act III :tab Drawing-Room at the Manor House, Woolton. ::: :::paragraphs{style="direction"} *Time: The Present.* ::: :::paragraphs{style="colophon"} Text: Project Gutenberg eBook #844, in the public domain. Set in Libre Baskerville, Abril Fatface and Playfair Display SC (SIL Open Font License). ::: # First Act ::resource{id="fleuron"} ### Scene :::paragraphs{style="direction"} *Morning-room in Algernon’s flat in Half-Moon Street. The room is luxuriously and artistically furnished. The sound of a piano is heard in the adjoining room.* *[Lane is arranging afternoon tea on the table, and after the music has ceased, Algernon enters.]* ::: :::space **ALGERNON.** Did you hear what I was playing, Lane? **LANE.** I didn’t think it polite to listen, sir. **ALGERNON.** I’m sorry for that, for your sake. I don’t play accurately—any one can play accurately—but I play with wonderful expression. As far as the piano is concerned, sentiment is my forte. I keep science for Life. **LANE.** Yes, sir. **ALGERNON.** And, speaking of the science of Life, have you got the cucumber sandwiches cut for Lady Bracknell? **LANE.** Yes, sir. *[Hands them on a salver.]* **ALGERNON.** *[Inspects them, takes two, and sits down on the sofa.]* Oh!… by the way, Lane, I see from your book that on Thursday night, when Lord Shoreman and Mr. Worthing were dining with me, eight bottles of champagne are entered as having been consumed. **LANE.** Yes, sir; eight bottles and a pint. **ALGERNON.** Why is it that at a bachelor’s establishment the servants invariably drink the champagne? I ask merely for information. **LANE.** I attribute it to the superior quality of the wine, sir. I have often observed that in married households the champagne is rarely of a first-rate brand. **ALGERNON.** Good heavens! Is marriage so demoralising as that? **LANE.** I believe it *is* a very pleasant state, sir. I have had very little experience of it myself up to the present. I have only been married once. That was in consequence of a misunderstanding between myself and a young person. **ALGERNON.** *[Languidly.]* I don’t know that I am much interested in your family life, Lane. **LANE.** No, sir; it is not a very interesting subject. I never think of it myself. **ALGERNON.** Very natural, I am sure. That will do, Lane, thank you. **LANE.** Thank you, sir. *[Lane goes out.]* **ALGERNON.** Lane’s views on marriage seem somewhat lax. Really, if the lower orders don’t set us a good example, what on earth is the use of them? They seem, as a class, to have absolutely no sense of moral responsibility. :::paragraphs{style="direction"} *[Enter Lane.]* ::: **LANE.** Mr. Ernest Worthing. :::paragraphs{style="direction"} *[Enter Jack.]* *[Lane goes out.]* ::: **ALGERNON.** How are you, my dear Ernest? What brings you up to town? **JACK.** Oh, pleasure, pleasure! What else should bring one anywhere? Eating as usual, I see, Algy! **ALGERNON.** *[Stiffly.]* I believe it is customary in good society to take some slight refreshment at five o’clock. Where have you been since last Thursday? **JACK.** *[Sitting down on the sofa.]* In the country. **ALGERNON.** What on earth do you do there? **JACK.** *[Pulling off his gloves.]* When one is in town one amuses oneself. When one is in the country one amuses other people. It is excessively boring. **ALGERNON.** And who are the people you amuse? **JACK.** *[Airily.]* Oh, neighbours, neighbours. **ALGERNON.** Got nice neighbours in your part of Shropshire? **JACK.** Perfectly horrid! Never speak to one of them. **ALGERNON.** How immensely you must amuse them! *[Goes over and takes sandwich.]* By the way, Shropshire is your county, is it not? **JACK.** Eh? Shropshire? Yes, of course. Hallo! Why all these cups? Why cucumber sandwiches? Why such reckless extravagance in one so young? Who is coming to tea? **ALGERNON.** Oh! merely Aunt Augusta and Gwendolen. **JACK.** How perfectly delightful! **ALGERNON.** Yes, that is all very well; but I am afraid Aunt Augusta won’t quite approve of your being here. **JACK.** May I ask why? **ALGERNON.** My dear fellow, the way you flirt with Gwendolen is perfectly disgraceful. It is almost as bad as the way Gwendolen flirts with you. **JACK.** I am in love with Gwendolen. I have come up to town expressly to propose to her. **ALGERNON.** I thought you had come up for pleasure?… I call that business. **JACK.** How utterly unromantic you are! **ALGERNON.** I really don’t see anything romantic in proposing. It is very romantic to be in love. But there is nothing romantic about a definite proposal. Why, one may be accepted. One usually is, I believe. Then the excitement is all over. The very essence of romance is uncertainty. If ever I get married, I’ll certainly try to forget the fact. **JACK.** I have no doubt about that, dear Algy. The Divorce Court was specially invented for people whose memories are so curiously constituted. **ALGERNON.** Oh! there is no use speculating on that subject. Divorces are made in Heaven—*[Jack puts out his hand to take a sandwich. Algernon at once interferes.]* Please don’t touch the cucumber sandwiches. They are ordered specially for Aunt Augusta. *[Takes one and eats it.]*`; // content.<lang>.md, inlined by the Cookbook // #region art: the title page's ground and double frame, and the carnation fleuron, in mm let seed = 1895; // Mulberry32, a seeded PRNG: never Math.random() in a recipe const rand = () => { let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; const f = (n) => +n.toFixed(2); const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(palette[a].slice(i, i + 2), 16) * (1 - k) + parseInt(palette[b].slice(i, i + 2), 16) * k).toString(16).padStart(2, '0')) .join('')}`; const svgOf = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const pts = (list) => list.map(([x, y]) => `${f(x)} ${f(y)}`).join('L'); const poly = (list, fill) => `<path d="M${pts(list)}Z" fill="${fill}"/>`; const stroke = (list, color, w) => `<path d="M${pts(list)}" fill="none" stroke="${color}" ` + `stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"/>`; const quad = ([x0, y0], [cx, cy], [x1, y1], t) => [ (1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t * t * x1, (1 - t) ** 2 * y0 + 2 * (1 - t) * t * cy + t * t * y1]; // A carnation `s` mm tall standing on (cx, cy), seen from the side: three fans of pinked // petals spring from the rim of a calyx, notched apart in the ground colour behind them. const DEG = Math.PI / 180; function carnation(cx, cy, s, [back, mid, front], green, ground) { const out = []; const [rx, ry] = [cx, cy - s * 0.32]; // the rim of the calyx const fan = (from, to, r, lift, petals, fill) => { // a fan from `from`° to `to`°, r mm deep const at = (deg, k) => [rx + Math.cos(deg * DEG) * r * k, ry - lift + Math.sin(deg * DEG) * r * k * 0.92]; const edge = []; for (let i = 0; i <= petals * 6; i++) { // six teeth to a petal, each petal a rounded lobe const lobe = 0.84 + 0.16 * Math.sin((Math.PI * (i % 6)) / 6); edge.push(at(from + ((to - from) * i) / (petals * 6), (i % 2 ? 0.9 : 1) * lobe * (0.98 + rand() * 0.04))); } out.push(poly([[rx, ry - lift], ...edge], fill)); const notch = (to - from) / petals / 14; // half the angle of a notch at the edge for (let p = 1; p < petals; p++) { // a thin wedge between two petals const deg = from + ((to - from) * p) / petals; out.push(poly([at(deg, 0.66), at(deg - notch, 1.1), at(deg + notch, 1.1)], ground)); } }; fan(-162, -18, s * 0.74, 0, 7, back); fan(-146, -34, s * 0.56, s * 0.06, 5, mid); fan(-124, -56, s * 0.36, s * 0.1, 3, front); // The calyx narrows to the stem; three short sepals rise over the petals, and an outline in // the ground colour keeps it clear of them. const [a, b] = [s * 0.11, s * 0.045]; // half-widths at the rim and at the stem const cup = [[rx - a, ry + s * 0.03], [rx - a * 1.25, ry - s * 0.07], [rx - a * 0.45, ry - s * 0.01], [rx, ry - s * 0.1], [rx + a * 0.45, ry - s * 0.01], [rx + a * 1.25, ry - s * 0.07], [rx + a, ry + s * 0.03], [cx + b, cy], [cx - b, cy]]; out.push(`<path d="M${pts(cup)}Z" fill="${green}" stroke="${ground}" ` + `stroke-width="${f(s * 0.03)}" stroke-linejoin="round"/>`); return out.join(''); } // A scroll: an arm out from the stem that ends in a spiral curl. function scroll(x0, y0, dir, len, curl, color, w) { const list = []; for (let i = 0; i <= 24; i++) { const t = i / 24; list.push([x0 + dir * len * t, y0 + Math.sin(t * Math.PI) * curl * 0.3 - t * curl * 0.25]); } const [ex, ey] = list[list.length - 1]; for (let i = 1; i <= 48; i++) { const a = (i / 48) * Math.PI * 1.8; const r = curl * 0.5 * Math.exp(-0.38 * a); list.push([ex + dir * Math.sin(a) * r, ey - curl * 0.5 + Math.cos(a) * r]); } return stroke(list, color, w); } // A carnation leaf: a narrow blade along a curve from its base. function blade(p0, c, p1, w, fill) { const [left, right] = [[], []]; for (let i = 0; i <= 16; i++) { const t = i / 16; const [x, y] = quad(p0, c, p1, t); const [x2, y2] = quad(p0, c, p1, Math.min(1, t + 0.01)); const [dx, dy] = [x2 - x, y2 - y]; const k = (w * Math.sin(Math.PI * Math.min(1, t * 1.15)) ** 0.7) / 2 / (Math.hypot(dx, dy) || 1); left.push([x - dy * k, y + dx * k]); right.unshift([x + dy * k, y - dx * k]); } return poly([...left, ...right], fill); } // The fleuron, w × h mm: the carnation between two leaves and two scrolls, on `ground`. function fleuron(w, h, petals, green, ground) { const [cx, base] = [w / 2, h * 0.9]; const parts = [-1, 1].map((d) => scroll(cx + d * 0.6, base, d, w * 0.38, h * 0.46, green, h * 0.035) + blade([cx + d * 0.8, base - 0.2], [cx + d * w * 0.12, base - h * 0.02], [cx + d * w * 0.24, base - h * 0.3], h * 0.07, green)); return svgOf(w, h, parts.join('') + carnation(cx, base, h * 0.88, petals, green, ground)); } // The title page: a plum sheet, a double gold frame whose rules cross at the corners // (Oxford corners), and the carnation in gold between the title and the author. const FLEURON_Y = 109; // mm: the top of the title page's carnation function playbillArt(W, H) { const out = [`<rect width="${W}" height="${H}" fill="${palette.plum}"/>`]; const frame = (inset, w, reach) => { // four rules `inset` mm in, running `reach` mm past const [a, bx, by] = [inset, W - inset, H - inset]; for (const [p, q] of [[[a - reach, a], [bx + reach, a]], [[a - reach, by], [bx + reach, by]], [[a, a - reach], [a, by + reach]], [[bx, a - reach], [bx, by + reach]]]) { out.push(stroke([p, q], palette.gold, w)); } }; frame(9, 0.55, 3.2); frame(11, 0.22, -1.2); for (const [x, y] of [[9, 9], [W - 9, 9], [9, H - 9], [W - 9, H - 9]]) { out.push(`<circle cx="${x}" cy="${y}" r="0.9" fill="${palette.gold}"/>`); } const petals = [mix('gold', 'plum', 0.35), palette.gold, mix('paper', 'gold', 0.2)]; const sprig = fleuron(46, 16, petals, palette.gold, palette.plum) .replace(/^<svg[^>]*>|<\/svg>$/g, ''); out.push(`<g transform="translate(${(W - 46) / 2} ${FLEURON_Y})">${sprig}</g>`); return svgOf(W, H, out.join('')); } await loadSvg('playbill.svg', playbillArt(TRIM.width, TRIM.height)); await loadSvg('fleuron.svg', fleuron(30, 11, [mix('plum', 'ink', 0.4), mix('plum', 'paper', 0.12), mix('plum', 'paper', 0.45)], palette.gold, palette.paper)); const svg = (id, w, h, altText) => ({ id, typeId: 'ornament', kind: 'svg', altText, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, createdAt: 0, updatedAt: 0 }); const art = [ svg('playbill', TRIM.width, TRIM.height, 'A plum title page in a double gold frame whose ' + 'rules cross at the corners, with a gold carnation between two scrolls.'), svg('fleuron', 30, 11, 'Ornament: a plum carnation between two gold scrolls.'), ]; // #endregion const resources = art; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Libre Baskerville': ['400', '400i', '700'], 'Abril Fatface': ['400'], 'Playfair Display SC': ['400'] }; // all loaded before the first build // ─── 4 · Build & show ─────────────────────────────────────────────────────── const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); showPages(doc, { title: doc.metadata.title }); // the frontmatter's titleKit · 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
#Die Regieanweisungen an den Folgezeilen ausrichten
Linksbündig mit 5 mm Erstzeileneinzug beginnt eine einzeilige Regieanweisung dort, wo die Folgezeilen der Repliken beginnen, und die Szenerie wird zu zwei eingezogenen Absätzen unter ihrer zentrierten Bezeichnung.
const direction = { id: 'direction', fontSize: pt(8.6),
- textAlign: 'center', firstLineIndent: pt(0) };
+ textAlign: 'left', firstLineIndent: mm(5) };#Eine Zeile zwischen den Repliken lassen
paragraphSpacing: true fügt nach jeder Replik eine leere Rasterzeile ein. Der englische Akt läuft dann auf fünf statt drei Seiten, der spanische auf vier.
firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung
+ paragraphSpacing: true,
};Häufige Fehler
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 →
- Jeder kursive Lauf im Dialog nimmt
italicColoran, Hervorhebungen eingeschlossen, sodass Lanes betontes is im Grau der Regieanweisungen erscheint. Das Grau sitzt anbodyText, weil die meisten Regieanweisungen innerhalb einer Replik stehen, im selben Absatz wie die Hervorhebung; nur aufrechte Schrift hielte das is in Textfarbe. - Drei von Wildes Repliken enden mit einem einzelnen Wort: information., you. und that? Die Korrektur zu kurzer Ausgangszeilen kann das dritte in die Zeile davor ziehen, aber nur mit 1 % negativer Laufweite, so viel, wie
bodyText.maxRuntTrackingstandardmäßig erlaubt, und die Replik wirkt dann dunkler als die umgebenden.maxRuntTracking: 5halbiert diese Grenze: that? bricht um, und die Zeile darüber öffnet sich auf das 1,78-Fache des normalen Wortabstands, innerhalb der Grenze von 1,8. Die englischen Seiten behalten die drei kurzen Ausgangszeilen, statt Wildes Worte zu ändern. Die spanische Übersetzung wurde überall umformuliert, wo eine Zeile auf einem Wort aus einem Buchstaben endete oder eine Silbe allein stand. - Namen werden getrennt wie jedes andere Wort: Au-gusta und Gwen-dolen brechen auf Seite 5 über Zeilen um. Ein Wortverbinder (U+2060) in Augusta hält es ganz, dehnt aber die Zeile darüber auf das 1,71-Fache des normalen Wortabstands, deshalb behält der englische Text die Trennstriche.
- Playfair Display SC zeichnet fi auf dem Canvas als Kleinbuchstaben-Ligatur, sodass first ein kleines fi zwischen Kapitälchen drucken würde. Deshalb lautet der Kopf des Personenverzeichnisses The original cast.
Nachweise
- Rezept
- Ignacio Ferro
- Text
- The Importance of Being Earnest (1895): the persons and scenes of the play, the first cast and the opening of the first act · Oscar Wilde · gemeinfrei
- The Spanish translation of the extract, the cast list and the scenes · Postext Cookbook · eigener Text
- Bilder
- The title page’s frame and the carnation fleuron, drawn in code in the page’s palette · Ignacio Ferro · MIT
- Schriften
- Libre Baskerville (SIL OFL 1.1) · Abril Fatface (SIL OFL 1.1) · Playfair Display SC (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


