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

Receitas · Capítulo 1 · Página e grade

Poemas Tang diante da sua versão inglesa

Cada poema Tang abre uma página ímpar tingida, e o inglês de Giles fica na par em frente: um estilo de título quebra para página par, o outro para ímpar.

Nesta página
Gênero
Poesia
Saída
Canvas · PDF
Postext
Testada com o Postext 1.19.1
Requer ≥ 1.11.0 · postext-pdf ≥ 1.11.0
Licença
Atualizada em 1 de out. de 2026
Código MIT · Texto MIT

p. 2–3 de 10

  • Amostra em inglês: ainda sem edição em português
  • Refile 148 × 210 mm
  • 1 coluna
  • Noto Serif TC 10,5/16
  • LXGW WenKai TC
  • Noto Sans TC
  • Source Sans 3
  • Source Serif 4
  • 10 páginas
  • Nível
  • Postext 1.19.1
  • Diagramado em 19 ms
  • 180 linhas de código

Em poucas palavras

Um livrinho de poemas chineses da dinastia Tang, com uma tradução inglesa antiga na página da frente. Cada linha de versos chineses fica na altura dos versos ingleses que a traduzem.

O que você vai compor

Um pequeno livro bilíngue de poemas Tang, composto como as edições bilíngues compõem um clássico: o chinês na página da direita, numa folha tingida como papel antigo, e os versos rimados de Herbert A. Giles, de 1901, na página da esquerda, em frente. Quatro poemas de Li Bai, Du Fu e Wang Wei ocupam uma página dupla cada um, com título em cada língua e assinados com o nome do poeta. O chinês vai em LXGW WenKai, uma Kai desenhada a partir do pincel, com dois versos por linha, de modo que cada linha fica na altura dos dois versos ingleses que a traduzem. As notas, na língua do leitor, ficam no pé da página chinesa. Tradução lado a lado, estrofe por estrofe põe um poema espanhol ao lado da versão inglesa numa só página; aqui cada língua tem a sua página.

Esta receita responde a

  • Como componho um poema e sua tradução em páginas opostas, com o original sempre na página ímpar?
  • Como componho poesia: um verso por linha, espaço entre estrofes, recuo deslocado nos versos que quebram e sem hifenização?
  • Como faço notas de rodapé?
  • Como forço uma quebra de página ou de coluna e faço cada capítulo começar em página ímpar?

A resposta curta

script.js · linhas 39–63no código 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' } } }]) };

Ingredientes

Tipografia
Noto Serif TC, LXGW WenKai TC, Noto Sans TC, Source Serif 4, Source Sans 3 (SIL OFL 1.1)
Materiais
Nenhum: todas as imagens são desenhadas em código

Preparo

#1 · Um estilo de título para cada lado da página dupla

O código é a resposta curta acima. Cada poema são dois títulos de nível 1: # Night Thoughts {style="en" …} com os versos de Giles e depois # 靜夜思 {style="zh" …} com o poema. O estilo en quebra para a próxima página par; o estilo zh, para a próxima ímpar, a que fica em frente. Enquanto cada poema couber na sua página, nenhuma página em branco entra; uma versão inglesa que passasse da página empurraria o original para a ímpar seguinte, atrás de uma par em branco (Quebrar antes). Os dois designs reservam seis linhas da grade, então o inglês e o chinês começam na mesma linha. O estilo chinês usa span: 'page' porque o primeiro elemento dele, uma caixa do tamanho da página, é a folha tingida, e um design preso à coluna para no pé da coluna (Span e design avançado).

Página 2: o inglês abre a página par seis linhas abaixo, na mesma linha da grade em que o chinês abre na página 3.

#2 · Uma linha de chinês para dois versos de inglês

script.js · linhas 67–79no código completo
// Each indent centres the longest line of the poems that use it on the 103.7 mm measure:
// 97.8 mm in the moonlight poem, 91–93 mm in two quatrains, 66 mm in Wang Wei's.
const verses = [['verse-long', 3], ['verse', 6], ['verse-short', 19]].map(([id, indent]) => ({
  id, fontFamily: ROMAN, fontSize: pt(BODY), lineHeight: pt(LEAD), textAlign: 'left',
  firstLineIndent: pt(0), indent: mm(indent) }));
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) };
// A paragraph drops its leading spaces (gap: hard-line-breaks): the two spaces Giles indents
// his rhyming lines by become two em spaces behind a word joiner, which nothing trims.
const indentVerse = (md) => md.replace(/^ {2}(?=\S)/gm, '\u2060\u2003\u2003');

As linhas chinesas têm o dobro da entrelinha dos versos ingleses, 32 pt contra 16, então uma linha de dois versos de cinco caracteres fica em frente ao dístico que Giles faz dela. No poema da lua, nas páginas 4 e 5, o mesmo :::space{lines=1} entra nos dois textos, e as três estrofes continuam niveladas na página dupla. A linha chinesa começa com um recuo de 51 pt, o que centraliza os seus doze emes nos 28 da grade, e cada chamada de nota fica depois do 。 da sua linha, fora dos doze: dentro da linha, ela empurraria para a direita seis décimos de eme todos os caracteres seguintes, que deixariam de cair sobre os da linha de baixo. Os recuos ingleses saem da medição do verso mais longo de cada poema. Giles recua um verso sim, outro não; o Markdown guarda os dois espaços dele, e indentVerse os troca por espaços eme depois de um word joiner, porque um parágrafo descarta os espaços com que começa.

#3 · Notas no pé da página chinesa

script.js · linhas 83–86no código 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') } };

As notas são notas de rodapé, uma por linha: 疑是地上霜。[^bed] fecha a primeira linha do poema e [^bed]: … vem depois dela, com cada palavra explicada em negrito, já que o número não fica mais ao lado delas. São numeradas como um livro chinês numera as suas notas (页下注): números em círculo sobre a linha de base, contados de novo a partir de ① em cada página (numberFormat: 'circled-decimal', numbering: 'page'). No verso, a chamada é composta a seis décimos do corpo do poema, markerSize: em(0.6), perto do corpo das notas; no pé da página, cada nota abre com o seu ① no corpo do texto da nota (Notas de rodapé). As notas são compostas na fonte do texto corrido, e a deste livro é a Noto Serif TC porque uma nota mistura caracteres e pinyin; as letras latinas da Noto Serif TC vêm da Source Serif, e por isso as páginas em inglês são compostas em Source Serif 4. As notas são escritas na língua da edição: junto com o cabeço da página par e a nota sobre o texto, são o que muda na edição em espanhol.

#4 · Cabeços na língua de cada página

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

Todas as páginas deste livro abrem com um título, então todas são aberturas, e os cabeços usam pages: 'opener'. Uma página em branco criada por uma quebra ficaria limpa, e a folha de rosto os tira com um cabeçalho vazio próprio. A página par pega o título do livro no subtitle do frontmatter, que cada edição escreve na sua língua; a ímpar pega o nome do poeta no atributo name do título chinês (Cabeços e rodapés).

#5 · Uma folha de rosto sobre uma lua

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

A folha de rosto também é um estilo de título, com runningChapter: false para não nomear nenhum capítulo. A lua é uma caixa de design cujo raio de canto é metade do lado, e 唐詩 é centralizado sobre ela por aritmética: o meio da lua menos metade da altura do título. O selo compõe 明月 com writingMode: 'vertical-rl', um caractere sobre o outro, como se gravam os selos.

#6 · Cada fonte chinesa carrega o que compõe

script.js · linhas 332–341no código completo
const grab = (re) => (markdown.match(re) ?? []).join('');
const poems = grab(/:::paragraphs\{style="shi"\}[\s\S]*?\n:::/g) + '①②③④⑤⑥⑦⑧⑨⑩'; // and note numbers
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)),
]);

O Fontsource corta cada fonte chinesa em cerca de cem arquivos por faixa de caracteres, e loadCjkFonts baixa só os arquivos de que o texto precisa (Fontes chinesas, japonesas e coreanas). A Kai recebe os poemas e o selo; a Hei, as linhas dos poetas e os nomes; a Song em negrito, os quatro títulos e as palavras explicadas nas notas; e o peso black dela, os dois caracteres da folha de rosto.

A receita 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.11.0
import { buildDocument, renderPageToCanvas, clearMeasurementCache } 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 indent centres the longest line of the poems that use it on the 103.7 mm measure:
// 97.8 mm in the moonlight poem, 91–93 mm in two quatrains, 66 mm in Wang Wei's.
const verses = [['verse-long', 3], ['verse', 6], ['verse-short', 19]].map(([id, indent]) => ({
  id, fontFamily: ROMAN, fontSize: pt(BODY), lineHeight: pt(LEAD), textAlign: 'left',
  firstLineIndent: pt(0), indent: mm(indent) }));
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) };
// A paragraph drops its leading spaces (gap: hard-line-breaks): the two spaces Giles indents
// his rhyming lines by become two em spaces behind a word joiner, which nothing trims.
const indentVerse = (md) => md.replace(/^ {2}(?=\S)/gm, '\u2060\u2003\u2003');
// #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') } };
// #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, ...verses,
  // 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 = () => ({ // a factory: the engine caches resolved configs per object
  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
  // The designs paint the titles; weight 400 keeps the heading blocks in a loaded face.
  // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
  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`---
Amostra em Markdown · 153 linhas · 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"} :::paragraphs{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"} :::paragraphs{style="verse-long"} 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. :::space{lines=1} 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. :::space{lines=1} 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"} :::paragraphs{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"} :::paragraphs{style="verse-short"} 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 (gotcha: fonts-first) '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(''); const poems = grab(/:::paragraphs\{style="shi"\}[\s\S]*?\n:::/g) + '①②③④⑤⑥⑦⑧⑨⑩'; // and note numbers 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 buildWithFonts( () => buildDocument({ markdown: indentVerse(markdown) }, config()), 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: igual em todas as receitas · 459 linhas// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout; `base` marks a block's own face. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v2 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType: the nearest weight the * family ships, upright if it has no italic; latin, then what the face's * 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 more = kitSubsetsFor(text, meta); const files = await Promise.all(['latin', ...more].map(async (subset) => { const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); 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 v1 ── 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 = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { cjkSlices.cache.set(css, fetch(css) .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], css).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` that holds code point `cp`, if any. */ function cjkSliceFor(slices, cp) { return slices.find((slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi)); } /** 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 buildWithFonts 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 that * hold the characters its pages set (`request.codePoints`); 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.filter((slice) => picked.has(slice)).map(async (slice) => { const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); } /** 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. Latin comes first: postext-pdf draws a character from the first * file that has it, as the browser takes a character both files hold from * latin. 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 = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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)]); return ext ? [latin, ext] : 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 ───────────────────────────────────────────────────────────────────────

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

Variações

#Pôr o original na página par

As edições Loeb e muitos livros bilíngues de chinês e inglês põem o original à esquerda: troque as paridades dos dois estilos de título e dos dois cabeços que nomeiam o livro e o poeta, e escreva no Markdown cada título e poema chineses antes da versão inglesa.

-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,

#Pôr as notas logo abaixo do poema

As antologias chinesas anotadas (注釋本) imprimem as notas depois de cada poema. Aqui as notas de fim de capítulo fazem isso, já que cada poema é um capítulo.

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

Erros comuns

Erro comum

Um estilo de título herda a quebra de página do seu nível

Uma entrada de headingStyles recebe do seu nível de título todos os campos que não define, inclusive breakBefore. Um sumário ou um colofão com estilo sobre um H1 depois de um :::pagebreak herda a paridade 'odd' e cai depois de uma página em branco. Dê a esse estilo breakBefore: { enabled: false }. Estilos de título →

Erro comum

A página 1 é ímpar: planeje com os números físicos

A página 1 fica à direita e a 2 é a primeira página par, então planeje as páginas duplas com os números físicos de página: uma abertura em página par fica de frente para a ímpar que vem depois dela. Quebras de página e de coluna →

Erro comum

Fontes chinesas são carregadas em fatias, pelo bloco cjk

O Fontsource serve uma família chinesa, japonesa ou coreana em cerca de cem arquivos por peso, cada um com um intervalo de caracteres. loadFonts baixa só o arquivo latin, então na tela os caracteres chineses vêm de uma fonte do sistema e são medidos errado, e o fontsourceProvider entrega ao PDF esse arquivo latin, que os imprime como caixas vazias. Inclua o bloco cjk do kit, chame loadCjkFonts(FONTS, markdown) depois de loadFonts (uma vez por voz, com o texto que ela compõe, quando o livro usa várias fontes CJK) e passe a renderToPdf fontProvider: cjkPdfProvider: os dois pegam os arquivos que contêm os caracteres do texto. Fontes chinesas, japonesas e coreanas →

Erro comum

Marque o documento como zh-Hans ou zh-Hant, não com LANG

As edições de uma receita são en e es, mas uma amostra chinesa é chinesa nas duas: `locale: LANG` a marcaria como inglês ou espanhol, hifenizaria as suas palavras latinas, chamaria as suas figuras de Figure ou Figura e daria ao PDF o idioma errado. Escreva você mesmo a marcação: 'zh-Hans' (convenções da China continental: quebra de linha GB, pontuação Kaiming) ou 'zh-Hant' (Taiwan: pontuação de largura inteira centralizada); 'zh-HK' para Hong Kong. Um 'zh' sozinho é lido como chinês simplificado continental. Uma amostra japonesa leva 'ja' (armadilha ja-locale-tag). Quebra de linha em chinês →

Erro comum

Uma fonte chinesa compõe aspas e apóstrofos em largura inteira, também em texto latino

A Noto Serif TC e as outras fontes de chinês tradicional desenham ‘ ’ “ ” com um eme de largura, como o texto chinês as usa, e esse é o glifo que recebe uma palavra latina composta com a fonte: Li Bai’s sai com um espaço largo depois do apóstrofo, e uma citação em inglês abre e fecha com um eme inteiro em branco. Uma fonte CJK também não tem itálico, e a inclinação simulada na tela sai reta no PDF. Texto que mistura chinês e uma língua latina em uma só fonte (as notas de rodapé, que usam a fonte do corpo, ou glosas com pinyin) fica melhor sem apóstrofos, aspas curvas e itálicos; « » e os sinais do Latin-1 mantêm a largura. Quando o texto latino vem à parte, dê a ele uma fonte latina com um estilo de parágrafo. Fontes chinesas, japonesas e coreanas →

Erro comum

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

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

Erro comum

Carregue todas as fontes antes do layout

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

Créditos

Texto
  • 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 · domínio público
  • Du Fu’s 絕句, the second of his 絕句二首, as the Complete Tang Poems (1705, juan 228) prints it · Du Fu · domínio público
  • The English verse translations, from A History of Chinese Literature (1901), Project Gutenberg eBook 43711 · Herbert A. Giles · domínio público
  • The notes, the English titles, the note on the text and the colophon · Postext Cookbook · original
Fontes
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