Vai al contenuto principale
Ricetta numero 83

Ricettario · Capitolo 1 · Pagina e griglia

Poesie Tang con i versi inglesi a fronte

Ogni poesia Tang apre un recto colorato e l’inglese di Giles il verso di fronte: uno stile di titolo va alle pagine pari, l’altro alle dispari.

In questa pagina
Genere
Poesia
Output
Canvas · PDF
Livello
Intermedio
Postext
Provata con Postext 1.31.0
Richiede ≥ 1.28.0 · postext-pdf ≥ 1.28.0
Licenza
Aggiornata il 11 ott 2026
Codice MIT · Testo MIT

pp. 2–3 di 10

  • Esempio in inglese: non c’è ancora un’edizione italiana
  • Formato 148 × 210 mm
  • 1 colonna
  • Noto Serif TC 10,5/16
  • LXGW WenKai TC
  • Noto Sans TC
  • Source Sans 3
  • Source Serif 4
  • 10 pagine
  • Livello
  • Postext 1.31.0
  • Impaginato in 118 ms
  • 180 righe di codice

In breve

Un piccolo libro di poesie cinesi della dinastia Tang, con una vecchia traduzione inglese sulla pagina a fronte. Ogni riga di versi cinesi sta alla stessa altezza dei versi inglesi che la traducono.

Che cosa comporrai

Un piccolo libro bilingue di poesie Tang, composto come le edizioni con testo a fronte compongono un classico: il cinese sulla pagina di destra, su una carta colorata come carta antica, e l’inglese in rima di Herbert A. Giles, del 1901, sulla pagina di sinistra di fronte. Quattro poesie di Li Bai, Du Fu e Wang Wei occupano ciascuna una doppia pagina, con il titolo in ogni lingua e la firma del poeta. Il cinese è composto in LXGW WenKai, un carattere Kai disegnato sul modello del pennello, due versi per riga, così ogni riga sta alla stessa altezza dei due versi inglesi che la traducono. Le glosse nella lingua del lettore stanno al piede della pagina cinese. Traduzione a fronte, strofa per strofa compone una poesia spagnola accanto al suo inglese su una sola pagina; qui ogni lingua ha una pagina sua.

Questa ricetta risponde a

  • Come compongo una poesia e la sua traduzione su pagine affiancate, con l’originale sempre sul recto?
  • Come compongo poesia: una riga per verso, spazi tra le strofe, rientri sporgenti per i versi che vanno a capo, niente sillabazione?
  • Come faccio le note a piè di pagina?
  • Come forzo un’interruzione di pagina o di colonna, e faccio cominciare ogni capitolo su una pagina destra?

La risposta breve

script.js · righe 39–63nel codice completo
const SINK = 6; // lines of LEAD under both titles, so the two poems start on the same line
const at = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
// A title whose foot is 23 mm down at any size, and the poet's line under it at 24 mm.
const titled = (size, title, poet, under = []) => ({
  enabled: true, minHeight: pt(SINK * LEAD),
  slot: { elements: [...under,
    { kind: 'text', id: 'title', content: '{titleText}', fontSize: pt(size), lineHeight: 1.2,
      align: 'center', overflow: 'wrap', color: col('ink'),
      placement: at(23 - (size * 1.2 * 25.4) / 72), ...title },
    { kind: 'text', id: 'poet', align: 'center', overflow: 'wrap', color: col('seal'),
      placement: at(24), ...poet },
  ] },
});
// Each style breaks to its own side of the spread (gotcha: style-inherits-break).
const english = { id: 'en', breakBefore: { enabled: true, parity: 'even' }, // a verso
  advancedDesign: titled(19, { fontFamily: ROMAN, italic: true },
    { content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600,
      letterSpacing: pt(1.5), textTransform: 'uppercase' }) };
// The original on a tinted leaf: span 'page' lets the tint reach past the text column.
const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'odd' },
  advancedDesign: titled(28, { fontFamily: SERIF, fontWeight: 700, letterSpacing: pt(7) },
    { content: '{attr.poet}', fontFamily: HEI, fontSize: pt(9), letterSpacing: pt(1.5) },
    [{ kind: 'box', id: 'leaf', reserve: false, style: { backgroundColor: col('leaf') },
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: 'fill' } } }]) };

Ingredienti

Tipo
Noto Serif TC, LXGW WenKai TC, Noto Sans TC, Source Serif 4, Source Sans 3 (SIL OFL 1.1)
Risorse
Nessuna: ogni immagine è disegnata nel codice

Preparazione

#1 · Uno stile di titolo per ogni lato della doppia pagina

Il codice è nella risposta breve qui sopra. Ogni poesia è fatta di due titoli di livello 1: # Night Thoughts {style="en" …} con i versi di Giles, poi # 靜夜思 {style="zh" …} con la poesia. Lo stile en passa alla pagina pari successiva, un verso; lo stile zh alla pagina dispari successiva, il recto di fronte. Finché ogni poesia sta nella sua pagina non entra nessuna pagina bianca; una versione inglese che sforasse spingerebbe l’originale al recto seguente, dietro un verso bianco (Interruzione prima). Entrambi i progetti grafici riservano sei righe della griglia, così l’inglese e il cinese cominciano sulla stessa riga. Lo stile cinese ha span: 'page' perché il suo primo elemento, un riquadro grande quanto la pagina, è la carta colorata, e un progetto grafico tenuto nella colonna si ferma al piede della colonna (Progetto grafico a tutta larghezza e avanzato).

Pagina 2: l’inglese apre il verso sei righe più in basso, sulla riga di griglia dove il cinese apre pagina 3.

#2 · Una riga di cinese per due versi inglesi

script.js · righe 67–76nel codice completo
// Each English poem is a :::verse block: a line of verse a line of Markdown, Giles's indents
// as two leading spaces (two ems: bodyText.verse.indentStep), a blank line between stanzas.
// The block is centred on its longest line in the 103.7 mm measure, so a short poem and a
// long one both sit in the middle.
const verse = { id: 'verse', fontFamily: ROMAN, fontSize: pt(BODY), lineHeight: pt(LEAD) };
const shi = { id: 'shi', fontFamily: KAI, fontSize: pt(SHI), lineHeight: pt(2 * LEAD),
  textAlign: 'left', firstLineIndent: pt(0), // two lines of verse to a row: 2 × LEAD
  // Five characters and a full-width mark, twice: 12 ems, centred in the 28 of the grid.
  // A fixed left edge, and the note numbers after each row's 。: every character keeps its column.
  indent: pt((CHARS * BODY - 12 * SHI) / 2) };

Le righe cinesi hanno interlinea doppia rispetto ai versi inglesi, 32 pt contro 16, così una riga di due versi di cinque caratteri sta di fronte al distico che Giles ne ricava. Nella poesia della luna alle pagine 4 e 5 una riga vuota tra le strofe inglesi e un :::space{lines=1} tra quelle cinesi lasciano ciascuno una riga, e le sue tre strofe restano allineate sulla doppia pagina. La riga cinese comincia a 51 pt dal margine, il che centra i suoi dodici em nei 28 della griglia, e ogni rimando di nota sta dopo il 。 della sua riga, fuori dai dodici: dentro la riga spingerebbe ogni carattere successivo sei decimi di em a destra, fuori allineamento con la riga sotto. Ogni poesia inglese è un blocco :::verse, centrato sul suo verso più lungo, così non c’è nessun rientro da misurare. Giles rientra un verso sì e uno no; il Markdown mantiene i suoi due spazi, che bodyText.verse.indentStep rende profondi due em.

#3 · Glosse al piede della pagina cinese

script.js · righe 80–84nel codice completo
const footnotes = { placement: 'column', numbering: 'page', // the page foot; ① again on each page
  numberFormat: 'circled-decimal', markerSize: em(0.6), fontSize: pt(8), lineHeight: pt(11),
  color: col('ink'), textAlign: 'left', spaceAbove: pt(LEAD), spaceBelowRule: pt(5),
  spaceBetween: pt(3), separator: { width: 0.12, lineWidth: pt(0.6), color: col('seal') },
  wordBreak: 'keep-all' }; // a reading a note quotes (山月, 看月光) is never broken

Le glosse sono note a piè di pagina, una per riga: 疑是地上霜。[^bed] chiude la prima riga della poesia e [^bed]: … la segue, nominando in grassetto ogni parola che glossa, perché il numero non sta più accanto a esse. Sono numerate come un libro cinese numera le sue note (页下注): numeri cerchiati composti sulla linea di base, contati di nuovo da ① a ogni pagina (numberFormat: 'circled-decimal', numbering: 'page'). Nei versi il rimando è composto a sei decimi del corpo della poesia, markerSize: em(0.6), vicino al corpo delle note; al piede della pagina ogni nota si apre con il suo ① nel corpo del testo della nota (Note a piè di pagina). Le note si compongono nel carattere del testo, e il carattere del testo di questo libro è Noto Serif TC perché una glossa mescola caratteri e pinyin; le lettere latine di Noto Serif TC vengono da Source Serif, ed è per questo che le pagine inglesi sono composte in Source Serif 4. Le note sono scritte nella lingua dell’edizione: insieme alla testatina del verso e alla nota sul testo, sono ciò che cambia l’edizione spagnola.

#4 · Testatine nella lingua di ogni pagina

script.js · righe 117–128nel codice completo
const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity,
  pages: 'opener', // every poem page opens with its title; a blank page stays bare
  fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...style });
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', EDGE, { color: col('ink') }),
  head('verso-book', '{subtitle}', 'even', 'top-left', EDGE + 8,
    { letterSpacing: pt(1.2), textTransform: 'uppercase' }),
  head('recto-poet', '{attr.name}', 'odd', 'top-right', -(EDGE + 8),
    { fontFamily: HEI, fontWeight: 400, fontSize: pt(8), letterSpacing: pt(3) }),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -EDGE, { color: col('ink') }),
] };

Ogni pagina di questo libro si apre con un titolo, quindi ogni pagina è un’apertura, e le testatine usano pages: 'opener'. Una pagina bianca aggiunta da un’interruzione resterebbe spoglia, e il frontespizio le toglie con un’intestazione vuota tutta sua. Il verso prende il titolo del libro dal subtitle del frontmatter, che ogni edizione scrive nella sua lingua; il recto prende il nome del poeta dall’attributo name del titolo cinese (Intestazioni e piè di pagina).

#5 · Un frontespizio su una luna

script.js · righe 88–113nel codice completo
const onPage = (y, size) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) },
  ...(size ? { size: { width: mm(size), height: mm(size) } } : {}) });
const [MOON_Y, MOON, HAN] = [36, 78, 60]; // mm: the moon's top and width; pt: the title
const UNDER = MOON_Y + MOON; // mm: the English lines start under the moon
const line = (id, content, y, style) => ({ kind: 'text', id, content, align: 'center',
  overflow: 'wrap', color: col('ink'), placement: onPage(y), ...style });
const titlePage = { id: 'title', span: 'page', runningChapter: false,
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'moon', placement: onPage(MOON_Y, MOON),
      style: { backgroundColor: col('moon'), borderRadius: mm(MOON / 2) } },
    line('han', '{titleText}', MOON_Y + MOON / 2 - (HAN * 25.4) / 144, { fontFamily: SERIF,
      fontWeight: 900, fontSize: pt(HAN), letterSpacing: pt(HAN / 4), lineHeight: 1 }),
    line('en', '{attr.en}', UNDER + 10, { fontFamily: LABEL, fontSize: pt(10), fontWeight: 600,
      letterSpacing: pt(3), textTransform: 'uppercase', color: col('seal') }),
    line('poets', '{attr.poets}', UNDER + 19, { fontFamily: ROMAN, fontSize: pt(12) }),
    line('giles', '{attr.english}', UNDER + 26, { fontFamily: ROMAN, fontSize: pt(11),
      italic: true }),
    // A seal reading 明月, the bright moon of three of the four poems, set down its square.
    { kind: 'box', id: 'seal', placement: onPage(170, 13),
      style: { backgroundColor: col('seal'), borderRadius: mm(1) } },
    line('seal-text', '明月', 170, { placement: onPage(170, 13), writingMode: 'vertical-rl',
      fontFamily: KAI, fontSize: pt(14), lineHeight: 1, color: col('paper'),
      verticalAlign: 'middle' }),
  ] } },
};

Anche il frontespizio è uno stile di titolo, con runningChapter: false perché non nomini nessun capitolo. La luna è un riquadro del progetto grafico con il raggio d’angolo pari a metà del lato, e 唐詩 vi è centrato con un calcolo: il centro della luna meno metà dell’altezza del titolo. Il sigillo compone 明月 con writingMode: 'vertical-rl', un carattere sopra l’altro, come si incidono i sigilli.

#6 · Ogni carattere cinese carica ciò che compone

script.js · righe 306–316nel codice completo
const grab = (re) => (markdown.match(re) ?? []).join('');
// Each poem to its closing `:::` (not the `:::space` between stanzas), then the note numbers.
const poems = grab(/:::paragraphs\{style="shi"\}[\s\S]*?\n:::(?=\n|$)/g) + '①②③④⑤⑥⑦⑧⑨⑩';
await loadFonts(FONTS, markdown);
await Promise.all([
  loadCjkFonts({ [SERIF]: ['400'] }, markdown + poems), // the notes and their ①, the heading blocks
  loadCjkFonts({ [SERIF]: ['700'] }, grab(/^# \S+(?= \{style="zh")|\*\*[^*]+\*\*/gm)),
  loadCjkFonts({ [SERIF]: ['900'] }, grab(/^# \S+(?= \{style="title")/gm)),
  loadCjkFonts({ [KAI]: ['400'] }, `${poems}明月`),
  loadCjkFonts({ [HEI]: ['400'] }, grab(/(?<=(?:poet|name)=")[^"]*/g)),
]);

Fontsource divide ogni carattere cinese in circa cento file per intervallo di caratteri, e loadCjkFonts scarica solo i file di cui il suo testo ha bisogno (Font cinesi, giapponesi e coreani). Il Kai riceve le poesie e il sigillo, lo Hei le righe dei poeti e i nomi, il Song grassetto i quattro titoli e i lemmi delle note, e il suo peso black i due caratteri del frontespizio.

La ricetta completa

Sandbox
// ═══ Postext Cookbook · Nº 083 · Tang poems facing their English verse ═══════════════
// https://postext.dev/en/cookbook/tang-poems-facing-english
// Code: MIT · Text: Tang poems, zh.wikisource; H. A. Giles, 1901, Project Gutenberg (PD)
// Fonts: Noto Serif TC, LXGW WenKai TC, Noto Sans TC, Source Serif 4 (OFL) · Needs postext ≥ 1.28.0
import { buildDocument, withLoadedFonts, renderPageToCanvas } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'tang-poems-facing-english';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Ink and seal red on a cream paper, and a pale moon for the title page.
const palette = {
  ink: '#1f1b18', // text: a warm near-black
  seal: '#ad3526', // the accent: vermilion seal paste, for the poets, the rules and the seal
  moon: '#eadcb4', // the title page's moon
  leaf: '#f2e8cf', // the recto's tint, under the Chinese
  muted: '#6d655c', // running heads and the colophon
  paper: '#faf6ed', // a cream paper
};
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' } })),
  { id: 'main-color', name: 'seal (defaults)', value: { hex: palette.seal, model: 'hex' } },
];
const SERIF = 'Noto Serif TC'; // 明: the Chinese titles and the notes
const KAI = 'LXGW WenKai TC'; // 楷: the poems
const HEI = 'Noto Sans TC'; // 黑: the poet lines and the recto's running head
const ROMAN = 'Source Serif 4'; // the English: the Latin that Noto Serif TC is built on
const LABEL = 'Source Sans 3'; // English labels and folios
const [BODY, LEAD, SHI] = [10.5, 16, 16]; // pt: 五号 on a 16 pt pitch; the poems' size
const [CHARS, LINES] = [28, 30]; // the character grid: 28 ems by 30 lines
const TRIM = { width: 148, height: 210 }; // mm: 25開, a Taiwan trade trim
const [INNER, OUTER] = [18, 21]; // mm: minimum sides; the grid centres its measure between them
const MEASURE = (CHARS * BODY * 25.4) / 72; // 103.7 mm: 28 ems of 五号
const EDGE = OUTER + (TRIM.width - INNER - OUTER - MEASURE) / 2; // 23.6 mm: the outer text edge

// #region answer: the English opens the next verso, the Chinese the recto that faces it
const SINK = 6; // lines of LEAD under both titles, so the two poems start on the same line
const at = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
// A title whose foot is 23 mm down at any size, and the poet's line under it at 24 mm.
const titled = (size, title, poet, under = []) => ({
  enabled: true, minHeight: pt(SINK * LEAD),
  slot: { elements: [...under,
    { kind: 'text', id: 'title', content: '{titleText}', fontSize: pt(size), lineHeight: 1.2,
      align: 'center', overflow: 'wrap', color: col('ink'),
      placement: at(23 - (size * 1.2 * 25.4) / 72), ...title },
    { kind: 'text', id: 'poet', align: 'center', overflow: 'wrap', color: col('seal'),
      placement: at(24), ...poet },
  ] },
});
// Each style breaks to its own side of the spread (gotcha: style-inherits-break).
const english = { id: 'en', breakBefore: { enabled: true, parity: 'even' }, // a verso
  advancedDesign: titled(19, { fontFamily: ROMAN, italic: true },
    { content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600,
      letterSpacing: pt(1.5), textTransform: 'uppercase' }) };
// The original on a tinted leaf: span 'page' lets the tint reach past the text column.
const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'odd' },
  advancedDesign: titled(28, { fontFamily: SERIF, fontWeight: 700, letterSpacing: pt(7) },
    { content: '{attr.poet}', fontFamily: HEI, fontSize: pt(9), letterSpacing: pt(1.5) },
    [{ kind: 'box', id: 'leaf', reserve: false, style: { backgroundColor: col('leaf') },
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: 'fill' } } }]) };
// #endregion

// #region verse: a Chinese row faces two lines of English on the grid both pages share
// Each English poem is a :::verse block: a line of verse a line of Markdown, Giles's indents
// as two leading spaces (two ems: bodyText.verse.indentStep), a blank line between stanzas.
// The block is centred on its longest line in the 103.7 mm measure, so a short poem and a
// long one both sit in the middle.
const verse = { id: 'verse', fontFamily: ROMAN, fontSize: pt(BODY), lineHeight: pt(LEAD) };
const shi = { id: 'shi', fontFamily: KAI, fontSize: pt(SHI), lineHeight: pt(2 * LEAD),
  textAlign: 'left', firstLineIndent: pt(0), // two lines of verse to a row: 2 × LEAD
  // Five characters and a full-width mark, twice: 12 ems, centred in the 28 of the grid.
  // A fixed left edge, and the note numbers after each row's 。: every character keeps its column.
  indent: pt((CHARS * BODY - 12 * SHI) / 2) };
// #endregion

// #region notes: glosses at the foot of the recto, in the edition's language
const footnotes = { placement: 'column', numbering: 'page', // the page foot; ① again on each page
  numberFormat: 'circled-decimal', markerSize: em(0.6), fontSize: pt(8), lineHeight: pt(11),
  color: col('ink'), textAlign: 'left', spaceAbove: pt(LEAD), spaceBelowRule: pt(5),
  spaceBetween: pt(3), separator: { width: 0.12, lineWidth: pt(0.6), color: col('seal') },
  wordBreak: 'keep-all' }; // a reading a note quotes (山月, 看月光) is never broken
// #endregion

// #region title: 唐詩 on a pale moon, with a seal
const onPage = (y, size) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) },
  ...(size ? { size: { width: mm(size), height: mm(size) } } : {}) });
const [MOON_Y, MOON, HAN] = [36, 78, 60]; // mm: the moon's top and width; pt: the title
const UNDER = MOON_Y + MOON; // mm: the English lines start under the moon
const line = (id, content, y, style) => ({ kind: 'text', id, content, align: 'center',
  overflow: 'wrap', color: col('ink'), placement: onPage(y), ...style });
const titlePage = { id: 'title', span: 'page', runningChapter: false,
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'moon', placement: onPage(MOON_Y, MOON),
      style: { backgroundColor: col('moon'), borderRadius: mm(MOON / 2) } },
    line('han', '{titleText}', MOON_Y + MOON / 2 - (HAN * 25.4) / 144, { fontFamily: SERIF,
      fontWeight: 900, fontSize: pt(HAN), letterSpacing: pt(HAN / 4), lineHeight: 1 }),
    line('en', '{attr.en}', UNDER + 10, { fontFamily: LABEL, fontSize: pt(10), fontWeight: 600,
      letterSpacing: pt(3), textTransform: 'uppercase', color: col('seal') }),
    line('poets', '{attr.poets}', UNDER + 19, { fontFamily: ROMAN, fontSize: pt(12) }),
    line('giles', '{attr.english}', UNDER + 26, { fontFamily: ROMAN, fontSize: pt(11),
      italic: true }),
    // A seal reading 明月, the bright moon of three of the four poems, set down its square.
    { kind: 'box', id: 'seal', placement: onPage(170, 13),
      style: { backgroundColor: col('seal'), borderRadius: mm(1) } },
    line('seal-text', '明月', 170, { placement: onPage(170, 13), writingMode: 'vertical-rl',
      fontFamily: KAI, fontSize: pt(14), lineHeight: 1, color: col('paper'),
      verticalAlign: 'middle' }),
  ] } },
};
// #endregion

// #region heads: the book in English over the verso, the poet in Chinese over the recto
const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity,
  pages: 'opener', // every poem page opens with its title; a blank page stays bare
  fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...style });
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', EDGE, { color: col('ink') }),
  head('verso-book', '{subtitle}', 'even', 'top-left', EDGE + 8,
    { letterSpacing: pt(1.2), textTransform: 'uppercase' }),
  head('recto-poet', '{attr.name}', 'odd', 'top-right', -(EDGE + 8),
    { fontFamily: HEI, fontWeight: 400, fontSize: pt(8), letterSpacing: pt(3) }),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -EDGE, { color: col('ink') }),
] };
// #endregion

const paragraphStyles = [shi, verse,
  // Justified like the body, and divided by the patterns of the edition's language (bodyText).
  { id: 'prose', fontFamily: ROMAN, fontSize: pt(10), lineHeight: pt(LEAD),
    firstLineIndent: mm(4) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(10), color: col('muted'),
    italicColor: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
];

const config = () => ({
  locale: 'zh-Hant', // the Chinese sets the rules; written out, never LANG (gotcha: cjk-locale-tag)
  colorPalette,
  page: {
    width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
    // 23 + 17.5 mm leave 169.5 mm for 30 lines of 16 pt (169.3 mm): a head deeper than the foot.
    margins: { top: mm(23), bottom: mm(17.5), left: mm(INNER), right: mm(OUTER), mirror: true },
  },
  layout: { layoutType: 'single' },
  cjk: { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } },
  bodyText: { fontFamily: SERIF, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: false,
    hyphenation: { enabled: true, locale: LANG }, // no Chinese patterns: the Latin's language
    verse: { indentStep: em(1) } }, // Giles's two-space indents: two ems
  // The designs paint the titles; weight 400 keeps the heading blocks in a loaded face.
  // parity 'any': a level-1 heading takes the next page; the styles below choose the side.
  headings: { fontFamily: SERIF, fontWeight: 400, levels: [{ level: 1, marginTop: pt(0),
    marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' } }] },
  headingStyles: [titlePage, english, chinese],
  paragraphStyles, footnotes, header, footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Esempio Markdown · 129 righe · content.en.mdtitle: "唐詩" subtitle: "Tang Poems" --- # 唐詩 {style="title" en="Tang Poems" poets="Li Bai · Du Fu · Wang Wei" english="with the English verse of Herbert A. Giles"} # Night Thoughts {style="en" kicker="Li Bai · 701–762"} :::verse{style="verse"} I wake, and moonbeams play around my bed, Glittering like hoar-frost to my wondering eyes; Up towards the glorious moon I raise my head, Then lay me down,—and thoughts of home arise. ::: # 靜夜思 {style="zh" poet="〔唐〕李白" name="李白"} :::paragraphs{style="shi"} 牀前明月光,疑是地上霜。[^bed] 舉頭望明月,低頭思故鄉。[^moon] ::: [^bed]: **牀** chuáng, now written 床: a bed. Some commentators read it as the railing round a well, others as a folding seat set out in the courtyard; Giles keeps the bed. [^moon]: **明月** míngyuè, the bright moon. The collected works of Li Bai read 山月 here, the moon over the hills, and 看月光 in the first line; the 1705 edition of all Tang poetry reads 山月 but keeps 明月光. This page follows the anthology of 1763, the text Chinese children learn by heart. **鄉** xiāng rhymes with 光 guāng and 霜 shuāng at the ends of the first two lines; Giles rhymes his quatrain on alternate lines instead. # Drinking Alone under the Moon {style="en" kicker="Li Bai · 701–762"} :::verse{style="verse"} An arbour of flowers and a kettle of wine: Alas! in the bowers no companion is mine. Then the moon sheds her rays on my goblet and me, And my shadow betrays we’re a party of three. Though the moon cannot swallow her share of the grog, And my shadow must follow wherever I jog,— Yet their friendship I’ll borrow and gaily carouse, And laugh away sorrow while spring-time allows. See the moon,—how she glances response to my song; See my shadow,—it dances so lightly along! While sober I feel you are both my good friends; When drunken I reel, our companionship ends. But we’ll soon have a greeting without a good-bye, At our next merry meeting away in the sky. ::: # 月下獨酌 {style="zh" poet="〔唐〕李白" name="李白"} :::paragraphs{style="shi"} 花間一壺酒,獨酌無相親。 舉杯邀明月,對影成三人。[^three] :::space{lines=1} 月既不解飲,影徒隨我身。 暫伴月將影,行樂須及春。[^jiang] :::space{lines=1} 我歌月徘徊,我舞影零亂。 醒時同交歡,醉後各分散。 永結無情遊,相期邈雲漢。[^wuqing] ::: [^three]: **三人** sān rén, three people: the poet, the moon and his shadow. [^jiang]: **將** jiāng: here it joins 月 to 影, the moon to the shadow. [^wuqing]: **無情遊** wúqíng yóu: a friendship with companions who feel nothing, the moon and the shadow; the poet binds himself to it for good. **雲漢** yúnhàn: the Milky Way, where the three will meet again, away in the sky. The rhyme changes at the ninth line, where the third stanza of Giles begins. # Stop-short {style="en" kicker="Du Fu · 712–770"} :::verse{style="verse"} White gleam the gulls across the darkling tide, On the green hills the red flowers seem to burn; Alas! I see another spring has died…. When will it come—the day of my return? ::: # 絕句 {style="zh" poet="〔唐〕杜甫" name="杜甫"} :::paragraphs{style="shi"} 江碧鳥逾白,山青花欲燃。[^yu] 今春看又過,何日是歸年。[^guinian] ::: [^yu]: **逾** yú, all the more: against the green of the river the birds look whiter still. **欲燃** yù rán, about to catch fire: the red of the flowers, which in Giles seem to burn. [^guinian]: **歸年** guī nián, the year of going home. Du Fu wrote this quatrain, the second of a pair, in the spring of 764 at Chengdu, where the war in the north had driven him. # The Bamboo Lodge {style="en" kicker="Wang Wei · 701–761"} :::verse{style="verse"} Beneath the bamboo grove, alone, I seize my lute and sit and croon; No ear to hear me, save mine own: No eye to see me—save the moon. ::: # 竹里館 {style="zh" poet="〔唐〕王維" name="王維"} :::paragraphs{style="shi"} 獨坐幽篁裏,彈琴復長嘯。[^huang] 深林人不知,明月來相照。 ::: [^huang]: **幽篁** yōuhuáng: a deep, dark grove of bamboo. **長嘯** cháng xiào: a long whistle through pursed lips, the art of recluses; in Giles the poet croons. The Bamboo Lodge was one of twenty places on the estate of Wang Wei at Wangchuan, south-east of the Tang capital, and he gave each of them a quatrain in 輞川集, the Wangchuan collection. # A note on the text {style="en" kicker="About this edition"} :::paragraphs{style="prose"} The Chinese poems follow *Three Hundred Tang Poems*, the anthology Sun Zhu compiled in 1763 under the name Hengtang Tuishi, except Du Fu’s quatrain, which is not in it and is printed as the *Complete Tang Poems* of 1705 gives it. All four are taken from Wikisource in traditional characters, with the old forms of some characters as found there. The English verse is Herbert A. Giles’s, as he printed it in *A History of Chinese Literature* (1901), where the poems have no titles; the English titles are this edition’s. Giles wrote Li Po and Tu Fu for Li Bai and Du Fu, and called the four-line *jueju* a “stop-short”, which gives Du Fu’s poem its English name. His rhymes, indents and punctuation are kept; the moonlight poem, which his book printed in half-lines, is set here in whole lines. Each Chinese poem is set two lines of verse to a row, so that every row faces the two lines of English that render it. The space between the stanzas of the moonlight poem is the editor’s, added on both pages. ::: :::paragraphs{style="colophon"} Set in Noto Serif TC, LXGW WenKai TC and Noto Sans TC for the Chinese, Source Serif 4 and Source Sans 3 for the English, all under the SIL Open Font License · Chinese text: Wikisource · English verse: H. A. Giles, *A History of Chinese Literature*, 1901, Project Gutenberg eBook 43711 :::
`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build 'Noto Serif TC': ['400', '700', '900'], // SERIF: the notes; the titles; 唐詩 'LXGW WenKai TC': ['400'], // KAI: the poems and the seal 'Noto Sans TC': ['400'], // HEI: the poet lines, the recto's head 'Source Serif 4': ['400', '400i'], // ROMAN: the verse, the titles, the note on the text 'Source Sans 3': ['400', '400i', '600'], // LABEL: kickers, the verso's head, folios, colophon }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region voices: each Chinese face loads the files of the characters it sets const grab = (re) => (markdown.match(re) ?? []).join(''); // Each poem to its closing `:::` (not the `:::space` between stanzas), then the note numbers. const poems = grab(/:::paragraphs\{style="shi"\}[\s\S]*?\n:::(?=\n|$)/g) + '①②③④⑤⑥⑦⑧⑨⑩'; await loadFonts(FONTS, markdown); await Promise.all([ loadCjkFonts({ [SERIF]: ['400'] }, markdown + poems), // the notes and their ①, the heading blocks loadCjkFonts({ [SERIF]: ['700'] }, grab(/^# \S+(?= \{style="zh")|\*\*[^*]+\*\*/gm)), loadCjkFonts({ [SERIF]: ['900'] }, grab(/^# \S+(?= \{style="title")/gm)), loadCjkFonts({ [KAI]: ['400'] }, `${poems}明月`), loadCjkFonts({ [HEI]: ['400'] }, grab(/(?<=(?:poet|name)=")[^"]*/g)), ]); // #endregion const doc = await withLoadedFonts( () => buildDocument({ markdown }, config()), { ...kitFonts(FONTS), text: markdown }); showPages(doc, { title: t({ en: 'Tang poems facing their English verse', es: 'Poemas Tang frente a su versión inglesa' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);
Kit · core, fonts, viewer, pdf, cjk: uguale in ogni ricetta · 458 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 · pdf v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · cjk v2 ── Chinese, Japanese and Korean books · postext.dev/cookbook // Fontsource ships a CJK family as about a hundred files per weight, each // declared in its stylesheet with the unicode-range it covers. The screen // loads the files the sample touches; the PDF gets the same files for the // characters its pages set in each face, and embeds each as a subset. // A book bound on the right (vertical text) is shown with its spreads // mirrored: page 1 alone on the left of the spine, then [3 | 2]. /** The files of a Fontsource face, read from its stylesheet: { url, range, * ranges }, the last declared first (the order the browser tries them in). */ function cjkSlices(family, weight, style) { cjkSlices.cache ??= new Map(); const id = fontsourceId(family); const css = `${id}/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { let at; cjkSlices.cache.set(css, fontsourceAt(id).then((a) => fetch(at = a + css.slice(id.length + 1))) .then((res) => { if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style} (${res.status})`); return res.text(); }) .then((text) => [...text.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => { const range = /unicode-range:\s*([^;]+);/.exec(rule)?.[1].trim() ?? 'U+0-10FFFF'; const ranges = range.split(',').map((part) => { const [lo, hi = lo] = part.trim().slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return { url: new URL(/url\(([^)]+?\.woff2)\)/.exec(rule)[1], at).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` the screen sets code point `cp` in, if any: the * last one loadCjkFonts adds whose range holds it. A numbered file that * repeats a letter of latin is added after latin, and wins it. */ function cjkSliceFor(slices, cp) { const holds = (slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi); return slices.reduce((last, slice) => (holds(slice) ? slice : last), undefined); } /** Whether Fontsource serves `family` as a Chinese, Japanese or Korean * family (its subsets name the script). Fails when the API does not * answer: a CJK face taken for a Latin one would paint in a system face. */ async function isCjkFamily(family) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); return !!meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset)); } /** faces = { 'Noto Serif TC': ['400', '700'] }, as for loadFonts: the * whole FONTS object may be passed, its other families are left to * loadFonts. Adds one FontFace per file of each CJK face with its * unicodeRange, then loads the files `text` touches. `text` is what the * faces set: the sample for the text face; a book in several voices calls * it once per voice (loadCjkFonts({ 'LXGW WenKai TC': ['400'] }, quotes)), * so the heading and quotation faces fetch and check only their own * characters. Fails when a character of `text` is in no file of a face. * List every weight the pages use: a weight left to the engine's later round * gets the latin file only. With { vertical: true } it also loads each * family's vertical forms (brackets, quotes, pause marks) for the canvas, * which needs loadVerticalAlternates imported from postext. Resolves to * the number of files loaded. */ async function loadCjkFonts(faces, text, { vertical = false } = {}) { kitStatus('Loading fonts…'); let loaded = 0; try { if (vertical && typeof loadVerticalAlternates !== 'function') { throw new Error('loadCjkFonts(…, { vertical: true }) needs loadVerticalAlternates imported from postext'); } for (const [family, specs] of Object.entries(faces)) { if (!(await isCjkFamily(family))) continue; const twin = []; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const slices = await cjkSlices(family, weight, style); const missing = [...new Set(text)].filter((ch) => /\S/.test(ch) && !cjkSliceFor(slices, ch.codePointAt(0))); if (missing.length) { throw new Error(`${family} ${spec} has no file for ${missing.slice(0, 12).join(' ')}: ` + `give each face the text it sets (loadCjkFonts({ '${family}': ['${spec}'] }, text))`); } for (const slice of slices) { document.fonts.add(new FontFace(family, `url(${slice.url}) format('woff2')`, { weight: String(weight), style, unicodeRange: slice.range })); twin.push({ source: slice.url, weight: String(weight), style, unicodeRange: slice.range }); } const font = `${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`; loaded += (await document.fonts.load(font, text)).length; if (!document.fonts.check(font, text)) throw new Error(`${family} ${spec} did not load for the sample`); } // The same files under a twin name with the `vert` feature on: the // canvas paints the punctuation of vertical lines with it. if (vertical && twin.length) await loadVerticalAlternates(family, twin); } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with CJK faces: a family whose * Fontsource subsets are Chinese, Japanese or Korean gets the files the * screen set its pages' characters in (`request.codePoints`), each marked * with its unicode-range and its place in the order loadCjkFonts added * them: postext-pdf sets a character two files hold in the one added * last, as the browser does. Any other family gets the latin file * fontsourceProvider fetches (the "pdf" block) and, when the face sets * letters only latin-ext has, that file too. */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return cjkLatinPdfFiles(family, weight, style, request); const meta = await fontsourceMeta(family); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const slices = await cjkSlices(family, w, s); const picked = new Set(); for (const cp of request?.codePoints ?? []) { const slice = cjkSliceFor(slices, cp); if (slice) picked.add(slice); } if (!picked.size) picked.add(slices[0]); return Promise.all(slices.map(async (slice, order) => { if (!picked.has(slice)) return null; const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer())); return Object.assign(bytes, { unicodeRange: slice.range, order }); })).then((files) => files.filter(Boolean)); } /** A Latin family set next to the CJK faces: its latin file, then its * latin-ext file when the face sets letters only latin-ext has (ō ū in * Hepburn rōmaji, ǎ in pinyin), the file loadFonts adds on screen for * them, each marked with its unicode-range and its place in that order: * the PDF sets a character in the file the browser took it from. A face * Fontsource ships without latin-ext, or whose file does not come, gets * latin alone, and the PDF names the letters it lacks. */ async function cjkLatinPdfFiles(family, weight, style, request) { const meta = await fontsourceMeta(family); const beyond = [...(request?.codePoints ?? [])].some(cjkLatinExtOnly); if (!beyond || !meta?.subsets?.includes('latin-ext')) return fontsourceProvider(family, weight, style); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const id = fontsourceId(family); const url = `${await fontsourceAt(id)}files/${id}-latin-ext-${w}-${s}.woff2`; const [latin, ext] = await Promise.all([fontsourceProvider(family, weight, style), fetch(url) .then(async (res) => (res.ok ? decompressWoff2(new Uint8Array(await res.arrayBuffer())) : null), () => null)]); const ranges = kitRanges(); return ext ? [Object.assign(latin, { unicodeRange: ranges.latin, order: 0 }), Object.assign(ext, { unicodeRange: ranges['latin-ext'], order: 1 })] : latin; } /** Whether code point `cp` is in Fontsource's latin-ext file and not in * its latin file: Latin Extended-A and -B, IPA, the spacing modifiers and * Latin Extended Additional (loadFonts's test for latin-ext), less the * few latin holds too (ı Œ œ ʻ ʼ ˆ ˚ ˜). */ function cjkLatinExtOnly(cp) { if (!((cp >= 0x100 && cp <= 0x2ff) || (cp >= 0x1e00 && cp <= 0x1eff))) return false; return ![0x131, 0x152, 0x153, 0x2bb, 0x2bc, 0x2c6, 0x2da, 0x2dc].includes(cp); } /** showPages for a book bound on either edge. A right-bound book (the * document says so: doc.binding is 'right' for page.binding 'right' and * for vertical text) lies on the desk as it opens: page 1 alone on the * left of the spine, then [3 | 2], the spine shade on each page's inner * edge. `binding` ('left' | 'right') overrides the document's. */ function showBook(docs, { binding, ...options } = {}) { const count = showPages(docs, options); const right = (binding ?? [docs].flat()[0]?.binding) === 'right'; if (!document.getElementById('pt-kit-cjk')) { // The pages keep direction ltr: a canvas draws text in the direction its // element inherits, and under rtl each run would end where the engine // starts it, its brackets mirrored. document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk"> .pt-spread[dir="rtl"] canvas { direction: ltr; } .pt-spread[dir="rtl"] 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); } </style>`); } // Each pair stays [verso, recto] in the page; right to left, the verso // sits on the right. Phones stack the pages in reading order either way. for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr'; document.getElementById('pages').dataset.binding = right ? 'right' : 'left'; return count; } // ─── /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

#Metti l’originale sul verso

Le edizioni Loeb e molti libri di lettura cinese–inglese mettono l’originale a sinistra: scambia le parità dei due stili di titolo e delle due testatine che nominano il libro e il poeta, e scrivi nel Markdown ogni titolo e poesia cinese prima del suo inglese.

-const english = { id: 'en', breakBefore: { enabled: true, parity: 'even' }, // a verso
+const english = { id: 'en', breakBefore: { enabled: true, parity: 'odd' }, // a recto
-const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'odd' },
+const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'even' },
-  head('verso-book', '{subtitle}', 'even', 'top-left', EDGE + 8,
+  head('verso-book', '{subtitle}', 'odd', 'top-right', -(EDGE + 8),
-  head('recto-poet', '{attr.name}', 'odd', 'top-right', -(EDGE + 8),
+  head('recto-poet', '{attr.name}', 'even', 'top-left', EDGE + 8,

#Metti le glosse subito sotto la poesia

Le antologie cinesi annotate (注釋本) stampano le note dopo ogni poesia. Qui lo fanno le note di fine capitolo, perché ogni poesia è un capitolo a sé.

-const footnotes = { placement: 'column', numbering: 'page', // the page foot; ① again on each page
+const footnotes = { placement: 'chapterEnd', chapterEndAlign: 'text',

Errori frequenti

Errore frequente

Uno stile di titolo eredita l’interruzione di pagina del suo livello

Una voce di headingStyles prende dal suo livello di titolo ogni campo che omette, breakBefore compreso. Un indice o un colophon con lo stile di un H1 dopo un :::pagebreak eredita parity 'odd' e finisce dietro una pagina bianca. Dai a uno stile del genere breakBefore: { enabled: false }. Stili di titolo →

Errore frequente

La pagina 1 è un recto: pianifica le pagine con i numeri fisici

La pagina 1 è una pagina destra e la pagina 2 il primo verso, quindi pianifica le doppie pagine con i numeri di pagina fisici: un’apertura su una pagina pari sta di fronte alla pagina dispari che la segue. Interruzioni di pagina e di colonna →

Errore frequente

I caratteri cinesi si caricano a fette, tramite il blocco cjk

Fontsource serve una famiglia cinese, giapponese o coreana come un centinaio di file per peso, ognuno dei quali copre un intervallo di caratteri. loadFonts scarica solo il file latin, così a schermo i caratteri Han vengono da un font di sistema e sono misurati male, e fontsourceProvider passa al PDF quel file latin, che li stampa come riquadri vuoti. Elenca il blocco cjk del kit, chiama loadCjkFonts(FONTS, markdown) dopo loadFonts (una volta per voce tipografica, con il testo che compone, quando il libro usa più caratteri CJK) e passa a renderToPdf fontProvider: cjkPdfProvider: entrambi prendono i file che contengono i caratteri del testo. Font cinesi, giapponesi e coreani →

Errore frequente

Assegna al documento il tag zh-Hans o zh-Hant, non LANG

Le edizioni di una ricetta sono en ed es, ma un esempio cinese è cinese in entrambe: `locale: LANG` gli darebbe il tag inglese o spagnolo, sillaberebbe le sue parole latine, etichetterebbe le figure Figure o Figura e darebbe al PDF la lingua sbagliata. Da postext 1.30 il tag è anche la lingua usata per lo shaping del testo (misura, disegno e incorporamento), quindi con un tag inglese o spagnolo un carattere cinese compone in altre forme le cifre e la punteggiatura che condivide con il testo latino, e le righe vanno a capo altrove. Scrivi tu il tag: 'zh-Hans' (convenzioni della Cina continentale: divisione in righe GB, punteggiatura Kaiming) o 'zh-Hant' (Taiwan: punteggiatura centrata a tutta larghezza); 'zh-HK' per Hong Kong. Un 'zh' da solo vale come semplificato, Cina continentale. Un esempio giapponese prende 'ja' (errore frequente ja-locale-tag). Divisione in righe del cinese →

Errore frequente

Un carattere cinese compone virgolette e apostrofi a tutta larghezza, anche nel testo latino

Noto Serif TC e gli altri caratteri cinesi tradizionali disegnano ‘ ’ “ ” larghi un em, come li usa il testo cinese, e quel glifo è ciò che riceve una parola latina composta in quel carattere: Li Bai’s si stampa con un ampio vuoto dopo l’apostrofo, e una citazione inglese si apre e si chiude con un em pieno di bianco. Un carattere CJK non ha nemmeno il corsivo, e l’inclinazione simulata a schermo si stampa in tondo nel PDF. Il testo che mescola il cinese con una lingua latina in un solo carattere (le note a piè di pagina, che prendono il carattere del corpo, o le glosse con pinyin) si legge meglio senza apostrofi, virgolette curve e corsivi; « » e i segni Latin-1 mantengono la loro larghezza. Dove il testo latino scorre da solo, dagli un carattere latino tramite uno stile di paragrafo. Font cinesi, giapponesi e coreani →

Crediti

Testo
  • Three Tang poems in traditional characters, Li Bai’s 靜夜思 and 月下獨酌 and Wang Wei’s 竹里館, as Three Hundred Tang Poems (1763) gives them · Li Bai, Wang Wei · pubblico dominio
  • Du Fu’s 絕句, the second of his 絕句二首, as the Complete Tang Poems (1705, juan 228) prints it · Du Fu · pubblico dominio
  • The English verse translations, from A History of Chinese Literature (1901), Project Gutenberg eBook 43711 · Herbert A. Giles · pubblico dominio
  • The notes, the English titles, the note on the text and the colophon · Postext Cookbook · originale
Font
Noto Serif TC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1) · Source Serif 4 (SIL OFL 1.1) · Source Sans 3 (SIL OFL 1.1)
SandboxPDF