Aller au contenu principal
Recette numéro 70

Recettes · Chapitre 9 · Publications complètes

Texte de théâtre : distribution, répliques et didascalies

La pièce de Wilde en édition de jeu : noms en gras prune, didascalies en italique gris, distribution avec points de conduite jusqu’à chaque acteur.

p. 2–3 sur 5

  • Exemple en anglais : pas encore d’édition française
  • Format 140 × 216 mm
  • 1 colonne
  • Libre Baskerville 9,4/13,6
  • Abril Fatface
  • Playfair Display SC
  • 5 pages
  • Niveau
  • Postext 1.30.0
  • Mis en page en 126 ms
  • 160 lignes de code

En bref

Le début d’une comédie célèbre d’Oscar Wilde, imprimé pour les comédiens. Les noms des personnages ressortent en gras et en couleur, et les didascalies sont en gris pour qu’on puisse les sauter.

Ce que vous allez composer

Le début de The Importance of Being Earnest d’Oscar Wilde, composé en édition de jeu de cinq pages au format 140 × 216 mm. La page de titre est une affiche prune dans un double cadre or, avec Importance et Earnest en capitales grasses à fort contraste et un œillet or sous le titre. Face au premier acte, la distribution nomme l’acteur qui a créé chaque rôle au St James’s Theatre en 1895. Chaque réplique s’ouvre sur le nom du personnage en gras prune, et ses lignes suivantes sont en retrait suspendu de 5 mm : un comédien trouve sa prochaine réplique d’un coup d’œil. Les didascalies, dans une réplique ou entre deux, sont en italique gris, et l’œil peut les sauter quand on répète son texte. L’édition espagnole est une nouvelle traduction qui suit l’usage théâtral espagnol, avec un tiret après chaque nom et les didascalies entre parenthèses.

Cette recette répond à

  • Comment composer un scénario ou une pièce de théâtre : en-têtes de scène, noms des personnages et dialogues en retrait des deux côtés ?
  • Comment colorer les termes clés (en gras ou en italique) dans le texte ou dans les encadrés ?
  • Comment composer des illustrations non numérotées : ornements, vignettes, logos ?

La réponse courte

script.js · lignes 34–54dans le code complet
// 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) };

Ingrédients

Typographie
Libre Baskerville, Abril Fatface, Playfair Display SC (SIL OFL 1.1)
Ressources
  • The title page’s frame and the carnation fleuron, drawn in code in the page’s palette (Ignacio Ferro, MIT)

Préparation

#1 · Un acte est un simple titre

script.js · lignes 58–69dans le code complet
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') },
  ],
};

Le titre de l’acte est un titre de niveau 1 en Abril Fatface à 24 pt, sans emplacement de maquette. textTransform le compose en capitales, et ses trois lignes de grille de 13,6 pt placent les capitales à 5,5 mm sous le haut du bloc de texte (titres). textAlign: 'center' ne se règle pas par niveau : il centre donc aussi les titres de la distribution et l’étiquette Scene. breakBefore avec la parité 'odd' fait commencer l’acte sur un recto, face à la distribution. La valeur par défaut, 'always-odd', mettrait deux pages blanches entre les deux (saut avant).

#2 · La distribution est une ligne de points jusqu’à chaque acteur

script.js · lignes 73–85dans le code complet
// 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) }] };

Chaque personnage est un paragraphe du style person, l’acteur après :tab (tabulations et taquets). Le style a un seul taquet, à 'end', la fin de la justification, et avec align: 'end' le nom de l’acteur s’y termine. Son leader: '.' remplit l’espace avant le taquet de points, à un demi-cadratin du personnage et de l’acteur, et spaceBetween place un personnage toutes les lignes et demie. Les noms des personnages sont en gras : ils s’impriment donc en prune, comme les noms des personnages dans le dialogue. La distribution d’origine, le théâtre et la date coiffent la liste dans le style bill-head, centré en Playfair Display SC, dont les minuscules sont des petites capitales. Les décors prennent deux taquets : :tab Act I :tab … termine Act I sur le premier, à 15 mm, avec align: 'end', si bien que les trois actes s’alignent à droite sur une colonne, et fait commencer le lieu sur le second, 2 mm plus loin. L’époque de l’action est une ligne centrée dans le style des didascalies.

#3 · Des ornements sans numéro ni légende

script.js · lignes 89–94dans le code complet
// ::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' }),
];

Un type de ressource avec captionPrefix et shortLabel vides imprime ses images nues (types de ressources). Son defaultPlacement place l’œillet là où se trouve ::resource{id="fleuron"}, centré et large de 0,24 de la justification de 107 mm, soit environ 26 mm.

#4 · La page de titre est un style de titre

script.js · lignes 98–120dans le code complet
// # 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" …} porte le titre en quatre attributs, que la maquette compose en deux caractères : Abril Fatface à 40 et 60 pt pour les noms, Libre Baskerville italique à 17 pt pour The et of Being (attributs de titre). Le fond prune, le cadre et l’œillet forment un seul SVG dessiné en code et ancré au fond perdu (éléments image). Le style règle span: 'page' alors que le livre n’a qu’une colonne. Gardée dans la colonne, la maquette serait coupée au pied du bloc de texte, 23,3 mm au-dessus du pied de la feuille. L’header vide du style écarte les titres courants de la distribution, qui appartient à la même section, et son footer vide écarte le folio en pied de la page de titre, où il s’imprimerait prune sur prune (styles de titre).

Le fond est un élément image, et une ouverture réserve la place jusqu’au pied de ses images : le titre occupe donc toute la feuille. The Persons of the Play commence en page 2 sans :::pagebreak après le titre, quelle que soit la position des lignes de la page de titre. Une ligne centrée avec letterSpacing ne demande aucune correction non plus. L’approche après sa dernière lettre est exclue du centrage, et OSCAR WILDE tombe au centre de la page.

#5 · Des titres courants qui sautent la première page de l’acte

script.js · lignes 124–140dans le code complet
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) } } }] };

Le verso porte le titre de la pièce, tiré du frontmatter, et le recto l’acte, tiré de {chapterTitle}, tous deux en capitales Playfair Display SC avec le folio en prune au bord extérieur (éléments texte). La première page de l’acte compte comme une ouverture, car son premier bloc est un titre qui provoque un saut de page : pages: 'body' en écarte donc les titres courants, et le pied de page lui donne à la place un folio en pied.

La recette complète

Sandbox
// ═══ 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`---
Exemple Markdown · 156 lignes · content.en.mdtitle: "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 title
Kit · core, fonts, viewer, images : identique dans toutes les recettes · 270 lignes// ─── 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 ───────────────────────────────────────────────────────────────────────

Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)

Variantes

#Aligner les didascalies sur les lignes suivantes

Composée au fer à gauche avec un alinéa de 5 mm, une didascalie d’une ligne commence là où commencent les lignes suivantes des répliques, et la description du décor devient deux paragraphes en alinéa sous son étiquette centrée.

 const direction = { id: 'direction', fontSize: pt(8.6),
-  textAlign: 'center', firstLineIndent: pt(0) };
+  textAlign: 'left', firstLineIndent: mm(5) };

#Laisser une ligne entre les répliques

paragraphSpacing: true ajoute une ligne de grille blanche après chaque réplique. L’acte anglais court alors sur cinq pages au lieu de trois, l’espagnol sur quatre.

   firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung
+  paragraphSpacing: true,
 };

Erreurs fréquentes

Erreur fréquente

::resource{id="…"} n’accepte que les guillemets doubles

Une intégration en bloc n’est reconnue que sous la forme ::resource{id="…"} avec des guillemets doubles ; toute autre forme reste dans le texte comme une ligne visible. Des figures exactement ici →

  • Tout passage en italique du dialogue prend italicColor, y compris l’emphase : le is appuyé de Lane s’imprime donc dans le gris des didascalies. Le gris est posé sur bodyText parce que la plupart des didascalies se trouvent dans une réplique, dans le même paragraphe que l’emphase ; seul le romain garderait le is à l’encre.
  • Trois répliques de Wilde se terminent sur un mot seul : information., you. et that? La correction des fins de paragraphe trop courtes peut remonter la troisième sur la ligne précédente, mais seulement avec le 1 % d’approche négative que bodyText.maxRuntTracking autorise par défaut, et la réplique paraît alors plus sombre que ses voisines. maxRuntTracking: 5 divise ce plafond par deux : that? passe à la ligne, et la ligne au-dessus s’ouvre à 1,78 fois l’espace normal, sous la limite de 1,8. Les pages anglaises gardent les trois fins courtes plutôt que de changer les mots de Wilde. La traduction espagnole a été reformulée partout où une ligne finissait sur un mot d’une lettre ou laissait une syllabe seule.
  • Les noms se coupent comme n’importe quel mot : Au-gusta et Gwen-dolen sont coupés en fin de ligne en page 5. Un gluon (U+2060) dans Augusta le garde entier, mais il étire la ligne au-dessus à 1,71 fois l’espace normal : le texte anglais garde donc les césures.
  • Playfair Display SC dessine fi comme une ligature minuscule sur le canvas : first s’imprimerait avec un fi minuscule parmi des petites capitales. C’est pourquoi l’en-tête de la distribution dit The original cast.

Crédits

Texte
  • 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 · domaine public
  • 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
Polices
Libre Baskerville (SIL OFL 1.1) · Abril Fatface (SIL OFL 1.1) · Playfair Display SC (SIL OFL 1.1)
Sandbox