Aller au contenu principal
Recette numéro 75

Recettes · Chapitre 1 · Page et grille

Un roman chinois vertical, relié à droite

Le chapitre 1 du 三國演義 en 25開 taïwanais : 40 caractères sur 16 colonnes, ouverture 回目 filetée, titres en marge, folios chinois, lecture de droite à gauche.

Sur cette page
Sortie
Canvas · PDF
Niveau
Avancé
Postext
Testé avec Postext 1.31.0
Nécessite ≥ 1.25.0 · postext-pdf ≥ 1.25.0
Licence
Mis à jour le 10 oct. 2026
Code MIT · Texte CC BY 4.0

p. 二–一 · 2–3 sur 11

  • Exemple en anglais : pas encore d’édition française
  • Format 148 × 210 mm
  • 1 colonne
  • Noto Serif TC 10,5/19
  • LXGW WenKai TC
  • Noto Sans TC
  • Source Serif 4
  • 11 pages
  • Niveau
  • Postext 1.30.0
  • Mis en page en 88 ms
  • 204 lignes de code

En bref

Le premier chapitre d’un roman chinois classique, en livre de poche taïwanais. Le texte court en colonnes de haut en bas, lues de droite à gauche, et le livre s’ouvre par la droite.

Ce que vous allez composer

Onze pages d’une édition de poche taïwanaise des Trois Royaumes : tout le chapitre 1, dans la recension de Mao Zonggang, composé de haut en bas en colonnes lues à partir de la droite, sur une page de 148 × 210 mm (25開) reliée à droite. La page de titre est seule à gauche du dos. Sur la double page suivante, une gravure sur bois du serment du jardin des pêchers, taillée en 1592, fait face à l’ouverture du chapitre, où 第一回 et son distique se tiennent entre des filets vermillon. Chaque page de texte contient 16 colonnes de 40 caractères, chaque signe centré dans sa case. Le titre du livre descend la marge extérieure des pages de droite et celui du chapitre celle des pages de gauche, au-dessus de folios en chiffres chinois. La même structure de livre dans un roman de poche européen se trouve dans Nº 038.

Cette recette répond à

  • Comment composer un livre chinois à la verticale, relié à droite ?
  • Comment numéroter les chapitres 第一回, 第二回 en chiffres chinois ?

La réponse courte

script.js · lignes 34–51dans le code complet
// 'vertical-rl' turns the flow a quarter turn: lines run down, columns from right to left,
// and page.binding 'auto' becomes 'right', so page 1 is a left-hand page and the spreads
// read [3 | 2]. The grid sets the type area in characters; the margins are minimums it
// grows. zh-Hant resolves to Taiwan's rules: every mark full width and centred in its cell.
const page = {
  sizePreset: 'custom', width: mm(148), height: mm(210), dpi: 150,
  backgroundColor: col('paper'),
  // 天頭 above 地腳; left is the spine side (the right edge of a left-hand page).
  margins: { top: mm(32), bottom: mm(26), left: mm(16), right: mm(22), mirror: true },
  pageNumbering: { format: 'trad-chinese-informal' }, // folios 一, 二 … 九, from the chapter
};
const layout = { layoutType: 'single', writingMode: 'vertical-rl' };
const cjk = { grid: { enabled: true, charsPerLine: 40, linesPerPage: 16 } };
const chapter = {
  level: 1, numberingTemplate: '第{1:一}回', numberSeparator: ' ', // 第一回 宴桃園…
  breakBefore: { enabled: true, parity: 'odd' }, // a recto, with no blank page forced
  marginBottom: pt(0),
};

Ingrédients

Typographie
Noto Serif TC, LXGW WenKai TC, Noto Sans TC, Source Serif 4 (SIL OFL 1.1)
Ressources
  • peach-garden-oath-1592-v2.jpg
  • 桃園結義 (The Oath in the Peach Garden), woodcut from Yu Xiangdou’s 新刊京本校正演義全像三國志傳評林, Jianyang, 1592 (Waseda University Library); scan from Wikimedia Commons, cropped inside its frame and clear of what was left of it, cleaned and toned to the page’s ink and paper (Yu Xiangdou (publisher), anonymous block cutter, domaine public)

Préparation

#1 · Tourner la page d’un quart de tour et la relier à droite

Le code est dans la réponse courte plus haut. layout.writingMode: 'vertical-rl' compose chaque page comme une page horizontale tournée d’un quart de tour dans le sens des aiguilles d’une montre : une ligne du flux est une colonne de caractères, lue à partir de la droite, et la coupure des lignes, la justification et le retrait de deux caractères fonctionnent le long de la colonne (Écriture verticale). page.binding reste à 'auto', qui relie un livre vertical à droite : la page 1 est toujours le recto, mais c’est une page de gauche, et mirror: true place sa marge intérieure, left, à sa droite (Reliure). Le showBook du kit dispose les doubles pages comme le livre s’ouvre, la page 1 seule puis [3 | 2] ; le PDF demande le même ordre aux visionneuses avec /Direction /R2L, ce que suit Acrobat et qu’ignore la visionneuse de Chrome.

La grille de caractères fixe le bloc de texte à 40 caractères de 10,5 pt en hauteur et 16 colonnes de 19 pt en largeur, soit 148,2 × 107,2 mm. Les marges de page sont des minimums : la grille agrandit chaque paire de la même façon, jusqu’à 33,9 mm en tête et 27,9 en pied, 17,4 au petit fond et 23,4 en marge extérieure. locale: 'zh-Hant' donne aux réglages d’Asie orientale les valeurs de Taïwan, chaque signe sur un cadratin plein et centré dans sa case, sans compression entre les signes. trad-chinese-informal imprime les folios 一 à 九, et 第{1:一}回 numérote le chapitre (Numérotation).

#2 · Fileter le 回目 sur deux colonnes

script.js · lignes 55–76dans le code complet
// In the flow frame x runs down the column and y across the page, right to left, so a
// 'horizontal' rule is a vertical line on the sheet: three of them rule the title columns.
const [TITLE, PITCH] = [13.5, 1.5 * LEAD]; // pt: the couplet's size and its column pitch
const rule = (n) => ({ kind: 'rule', id: `rule-${n}`, direction: 'horizontal',
  thickness: pt(0.5), color: col('vermilion'),
  placement: { anchor: { to: 'container', edge: 'top-left' },
    offset: { y: pt(LEAD + n * PITCH) }, size: { width: 'fill' } } });
const title = (id, content, x, family, weight) => ({ kind: 'text', id, content,
  fontFamily: family, fontWeight: weight, fontSize: pt(TITLE), lineHeight: PITCH / TITLE,
  color: col('ink'), align: 'left', // the head of the column
  placement: { anchor: { to: 'container', edge: 'top-left' },
    offset: { x: pt(x), y: pt(LEAD) } } }); // a line of PITCH, centred between two rules
const opener = {
  enabled: true,
  minHeight: cols(5), // a blank column, the two title columns, a blank one: 5 × 19 pt
  slot: { elements: [
    rule(0), rule(1), rule(2),
    title('number', '{number}', 2 * BODY, SONG, 700), // 第一回, two characters down
    // The couplet's two halves, broken by the \\ in the heading, start level with each other.
    title('couplet', '{titleText}', 2 * BODY + 4 * TITLE, KAI, 400),
  ] },
};

Sur une page verticale, une maquette de titre se dessine dans le cadre du flux : x descend la colonne à partir de la tête du bloc de texte et y traverse la page à partir de son bord droit. Un filet 'horizontal' est donc une ligne verticale sur la feuille, aussi longue que la colonne. Trois filets de 0,5 pt encadrent deux colonnes de titre espacées de 28,5 pt, 1,5 fois le pas des colonnes du texte. Chaque colonne est une ligne de 28,5 pt de haut dont les caractères se tiennent au milieu, à mi-chemin entre deux filets. {number} imprime 第一回 en Noto Serif TC Bold deux caractères plus bas ; {titleText} imprime le distique en LXGW WenKai TC, un caractère après. Le titre s’écrit # 宴桃園豪傑三結義 \\ 斬黃巾英雄首立功, et le saut forcé fait commencer la seconde moitié dans la colonne suivante, à la hauteur de la première. minHeight réserve cinq colonnes de 19 pt, si bien que le texte commence sur sa grille (Emplacements et maquette avancée). Là où le titre se lit sur une seule ligne, dans les titres courants et les signets du PDF, le saut devient une espace idéographique.

#3 · Placer les titres courants et les folios le long de la marge extérieure

script.js · lignes 80–89dans le code complet
const foreEdge = (id, content, parity, edge, y, pages) => ({
  kind: 'text', id, content, parity, pages, writingMode: 'vertical-rl',
  fontFamily: HEI, fontSize: pt(8.5), color: col('muted'), overflow: 'clip',
  placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } },
});
const header = { elements: [
  foreEdge('book', '{title}', 'even', 'top', 4, 'body'), // 三國演義 on the right-hand page
  foreEdge('chapter', '{chapterNumber} {chapterTitle}', 'odd', 'top', 4, 'body'),
  foreEdge('folio', '{pageNumber}', 'all', 'bottom', -5, 'all'), // on openers too
] };

Les livres verticaux de Taïwan placent souvent le titre courant et le folio le long de la marge extérieure. writingMode: 'vertical-rl' compose un élément d’en-tête de haut en bas sur la feuille, et anchor.to: 'outer' le place dans la marge extérieure, la gauche d’une page impaire et la droite d’une page paire quand le livre est relié à droite (Éléments en texte vertical). Les décalages sont en cadratins du corps propre de l’élément, 8,5 pt, environ 80 % du corps du texte : le titre courant commence quatre caractères sous la tête du bloc de texte et le folio se termine cinq caractères au-dessus de son pied. parity met 三國演義 sur les pages de droite et le chapitre sur les pages de gauche. pages: 'body' tient les deux titres courants à l’écart de la page d’ouverture, qui garde son folio.

#4 · Placer la planche avant le chapitre

script.js · lignes 93–156dans le code complet
const none = { elements: [] };
// A point of a page design in the flow frame: mm down the column, mm leftward across the
// page from the right edge of the type area.
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: mm(down), y: mm(across) } });
// The title slip (題簽), 96 × 22 mm: its axis, 55 mm in from the right of the type area, is
// the axis of the page, and of the imprint under it.
const SLIP = { down: 10, across: 44, long: 96, wide: 22 };
const AXIS = SLIP.across + SLIP.wide / 2;
const slip = (id, inset, thickness) => ({ kind: 'box', id,
  placement: { ...at(SLIP.down + inset, SLIP.across + inset),
    size: { width: mm(SLIP.long - 2 * inset), height: mm(SLIP.wide - 2 * inset) } },
  style: { borderColor: col('vermilion'), borderWidth: pt(thickness) } });
const RUN = (4 * 40 + 3 * 10) * PT; // 三國演義 down the slip: four 40 pt characters, 3 gaps
const titlePage = {
  id: 'title', numbered: false, toc: false, span: 'page', header: none,
  breakBefore: { enabled: true, parity: 'any' },
  footer: { elements: [{ kind: 'text', id: 'imprint', content: '{attr.colophon}',
    fontFamily: ROMAN, fontSize: pt(6.5), lineHeight: 1.4, color: col('muted'),
    // In the edition's language, set across and centred on the axis: the box runs from the
    // left of the type area (16 columns) as far past the axis. Each \n in the attribute
    // starts a line, and *…* sets the book's title in italic.
    inlineMarks: true, overflow: 'wrap', align: 'center',
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(4) },
      size: { width: mm(2 * (16 * LEAD * PT - AXIS)) } } }] },
  advancedDesign: { enabled: true, minHeight: cols(16), slot: { elements: [
    slip('slip', 0, 1.4), slip('slip-in', 1.2, 0.4), // a heavy and a light vermilion rule
    { kind: 'text', id: 'book', content: '{titleText}', fontFamily: SONG, fontWeight: 700,
      fontSize: pt(40), lineHeight: 1, letterSpacing: pt(10), color: col('ink'),
      placement: at(SLIP.down + (SLIP.long - RUN) / 2, AXIS - 20 * PT) }, // 40 pt line on the axis
    { kind: 'text', id: 'author', content: '{author} 著', fontFamily: KAI, fontSize: pt(12),
      color: col('ink'), placement: at(52, 72) },
    { kind: 'text', id: 'editor', content: '{attr.editor}', fontFamily: KAI, fontSize: pt(12),
      color: col('ink'), placement: at(52, 80) },
  ] } },
};
// The woodcut, 138 mm tall in a double frame (四周雙邊), hangs from the top right corner of
// the type area, and its caption runs down the column on its left. In the flow frame a
// box's width runs down the page: the picture's width is its height on the sheet, and it
// stands upright in the canvas, the HTML and the PDF.
const PLATE = { right: 8.3, top: 5.1, h: 138, w: (138 * 806) / 1427 }; // mm
const frame = (id, inset, thickness) => ({ kind: 'box', id,
  placement: { ...at(PLATE.top - inset, PLATE.right - inset),
    size: { width: mm(PLATE.h + 2 * inset), height: mm(PLATE.w + 2 * inset) } },
  style: { borderColor: col('ink'), borderWidth: pt(thickness) } });
const plate = {
  id: 'plate', numbered: false, toc: false, span: 'page', header: none, footer: none,
  breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: cols(16), slot: { elements: [
    { kind: 'image', id: 'woodcut', resourceId: 'peach-garden',
      placement: { ...at(PLATE.top, PLATE.right), size: { width: mm(PLATE.h), height: 'auto' } } },
    frame('inner', 1.6, 0.4), frame('outer', 2.8, 1.4),
    { kind: 'text', id: 'caption', content: '{titleText}', fontFamily: HEI, fontSize: pt(9),
      color: col('ink'), placement: at(PLATE.top - 2.8, PLATE.right + PLATE.w + 7) },
    { kind: 'text', id: 'note', content: '{attr.note}', fontFamily: KAI, fontSize: pt(8),
      color: col('muted'), placement: at(PLATE.top - 2.8, PLATE.right + PLATE.w + 12) },
  ] } },
};
const resources = [{
  id: 'peach-garden', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'peach-garden.jpg', format: 'jpeg', width: 806, height: 1427 },
  caption: '桃園結義',
  altText: '桃園結義圖:劉備、關羽、張飛立於祭桌前,桌上香爐燭臺與三杯酒,旁有烏牛白馬。',
}];

La page de titre et la planche sont des titres de niveau 1 avec leurs propres styles, # 三國演義 {style="title" …} et # 桃園結義 {style="plate" …}. numbered: false les sort du décompte, si bien que le chapitre reste 第一回, et leur propre header, vide, remplace les titres courants en marge. Le breakBefore du chapitre demande une page impaire, la page 3, dont la double page porte la planche à droite : le lecteur voit l’image avant le texte, comme dans les éditions illustrées (繡像) (Styles de titre). Les folios laissent aussi ces deux pages de côté. Un éditeur chinois numérote le texte (正文) à partir de sa première page : une ligne :::numbering{startAt=1} juste avant le titre du chapitre fait donc repartir le décompte à 一 en page 3 (:::numbering) ; la page de titre et la planche comptent toujours comme feuillets pour la parité, et la visionneuse et le PDF les étiquettent malgré tout 一 et 二 (une variante ci-dessous leur donne des étiquettes à part).

三國演義 est centré le long de son bandeau : RUN est la longueur de quatre caractères de 40 pt et des trois espaces de 10 pt qui les séparent, sans l’approche qui suit le dernier. Le pied de page de la page de titre compose la mention d’édition en pied, dans la langue de l’édition, à partir de l’attribut colophon du titre. Noto Serif TC n’a pas d’italique : la mention d’édition est donc composée en Source Serif 4. inlineMarks: true lit les astérisques autour du titre du livre, et chaque \n de l’attribut commence une ligne, si bien que les lignes se coupent là où le sens le demande (Éléments de texte). La boîte est deux fois plus large que la distance entre la gauche du bloc de texte et l’axe du bandeau, ce qui place le centre de chaque ligne sous 三國演義. La maquette de la planche suspend la gravure et son double cadre au coin supérieur droit du bloc de texte et compose la légende sur deux colonnes à sa gauche. Dans le cadre du flux, la largeur d’une boîte descend la page : la width de l’image est donc sa hauteur sur la feuille ; elle reste debout dans le canvas, le HTML et le PDF, et le PDF balisé la lit comme une figure avec son texte alternatif.

#5 · Charger chaque voix avec les caractères qu’elle compose

script.js · lignes 265–276dans le code complet
// Fontsource cuts a Chinese face into about a hundred files; loadCjkFonts fetches those
// that hold the characters it is given (gotcha: cjk-fonts-slices). vertical: true loads the
// face again with its vertical punctuation forms, for the canvas.
const heads = markdown.match(/^# .*$/gm).join('').replace(/\{[^}]*\}/g, ''); // no attributes
const attr = (key) => markdown.match(new RegExp(`${key}="([^"]*)"`))?.[1] ?? '';
const verse = markdown.match(/:::paragraphs\{style="verse"\}[\s\S]*?\n:::/g).join('');
await loadFonts(FONTS, markdown); // the Latin files: the imprint
await loadCjkFonts({ [SONG]: ['400'] }, markdown, { vertical: true });
await loadCjkFonts({ [SONG]: ['700'] }, '三國演義第一回'); // no punctuation: no vertical twin
await loadCjkFonts({ [KAI]: ['400'] }, `${heads}${verse}${attr('editor')}${attr('note')}羅貫中著`,
  { vertical: true });
await loadCjkFonts({ [HEI]: ['400'] }, `${heads}第回一二三四五六七八九十`, { vertical: true });

Le livre a trois voix : Noto Serif TC (宋) pour le texte, LXGW WenKai TC (楷) pour le distique et les vers, Noto Sans TC (黑) pour la marge extérieure. Fontsource sert chaque graisse d’une police chinoise sous la forme d’une centaine de fichiers, chacun couvrant une plage de caractères, et loadCjkFonts télécharge les fichiers qui contiennent le texte qu’on lui donne (Polices chinoises, japonaises et coréennes). La police du texte reçoit tout le chapitre et télécharge 67 fichiers ; la Kai reçoit les titres et les vers et en télécharge 25, la Hei 15 et le gras 4. Le gras compose sept caractères et aucune ponctuation : il ne charge donc aucune forme verticale. Le Source Serif 4 de la mention d’édition arrive par loadFonts comme n’importe quelle police latine, romain et italique. renderToPdf prend cjkPdfProvider, qui incorpore les mêmes fichiers en sous-ensembles, et imageBytes pour la gravure.

La recette complète

Sandbox
// ═══ Postext Cookbook · Nº 075 · A vertical Chinese novel, bound on the right ════════
// https://postext.dev/en/cookbook/vertical-novel-right-bound
// Code: MIT · Text: 三國演義 ch. 1, zh.wikisource (CC BY-SA 4.0) · Plate: woodcut, 1592 (PD)
// Fonts: Noto Serif TC, LXGW WenKai TC, Noto Sans TC, Source Serif 4 (OFL) · Needs postext ≥ 1.25.0
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage, loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the frame; the novel is Chinese in both editions
const RECIPE = 'vertical-novel-right-bound';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: black ink, one vermilion, the paper the plate was toned to
const palette = {
  ink: '#2a221d', // the text, and the woodcut's lines
  vermilion: '#a3301f', // 朱: the ruled columns of the opener, the title slip
  muted: '#6f655c', // fore-edge heads, folios, the imprint
  paper: '#fbf8f1', // the page; the plate's paper was toned to this value
};
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: 'defaults', value: { hex: palette.vermilion, model: 'hex' } },
];
// #endregion
const [SONG, KAI, HEI] = ['Noto Serif TC', 'LXGW WenKai TC', 'Noto Sans TC']; // 宋, 楷, 黑
const ROMAN = 'Source Serif 4'; // the imprint: Noto Serif TC has no italic
const [BODY, LEAD] = [10.5, 19]; // pt: 五號 text on a column pitch of 1.8 em
const cols = (n) => pt(n * LEAD); // n columns across the page
const PT = 25.4 / 72; // mm in a point

// #region answer: vertical text on a 25開 page, bound on the right, 40 characters × 16 columns
// 'vertical-rl' turns the flow a quarter turn: lines run down, columns from right to left,
// and page.binding 'auto' becomes 'right', so page 1 is a left-hand page and the spreads
// read [3 | 2]. The grid sets the type area in characters; the margins are minimums it
// grows. zh-Hant resolves to Taiwan's rules: every mark full width and centred in its cell.
const page = {
  sizePreset: 'custom', width: mm(148), height: mm(210), dpi: 150,
  backgroundColor: col('paper'),
  // 天頭 above 地腳; left is the spine side (the right edge of a left-hand page).
  margins: { top: mm(32), bottom: mm(26), left: mm(16), right: mm(22), mirror: true },
  pageNumbering: { format: 'trad-chinese-informal' }, // folios 一, 二 … 九, from the chapter
};
const layout = { layoutType: 'single', writingMode: 'vertical-rl' };
const cjk = { grid: { enabled: true, charsPerLine: 40, linesPerPage: 16 } };
const chapter = {
  level: 1, numberingTemplate: '第{1:一}回', numberSeparator: ' ', // 第一回 宴桃園…
  breakBefore: { enabled: true, parity: 'odd' }, // a recto, with no blank page forced
  marginBottom: pt(0),
};
// #endregion

// #region opener: 第一回 and the couplet in two ruled columns, a quotation of the woodblock
// In the flow frame x runs down the column and y across the page, right to left, so a
// 'horizontal' rule is a vertical line on the sheet: three of them rule the title columns.
const [TITLE, PITCH] = [13.5, 1.5 * LEAD]; // pt: the couplet's size and its column pitch
const rule = (n) => ({ kind: 'rule', id: `rule-${n}`, direction: 'horizontal',
  thickness: pt(0.5), color: col('vermilion'),
  placement: { anchor: { to: 'container', edge: 'top-left' },
    offset: { y: pt(LEAD + n * PITCH) }, size: { width: 'fill' } } });
const title = (id, content, x, family, weight) => ({ kind: 'text', id, content,
  fontFamily: family, fontWeight: weight, fontSize: pt(TITLE), lineHeight: PITCH / TITLE,
  color: col('ink'), align: 'left', // the head of the column
  placement: { anchor: { to: 'container', edge: 'top-left' },
    offset: { x: pt(x), y: pt(LEAD) } } }); // a line of PITCH, centred between two rules
const opener = {
  enabled: true,
  minHeight: cols(5), // a blank column, the two title columns, a blank one: 5 × 19 pt
  slot: { elements: [
    rule(0), rule(1), rule(2),
    title('number', '{number}', 2 * BODY, SONG, 700), // 第一回, two characters down
    // The couplet's two halves, broken by the \\ in the heading, start level with each other.
    title('couplet', '{titleText}', 2 * BODY + 4 * TITLE, KAI, 400),
  ] },
};
// #endregion

// #region fore-edge: the running head and the folio down the outer margin, in 黑
const foreEdge = (id, content, parity, edge, y, pages) => ({
  kind: 'text', id, content, parity, pages, writingMode: 'vertical-rl',
  fontFamily: HEI, fontSize: pt(8.5), color: col('muted'), overflow: 'clip',
  placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } },
});
const header = { elements: [
  foreEdge('book', '{title}', 'even', 'top', 4, 'body'), // 三國演義 on the right-hand page
  foreEdge('chapter', '{chapterNumber} {chapterTitle}', 'odd', 'top', 4, 'body'),
  foreEdge('folio', '{pageNumber}', 'all', 'bottom', -5, 'all'), // on openers too
] };
// #endregion

// #region front: a title page and the plate facing the opener, no heads or folios
const none = { elements: [] };
// A point of a page design in the flow frame: mm down the column, mm leftward across the
// page from the right edge of the type area.
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: mm(down), y: mm(across) } });
// The title slip (題簽), 96 × 22 mm: its axis, 55 mm in from the right of the type area, is
// the axis of the page, and of the imprint under it.
const SLIP = { down: 10, across: 44, long: 96, wide: 22 };
const AXIS = SLIP.across + SLIP.wide / 2;
const slip = (id, inset, thickness) => ({ kind: 'box', id,
  placement: { ...at(SLIP.down + inset, SLIP.across + inset),
    size: { width: mm(SLIP.long - 2 * inset), height: mm(SLIP.wide - 2 * inset) } },
  style: { borderColor: col('vermilion'), borderWidth: pt(thickness) } });
const RUN = (4 * 40 + 3 * 10) * PT; // 三國演義 down the slip: four 40 pt characters, 3 gaps
const titlePage = {
  id: 'title', numbered: false, toc: false, span: 'page', header: none,
  breakBefore: { enabled: true, parity: 'any' },
  footer: { elements: [{ kind: 'text', id: 'imprint', content: '{attr.colophon}',
    fontFamily: ROMAN, fontSize: pt(6.5), lineHeight: 1.4, color: col('muted'),
    // In the edition's language, set across and centred on the axis: the box runs from the
    // left of the type area (16 columns) as far past the axis. Each \n in the attribute
    // starts a line, and *…* sets the book's title in italic.
    inlineMarks: true, overflow: 'wrap', align: 'center',
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(4) },
      size: { width: mm(2 * (16 * LEAD * PT - AXIS)) } } }] },
  advancedDesign: { enabled: true, minHeight: cols(16), slot: { elements: [
    slip('slip', 0, 1.4), slip('slip-in', 1.2, 0.4), // a heavy and a light vermilion rule
    { kind: 'text', id: 'book', content: '{titleText}', fontFamily: SONG, fontWeight: 700,
      fontSize: pt(40), lineHeight: 1, letterSpacing: pt(10), color: col('ink'),
      placement: at(SLIP.down + (SLIP.long - RUN) / 2, AXIS - 20 * PT) }, // 40 pt line on the axis
    { kind: 'text', id: 'author', content: '{author} 著', fontFamily: KAI, fontSize: pt(12),
      color: col('ink'), placement: at(52, 72) },
    { kind: 'text', id: 'editor', content: '{attr.editor}', fontFamily: KAI, fontSize: pt(12),
      color: col('ink'), placement: at(52, 80) },
  ] } },
};
// The woodcut, 138 mm tall in a double frame (四周雙邊), hangs from the top right corner of
// the type area, and its caption runs down the column on its left. In the flow frame a
// box's width runs down the page: the picture's width is its height on the sheet, and it
// stands upright in the canvas, the HTML and the PDF.
const PLATE = { right: 8.3, top: 5.1, h: 138, w: (138 * 806) / 1427 }; // mm
const frame = (id, inset, thickness) => ({ kind: 'box', id,
  placement: { ...at(PLATE.top - inset, PLATE.right - inset),
    size: { width: mm(PLATE.h + 2 * inset), height: mm(PLATE.w + 2 * inset) } },
  style: { borderColor: col('ink'), borderWidth: pt(thickness) } });
const plate = {
  id: 'plate', numbered: false, toc: false, span: 'page', header: none, footer: none,
  breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: cols(16), slot: { elements: [
    { kind: 'image', id: 'woodcut', resourceId: 'peach-garden',
      placement: { ...at(PLATE.top, PLATE.right), size: { width: mm(PLATE.h), height: 'auto' } } },
    frame('inner', 1.6, 0.4), frame('outer', 2.8, 1.4),
    { kind: 'text', id: 'caption', content: '{titleText}', fontFamily: HEI, fontSize: pt(9),
      color: col('ink'), placement: at(PLATE.top - 2.8, PLATE.right + PLATE.w + 7) },
    { kind: 'text', id: 'note', content: '{attr.note}', fontFamily: KAI, fontSize: pt(8),
      color: col('muted'), placement: at(PLATE.top - 2.8, PLATE.right + PLATE.w + 12) },
  ] } },
};
const resources = [{
  id: 'peach-garden', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'peach-garden.jpg', format: 'jpeg', width: 806, height: 1427 },
  caption: '桃園結義',
  altText: '桃園結義圖:劉備、關羽、張飛立於祭桌前,桌上香爐燭臺與三杯酒,旁有烏牛白馬。',
}];
// #endregion

const config = () => ({
  locale: 'zh-Hant', // written out, never LANG (gotcha: cjk-locale-tag)
  colorPalette,
  page,
  layout,
  cjk,
  bodyText: {
    fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true, // every paragraph
  },
  headings: { fontFamily: SONG, fontWeight: 700, color: col('ink'),
    levels: [{ ...chapter, advancedDesign: opener }] },
  headingStyles: [titlePage, plate],
  paragraphStyles: [
    // 詞 and 詩: one line to a column, four characters down, in 楷.
    { id: 'verse', fontFamily: KAI, textAlign: 'left', indent: em(4), firstLineIndent: em(0),
      marginTop: pt(0), marginBottom: pt(0) },
  ],
  header,
  footer: none,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Exemple Markdown · 69 lignes · content.en.mdtitle: "三國演義" author: "羅貫中" --- # 三國演義 {style="title" editor="毛宗崗 評" colophon="Luo Guanzhong, *Romance of the Three Kingdoms*, chapter 1,\nin the recension of Mao Zonggang (about 1679).\nText: Chinese Wikisource, revision 2583915, CC BY-SA 4.0; one character corrected.\nPlate: woodcut from the edition of Yu Xiangdou, 1592.\nSet in Noto Serif TC, LXGW WenKai TC, Noto Sans TC and Source Serif 4 (SIL OFL)."} # 桃園結義 {style="plate" note="明萬曆二十年余象斗刊《三國志傳評林》插圖"} :::numbering{startAt=1} # 宴桃園豪傑三結義 \\ 斬黃巾英雄首立功 詞曰: :::paragraphs{style="verse"} 滾滾長江東逝水,浪花淘盡英雄。 是非成敗轉頭空:青山依舊在,幾度夕陽紅。 白髮漁樵江渚上,慣看秋月春風。 一壺濁酒喜相逢:古今多少事,都付笑談中。 ::: 話說天下大勢,分久必合,合久必分。周末七國分爭,併入於秦。及秦滅之後,楚、漢分爭,又併入於漢。漢朝自高祖斬白蛇而起義,一統天下。後來光武中興,傳至獻帝,遂分為三國。推其致亂之由,殆始於桓、靈二帝。桓帝禁錮善類,崇信宦官。及桓帝崩,靈帝即位,大將軍竇武、太傅陳蕃,共相輔佐。時有宦官曹節等弄權,竇武、陳蕃謀誅之,作事不密,反為所害,中涓自此愈橫。 建寧二年四月望日,帝御溫德殿。方陞座,殿角狂風驟起,只見一條大青蛇,從梁上飛將下來,蟠於椅上。帝驚倒,左右急救入宮,百官俱奔避。須臾,蛇不見了。忽然大雷大雨,加以冰雹,落到半夜方止,壞卻房屋無數。建寧四年二月,洛陽地震;又海水泛濫,沿海居民,盡被大浪捲入海中。光和元年,雌雞化雄。六月朔,黑氣十餘丈,飛入溫德殿中。秋七月,有虹現於玉堂,五原山岸,盡皆崩裂。種種不祥,非止一端。帝下詔問群臣以災異之由,議郎蔡邕上疏,以為霓墮雞化,乃婦寺干政之所致,言頗切直。帝覽奏嘆息,因起更衣。曹節在後竊視,悉宣告左右,遂以他事陷邕於罪,放歸田里。後張讓、趙忠、封諝、段圭、曹節、侯覽、蹇碩、程曠、夏惲、郭勝十人朋比為奸,號為「十常侍」。帝尊信張讓,呼為「阿父」。朝政日非,以致天下人心思亂,盜賊蜂起。 時鉅鹿郡有兄弟三人:一名張角,一名張寶,一名張梁。那張角本是個不第秀才,因入山採藥,遇一老人,碧眼童顏,手執藜杖,喚角至一洞中,以天書三卷授之,曰:「此名太平要術。汝得之,當代天宣化,普救世人。若萌異心,必獲惡報。」角拜問姓名。老人曰:「吾乃南華老仙也。」言訖,化陣清風而去。角得此書,曉夜功習,能呼風喚雨,號為「太平道人」。中平元年正月內,疫氣流行,張角散施符水,為人治病,自稱「大賢良師」。角有徒弟五百餘人,雲游四方,皆能書符念咒。次後徒眾日多,角乃立三十六方,大方萬餘人,小方六七千,各立渠帥,稱為將軍;訛言:「蒼天已死,黃天當立;歲在甲子,天下大吉。」令人各以白土,書「甲子」二字於家中大門上。青、幽、徐、冀、荊、揚、兗、豫八州之人,家家侍奉大賢良師張角名字。角遣其黨馬元義,暗齎金帛,結交中涓封胥,以為內應。角與二弟商議曰:「至難得者,民心也。今民心已順,若不乘勢取天下,誠為可惜。」遂一面私造黃旗,約期舉事;一面使弟子唐周,持書報封諝。唐周乃徑赴省中告變。帝召大將軍何進調兵擒馬元義,斬之;次收封諝等一干人下獄。張角聞知事露,星夜舉兵,自稱「天公將軍」,張寶稱「地公將軍」,張梁稱「人公將軍」;申言於眾曰:「今漢運將終,大聖人出。汝等皆宜順天從正,以樂太平。」四方百姓,裹黃巾從張角反者四五十萬。賊勢浩大,官軍望風而靡。何進奏帝火速降詔,令各處備禦,討賊立功;一面遣中郎將盧植、皇甫嵩、朱雋,各引精兵,分三路討之。 且說張角一軍,前犯幽州界分。幽州太守劉焉,乃江夏竟陵人氏,漢魯恭王之後也;當時聞得賊兵將至,召校尉鄒靖計議。靖曰:「賊兵眾,我兵寡,明公宜作速招軍應敵。」劉焉然其說,隨即出榜招募義兵。榜文行到涿縣,引出涿縣中一個英雄。那人不甚好讀書;性寬和,寡言語,喜怒不言於色;素有大志,專好結交天下豪傑;生得身長七尺五寸,兩耳垂肩,雙手過膝,目能自顧其耳,面如冠玉,唇如塗脂;中山靖王劉勝之後,漢景帝閣下玄孫:姓劉,名備,字玄德。昔劉勝之子劉貞,漢武時封涿鹿亭侯,後坐酌金失侯,因此遺這一支在涿縣。玄德祖劉雄,父劉弘。弘曾舉孝廉,亦嘗作吏,早喪。玄德孤幼,事母至孝;家貧,販屨織席為業。家住本縣樓桑村。其家之東南,有一大桑樹,高五丈餘,遙望之,童童如車蓋。相者云:「此家必出貴人。」玄德幼時,與鄉中小兒戲於樹下,曰:「我為天子,當乘此車蓋。」叔父劉元起奇其言,曰:「此兒非常人也!」因見玄德家貧,常資給之。年十五歲,母使游學,嘗師事鄭玄、盧植,與公孫瓚等為友。及劉焉發榜招軍時,玄德年已二十八歲矣。 當日見了榜文,慨然長嘆。隨後一人厲聲言曰:「大丈夫不與國家出力,何故長嘆?」玄德回視其人:身長八尺,豹頭環眼,燕頷虎鬚,聲若巨雷,勢如奔馬。玄德見他形貌異常,問其姓名。其人曰:「某姓張,名飛,字翼德。世居涿郡,頗有莊田,賣酒屠豬,專好結交天下豪傑。適纔見公看榜而嘆,故此相問。」玄德曰:「我本漢室宗親,姓劉,名備。今聞黃巾倡亂,有志欲破賊安民;恨力不能,故長嘆耳。」飛曰:「吾頗有資財,當招募鄉勇,與公同舉大事,如何?」玄德甚喜,遂與同入村店中飲酒。正飲間,見一大漢,推著一輛車子,到店門首歇了;入店坐下,便喚酒保:「快斟酒來吃,我待趕入城去投軍。」玄德看其人:身長九尺,髯長二尺;面如重棗,唇如塗脂;丹鳳眼,臥蠶眉:相貌堂堂,威風凜凜。玄德就邀他同坐,叩其姓名。其人曰:「吾姓關,名羽,字長生,後改雲長,河東解良人也。因本處勢豪,倚勢凌人,被吾殺了;逃難江湖,五六年矣。今聞此處招軍破賊,特來應募。」玄德遂以己志告之。雲長大喜。同到張飛莊上,共議大事。 飛曰:「吾莊後有一桃園,花開正盛;明日當於園中祭告天地,我三人結為兄弟,協力同心,然後可圖大事。」玄德、雲長齊聲應曰:「如此甚好。」次日,於桃園中,備下烏牛白馬祭禮等項,三人焚香再拜而說誓曰:「念劉備、關羽、張飛,雖然異姓,既結為兄弟,則同心協力,救困扶危;上報國家,下安黎庶;不求同年同月同日生,只願同年同月同日死。皇天后土,實鑒此心。背義忘恩,天人共戮!」誓畢,拜玄德為兄,關羽次之,張飛為弟。祭罷天地,復宰牛設酒,聚鄉中勇士,得三百餘人,就桃園中痛飲一醉。來日收拾軍器,但恨無馬匹可乘。正思慮間,人報有兩個客人,引一夥伴儅,趕一群馬,投莊上來。玄德曰:「此天佑我也!」三人出莊迎接。原來二客乃中山大商:一名張世平,一名蘇雙,每年往北販馬,近因寇發而回。玄德請二人到莊,置酒管待,訴說欲討賊安民之意。二客大喜,願將良馬五十匹相送;又贈金銀五百兩,鑌鐵一千斤,以資器用。玄德謝別二客,便命良匠打造雙股劍。雲長造青龍偃月刀,又名「冷艷鋸」,重八十二斤。張飛造丈八點鋼矛。各置全身鎧甲。共聚鄉勇五百餘人,來見鄒靖。鄒靖引見太守劉焉。三人參見畢,各通姓名。玄德說起宗派,劉焉大喜,遂認玄德為侄。 不數日,人報黃巾賊將程遠志統兵五萬來犯涿郡。劉焉令鄒靖引玄德等三人,統兵五百,前去破敵。玄德等欣然領軍前進,直至大興山下,與賊相見。賊眾皆披髮,以黃巾抹額。當下兩軍相對,玄德出馬,左有雲長,右有翼德,揚鞭大罵:「反國逆賊,何不早降!」程遠志大怒,遣副將鄧茂出戰。張飛挺丈八蛇矛直出,手起處,刺中鄧茂心窩,翻身落馬。程遠志見折了鄧茂,拍馬舞刀,直取張飛。雲長舞動大刀,縱馬飛迎。程遠志見了,早吃一驚,措手不及,被雲長刀起處,揮為兩段。後人有詩讚二人曰: :::paragraphs{style="verse"} 英雄露穎在今朝,一試矛兮一試刀。 初出便將威力展,三分好把姓名標。 ::: 眾賊見程遠志被斬,皆倒戈而走。玄德揮軍追趕,投降者不計其數,大勝而回。劉焉親自迎接,賞勞軍士。次日,接得青州太守龔景牒文,言黃巾賊圍城將陷,乞賜救援。劉焉與玄德商議。玄德曰:「備願往救之。」劉焉令鄒靖將兵五千,同玄德、關、張,投青州來。賊眾見救兵至,分兵混戰。玄德兵寡不勝,退三十里下寨。玄德謂關、張曰:「賊眾我寡;必出奇兵,方可取勝。」乃分關公引一千軍伏山左,張飛引一千軍伏山右,鳴金為號,齊出接應。次日,玄德與鄒靖引軍鼓噪而進。賊眾迎戰,玄德引軍便退。賊眾乘勢追趕,方過山嶺,玄德軍中一齊鳴金,左右兩軍齊出,玄德麾軍回身復殺。三路夾攻,賊眾大潰。直趕至青州城下,太守龔景亦率民兵出城助戰。賊勢大敗,剿戮極多,遂解青州之圍。後人有詩讚玄德曰: :::paragraphs{style="verse"} 運籌決算有神功,二虎還須遜一龍。 初出便能垂偉績,自應分鼎在孤窮。 ::: 龔景犒軍畢,鄒靖欲回。玄德曰:「近聞中郎將盧植與賊首張角戰於廣宗,備昔曾師事盧植,欲往助之。」於是鄒靖引軍自回,玄德與關、張引本部五百人投廣宗來。至盧植軍中,入帳施禮,具道來意。盧植大喜,留在帳前聽調。 時張角賊眾十五萬,植兵五萬,相拒於廣宗,未見勝負。植謂玄德曰:「我今圍賊在此,賊弟張梁、張寶在潁川,與皇甫嵩、朱雋對壘。汝可引本部人馬,我更助汝一千官軍,前去潁川打探消息,約期剿捕。」玄德領命,引軍星夜投潁川來。時皇甫嵩、朱儁領軍拒賊,賊戰不利,退入長社,依草結營。嵩與儁計曰:「賊依草結營,當用火攻之。」遂令軍士,每人束草一把,暗地埋伏。其夜大風忽起。二更以後,一齊縱火,嵩與儁各引兵攻擊賊寨,火焰張天,賊眾驚慌,馬不及鞍,人不及甲,四散奔走。 殺到天明,張梁、張寶引敗殘軍士,奪路而走。忽見一彪軍馬,盡打紅旗,當頭來到,截住去路。為首閃出一將:身長七尺,細眼長髯;官拜騎都尉;沛國譙郡人也,姓曹,名操,字孟德。操父曹嵩,本姓夏侯氏;因為中常侍曹騰之養子,故冒姓曹。曹嵩生操,小字阿瞞,一名吉利。操幼時,好游獵,喜歌舞;有權謀,多機變。操有叔父,見操游蕩無度,嘗怒之,言於曹嵩。嵩責操。操忽心生一計:見叔父來,詐倒於地,作中風之狀。叔父驚告嵩,嵩急視之,操故無恙。嵩曰:「叔言汝中風,今已愈乎?」操曰:「兒自來無此病;因失愛於叔父,故見罔耳。」嵩信其言。後叔父但言操過,嵩並不聽。因此,操得恣意放蕩。時人有橋玄者,謂操曰:「天下將亂,非命世之才不能濟。能安之者,其在君乎?」南陽何顒見操,言:「漢室將亡,安天下者,必此人也。」汝南許劭,有知人之名。操往見之,問曰:「我何如人?」劭不答。又問,劭曰:「子治世之能臣,亂世之奸雄也。」操聞言大喜。年二十,舉孝廉,為郎,除洛陽北都尉。初到任,即設五色棒十餘條於縣之四門,有犯禁者,不避富豪,皆責之。中常侍蹇碩之叔,提刀夜行,操巡夜拿住,就棒責之。由是,內外莫敢犯者,威名頗震。後為頓丘令。因黃巾起,拜為騎都尉,引馬步軍五千,前來潁川助戰。正值張梁、張寶敗走,曹操攔住,大殺一陣,斬首萬餘級,奪得旗旌、金鼓、馬匹極多。張梁、張寶死戰得脫。操見過皇甫嵩、朱儁,隨即引兵追襲張梁、張寶去了。 卻說玄德引關、張來潁川,聽得喊殺之聲,又望見火光燭天,急引兵來時,賊已敗散。玄德見皇甫嵩、朱儁,具道盧植之意。嵩曰:「張梁、張寶勢窮力乏,必投廣宗去依張角。玄德可即星夜往助。」玄德領命,遂引兵復回。到得半路,只見一簇軍馬,護送一輛檻車;車中之囚,乃盧植也。玄德大驚,滾鞍下馬,問其緣故。植曰:「我圍張角,將次可破;因角用妖術,未能即勝。朝廷差黃門左豐前來體探,問我索取賄賂。我答曰:『軍糧尚缺,安有餘錢奉承天使?』左豐挾恨,回奏朝廷,說我高壘不戰,惰慢軍心;因此朝廷震怒,遣中郎將董卓來代將我兵,取我回京問罪。」張飛聽罷,大怒,要斬護送軍人,以救盧植。玄德急止之曰:「朝廷自有公論,汝豈可造次?」軍士簇擁盧植去了。 關公曰:「盧中郎已被逮,別人領兵,我等去無所依,不如且回涿郡。」玄德從其言,遂引軍北行。行無二日,忽聞山後喊聲大震。玄德引關、張縱馬上高岡望之,見漢軍大敗,後面漫山塞野,黃巾蓋地而來,旗上大書「天公將軍」。玄德曰:「此張角也!可速戰。」三人飛馬引軍而出。張角正殺敗董卓,乘勢趕來,忽遇三人衝殺,角軍大亂,敗走五十餘里。三人救了董卓回寨。卓問三人現居何職。玄德曰:「白身。」卓甚輕之,不為禮。玄德出,張飛大怒曰:「我等親赴血戰,救了這廝,他卻如此無禮!若不殺他,難消我氣!」便要提刀入帳來殺董卓。正是: :::paragraphs{style="verse"} 人情勢利古猶今,誰識英雄是白身? 安得快人如翼德,盡誅世上負心人! ::: 畢竟董卓性命如何,且聽下文分解。
`; // content.<lang>.md: the same Chinese text in both // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Noto Serif TC': ['400', '700'], // 宋: the text; 700 for 第一回 and the title 'LXGW WenKai TC': ['400'], // 楷: the couplet, the 詞 and the poems, the plate's note 'Noto Sans TC': ['400'], // 黑: the fore-edge heads and folios, the plate's title 'Source Serif 4': ['400', '400i'], // the imprint, with the book's title in italic }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region voices: each face loads the slices of the characters it sets // Fontsource cuts a Chinese face into about a hundred files; loadCjkFonts fetches those // that hold the characters it is given (gotcha: cjk-fonts-slices). vertical: true loads the // face again with its vertical punctuation forms, for the canvas. const heads = markdown.match(/^# .*$/gm).join('').replace(/\{[^}]*\}/g, ''); // no attributes const attr = (key) => markdown.match(new RegExp(`${key}="([^"]*)"`))?.[1] ?? ''; const verse = markdown.match(/:::paragraphs\{style="verse"\}[\s\S]*?\n:::/g).join(''); await loadFonts(FONTS, markdown); // the Latin files: the imprint await loadCjkFonts({ [SONG]: ['400'] }, markdown, { vertical: true }); await loadCjkFonts({ [SONG]: ['700'] }, '三國演義第一回'); // no punctuation: no vertical twin await loadCjkFonts({ [KAI]: ['400'] }, `${heads}${verse}${attr('editor')}${attr('note')}羅貫中著`, { vertical: true }); await loadCjkFonts({ [HEI]: ['400'] }, `${heads}第回一二三四五六七八九十`, { vertical: true }); // #endregion await loadImage('peach-garden.jpg', asset('peach-garden-oath-1592-v2.jpg')); const doc = await withLoadedFonts( () => buildDocument({ markdown, resources }, config()), { ...kitFonts(FONTS), text: markdown }); showBook(doc, { title: t({ en: 'A vertical Chinese novel, bound on the right', es: 'Una novela china vertical, encuadernada por la derecha' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);
Kit · core, fonts, viewer, pdf, images, cjk : identique dans toutes les recettes · 504 lignes// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · 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 · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── Kit · 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 ───────────────────────────────────────────────────────────────────────

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

Variantes

#Écrire les folios chiffre par chiffre

cjk-decimal écrit un chiffre par position. Ce chapitre se termine au folio 九, où les deux formats s’accordent ; à partir de la dixième page de texte, 十 et 十一 deviendraient 一〇 et 一一.

-  pageNumbering: { format: 'trad-chinese-informal' }, // folios 一, 二 … 九, from the chapter
+  pageNumbering: { format: 'cjk-decimal' }, // folios 一, 二 … 九, 一〇, 一一

#Étiqueter à part les pages liminaires

La page de titre et la planche n’impriment pas de folio, mais la visionneuse et le PDF les étiquettent 一 et 二, comme les deux premières pages du texte ; avec des étiquettes romaines pour les pages liminaires et les chiffres chinois déplacés sur la ligne :::numbering, le PDF compte i, ii, 一, 二…

-  pageNumbering: { format: 'trad-chinese-informal' }, // folios 一, 二 … 九, from the chapter
+  pageNumbering: { format: 'lower-roman' }, // i, ii: the title page and the plate
-:::numbering{startAt=1}
+:::numbering{format="trad-chinese-informal" startAt=1}

#Composer le chapitre à l’horizontale

L’usage du continent compose le même genre de chapitre à l’horizontale, relié à gauche, en caractères simplifiés avec la ponctuation Kaiming : Une page de roman chinois sur une grille de 28 × 28.

Erreurs fréquentes

Erreur fréquente

Balisez le document zh-Hans ou zh-Hant, pas avec LANG

Les éditions d’une recette sont en et es, mais un exemple chinois est en chinois dans les deux : `locale: LANG` le baliserait en anglais ou en espagnol, couperait ses mots latins, nommerait ses figures Figure ou Figura et donnerait au PDF la mauvaise langue. Depuis postext 1.30, la balise est aussi la langue dans laquelle le texte est mis en forme (mesuré, peint et incorporé) : sous une balise anglaise ou espagnole, une police chinoise compose ses chiffres et la ponctuation qu’elle partage avec le texte latin sous d’autres formes, et les lignes se coupent ailleurs. Écrivez la balise vous-même : 'zh-Hans' (conventions de Chine continentale : coupure des lignes GB, ponctuation Kaiming) ou 'zh-Hant' (Taïwan : ponctuation pleine chasse centrée) ; 'zh-HK' pour Hong Kong. Un 'zh' seul se lit comme du chinois simplifié de Chine continentale. Un exemple japonais prend 'ja' (piège ja-locale-tag). Coupure des lignes en chinois →

Erreur fréquente

Les polices chinoises se chargent par tranches, avec le bloc cjk

Fontsource sert une famille chinoise, japonaise ou coréenne sous la forme d’une centaine de fichiers par graisse, chacun couvrant une plage de caractères. loadFonts ne récupère que le fichier latin : à l’écran, les caractères han viennent d’une police système et sont mal mesurés, et fontsourceProvider remet ce fichier latin au PDF, qui les imprime en cases vides. Listez le bloc cjk du kit, appelez loadCjkFonts(FONTS, markdown) après loadFonts (une fois par voix, avec le texte qu’elle compose, quand le livre utilise plusieurs polices CJK) et donnez à renderToPdf fontProvider: cjkPdfProvider : les deux prennent les fichiers qui contiennent les caractères du texte. Polices chinoises, japonaises et coréennes →

Erreur fréquente

Un livre relié à droite montre ses doubles pages avec showBook

Dans un livre relié à droite (texte arabe, hébreu ou persan, chinois vertical, ou page.binding 'right'), la page 1 reste le recto, mais elle se trouve à gauche du dos, et les paires se lisent [3 | 2]. showPages présente tout livre relié à gauche ; showBook, du bloc book (le bloc cjk contient la même fonction), lit doc.binding et inverse les paires. capture.hero désigne toujours une double page dans l’ordre de lecture, [verso, recto] : [2, 3]. Livres reliés à droite →

Erreur fréquente

Les transcriptions traditionnelles contiennent des caractères simplifiés égarés

Les textes copiés depuis Wikisource ou une autre édition web d’un livre en chinois traditionnel contiennent parfois une forme simplifiée (颙 pour 顒 dans 三國演義, chapitre 1), et une police TC comme Noto Serif TC peut ne pas l’avoir. loadCjkFonts arrête le pen en indiquant le caractère dans son message ; sur une page, il s’imprimerait dans une police de substitution. Rétablissez la forme traditionnelle dans l’exemple et indiquez-le dans les crédits. Polices chinoises, japonaises et coréennes →

Crédits

Texte
Images
  • 桃園結義 (The Oath in the Peach Garden), woodcut from Yu Xiangdou’s 新刊京本校正演義全像三國志傳評林, Jianyang, 1592 (Waseda University Library); scan from Wikimedia Commons, cropped inside its frame and clear of what was left of it, cleaned and toned to the page’s ink and paper · Yu Xiangdou (publisher), anonymous block cutter · domaine public
Polices
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)
SandboxPDF