Vai al contenuto principale
Ricetta numero 65

Ricettario · Capitolo 3 · Titoli e aperture

Classico tascabile: capitoli brevi di seguito

Dom Casmurro come libro tascabile: capitoli brevi di seguito sotto titoli disegnati nella colonna, e nessun cambio di pagina lascia sola una riga o un titolo.

In questa pagina

pp. 212–213 · 2–3 di 7

  • Esempio in inglese: non c’è ancora un’edizione italiana
  • Formato 110 × 178 mm
  • 1 colonna
  • Tinos 9,5/12,6
  • Abril Fatface
  • League Spartan
  • 7 pagine
  • Livello
  • Postext 1.31.0
  • Impaginato in 79 ms
  • 167 righe di codice

In breve

Cinque pagine di un classico del romanzo brasiliano in un piccolo libro tascabile. Ogni capitolo breve comincia subito dopo il precedente, e nessuna pagina comincia o finisce con una riga isolata.

Che cosa comporrai

Cinque pagine di testo verso la fine di Dom Casmurro, il romanzo di Machado de Assis del 1899, composte nel portoghese originale come libro tascabile di 110 × 178 mm in una collana inventata, Coleção Casuarina. Una copertina a fasce prugna, crema e prugna porta il titolo in Abril Fatface e il tondo zafferano della collana, e una tavola dipinta del mare grosso davanti a Flamengo, generata con modelli di diffusione, sta di fronte a pagina 213. Il testo va dalla fine del capitolo CXVIII alla fine del CXXIII, e nessun capitolo apre una pagina. Ognuno comincia due righe sotto la fine del precedente (tre a CXIX, per riempire pagina 213), sotto un numerale romano prugna, il titolo in maiuscole spaziate e un breve filetto zafferano. La pagina 214 ha due titoli di capitolo, la 215 e la 216 uno ciascuna. Ogni pagina di testo tranne l’ultima finisce sulla sua 33ª riga, e nessuna apre su una riga isolata.

Questa ricetta risponde a

  • Come evito vedove, orfane e ultime righe di una sola parola (righe finali troppo corte), e tengo un titolo unito al suo testo?
  • Come ottengo colonne pareggiate al piede e un’ultima pagina bilanciata (giustificazione verticale)?
  • Come numero i titoli (1, 1.1, 1.1.1) e do a ogni livello uno stile diverso?
  • Come imposto le testatine: titolo del libro sulla pagina sinistra, titolo del capitolo sulla destra, numero di pagina all’esterno?

La risposta breve

script.js · righe 37–59nel codice completo
// Machado's chapters run a page or two, so none opens a page. The level's break is off (the
// H1 default would open each chapter on a recto) and a chapter starts two lines under the last.
const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm
const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line
const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text
  { kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL),
    lineHeight: 1, color: col('plum'), align: 'center',
    placement: { ...at('container', 'top'), size: { width: 'fill' } } },
  { kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'),
    align: 'center', overflow: 'wrap', // a long title takes a second line
    placement: { anchor: { to: '#numeral', edge: 'below' }, offset: { y: mm(GAP) },
      size: { width: 'fill' } } },
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'),
    placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } },
] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid
const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX…
  fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
// The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph
// at the foot of a page, and headings.keepWithNext takes the head along when the paragraph
// moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a
// last line shorter than about 20 characters as a fault. Column balancing adds lines above
// a head so that the page ends on line 33.

Ingredienti

Tipo
Tinos (Apache 2.0) · Abril Fatface, League Spartan (SIL OFL 1.1)
Risorse
  • plate-1100.jpg
  • The plate of the sea off Flamengo under the Sugarloaf, a painting (Generated With Diffusion Models, originale)
  • The casuarina roundel, drawn in code in the book’s palette (Ignacio Ferro, MIT)

Preparazione

#1 · Fai seguire i capitoli, ogni titolo tenuto con il suo testo

script.js · righe 37–59nel codice completo
// Machado's chapters run a page or two, so none opens a page. The level's break is off (the
// H1 default would open each chapter on a recto) and a chapter starts two lines under the last.
const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm
const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line
const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text
  { kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL),
    lineHeight: 1, color: col('plum'), align: 'center',
    placement: { ...at('container', 'top'), size: { width: 'fill' } } },
  { kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'),
    align: 'center', overflow: 'wrap', // a long title takes a second line
    placement: { anchor: { to: '#numeral', edge: 'below' }, offset: { y: mm(GAP) },
      size: { width: 'fill' } } },
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'),
    placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } },
] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid
const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX…
  fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
// The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph
// at the foot of a page, and headings.keepWithNext takes the head along when the paragraph
// moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a
// last line shorter than about 20 characters as a fault. Column balancing adds lines above
// a head so that the page ends on line 33.

Il livello del capitolo ha l’interruzione disattivata, e marginTop mette ogni titolo due righe sotto l’ultima riga del capitolo precedente. Il suo design non ha span, quindi il titolo è disegnato nella colonna, in un blocco alto tre righe, e il testo sotto resta sulla griglia (interruzione prima, span e design avanzato). Le regole di tenuta restano ai valori predefiniti. I paragrafi di quattro righe sotto CXIX e CXXI lasciano ciascuno due righe al piede della loro pagina e ne portano due alla successiva; con avoidOrphans: false si dividerebbero in tre e una, e le pagine 214 e 215 si aprirebbero su una riga isolata (orfane, vedove, righe corte). Il bilanciamento delle colonne aggiunge una terza riga di spazio sopra CXIX, così la pagina 213 finisce sulla riga 33 (bilanciamento delle colonne).

Pagina 213: il capitolo CXIX comincia tre righe sotto la fine di CXVIII e tiene due righe del suo testo al piede.

#2 · Imposta una pagina di 33 righe, con la sillabazione portoghese

script.js · righe 63–70nel codice completo
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD),
  color: col('ink'), referenceColor: col('ink'), // a :ref added later prints in ink
  firstLineIndent: mm(5) }; // every paragraph indented, the first after a head too
const page = { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * PT), // 16.3 mm
    left: mm(INNER), right: mm(OUTER), mirror: true } };
const LOCALE = 'pt'; // the Portuguese hyphenation patterns

Il margine di piede è di 16,3 mm, ciò che l’altezza di 178 mm lascia dopo il margine di testa di 15 mm e 33 righe da 12,6 pt, così ogni pagina piena finisce sulla stessa riga. Tinos, che ha le larghezze del Times New Roman, compone circa 59 caratteri per riga sulla giustezza di 84 mm a 9,5 pt. locale: 'pt' sillaba con i modelli portoghesi: at-tracção, af-fluencia, genealogica-mente (lingue supportate). Ogni paragrafo è rientrato, anche il primo dopo un titolo, come di solito nelle edizioni portoghesi e brasiliane, quindi indentAfterHeading mantiene il valore predefinito.

#3 · Nomina il libro sopra il verso e il capitolo sopra il recto

script.js · righe 74–87nel codice completo
const SHIFT = (INNER - OUTER) / 2; // mm: the text block sits off the page's centre
const [HEAD_Y, FOLIO_Y] = [8.5, 167]; // mm below the top edge
const head = (id, content, parity, x) => ({ kind: 'text', id, content, parity,
  pages: 'body', // never on the cover or the plate, which are opener pages
  ...caps(7.5, TRACK), color: col('muted'), align: 'center',
  placement: at('page', 'top', x, HEAD_Y) });
const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity,
  pages: 'body', ...caps(7.5, 0), color: col('ink'), placement: at('page', edge, x, FOLIO_Y) });
const header = { elements: [
  head('verso-title', '{title}', 'even', -SHIFT), // the title in the frontmatter
  head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // last chapter begun on or before it
] };
const footer = { elements: [folio('verso-folio', 'even', 'top-left', OUTER),
  { ...folio('recto-folio', 'odd', 'top-right', -OUTER), align: 'right' }] };

{title} stampa il titolo del frontmatter, e {chapterTitle} l’ultimo capitolo che comincia nella pagina o prima, così la pagina 217 nomina CXXIII, che è cominciato a pagina 216 (elementi di testo). La pagina 213 nomina CXIX, che comincia al suo piede, anche se le sue prime 25 righe chiudono CXVIII. {chapterTitleAtTop} nomina invece il capitolo in cui una pagina si apre, ma questo estratto non contiene un titolo per CXVIII. SHIFT sposta le testatine sul centro della gabbia del testo, 1 mm fuori dal centro della pagina perché il margine interno è 2 mm più largo di quello esterno.

#4 · Disegna la copertina e la tavola come titoli

script.js · righe 91–134nel codice completo
// span: 'page', in one column too, makes each an opener page: its art runs past the foot of
// the text column, and no running head or folio prints on it. Each design reaches the foot of
// the page, so the heading reserves the whole page and the text starts on the next one.
const onPage = (y, size) => ({ ...at('page', 'top', 0, y), ...(size && { size }) });
const cover = { id: 'cover', numbered: false, span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'top-band', style: { backgroundColor: col('plum') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(62) } } },
    { kind: 'text', id: 'author', content: '{author}', ...caps(10, 2.4), color: col('paper'),
      align: 'center', placement: at('page', 'top', 0, 44) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(44),
      lineHeight: 1, color: col('plum'), align: 'center', overflow: 'wrap',
      placement: onPage(72, { width: 'fill' }) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5),
      color: col('saffron'), placement: onPage(111, { width: mm(12) }) },
    { kind: 'box', id: 'foot-band', style: { backgroundColor: col('plum') },
      placement: { ...at('page', 'top-left', 0, 124), size: { width: 'fill', height: mm(54) } } },
    { kind: 'image', id: 'roundel', resourceId: 'roundel',
      placement: onPage(133, { width: mm(20) }) },
    { kind: 'text', id: 'series', content: 'Coleção Casuarina', ...caps(7.5, 1.8),
      color: col('saffron'), align: 'center', placement: at('page', 'top', 0, 159) },
  ] } } };
// The plate faces the first page of text: the morning sea off Flamengo, captioned with the
// line of chapter CXXIII it illustrates, which the heading carries in its quote attribute.
const plate = { id: 'plate', numbered: false, span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'sea', resourceId: 'sea',
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: 'fill' } } },
    { kind: 'text', id: 'label', content: '{titleText}', ...caps(7.5, 1.6), color: col('saffron'),
      align: 'center', placement: at('page', 'top', 0, 146) },
    { kind: 'text', id: 'quote', content: '{attr.quote}', fontFamily: TEXT, italic: true,
      fontSize: pt(8.6), lineHeight: 1.35, color: col('paper'), align: 'center', overflow: 'wrap',
      placement: onPage(152, { width: mm(78) }) },
  ] } } };
const resources = [
  { id: 'roundel', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'roundel.svg', width: 60, height: 60 },
    altText: 'The series mark: a casuarina tree in a ring.' },
  // The plate is a painting, a JPEG in assets/ cut to the trim, declared at its pixels.
  { id: 'sea', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: 'plate-1100.jpg', format: 'jpeg', width: 1100, height: 1780 },
    altText: 'A heavy morning sea under the Sugarloaf, a low sun, and a lone swimmer among '
      + 'the breakers.' },
];

Entrambe sono stili di titolo con numbered: false, quindi il primo capitolo numerato resta CXIX. span: 'page' fa di ciascuna una pagina d’apertura. Senza, la fascia di piede della copertina e l’immagine della tavola si fermano al piede della gabbia del testo, e la testatina e il folio si stampano su entrambe le pagine. La didascalia della tavola viene dal suo titolo: CAPÍTULO CXXIII è il testo del titolo, e la citazione in corsivo è il suo attributo quote (attributi dei titoli). Ogni design arriva al piede della sua pagina, quindi il titolo riserva l’intera pagina e il testo comincia alla successiva senza :::pagebreak.

#5 · Di’ all’estratto in che punto del libro si trova

script.js · riga 276nel codice completo
const continuation = { headings: { h1: 118, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } };

continuation.headings dà i contatori dei titoli da cui parte l’estratto. Con h1: 118 il primo titolo numerato è il capitolo CXIX; senza, i cinque capitoli sono numerati da I a V. numberingTemplate: '{1:I}' nel livello del capitolo compone quei numeri in numeri romani, e il design li stampa con {number}. :::numbering{startAt=212}, dopo la copertina, numera la tavola 212 e le pagine di testo da 213 a 217 (numerazione).

La ricetta completa

Sandbox
// ═══ Postext Cookbook · Nº 065 · Pocket classic: short chapters that run on ═══════════
// https://postext.dev/en/cookbook/short-chapters-run-on
// Code: MIT · Text: Machado de Assis, Dom Casmurro, 1899 (PD, Gutenberg #55752) · Plate: painted
// Fonts: Tinos (Apache 2.0), Abril Fatface, League Spartan (SIL 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 viewer's title; the sample is Portuguese
const RECIPE = 'short-chapters-run-on';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#24202a', // the text: a violet near-black
  paper: '#f6f1e6', // the pocket book's paper
  plum: '#4f2a49', // the series colour: cover bands, the chapter numerals
  saffron: '#d9a03c', // the second ink: rules and drawings, never text on paper (2.1:1)
  muted: '#6b616e', // the running heads and the colophon (5.2:1 on paper)
};
// Each colour names its palette entry and carries its hex.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The default bold and italic colours link to main-color, set here to the ink, not blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
const [TEXT, DISPLAY, LABEL] = ['Tinos', 'Abril Fatface', 'League Spartan'];
const TRIM = { width: 110, height: 178 }; // mm: a pocket book
const [TOP, INNER, OUTER] = [15, 14, 12]; // mm; mirrored, so the inner margin is at the spine
const [BODY, LEAD, LINES] = [9.5, 12.6, 33]; // pt, pt, and the lines of a full page
const PT = 25.4 / 72; // mm per point
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const caps = (size, track) => ({ fontFamily: LABEL, fontWeight: 600, fontSize: pt(size),
  letterSpacing: pt(track), textTransform: 'uppercase' });

// #region answer: chapters that run on, each under a centred head drawn in the column
// Machado's chapters run a page or two, so none opens a page. The level's break is off (the
// H1 default would open each chapter on a recto) and a chapter starts two lines under the last.
const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm
const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line
const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text
  { kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL),
    lineHeight: 1, color: col('plum'), align: 'center',
    placement: { ...at('container', 'top'), size: { width: 'fill' } } },
  { kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'),
    align: 'center', overflow: 'wrap', // a long title takes a second line
    placement: { anchor: { to: '#numeral', edge: 'below' }, offset: { y: mm(GAP) },
      size: { width: 'fill' } } },
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'),
    placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } },
] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid
const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX…
  fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
// The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph
// at the foot of a page, and headings.keepWithNext takes the head along when the paragraph
// moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a
// last line shorter than about 20 characters as a fault. Column balancing adds lines above
// a head so that the page ends on line 33.
// #endregion

// #region text: a pocket page of 33 lines, in Portuguese
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD),
  color: col('ink'), referenceColor: col('ink'), // a :ref added later prints in ink
  firstLineIndent: mm(5) }; // every paragraph indented, the first after a head too
const page = { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * PT), // 16.3 mm
    left: mm(INNER), right: mm(OUTER), mirror: true } };
const LOCALE = 'pt'; // the Portuguese hyphenation patterns
// #endregion

// #region heads: the book's title over the verso, the chapter over the recto, folios at the foot
const SHIFT = (INNER - OUTER) / 2; // mm: the text block sits off the page's centre
const [HEAD_Y, FOLIO_Y] = [8.5, 167]; // mm below the top edge
const head = (id, content, parity, x) => ({ kind: 'text', id, content, parity,
  pages: 'body', // never on the cover or the plate, which are opener pages
  ...caps(7.5, TRACK), color: col('muted'), align: 'center',
  placement: at('page', 'top', x, HEAD_Y) });
const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity,
  pages: 'body', ...caps(7.5, 0), color: col('ink'), placement: at('page', edge, x, FOLIO_Y) });
const header = { elements: [
  head('verso-title', '{title}', 'even', -SHIFT), // the title in the frontmatter
  head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // last chapter begun on or before it
] };
const footer = { elements: [folio('verso-folio', 'even', 'top-left', OUTER),
  { ...folio('recto-folio', 'odd', 'top-right', -OUTER), align: 'right' }] };
// #endregion

// #region cover: the cover and the plate, heading styles that fill a page each
// span: 'page', in one column too, makes each an opener page: its art runs past the foot of
// the text column, and no running head or folio prints on it. Each design reaches the foot of
// the page, so the heading reserves the whole page and the text starts on the next one.
const onPage = (y, size) => ({ ...at('page', 'top', 0, y), ...(size && { size }) });
const cover = { id: 'cover', numbered: false, span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'top-band', style: { backgroundColor: col('plum') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(62) } } },
    { kind: 'text', id: 'author', content: '{author}', ...caps(10, 2.4), color: col('paper'),
      align: 'center', placement: at('page', 'top', 0, 44) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(44),
      lineHeight: 1, color: col('plum'), align: 'center', overflow: 'wrap',
      placement: onPage(72, { width: 'fill' }) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5),
      color: col('saffron'), placement: onPage(111, { width: mm(12) }) },
    { kind: 'box', id: 'foot-band', style: { backgroundColor: col('plum') },
      placement: { ...at('page', 'top-left', 0, 124), size: { width: 'fill', height: mm(54) } } },
    { kind: 'image', id: 'roundel', resourceId: 'roundel',
      placement: onPage(133, { width: mm(20) }) },
    { kind: 'text', id: 'series', content: 'Coleção Casuarina', ...caps(7.5, 1.8),
      color: col('saffron'), align: 'center', placement: at('page', 'top', 0, 159) },
  ] } } };
// The plate faces the first page of text: the morning sea off Flamengo, captioned with the
// line of chapter CXXIII it illustrates, which the heading carries in its quote attribute.
const plate = { id: 'plate', numbered: false, span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'sea', resourceId: 'sea',
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: 'fill' } } },
    { kind: 'text', id: 'label', content: '{titleText}', ...caps(7.5, 1.6), color: col('saffron'),
      align: 'center', placement: at('page', 'top', 0, 146) },
    { kind: 'text', id: 'quote', content: '{attr.quote}', fontFamily: TEXT, italic: true,
      fontSize: pt(8.6), lineHeight: 1.35, color: col('paper'), align: 'center', overflow: 'wrap',
      placement: onPage(152, { width: mm(78) }) },
  ] } } };
const resources = [
  { id: 'roundel', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'roundel.svg', width: 60, height: 60 },
    altText: 'The series mark: a casuarina tree in a ring.' },
  // The plate is a painting, a JPEG in assets/ cut to the trim, declared at its pixels.
  { id: 'sea', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: 'plate-1100.jpg', format: 'jpeg', width: 1100, height: 1780 },
    altText: 'A heavy morning sea under the Sugarloaf, a low sun, and a lone swimmer among '
      + 'the breakers.' },
];
// #endregion

// The colophon floats to the foot of the last page, beside the series roundel.
const colophon = { id: 'colophon', placement: 'bottom', backgroundEnabled: false,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) }, marginBottom: pt(0),
  icon: { kind: 'resource', resourceId: 'roundel', size: mm(10) }, titleStyle: { gap: mm(3) },
  body: { fontFamily: TEXT, fontSize: pt(7.6), lineHeight: pt(LEAD * 0.8), color: col('muted'),
    italicColor: col('muted'), textAlign: 'left', firstLineIndent: pt(0) } };

const config = () => ({
  locale: LOCALE,
  colorPalette,
  page,
  layout: { layoutType: 'single' },
  bodyText,
  // The heading's own line is hidden under its design but still measured, in this face.
  headings: { fontFamily: LABEL, fontWeight: 600, levels: [chapters] },
  headingStyles: [cover, plate],
  calloutStyles: [colophon],
  header,
  footer,
});

// #region art: the series roundel, drawn in code in the book's two inks
let seed = 1871; // Mulberry32, seeded: 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 n = (v) => v.toFixed(2);
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}mm" `
  + `height="${h}mm" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const line = (d, stroke, width, extra = '') => `<path d="${d}" fill="none" stroke="${stroke}" `
  + `stroke-width="${n(width)}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`;

// The casuarina of Bento's garden (chapter II): a leaning trunk, and branches that arch out
// on alternate sides and let their needles hang.
function roundelSvg() {
  const { plum, saffron } = palette;
  const out = [`<circle cx="30" cy="30" r="29" fill="${saffron}"/>`,
    line('M30 30m-25.5 0a25.5 25.5 0 1 0 51 0a25.5 25.5 0 1 0 -51 0', plum, 0.9),
    line('M28.6 50Q30.6 33 30.2 10.5', plum, 1.6), line('M18.5 50.2H41.5', plum, 1.3)];
  for (let k = 0; k < 9; k++) {
    const side = k % 2 ? 1 : -1;
    const y0 = 12.5 + k * 3.7;
    const reach = (3 + k * 1.55) * (0.8 + rand() * 0.35);
    const x1 = 30.2 + side * reach;
    const y1 = y0 + 1.2 + rand() * 1.4;
    out.push(line(`M30.2 ${n(y0)}Q${n(30.2 + side * reach * 0.5)} ${n(y0 - 2.2)} ${n(x1)} ${n(y1)}`,
      plum, 0.95));
    const strands = 3 + Math.round(reach / 1.6);
    for (let j = 1; j <= strands; j++) { // needles hang from the arch
      const u = j / (strands + 0.5);
      const x = 30.2 + side * reach * u;
      const y = y0 + (y1 - y0) * u ** 2 - 2.2 * 2 * u * (1 - u) + 0.3;
      out.push(line(`M${n(x)} ${n(y)}q${n(side * 0.4)} ${n(2)} ${n(side * 0.1)} `
        + `${n(3 + rand() * 2.4)}`, plum, 0.7));
    }
  }
  return svg(60, 60, out.join(''));
}

// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
// Dom Casmurro in Portuguese, in the first edition's spelling: the cover, the plate, then
// pages 213 to 217 (:::numbering in the text), from the end of chapter CXVIII.
const markdown = String.raw`---
Esempio Markdown · 59 righe · content.en.mdtitle: "Dom Casmurro" author: "Machado de Assis" --- # Dom \\ Casmurro {style="cover"} :::numbering{startAt=212} # Capítulo CXXIII {style="plate" quote="«…grandes e abertos, como a vaga do mar lá fóra, como se quizesse tragar tambem o nadador da manhã.»"} O retrato de Escobar, que eu tinha alli, ao pé do de minha mãe, falou-me como se fosse a propria pessoa. Combati sinceramente os impulsos que trazia do Flamengo; rejeitei a figura da mulher do meu amigo, e chamei-me desleal. Demais, quem me affirmava que houvesse alguma intenção daquella especie no gesto da despedida e nos anteriores? Tudo podia ligar-se ao interesse da nossa viagem. Sancha e Capitú eram tão amigas que seria um prazer mais para ellas irem juntas. Quando houvesse alguma intenção sexual, quem me provaria que não era mais que uma sensação fulgurante, destinada a morrer com a noite e o somno? Ha remorsos que não nascem de outro peccado, nem tem maior duração. Agarrei-me a esta hypothese que se conciliava com a mão de Sancha, que eu sentia de memoria dentro da minha mão, quente e demorada, apertada e apertando… Sinceramente, eu achava-me mal entre um amigo e a attracção. A timidez póde ser que fosse outra causa daquella crise; não é só o ceu que dá as nossas virtudes, a timidez tambem, não contando o acaso, mas o acaso é um méro accidente; a melhor origem dellas é o ceu. Entretanto, como a timidez vem do ceu, que nos dá a compleição, a virtude, filha della é, genealogicamente, o mesmo sangue celestial. Assim reflectiria, se pudesse; mas a principio vaguei á tôa. Paixão não era nem inclinação. Capricho seria ou quê? Ao fim de vinte minutos era nada, inteiramente nada. O retrato de Escobar pareceu falar-me; vi-lhe a altitude franca e simples, sacudi a cabeça e fui deitar-me. # Não faça isso, querida A leitora, que é minha amiga e abriu este livro com o fim de descançar da cavatina de hontem para a valsa de hoje, quer fechal-o ás pressas, ao ver que beiramos um abysmo. Não faça isso, querida; eu mudo de rumo. # Os autos Na manhã seguinte accordei livre das abominações da vespera; chamei-lhes allucinações, tomei café, percorri os jornaes e fui estudar uns autos. Capitú e prima Justina sairam para a missa das nove, na Lapa. A figura de Sancha desappareceu inteiramente no meio das allegações da parte adversa, que eu ia lendo nos autos, allegações falsas, inadmissiveis, sem apoio na lei nem nas praxes. Vi que era facil ganhar a demanda; consultei Dalloz, Pereira e Souza… Uma só vez olhei para o retrato de Escobar. Era uma bella photographia tirada um anno antes. Estava de pé, sobrecasaca abotoada, a mão esquerda no dorso de uma cadeira, a direita mettida ao peito, o olhar ao longe para a esquerda do espectador. Tinha garbo e naturalidade. A moldura que lhe mandei pôr não encobria a dedicatoria, escripta embaixo, não nas costas do cartão: «Ao meu querido Bentinho o seu querido Escobar 20-4-70.» Estas palavras fortaleceram-me os pensamentos daquella manhã, e espancaram de todo as recordações da vespera. Naquelle tempo a minha vista era boa; eu podia lel-as do logar em que estava. Tornei aos autos. # A catastrophe No melhor delles, ouvi passos precipitados na escada, a campainha soou, soaram palmas, golpes na cancella, vozes, acudiram todos, acudi eu mesmo. Era um escravo da casa de Sancha que me chamava: —Para ir lá… sinhô nadando, sinhô morrendo. Não disse mais nada, ou eu não lhe ouvi o resto. Vesti-me, deixei recado a Capitú e corri ao Flamengo. Em caminho, fui adivinhando a verdade. Escobar metteu-se a nadar, como usava fazer, arriscou-se um pouco mais fóra que de costume, apesar do mar bravio, foi enrolado e morreu. As canoas que acudiram mal puderam trazer-lhe o cadaver. # O enterro A viuva… Poupo-vos as lagrimas da viuva, as minhas, as da outra gente. Sai de lá cerca de onze horas; Capitú e prima Justina esperavam-me, uma com o parecer abatido e estupido, outra enfastiada apenas. —Vão fazer companhia a pobre Sanchinha; eu vou cuidar do enterro. Assim fizemos. Quiz que o enterro fosse pomposo, e a affluencia dos amigos foi numerosa. Praia, ruas, praça da Gloria, tudo eram carros, muitos delles particulares. A casa, não sendo grande, não podiam lá caber todos; muitos estavam na praia, falando do desastre, apontando o logar em que Escobar fallecèra, ouvindo referir a chegada do morto. José Dias ouviu tambem falar dos negocios do finado, divergindo alguns na avaliação dos bens, mas havendo accordo em que o passivo devia ser pequeno. Elogiavam as qualidades de Escobar. Um ou outro discutia o recente gabinete Rio Branco; estavamos em Março de 1871. Nunca me esqueceu o mez nem o anno. Como eu houvesse resolvido falar no cemiterio, escrevi algumas linhas e mostrei-as em casa a José Dias, que as achou realmente dignas do morto e de mim. Pediu-me o papel, recitou lentamente o discurso, pesando as palavras, e confirmou a primeira opinião; no Flamengo espalhou a noticia. Alguns conhecidos vieram interrogar-me: —Então, vamos ouvil-o? —Quatro palavras. Poucas mais seriam. Tinha-as escripto com receio de que a emoção me impedisse de improvisar. No tilbury em que andei uma ou duas horas, não fizera mais que recordar o tempo do seminario, as relações de Escobar, as nossas sympathias, a nossa amizade, começada, continuada e nunca interrompida, até que um lance da fortuna fez separar para sempre duas creaturas que promettiam ficar por muito tempo unidas. De quando em quando enxugava os olhos. O cocheiro aventurou duas ou tres perguntas sobre a minha situação moral; não me arrancando nada, continuou o seu officio. Chegando a casa, deitei aquellas emoções ao papel; tal seria o discurso. # Olhos de ressaca Emfim, chegou a hora da encommendação e da partida. Sancha quiz despedir-se do marido, e o desespero daquelle lance consternou a todos. Muitos homens choravam tambem, as mulheres todas. Só Capitú, amparando a viuva, parecia vencer-se a si mesma. Consolava a outra, queria arrancal-a dalli. A confusão era geral. No meio della, Capitú olhou alguns instantes para o cadaver tão fixa, tão apaixonadamente fixa, que não admira lhe saltassem algumas lagrimas poucas e caladas… As minhas cessaram logo. Fiquei a ver as della; Capitú enxugou-as depressa, olhando a furto para a gente que estava na sala. Redobrou de caricias para a amiga, e quiz leval-a; mas o cadaver parece que a retinha tambem. Momento houve em que os olhos de Capitú fitaram o defuncto, quaes os da viuva, sem o pranto nem palavras desta, mas grandes e abertos, como a vaga do mar lá fóra, como se quizesse tragar tambem o nadador da manhã. :::callout{type="colophon"} Do fim do capítulo CXVIII ao fim do CXXIII de *Dom Casmurro* (1899), com a ortografia da edição Garnier transcrita pelo Project Gutenberg (n.º 55752); corrigiu-se um erro de transcrição no capítulo CXXIII. Ilustração pintada e marca da coleção feitas para esta edição. Composto em Tinos (Apache 2.0), Abril Fatface e League Spartan (SIL OFL). :::
`; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build Tinos: ['400', '400i'], // text, colophon; italic: the plate's quote, the colophon's title 'Abril Fatface': ['400'], // chapter numerals and the cover title 'League Spartan': ['600'], // chapter titles, running heads, folios, the cover's capitals }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadSvg('roundel.svg', roundelSvg()); await loadImage('plate-1100.jpg', asset('plate-1100.jpg')); // #region excerpt: these pages continue a book: chapter CXVIII is under way, the next is CXIX const continuation = { headings: { h1: 118, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // #endregion const doc = await buildDocumentWithFonts({ markdown, resources, continuation }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'Dom Casmurro, a pocket edition', es: 'Dom Casmurro, edición de bolsillo' }) });
Kit · core, fonts, viewer, images: uguale in ogni ricetta · 270 righe// ─── 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 ───────────────────────────────────────────────────────────────────────

Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)

Varianti

#Apri ogni capitolo su una pagina nuova

Con un’interruzione prima di ogni capitolo le cinque pagine di testo diventano sette: la pagina 214 contiene solo CXIX, il suo titolo e quattro righe, e la pagina 213, dove non comincia alcun capitolo, prende come testatina il titolo della tavola, CAPÍTULO CXXIII.

-  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
+  breakBefore: { enabled: true, parity: 'any' }, marginTop: pt(2 * LEAD),
+  advancedDesign: chapterHead };

#Metti il numero del capitolo sopra il recto

{chapterNumber} stampa il numerale dello stesso capitolo da cui viene il titolo: CXIX · NÃO FAÇA ISSO, QUERIDA.

-  head('recto-chapter', '{chapterTitle}', 'odd', SHIFT),
+  head('recto-chapter', '{chapterNumber} · {chapterTitle}', 'odd', SHIFT),

Errori frequenti

Errore frequente

avoidWidows protegge il piede di una colonna, avoidOrphans la sua testa

Postext chiama a modo suo le due righe isolate: avoidWidows (widowMinLines, widowPenalty) impedisce che la prima riga di un paragrafo resti sola al piede di una colonna, e avoidOrphans (orphanMinLines, orphanPenalty) impedisce che la sua ultima riga resti sola in testa alla successiva. Molti manuali di stile danno i due nomi al contrario, quindi scegli l’impostazione in base a dove agisce. Entrambe sono attive per impostazione predefinita, tranne su una griglia dei caratteri (cjk.grid): lì avoidWidows è disattivata, e nella regione Giappone anche avoidOrphans, perché una pagina contenga le righe che la sua griglia conta (da postext 1.28). Funzionano come penalità: l’impaginazione pesa ciascuna contro le righe vuote che rispettarla lascerebbe. Vedove, orfane e righe corte →

Errore frequente

:::numbering ha effetto dalla pagina successiva

:::numbering cambia il conteggio a partire dalla prossima pagina che comincia, non da quella corrente. Mettilo subito dopo un :::pagebreak (parity odd prima del primo capitolo) perché la nuova numerazione cominci dove intendi. Pagine preliminari in numeri romani →

  • Portare widowMinLines a 3 non lascia al paragrafo di quattro righe sotto CXIX alcun punto in cui dividersi: tre righe e una aprirebbero la pagina 214 su una riga isolata. Il titolo passa avanti con il suo testo, e la pagina 213 finisce sei righe più corta.
  • Se CXVIII cresce di qualche riga, CXIX ha spazio per una sola riga sotto il titolo. keepWithNext porta allora il titolo a pagina 214 e la pagina 213 finisce quattro o cinque righe più corta; con keepWithNext: false il titolo chiude la pagina 213 senza testo sotto.
  • La tavola è un H1, quindi {chapterTitle} ripiega su di essa: una pagina di testo in cui nessun capitolo è cominciato dopo la tavola, come la pagina 213 quando CXIX passa avanti, stampa CAPÍTULO CXXIII come testatina. Dai allo stile della tavola runningChapter: false e le testatine la ignorano.
  • Disattiva avoidRunts e l’ultima riga del libro contiene manhã. da sola invece di nadador da manhã.
  • Questi cambi di pagina valgono solo per questo testo a 9,5 su 12,6 pt su una giustezza di 84 mm. Dopo aver cambiato il testo, il corpo o la giustezza, ricontrolla il piede di ogni pagina e lo spazio sopra ogni titolo.

Crediti

Testo
  • Dom Casmurro (1899), from the end of chapter CXVIII to the end of chapter CXXIII, in the spelling of the Garnier edition, with one transcription slip corrected (homens, chapter CXXIII) · Machado de Assis · pubblico dominio
  • The colophon, and the series name Coleção Casuarina (invented for this recipe) · Postext Cookbook · originale
Immagini
  • The plate of the sea off Flamengo under the Sugarloaf, a painting · Generated With Diffusion Models · originale
  • The casuarina roundel, drawn in code in the book’s palette · Ignacio Ferro · MIT
Font
Tinos (Apache 2.0) · Abril Fatface (SIL OFL 1.1) · League Spartan (SIL OFL 1.1)
Sandbox