Pular para o conteúdo principal
Receita número 70

Receitas · Capítulo 9 · Publicações completas

Peça de teatro: elenco, personagens e rubricas

O início da peça de Wilde numa edição para atores. Os nomes saem em negrito ameixa e as rubricas em itálico cinza; o elenco é lido de linhas com tabulação.

  • Amostra em inglês: ainda sem edição em português
  • Refile 140 × 216 mm
  • 1 coluna
  • Libre Baskerville 9,4/13,6
  • Abril Fatface
  • Playfair Display SC
  • 5 páginas
  • Nível
  • Postext 1.19.1
  • Diagramado em 12 ms
  • 180 linhas de código

Em poucas palavras

O começo de uma comédia famosa de Oscar Wilde, impressa para os atores. O nome de quem fala se destaca em negrito colorido, e as rubricas ficam em cinza para que o ator possa pulá-las.

O que você vai compor

O começo de The Importance of Being Earnest, de Oscar Wilde, composto como uma edição para atores de cinco páginas em 140 × 216 mm. A folha de rosto é um cartaz cor de ameixa dentro de uma moldura dupla dourada, com Importance e Earnest em maiúsculas fat face e um cravo dourado sob o título. Diante do primeiro ato, o elenco dá o nome do ator que criou cada papel no St James’s Theatre em 1895. Cada fala abre com o nome do personagem em negrito ameixa e recua 5 mm as linhas seguintes, de modo que o ator acha a próxima deixa de relance. As rubricas, dentro de uma fala ou entre duas, vêm em itálico cinza, e o olho as salta quando o ator repassa o texto. A edição em espanhol é uma tradução nova que segue o costume do teatro em espanhol, com travessão depois de cada nome e as rubricas entre parênteses.

Esta receita responde a

  • Como componho um roteiro de cinema ou uma peça de teatro: cabeçalhos de cena, nomes de personagens e diálogo recuado dos dois lados?
  • Como dou cor aos termos-chave (em negrito ou itálico) no texto ou dentro dos boxes?
  • Como faço uma tabela com linhas de cabeçalho, células mescladas, larguras de coluna e alinhamento por célula?
  • Como componho ilustrações sem numeração: ornamentos, vinhetas, logotipos?

A resposta curta

script.js · linhas 34–52no código completo
// 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) };

Ingredientes

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

Preparo

#1 · Um ato é um título comum

script.js · linhas 56–68no código completo
// 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') },
  ],
};

O título do ato é um título de nível 1 em Abril Fatface de 24 pt, sem slot de design. textTransform o passa para maiúsculas, e as três linhas de 13,6 pt da grade que ele ocupa deixam as maiúsculas 5,5 mm abaixo do topo da mancha (títulos). textAlign: 'center' não aceita um valor por nível, então centraliza também os títulos do elenco e o rótulo Scene. breakBefore com paridade 'odd' leva o ato para uma página ímpar, diante do elenco. A configuração precisa repetir isso, porque qualquer objeto headings desativa a quebra de página do primeiro nível (quebrar antes).

#2 · O elenco é uma tabela lida de linhas com tabulação

script.js · linhas 72–92no código completo
// 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

O Postext não tem tabelas com barras verticais, então cada tabela é um recurso. As linhas estão em content.cast.en.md, uma por linha de texto com uma tabulação entre as células, e parseTSV as transforma num modelo (montar modelos de tabela). A única linha sem tabulação, a do elenco original com o teatro e a data, vira uma linha mesclada: mergeCells dá à primeira célula colSpan: 2 e mantém a segunda na grade, marcada com hiddenBy. headerRowCount: 1 põe essa linha na faixa ameixa. Nenhuma outra linha tem preenchimento, porque o canvas mostra uma emenda clara entre duas células preenchidas lado a lado. A coluna dos atores ocupa um terço da largura, e cada célula dela fica alinhada à direita. As cenas passam pela mesma função com um estilo nomeado sem fios (estilos de tabela nomeados). Uma tabela compõe todo o texto das células numa só cor, a tinta do texto, a não ser que bodyColor indique outra, por isso os nomes em negrito dos personagens saem em tinta aqui e em ameixa no diálogo.

#3 · Ornamentos sem número nem legenda

script.js · linhas 96–102no código completo
// ::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' }),
];

Um tipo de recurso com captionPrefix e shortLabel vazios imprime as imagens sozinhas (tipos de recurso). O defaultPlacement dele coloca o cravo onde está ::resource{id="fleuron"}, centralizado e com 0,24 da medida de 107 mm, uns 26 mm. As duas tabelas usam um segundo tipo sem numeração, então nenhuma leva o rótulo Table 1.

#4 · A folha de rosto é um estilo de título

script.js · linhas 106–132no código completo
// # 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" …} leva o título em quatro atributos, que o design compõe em duas famílias: Abril Fatface de 40 e 60 pt para os substantivos e Libre Baskerville itálico de 17 pt para The e of Being (atributos de título). O fundo ameixa, a moldura e o cravo são um único SVG desenhado em código e ancorado na sangria (elementos de imagem). O estilo usa span: 'page' embora o livro tenha uma só coluna. Preso à coluna, o design seria cortado no alto e no pé da mancha, 20 mm abaixo do topo da folha e 23,3 mm acima do pé. O header vazio do estilo tira os cabeços do elenco, que pertence à mesma seção, e o footer vazio tira da folha de rosto o fólio no pé, que sairia em ameixa sobre ameixa (estilos de título).

As imagens de uma abertura não reservam altura, então o título ocupa a página só até a linha de texto mais baixa, a da estreia, a 176 mm. O espaço que sobra embaixo é curto demais para o título seguinte, e as páginas capturadas saem iguais sem o :::pagebreak depois da folha de rosto. A quebra está ali para edições futuras: sem ela, subir o teatro e a estreia para 150 e 156 mm faz The Persons of the Play começar sobre a folha ameixa. Na 1.4.1, uma linha centralizada com letterSpacing fica deslocada para a esquerda metade desse espaçamento, por isso line() move cada linha espaçada a mesma distância para a direita. OSCAR WILDE fica a 0,07 mm do centro, e não a 0,56.

#5 · Cabeços que pulam a primeira página do ato

script.js · linhas 136–152no código completo
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) } } }] };

A página par traz o título da peça, tirado do frontmatter, e a ímpar o do ato, de {chapterTitle}, os dois em maiúsculas de Playfair Display SC, com o fólio em ameixa na margem externa (elementos de texto). A primeira página do ato conta como abertura porque o primeiro bloco dela é um título que quebra a página, então pages: 'body' tira os cabeços dela, e o rodapé lhe dá no lugar um fólio no pé.

A receita completa

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.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`---
Amostra em Markdown · 126 linhas · 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"} :::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 1895
Amostra em Markdown · 9 linhas · 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.
Amostra em Markdown · 3 linhas · content.scenes.en.mdAct 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 title
Kit · core, fonts, viewer, images: igual em todas as receitas · 271 linhas// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** 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. * 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', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; 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 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 = `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` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds 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. */ 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' }; } /** 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, '-'); } /** 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 v1 ── 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. */ 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 ───────────────────────────────────────────────────────────────────────

O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)

Variações

#Alinhar as rubricas com as linhas recuadas

Alinhada à esquerda e com um recuo de primeira linha de 5 mm, uma rubrica de uma linha começa onde começam as linhas recuadas das falas, e a descrição da cena vira dois parágrafos recuados sob o rótulo centralizado.

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

#Deixar uma linha entre as falas

paragraphSpacing: true acrescenta uma linha da grade em branco depois de cada fala. O ato em inglês passa então de três para cinco páginas, e o em espanhol para quatro.

   maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
+  paragraphSpacing: true,
 };

Erros comuns

Erro comum

Qualquer objeto headings desativa a quebra de página do H1

Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →

Erro comum

Um estilo de parágrafo não tem cor de itálico

No postext 1.4.1, um estilo de parágrafo define color e boldColor, mas não italicColor: os trechos em itálico usam bodyText.italicColor. Um estilo atenuado (letra miúda, a linha “Fonte:” de uma tabela) imprime os títulos em itálico mais escuros que o texto em volta. Mantenha esses estilos na cor do texto corrido ou evite itálico neles. Estilos de parágrafo →

Erro comum

Células mescladas precisam de hiddenBy: use mergeCells

As células são posicionadas pela posição no array da linha, então uma célula mesclada precisa de células de preenchimento marcadas com hiddenBy onde ela se estende; omiti-las, como no HTML, desloca todas as colunas seguintes. Crie as mesclagens com mergeCells. Tabelas a partir de dados →

Erro comum

Uma tabela 'here' nunca se divide

Só as tabelas flutuantes se dividem entre colunas e páginas; uma tabela posicionada como 'here' se move inteira. Deixe as tabelas longas flutuarem ou mantenha curtas as tabelas em linha. Tabelas que passam de página →

Erro comum

::resource{id="…"} só aceita aspas duplas

Uma inserção em bloco só é reconhecida como ::resource{id="…"} com aspas duplas; qualquer outra forma fica no texto como uma linha visível. Figuras exatamente aqui →

Erro comum

Uma paleta trocada não chega aos elementos de design nem à cor das referências

postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor. Paleta de cores semântica →

Erro comum

O lineHeight de um texto de design é um múltiplo, nunca uma medida

Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título. Textos, fios e caixas nos designs de página →

Erro comum

O ajuste de linhas curtas pode apertar um tracking que nunca é pintado

No postext 1.4.1, quando um parágrafo termina numa linha curta, a diagramação o compõe com uma linha a menos: primeiro aperta o espaçamento entre palavras, depois aplica até maxRuntTracking milésimos de em de tracking negativo. Os renderizadores de canvas e PDF só pintam tracking acima de zero, então o parágrafo sai impresso sem ele: as linhas justificadas perdem essa diferença nos espaços entre palavras, que ficam esmagados, e a última linha pode passar da medida e ser cortada na borda da coluna. Defina bodyText.maxRuntTracking: 0, que mantém o ajuste pelo espaçamento entre palavras, e reescreva os parágrafos que voltarem a terminar numa linha curta. Viúvas, órfãs e linhas curtas →

Erro comum

Coloque entre aspas cada valor do frontmatter

O YAML lê title: 1984 como número e uma data como objeto Date, e valores que não são strings saem vazios nos placeholders e deixam o PDF sem título. Coloque cada valor entre aspas: title: "1984". Metadados do documento →

Erro comum

Só 8 idiomas têm hifenização, com o código exato

A hifenização existe para en-us, es, fr, de, it, pt, ca e nl, com o código exato: 'es-ES' ou qualquer outro idioma passa sem aviso para o inglês americano. Hifenização e idioma do documento →

Erro comum

Carregue todas as fontes antes do layout

O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada. Fontes antes da diagramação →

Erro comum

Uma abertura que fica na sua coluna é cortada no alto da mancha

No postext 1.4.1, um título com design avançado que fica na sua coluna é recortado na borda superior da coluna: uma caixa ou uma imagem ancorada à página ou à sangria é pintada nas margens laterais, mas não na margem superior, e nenhum aviso informa isso. Dê span: 'page' a esse título, mesmo que o livro tenha uma só coluna: o design dele passa a ser pintado inteiro, como faixa de abertura da página. Aberturas desenhadas →

Erro comum

As imagens de uma abertura nunca contam para a altura que ela reserva

No postext 1.4.1, um título com design avançado mede a altura que reserva sem as imagens: textos, fios e caixas contam, mesmo quando ancorados na página, mas uma imagem, como uma ilustração sangrada no alto da página, não reserva nada, então o texto pode começar por cima dela. Defina com minHeight onde o texto deve começar. Aberturas desenhadas →

Erro comum

Entre duas células de tabela preenchidas aparece uma linha clara no canvas

No postext 1.4.1, o renderizador de canvas preenche cada célula da tabela com um retângulo próprio em posições de pixel fracionárias, então, onde duas células preenchidas se encontram (as de uma linha de cabeçalho em cor ou células do corpo com fundo), aparece entre elas um fio finíssimo da cor do papel. Preencha só linhas cujas células estejam mescladas em uma, trace os fios na cor do preenchimento ou deixe as células sem preenchimento. Estilo de tabela →

  • Todo trecho em itálico do diálogo assume italicColor, inclusive a ênfase, por isso o is enfático de Lane sai no cinza das rubricas. O cinza precisa ficar em bodyText porque, na 1.4.1, um estilo de parágrafo não consegue dar ao seu itálico uma cor própria; só em redondo o is ficaria em tinta.
  • Três falas de Wilde terminam numa palavra sozinha: information., that? e you. O script define bodyText.maxRuntTracking: 0 por causa do erro O ajuste de linhas curtas pode apertar um tracking que nunca é pintado. Com o valor padrão, a 1.4.1 sobe that? para a linha anterior com 2,0 mm de tracking negativo que o canvas não pinta, e a linha sai com os espaços entre palavras na metade da largura. Mexendo só no espaçamento entre palavras, that? sobe apenas com um minWordSpacing de 0,5, que esmaga a linha do mesmo jeito, ou com uma medida 1,5 mm mais larga, que deixa dez linhas mais frouxas que 1,5 vez o espaço normal, em vez de duas. As páginas em inglês ficam com as três linhas curtas para não mudar as palavras de Wilde. A tradução espanhola foi reescrita onde uma linha terminava numa palavra de uma letra ou onde uma sílaba ficava sozinha.
  • Os nomes são hifenizados como qualquer outra palavra: Au-gusta e Gwen-dolen se partem entre duas linhas na página 5. Um word joiner (U+2060) dentro de Augusta mantém o nome inteiro, mas estica a linha de cima até 1,71 vez o espaço normal, então o texto em inglês fica com os hífens.
  • No canvas, a Playfair Display SC desenha fi como uma ligadura em minúscula, e first sairia com um fi minúsculo entre versaletes. Por isso a faixa do elenco diz The original cast.

Créditos

Texto
  • 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 · domínio público
  • The Spanish translation of the extract, the cast list and the scenes · Postext Cookbook · original
Imagens
  • The title page’s frame and the carnation fleuron, drawn in code in the page’s palette · Ignacio Ferro · MIT
Fontes
Libre Baskerville (SIL OFL 1.1) · Abril Fatface (SIL OFL 1.1) · Playfair Display SC (SIL OFL 1.1)
Sandbox