What you'll build
The opening of Oscar Wilde’s The Importance of Being Earnest, set as a five-page acting edition at 140 × 216 mm. The title page is a plum playbill in a double gold frame, with Importance and Earnest in fat-face capitals and a gold carnation under the title. Facing the first act, the cast list names the actor who created each part at the St James’s Theatre in 1895. Each speech opens with the speaker’s name in bold plum and hangs its turnover lines 5 mm, so an actor finds the next cue at a glance. Stage directions, inside a speech or between two, are grey italic, and the eye can skip them when running lines. The Spanish edition is a new translation that follows Spanish stage practice, with a dash after each name and the directions in parentheses.
This recipe answers
- How do I set a screenplay or a play: scene headings, character names, and dialogue indented from both sides?
- How do I colour key terms (bold or italic) in the body or inside boxes?
- How do I make a table with header rows, merged cells, column widths and per-cell alignment?
- How do I set unnumbered artwork: ornaments, vignettes, logos?
The short answer
// 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
firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung
maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
};
// Directions: smaller and centred, on the same grid. A paragraph style has no italic switch
// or colour (gotcha: style-italic-colour): the text is written *…* and takes the grey.
const direction = { id: 'direction', fontSize: pt(8.6),
textAlign: 'center', firstLineIndent: pt(0) };
A speech is a paragraph: the name in bold, the directions in italic
Ingredients
- Features
- Paragraph stylesBold, italic and their coloursTables from dataIndents, alignment and paragraph spacingHeading levelsChapters that open on a rectoTable styleNamed table stylesCustom resource typesFigures exactly hereHeading stylesCovers, title pages and colophonsHeading attributesPictures in page designsText, rules and boxes in page designsRunning heads and foliosHeads by page roleDocument metadataPaper colourSemantic colour palette
- Also uses
- Designed openersPage and column breaksFigures and tables as resourcesRunning heads per sectionExplicit vertical space
- Type
- Libre Baskerville, Abril Fatface, Playfair Display SC (SIL OFL 1.1)
- Assets
- The title page’s frame and the carnation fleuron, drawn in code in the page’s palette (Ignacio Ferro, MIT)
Method
#1 · An act is a plain heading
// Any headings object drops the H1 break: restated (gotcha: headings-drop-h1-break).
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') },
],
};
The act title is a first-level heading in Abril Fatface at 24 pt, with no design slot. textTransform sets it in capitals, and its three grid lines of 13.6 pt put the capitals 5.5 mm below the top of the text block (headings). textAlign: 'center' cannot be set per level, so it also centres the cast list’s headings and the Scene label. breakBefore with parity 'odd' starts the act on a recto, facing the cast list. The config has to restate it, because any headings object turns the first level’s page break off (break before).
#2 · The cast list is a table read from tab-separated lines
// One row per line, cells split by tabs. A line with no tab spans the table: mergeCells
// joins its cells (gotcha: merged-cells-hiddenby). Each column has its own alignment.
function billTable(tsv, widths, align, headerRowCount = 0) {
let model = { ...parseTSV(tsv.trim()), columnWidths: widths, headerRowCount };
model.rows.forEach((row) => row.forEach((cell, c) => { cell.align = align[c]; }));
tsv.trim().split('\n').forEach((line, r) => {
if (line.includes('\t')) return;
model.rows[r][0].align = 'center';
const end = { row: r, col: widths.length - 1 };
model = mergeCells(model, { start: { row: r, col: 0 }, end });
});
return model;
}
// One filled header cell: two side by side would show a seam (gotcha: table-fill-seams).
const tableStyle = {
rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
headerBackground: col('plum'), headerColor: col('paper'), headerFontFamily: LABEL,
headerFontSize: pt(8.5), headerBold: false, // small capitals from the face itself
bodyFontSize: pt(8.8), cellPadding: mm(1.5), // the body's face and ink, a size smaller
};
const tableStyles = [{ id: 'scenes', rules: 'none', cellPadding: mm(1) }]; // the scenes
Postext has no pipe tables, so each table is a resource. The rows are in content.cast.en.md, one line per row with a tab between the cells, and parseTSV turns them into a model (building table models). The one line without a tab, the original cast with the theatre and the date, becomes a merged row: mergeCells gives its first cell colSpan: 2 and keeps the second in the grid, marked hiddenBy. headerRowCount: 1 sets that row in the plum band. No other row is filled, because the canvas shows a pale seam between two filled cells side by side. The actors’ column takes a third of the width, and each of its cells is set flush right. The scenes go through the same function under a named style with no rules (named table styles). A table sets all the text in its cells in one colour, the body ink unless bodyColor names another, so the bold names of the persons print in ink here and plum in the dialogue.
#3 · Ornaments 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' }),
unnumbered('bill', 'Bill', { position: 'here' }),
];
A resource type with an empty captionPrefix and shortLabel prints its pictures bare (resource types). Its defaultPlacement puts the carnation where ::resource{id="fleuron"} stands, centred and 0.24 of the 107 mm measure wide, about 26 mm. The two tables use a second unnumbered type, so neither is labelled Table 1.
#4 · The title page is a heading style
// # The Importance of Being Earnest {style="playbill" small="The" big="Importance" …}
// Each attribute is a playbill line in its own face and size, its lineHeight a multiple
// (gotcha: design-lineheight-multiple). The ground is an image and reserves no height
// (gotcha: opener-image-no-reserve): :::pagebreak keeps the title page alone if its foot
// lines move up.
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',
// Centred and tracked, a line sits half its tracking left of centre: x puts it back.
placement: { anchor: { to: 'page', edge: 'top' },
offset: { x: pt((extra.letterSpacing?.value ?? 0) / 2), y: mm(y) } }, ...extra });
const tracked = (track) => ({ textTransform: 'uppercase', letterSpacing: pt(track) });
const playbill = {
id: 'playbill', span: 'page', // even in one column (gotcha: opener-clipped-at-top)
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" …} carries the title in four attributes, which the design sets in two faces: Abril Fatface at 40 and 60 pt for the nouns, Libre Baskerville italic at 17 pt for The and of Being (heading attributes). The plum ground, the frame and the carnation are one SVG drawn in code and anchored to the bleed (image elements). The style sets span: 'page' although the book has one column. Kept in the column, the design would be cut off at the top and at the foot of the text block, 20 mm below the top of the sheet and 23.3 mm above its foot. The style’s empty header keeps the running heads off the cast list, which belongs to the same section, and its empty footer keeps the drop folio off the title page, where it would print plum on plum (heading styles).
An opener’s images reserve no height, so the heading holds the page only down to its lowest line of text, the premiere at 176 mm. The space left below it is too short for the next heading, and the captured pages come out the same without the :::pagebreak after the title. The break is there for later edits: without it, moving the theatre and the premiere up to 150 and 156 mm starts The Persons of the Play on the plum sheet. In 1.4.1 a centred line with letterSpacing sits half that spacing left of centre, so line() moves each tracked line right by the same amount. OSCAR WILDE is 0.07 mm off centre instead of 0.56.
#5 · Running heads that skip the act’s first page
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) } } }] };
The verso carries the play’s title from the frontmatter and the recto the act, from {chapterTitle}, both in Playfair Display SC capitals with the folio in plum at the outer edge (text elements). The act’s first page counts as an opener because its first block is a heading that breaks the page, so pages: 'body' keeps the heads off it, and the footer gives it a drop folio instead.
The whole recipe
// ═══ 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.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, parseTSV, mergeCells } 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) rule: '#d8cbb7', // hairlines between the persons of the cast list paper: '#fbf6ec', // a cream stock }; // The hex travels with the id: designs do not read the palette (gotcha: palette-skips-designs). 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 firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung maxRuntTracking: 0, // gotcha: runt-tracking-unpainted }; // Directions: smaller and centred, on the same grid. A paragraph style has no italic switch // or colour (gotcha: style-italic-colour): the text is written *…* and takes the 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 // Any headings object drops the H1 break: restated (gotcha: headings-drop-h1-break). 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: a table from tab-separated lines, with merged rows // One row per line, cells split by tabs. A line with no tab spans the table: mergeCells // joins its cells (gotcha: merged-cells-hiddenby). Each column has its own alignment. function billTable(tsv, widths, align, headerRowCount = 0) { let model = { ...parseTSV(tsv.trim()), columnWidths: widths, headerRowCount }; model.rows.forEach((row) => row.forEach((cell, c) => { cell.align = align[c]; })); tsv.trim().split('\n').forEach((line, r) => { if (line.includes('\t')) return; model.rows[r][0].align = 'center'; const end = { row: r, col: widths.length - 1 }; model = mergeCells(model, { start: { row: r, col: 0 }, end }); }); return model; } // One filled header cell: two side by side would show a seam (gotcha: table-fill-seams). const tableStyle = { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('plum'), headerColor: col('paper'), headerFontFamily: LABEL, headerFontSize: pt(8.5), headerBold: false, // small capitals from the face itself bodyFontSize: pt(8.8), cellPadding: mm(1.5), // the body's face and ink, a size smaller }; const tableStyles = [{ id: 'scenes', rules: 'none', cellPadding: mm(1) }]; // the scenes // #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' }), unnumbered('bill', 'Bill', { position: 'here' }), ]; // #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, its lineHeight a multiple // (gotcha: design-lineheight-multiple). The ground is an image and reserves no height // (gotcha: opener-image-no-reserve): :::pagebreak keeps the title page alone if its foot // lines move up. 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', // Centred and tracked, a line sits half its tracking left of centre: x puts it back. placement: { anchor: { to: 'page', edge: 'top' }, offset: { x: pt((extra.letterSpacing?.value ?? 0) / 2), y: mm(y) } }, ...extra }); const tracked = (track) => ({ textTransform: 'uppercase', letterSpacing: pt(track) }); const playbill = { id: 'playbill', span: 'page', // even in one column (gotcha: opener-clipped-at-top) 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 = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', es: 'es' }), // hyphenation by exact code (gotcha: hyphenation-locales) 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, { id: 'colophon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD) }], tableStyle, tableStyles, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown sample · 126 lines · 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"} :::pagebreak ## The Persons of the Play ::resource{id="persons"} ## The Scenes of the Play ::resource{id="scenes"} :::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 const cast = String.raw`The original cast · St James’s Theatre, 14 February 1895Markdown sample · 9 lines · content.cast.en.md
**John Worthing**, J.P. *Mr. George Alexander* **Algernon Moncrieff** *Mr. Allen Aynesworth* **Rev. Canon Chasuble**, D.D. *Mr. H. H. Vincent* **Merriman**, butler *Mr. Frank Dyall* **Lane**, manservant *Mr. F. Kinsey Peile* **Lady Bracknell** *Miss Rose Leclercq* **Hon. Gwendolen Fairfax** *Miss Irene Vanbrugh* **Cecily Cardew** *Miss Evelyn Millard* **Miss Prism**, governess *Mrs. George Canninge*`; // content.cast.<lang>.md: the persons, tab-separated const scenes = String.raw`Act I Algernon Moncrieff’s Flat in Half-Moon Street, W.Markdown sample · 3 lines · content.scenes.en.md
Act II The Garden at the Manor House, Woolton. Act III Drawing-Room at the Manor House, Woolton. *Time: The Present.*`; // content.scenes.<lang>.md: the acts and their places // #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 table = (id, model, styleId) => ({ id, typeId: 'bill', kind: 'table', table: { model, styleId }, createdAt: 0, updatedAt: 0 }); const resources = [...art, table('persons', billTable(cast, [2, 1], ['left', 'right'], 1)), // the first line heads it table('scenes', billTable(scenes, [3, 17], ['right', 'left']), 'scenes'), ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Libre Baskerville': ['400', '400i', '700'], 'Abril Fatface': ['400'], 'Playfair Display SC': ['400'] }; // all loaded before the first build (gotcha: fonts-first) // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown + cast + scenes); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: doc.metadata.title }); // the frontmatter's titleKit · core, fonts, viewer, images: the same in every recipe · 270 lines
// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { 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', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true 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; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { 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, { 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 v1 ── 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 (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** 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 ───────────────────────────────────────────────────────────────────────
The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗
Variations
#Line the directions up with the turnovers
Set flush left with a 5 mm first-line indent, a one-line direction starts where the turnover lines of the speeches do, and the scene description becomes two indented paragraphs under its centred label.
const direction = { id: 'direction', fontSize: pt(8.6),
- textAlign: 'center', firstLineIndent: pt(0) };
+ textAlign: 'left', firstLineIndent: mm(5) };#Leave a line between speeches
paragraphSpacing: true adds a blank grid line after every speech. The English act then runs to five pages instead of three, the Spanish one to four.
maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
+ paragraphSpacing: true,
};Pitfalls
Pitfall
Any headings object switches off the H1 page break
By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →
Pitfall
A paragraph style has no italic colour
In postext 1.4.1 a paragraph style sets color and boldColor but no italicColor: its italic runs take bodyText.italicColor. A muted style (small print, a source line) prints its italic titles darker than the words around them. Keep such styles in the body's ink, or avoid italics in them. Paragraph styles →
Pitfall
A 'here' table never splits
Only floated tables split across columns and pages; a table placed 'here' moves whole. Let a long table float, or keep inline tables short. Tables across pages →
Pitfall
::resource{id="…"} takes double quotes only
A block embed is recognised only as ::resource{id="…"} with double quotes; any other form stays in the text as a visible line. Figures exactly here →
Pitfall
A swapped palette misses design elements and the reference colour
postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build. Semantic colour palette →
Pitfall
A design text's lineHeight is a multiple, never a dimension
In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title. Text, rules and boxes in page designs →
Pitfall
A runt fix can tighten tracking that is never painted
In postext 1.4.1, when a paragraph ends on a runt, the layout sets it one line shorter: first with tighter word spacing, then with up to maxRuntTracking thousandths of an em of negative tracking. The canvas and PDF renderers paint tracking only above zero, so a tracked paragraph prints untracked: its justified lines lose the difference from their word spaces and look crushed, and its last line can run past the measure and be clipped at the column edge. Set bodyText.maxRuntTracking: 0, which keeps the word-spacing fix, and reword any runt that comes back. Widows, orphans and runts →
Pitfall
Quote every frontmatter value
YAML reads title: 1984 as a number and a date as a Date object, and non-string values print empty in placeholders and leave the PDF without a title. Quote every value: title: "1984". Document metadata →
Pitfall
Only 8 locales hyphenate, by exact code
Hyphenation ships for en-us, es, fr, de, it, pt, ca and nl, matched exactly: 'es-ES' or any other language silently falls back to American English. Hyphenation and document language →
Pitfall
Load every face before layout
Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →
Pitfall
An opener kept in its column is cut off at the top of the text block
In postext 1.4.1 an advanced-design heading that stays in its column is clipped at the column's top edge: a box or a picture anchored to the page or the bleed paints into the side margins but not into the top margin, and no warning says so. Give such a heading span: 'page', even in a single-column book: its design is then painted as the page's opener band, whole. Designed openers →
Pitfall
An opener's images never count towards the height it reserves
In postext 1.4.1 an advanced-design heading measures the height it reserves without its images: its texts, rules and boxes count, even when anchored to the page, but an image, such as a picture bled across the head of the page, reserves nothing, so the text can start on top of it. Set minHeight to where the text should begin. Designed openers →
Pitfall
Two filled table cells side by side show a pale seam on the canvas
In postext 1.4.1 the canvas renderer fills each table cell with a rectangle of its own at fractional pixel positions, so where two filled cells meet (the cells of a header row filled in colour, or tinted body cells) a hairline of the page shows between them. Fill only rows whose cells are merged into one, stroke the rules in the fill colour, or leave the cells unfilled. Table style →
- Every italic run in the dialogue takes
italicColor, emphasis included, so Lane’s emphatic is prints in the grey of the directions. The grey has to go onbodyTextbecause in 1.4.1 a paragraph style cannot give its italics a colour of their own; only roman type would keep the is in ink. - Three of Wilde’s speeches end on a word alone: information., that? and you. The script sets
bodyText.maxRuntTracking: 0because of the pitfall A runt fix can tighten tracking that is never painted. At the default, 1.4.1 pulls that? up with 2.0 mm of negative tracking that the canvas does not paint, and the line prints with its word spaces at half their width. With word spacing alone, that? moves up only with aminWordSpacingof 0.5, which crushes the line the same way, or with a measure 1.5 mm wider, which sets ten lines looser than 1.5 times the normal space instead of two. The English pages keep the three runts rather than change Wilde’s words. The Spanish translation was reworded wherever a line ended on a one-letter word or a syllable stood alone. - Names hyphenate like any other word: Au-gusta and Gwen-dolen break across lines on page 5. A word joiner (U+2060) inside Augusta keeps it whole, but it stretches the line above to 1.71 times the normal word space, so the English text keeps the hyphens.
- Playfair Display SC draws fi as a lowercase ligature on the canvas, so first would print a lowercase fi among small capitals. That is why the cast list’s band says The original cast.
Credits
- Recipe
- 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 · public domain
- The Spanish translation of the extract, the cast list and the scenes · Postext Cookbook · original
- Images
- The title page’s frame and the carnation fleuron, drawn in code in the page’s palette · Ignacio Ferro · MIT
- Fonts
- Libre Baskerville (SIL OFL 1.1) · Abril Fatface (SIL OFL 1.1) · Playfair Display SC (SIL OFL 1.1)


