En bref
Des pages d’un premier livre de lecture pour les enfants de Hong Kong. Il montre comment imprimer la prononciation en lettres latines au-dessus de chaque caractère chinois, avec des cases pour s’exercer à écrire.
Ce que vous allez composer
Deux doubles pages de 《蒙學誦讀》, un premier livre de lecture imaginaire de Hong Kong, où les enfants lisent à voix haute le Classique des trois caractères en putonghua. Chaque page est une leçon de quatre distiques en Kai de corps 一号 (26 pt), chaque caractère sous sa syllabe pinyin. Les syllabes sont composées en Andika parce que ce caractère dessine le a et le g à un seul étage qu’impriment les manuels scolaires chinois. Un bandeau pâle porte une pastille rouge de leçon, une petite aquarelle et le titre en 初号 (42 pt) avec ses propres lectures. Sous le texte, six cases d’écriture (田字格) contiennent les caractères à recopier, et une note explique à la famille le sens des vers. Le pendant espagnol est l’abécédaire aux pastilles de syllabes, où chaque syllabe est une pastille de couleur au lieu d’une lecture au-dessus d’un caractère.
Un abécédaire s’écarte volontairement des règles du livre chinois. Un enfant lit quelques grands caractères à la fois : une ligne en contient douze à 26 pt, là où un livre en compose 25 à 40 à 10,5 pt, et l’interlignage fait un peu plus de deux fois le corps pour loger les lectures. Chaque distique est centré sur une ligne à lui, ni justifié ni en retrait. Le texte est en Kai, le caractère qui suit le pinceau, parce qu’il montre les traits qu’un enfant apprend à écrire ; un livre le composerait en Song.
Cette recette répond à
- Comment ajouter du pinyin au-dessus des caractères chinois ?
La réponse courte
// {人之初|rén zhī chū} gives each character its own syllable (mono ruby): three readings for
// three characters, split on the spaces. The reading sits in the line gap, centred on its
// character; a syllable wider than the character widens that character's box by what the
// reading needs, less the quarter of the reading's size it may lend a neighbour.
const cjk = {
// The type area in characters: 12 per line, 11 lines of 54 pt. The margins grow to centre it.
grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11 },
ruby: {
fontFamily: PINYIN, // one-storey a and g, as a Chinese primer prints them
// 9.9 pt over the text, 16 pt over the title: the widest syllables (xiāng, zhuān) still fit
// over one character, so every couplet is 8 em long and keeps to the grid.
fontSize: em(0.38),
color: col('pinyin'),
},
};
// The line pitch never changes for a reading: the gap between lines (54 − 26 = 28 pt) must
// hold it, or the build warns rubyExceedsLeading.
const text = {
fontFamily: KAI, fontSize: pt(TEXT), lineHeight: pt(LINE),
textAlign: 'center', firstLineIndent: pt(0), // one couplet to a line, centred
};
Ingrédients
- Fonctionnalités
- Ruby : lectures en pinyin et en zhuyinPolices chinoises, japonaises et coréennesGrille de caractèresLargeur de la ponctuation chinoiseTexte courantOuvertures composéesAttributs de titreImages dans les maquettes de pageTexte, filets et boîtes dans les maquettes de pageEncadrésPalette de couleurs sémantiqueTitres courants et folios
- Utilise aussi
- Coupure des lignes en chinoisSortir de la grille volontairementBandeau de chapitre sur toute la largeurStyles de paragrapheFigures et tableaux comme ressources
- Typographie
- LXGW WenKai TC, Noto Sans TC, Andika (SIL OFL 1.1)
- Ressources
brush-800.jpgjade-800.jpgshuttle-800.jpgsprout-800.jpg- The writing squares, drawn in code in the page’s palette (Postext Cookbook, CC BY 4.0)
- Lesson 1’s vignette: a seedling, a watercolour (Generated With Diffusion Models, original)
- Lesson 2’s vignette: the shuttle of a loom, a watercolour (Generated With Diffusion Models, original)
- Lesson 3’s vignette: a brush and its first stroke, a watercolour (Generated With Diffusion Models, original)
- Lesson 4’s vignette: a jade disc on a red cord, a watercolour (Generated With Diffusion Models, original)
Préparation
#1 · Une syllabe au-dessus de chaque caractère
Le code est la réponse courte ci-dessus. {人之初|rén zhī chū} donne trois lectures à trois caractères, séparées aux espaces : chaque syllabe est centrée au-dessus de son caractère, et une ligne peut se couper entre elles. Une lecture ne prend pas de place à elle. Elle se loge dans l’interligne, ici 54 − 26 = 28 pt, et un interligne plus étroit que la lecture est signalé par rubyExceedsLeading. Une syllabe plus large que son caractère, elle, prend de la place : elle élargit la boîte de ce caractère, et dans une ligne centrée les caractères suivants sortent de la grille. Les lectures sont composées au corps où les syllabes les plus larges ici, xiāng et zhuān, tiennent encore au-dessus d’un seul caractère, 0,38 em (9,9 pt) : chaque distique fait huit caractères et chaque caractère garde son carré. À 0,45 em, 性相近,習相遠 s’étirerait sur huit caractères et demi et ouvrirait des blancs autour de 相.
#2 · Le titre garde ses lectures
const BAND = 68; // mm from the top edge
// Heading designs ignore parity: a place for the drawing on each side, 14 mm from the outer
// edge, named {left="…"} on a verso and {right="…"} on a recto. A missing attribute draws nothing.
const picture = (side, x) => ({ kind: 'image', id: `picture-${side}`,
resourceId: `{attr.${side}}`, decorative: true, reserve: false,
placement: { anchor: { to: 'page', edge: `top-${side}` }, offset: { x: mm(x), y: mm(14) },
size: { width: mm(42), height: mm(42) } } });
const opener = { enabled: true, slot: { elements: [
{ kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('tint') },
placement: { anchor: { to: 'page', edge: 'top-left' },
size: { width: mm(184), height: mm(BAND) } } },
picture('left', 14), picture('right', -14),
{ kind: 'text', id: 'lesson', content: '{titleText}', fontFamily: HEI, fontSize: pt(11),
fontWeight: 700, letterSpacing: pt(2), color: col('paper'), align: 'center', overflow: 'wrap',
placement: { anchor: { to: 'container', edge: 'top' } },
box: { backgroundColor: col('red'), borderRadius: mm(3.5),
padding: { top: mm(1.2), right: mm(3.6), bottom: mm(1.2), left: mm(3.6) } } },
] } };
Le texte de maquette n’imprime pas de lectures : le titre ne peut donc pas venir de la maquette de l’ouverture. Le titre de niveau 1, 第一課, dessine le bandeau, l’image et la pastille rouge ; le titre est le titre de niveau 2 en dessous, un titre simple en 初号 que le compositeur de texte compose avec son pinyin. reserve: false exclut le bandeau et l’image de la hauteur du titre, et span: 'page' les peint sous le texte.
Une maquette de titre ignore parity : l’image a donc un élément de chaque côté de la page, {attr.left} à 14 mm du bord gauche et {attr.right} à 14 mm du bord droit. Les leçons placées sur un verso écrivent # 第一課 {left="sprout"} et celles placées sur un recto {right="shuttle"} : chaque image se trouve du côté extérieur de la double page ; l’élément dont l’attribut manque ne dessine rien.
#3 · Six cases à partir d’un attribut
// Design text prints no readings, so the squares hold the characters alone. A Han character
// is one em wide, so tracking of (pitch − em) sets one in the middle of each square.
const [SQ, GAP, WRITE] = [15, 3.4, 30]; // mm, mm, pt
const ROW = 6 * SQ + 5 * GAP; // mm
const X0 = (MEASURE - ROW) / 2; // the row is centred on the measure
const EM = WRITE * 25.4 / 72; // mm: one character at 30 pt, 10.6 mm wide
const squares = { enabled: true, slot: { elements: [
{ kind: 'text', id: 'label', content: '{titleText}', fontFamily: HEI, fontSize: pt(10),
fontWeight: 700, letterSpacing: pt(1.5), color: col('red'), align: 'left', overflow: 'wrap',
placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(X0) } } },
...Array.from({ length: 6 }, (_, k) => ({ kind: 'image', id: `square${k}`, resourceId: 'tian',
decorative: true, placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { x: mm(X0 + k * (SQ + GAP)), y: mm(7) },
size: { width: mm(SQ), height: mm(SQ) } } })),
{ kind: 'text', id: 'chars', content: '{attr.write}', fontFamily: KAI, fontSize: pt(WRITE),
lineHeight: 1, letterSpacing: mm(SQ + GAP - EM), color: col('ink'), align: 'left',
verticalAlign: 'middle', overflow: 'clip',
placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { x: mm(X0 + (SQ - EM) / 2), y: mm(7) },
size: { width: mm(ROW + GAP), height: mm(SQ) } } },
] } };
### 我會寫 {write="人之本不相以"} donne à la maquette les six caractères dans un seul attribut. Chaque caractère han de LXGW WenKai TC fait un cadratin de large : une approche égale au pas des cases moins un cadratin (18,4 − 10,6 mm) fait passer les caractères de case en case, et un seul élément de texte remplit la rangée. La case est un SVG dessiné dans le code : un cadre rouge et une croix en tirets.
#4 · Chaque caractère charge ses propres glyphes
// Fontsource cuts a Chinese face into about a hundred files by character range
// (gotcha: cjk-fonts-slices). The Kai sets the whole sample; the Hei only the headings'
// labels and the footer's series line, so it fetches a few files.
const labels = (markdown.match(/^#{1,3} [^{\n]*/gm) ?? []).join('') + '蒙學誦讀 第一冊';
await loadFonts(FONTS, markdown); // the latin files, and Andika's latin-ext for ǎ ǐ ǒ ǔ
await Promise.all([loadCjkFonts({ [KAI]: FONTS[KAI] }, markdown),
loadCjkFonts({ [HEI]: FONTS[HEI] }, labels),
loadSvg('tian.svg', tian), ...VIGNETTES.map((fileId) => loadImage(fileId, asset(fileId)))]);
Fontsource découpe chaque caractère chinois en une centaine de fichiers par plage de caractères. Le Kai compose tout l’exemple et charge les fichiers qui contiennent ses caractères. Le Hei ne compose que les pastilles, les étiquettes et le pied de page : il reçoit ce texte-là et télécharge quelques fichiers. Andika vient de loadFonts, qui ajoute le fichier latin-ext quand le texte contient des lettres au-delà de Latin-1, comme ǎ et ǐ.
#5 · La ponctuation de Hong Kong par la locale
// Punctuation at full width, where LXGW WenKai TC centres it as Hong Kong and Taiwan print
// it, and the basic line-breaking rules. Written out, never LANG (gotcha: cjk-locale-tag).
locale: 'zh-HK',
zh-HK applique les règles de Hong Kong : ponctuation en pleine chasse et règles de base de coupure des lignes, dont aucun distique n’a besoin ici. LXGW WenKai TC dessine ses virgules et ses points au milieu du carré, comme on les imprime à Hong Kong et à Taïwan, et c’est pourquoi cet abécédaire vient de Hong Kong. Un premier livre de lecture de Chine continentale se compose lui aussi en Kai, en caractères simplifiés, avec la virgule et le point en bas à gauche du carré. Fontsource n’a cependant pas de Kai de texte pour le chinois simplifié, et le traditionnel collerait ces signes au caractère suivant : une page de Chine continentale prend donc dans ce livre de recettes le Noto Serif SC, le Song de la page de roman de Chine continentale.
La recette complète
// ═══ Postext Cookbook · Nº 076 · A pinyin primer: readings over every character ═══ // https://postext.dev/en/cookbook/pinyin-primer // Code: MIT · Text: 三字經 (PD); pinyin, notes: CC BY 4.0 · Vignettes: diffusion models // Fonts: LXGW WenKai TC, Noto Sans TC, Andika (SIL OFL 1.1) · Needs postext ≥ 1.25.0 import { buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'pinyin-primer'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: a primer's colours, every one linked by id const palette = { ink: '#29241f', // the characters: a warm near-black pinyin: '#355a4d', // the readings, a shade off the ink so the two layers part red: '#bf3a2b', // lesson badges and the writing squares jade: '#2f7a5e', // the folio discs tint: '#edf4ea', // the band behind each lesson's title cream: '#faf3e4', // the note for families muted: '#6d665e', // series line, colophon paper: '#ffffff', }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to 'main-color': point it at the red. const colorPalette = Object.entries({ ...palette, 'main-color': palette.red }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion const [KAI, HEI, PINYIN] = ['LXGW WenKai TC', 'Noto Sans TC', 'Andika']; const TEXT = 26; // pt: 一号, the size of a first reader's text const LINE = 54; // pt: 2.1 × the size, so a reading fits between two lines const CHARS = 12; // characters per line: the measure is 12 × 26 pt = 110 mm const MEASURE = CHARS * TEXT * 25.4 / 72; // mm // #region answer: one reading per character, in Andika, in a line gap wide enough to hold it // {人之初|rén zhī chū} gives each character its own syllable (mono ruby): three readings for // three characters, split on the spaces. The reading sits in the line gap, centred on its // character; a syllable wider than the character widens that character's box by what the // reading needs, less the quarter of the reading's size it may lend a neighbour. const cjk = { // The type area in characters: 12 per line, 11 lines of 54 pt. The margins grow to centre it. grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11 }, ruby: { fontFamily: PINYIN, // one-storey a and g, as a Chinese primer prints them // 9.9 pt over the text, 16 pt over the title: the widest syllables (xiāng, zhuān) still fit // over one character, so every couplet is 8 em long and keeps to the grid. fontSize: em(0.38), color: col('pinyin'), }, }; // The line pitch never changes for a reading: the gap between lines (54 − 26 = 28 pt) must // hold it, or the build warns rubyExceedsLeading. const text = { fontFamily: KAI, fontSize: pt(TEXT), lineHeight: pt(LINE), textAlign: 'center', firstLineIndent: pt(0), // one couplet to a line, centred }; // #endregion // #region opener: a tinted band with the lesson's badge and its drawing const BAND = 68; // mm from the top edge // Heading designs ignore parity: a place for the drawing on each side, 14 mm from the outer // edge, named {left="…"} on a verso and {right="…"} on a recto. A missing attribute draws nothing. const picture = (side, x) => ({ kind: 'image', id: `picture-${side}`, resourceId: `{attr.${side}}`, decorative: true, reserve: false, placement: { anchor: { to: 'page', edge: `top-${side}` }, offset: { x: mm(x), y: mm(14) }, size: { width: mm(42), height: mm(42) } } }); const opener = { enabled: true, slot: { elements: [ { kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('tint') }, placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: mm(184), height: mm(BAND) } } }, picture('left', 14), picture('right', -14), { kind: 'text', id: 'lesson', content: '{titleText}', fontFamily: HEI, fontSize: pt(11), fontWeight: 700, letterSpacing: pt(2), color: col('paper'), align: 'center', overflow: 'wrap', placement: { anchor: { to: 'container', edge: 'top' } }, box: { backgroundColor: col('red'), borderRadius: mm(3.5), padding: { top: mm(1.2), right: mm(3.6), bottom: mm(1.2), left: mm(3.6) } } }, ] } }; // #endregion // #region squares: six writing squares (田字格) with the lesson's characters to copy // Design text prints no readings, so the squares hold the characters alone. A Han character // is one em wide, so tracking of (pitch − em) sets one in the middle of each square. const [SQ, GAP, WRITE] = [15, 3.4, 30]; // mm, mm, pt const ROW = 6 * SQ + 5 * GAP; // mm const X0 = (MEASURE - ROW) / 2; // the row is centred on the measure const EM = WRITE * 25.4 / 72; // mm: one character at 30 pt, 10.6 mm wide const squares = { enabled: true, slot: { elements: [ { kind: 'text', id: 'label', content: '{titleText}', fontFamily: HEI, fontSize: pt(10), fontWeight: 700, letterSpacing: pt(1.5), color: col('red'), align: 'left', overflow: 'wrap', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(X0) } } }, ...Array.from({ length: 6 }, (_, k) => ({ kind: 'image', id: `square${k}`, resourceId: 'tian', decorative: true, placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(X0 + k * (SQ + GAP)), y: mm(7) }, size: { width: mm(SQ), height: mm(SQ) } } })), { kind: 'text', id: 'chars', content: '{attr.write}', fontFamily: KAI, fontSize: pt(WRITE), lineHeight: 1, letterSpacing: mm(SQ + GAP - EM), color: col('ink'), align: 'left', verticalAlign: 'middle', overflow: 'clip', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(X0 + (SQ - EM) / 2), y: mm(7) }, size: { width: mm(ROW + GAP), height: mm(SQ) } } }, ] } }; // #endregion // The page number in a jade disc at the outer foot, the series beside it. const DISC = 8; // mm const at = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-10) } }); const folio = (parity, edge, x, sign) => [ { kind: 'text', id: `n-${parity}`, parity, content: '{pageNumber}', fontFamily: PINYIN, fontSize: pt(10), fontWeight: 700, color: col('paper'), align: 'center', verticalAlign: 'middle', overflow: 'clip', placement: { ...at(edge, x), size: { width: mm(DISC), height: mm(DISC) } }, box: { backgroundColor: col('jade'), borderRadius: mm(DISC / 2) } }, { kind: 'text', id: `s-${parity}`, parity, content: '{title} 第一冊', fontFamily: HEI, fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1), color: col('muted'), align: sign > 0 ? 'left' : 'right', verticalAlign: 'middle', overflow: 'clip', placement: { ...at(edge, x + sign * (DISC + 3)), size: { width: mm(60), height: mm(DISC) } } }, ]; const config = () => ({ // #region locale: Hong Kong's rules, the ones the Kai face is drawn for // Punctuation at full width, where LXGW WenKai TC centres it as Hong Kong and Taiwan print // it, and the basic line-breaking rules. Written out, never LANG (gotcha: cjk-locale-tag). locale: 'zh-HK', // #endregion colorPalette, page: { sizePreset: 'custom', width: mm(184), height: mm(260), dpi: 150, // 16开 // Minimums, the head deeper than the foot (天头 over 地脚): cjk.grid adds what the // 12 × 11 type area (110 × 210 mm) leaves, 3.2 mm to each, so 27.2 over 23.2 mm. margins: { top: mm(24), bottom: mm(20), left: mm(18), right: mm(18), mirror: true } }, layout: { layoutType: 'single' }, cjk, bodyText: { ...text, color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink') }, // references in ink, not in the default accent headings: { fontFamily: KAI, color: col('ink'), fontWeight: 400, textAlign: 'center', snapToGrid: false, levels: [ // Every lesson opens a page, on either side; span 'page' paints the band under the text. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' }, marginBottom: mm(4), advancedDesign: opener }, // The lesson's title: a plain heading, so its readings print (初号, 42 pt). { level: 2, fontSize: pt(42), lineHeight: pt(76), marginTop: pt(0), marginBottom: mm(6) }, { level: 3, marginTop: mm(7), marginBottom: mm(6), advancedDesign: squares }, ] }, paragraphStyles: [ { id: 'colophon', fontFamily: PINYIN, fontSize: pt(7), lineHeight: pt(9), color: col('muted'), textAlign: 'center', marginTop: mm(3) }, ], calloutStyles: [ { id: 'family', background: col('cream'), borderRadius: mm(3), snapToGrid: false, marginTop: mm(0), marginBottom: mm(0), padding: { top: mm(3), right: mm(5), bottom: mm(3.5), left: mm(5) }, titleStyle: { fontFamily: PINYIN, fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('red') }, body: { fontFamily: PINYIN, fontSize: pt(9.5), lineHeight: pt(13), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true } }, ], header: { elements: [] }, footer: { elements: [...folio('even', 'bottom-left', 20, 1), ...folio('odd', 'bottom-right', -20, -1)] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Exemple Markdown · 86 lignes · content.en.md
title: "蒙學誦讀" --- # 第一課 {left="sprout"} ## {人之初|rén zhī chū} {人之初|rén zhī chū},{性本善|xìng běn shàn}。 {性相近|xìng xiāng jìn},{習相遠|xí xiāng yuǎn}。 {苟不教|gǒu bú jiào},{性乃遷|xìng nǎi qiān}。 {教之道|jiào zhī dào},{貴以專|guì yǐ zhuān}。 ### 我會寫 {write="人之本不相以"} :::callout{type="family" title="For families"} People are good when they are born. Their natures are much the same; their habits carry them apart. Left untaught, a nature drifts, and teaching works when it keeps at one thing. Read each line aloud together, one syllable to each character, pointing to the character as you say it. The marks over the vowels are the four tones: ā level, á rising, ǎ dipping, à falling. ::: # 第二課 {right="shuttle"} ## {昔孟母|xī mèng mǔ} {昔孟母|xī mèng mǔ},{擇鄰處|zé lín chǔ}。 {子不學|zǐ bù xué},{斷機杼|duàn jī zhù}。 {竇燕山|dòu yān shān},{有義方|yǒu yì fāng}。 {教五子|jiào wǔ zǐ},{名俱揚|míng jù yáng}。 ### 我會寫 {write="子母山五方名"} :::callout{type="family" title="For families"} Long ago, Mencius’s mother moved house to find good neighbours, and when her son skipped his lessons she cut the cloth on her loom. Dou Yanshan had the right method: he taught his five sons, and all five made their names. Mencius (Mèngzǐ, about 372–289 BC) is honoured as the Second Sage, after Confucius. Dou Yanshan, a tenth-century official, saw his five sons pass the imperial examinations. ::: # 第三課 {left="brush"} ## {養不教|yǎng bú jiào} {養不教|yǎng bú jiào},{父之過|fù zhī guò}。 {教不嚴|jiào bù yán},{師之惰|shī zhī duò}。 {子不學|zǐ bù xué},{非所宜|fēi suǒ yí}。 {幼不學|yòu bù xué},{老何為|lǎo hé wéi}? ### 我會寫 {write="父師學幼老何"} :::callout{type="family" title="For families"} To raise a child without teaching is the father’s fault; to teach without strictness is the teacher’s neglect. A child who does not study is not doing right: who does not learn when young, what will he do when old? The word bù, “not”, is said bú before a fourth tone, so the first line reads yǎng bú jiào. The book prints the tone you say. ::: # 第四課 {right="jade"} ## {玉不琢|yù bù zhuó} {玉不琢|yù bù zhuó},{不成器|bù chéng qì}。 {人不學|rén bù xué},{不知義|bù zhī yì}。 {為人子|wéi rén zǐ},{方少時|fāng shào shí}。 {親師友|qīn shī yǒu},{習禮儀|xí lǐ yí}。 ### 我會寫 {write="玉成知方友禮"} :::callout{type="family" title="For families"} Jade that is not carved does not become a vessel; a person who does not learn does not know what is right. While still young, a child keeps close to teachers and friends and learns good manners. Practise the six characters in the squares: first in the air with a finger, then with a pencil, stroke by stroke. ::: :::paragraphs{style="colophon"} Set in LXGW WenKai TC, Noto Sans TC and Andika (SIL OFL) · Text: the Three Character Classic (13th century), zh.wikisource · Pinyin and notes: Postext Cookbook, CC BY 4.0 :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the writing square in code, the lessons' vignettes as watercolours // The square: a red frame and a dashed cross, the guide for placing strokes. const P = palette; const mix = (a, b, t) => `#${[1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16) * (1 - t) + parseInt(b.slice(i, i + 2), 16) * t).toString(16).padStart(2, '0')).join('')}`; const tian = '<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" ' + 'viewBox="0 0 15 15"><path d="M7.5 .4V14.6M.4 7.5H14.6" fill="none" stroke-width=".18" ' + `stroke="${mix(P.red, P.paper, 0.55)}" stroke-dasharray=".7 .55"/><rect x=".2" y=".2" ` + `width="14.6" height="14.6" fill="none" stroke="${P.red}" stroke-width=".35"/></svg>`; // The vignettes: 人之初 a seedling, 昔孟母 the loom's shuttle, 養不教 a brush's first stroke, // 玉不琢 a jade disc. Painted on white and multiplied by the band's tint, so they sit on it. const VIGNETTES = ['sprout-800.jpg', 'shuttle-800.jpg', 'brush-800.jpg', 'jade-800.jpg']; const artwork = [{ id: 'tian', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'tian.svg', width: 150, height: 150 } }, ...VIGNETTES.map((fileId) => ({ id: fileId.split('-')[0], typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, bitmap: { fileId, format: 'jpeg', width: 800, height: 800 } }))]; // at their pixels // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses. Layout measures with the browser's fonts, so the // kit loads them from Fontsource before the first build. const FONTS = { 'LXGW WenKai TC': ['400'], // 楷: the text and the titles 'Noto Sans TC': ['700'], // 黑: badges, labels, the series line Andika: ['400', '700'], // the pinyin, the notes, the folios }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region voices: each Chinese face loads the files of the characters it sets // Fontsource cuts a Chinese face into about a hundred files by character range // (gotcha: cjk-fonts-slices). The Kai sets the whole sample; the Hei only the headings' // labels and the footer's series line, so it fetches a few files. const labels = (markdown.match(/^#{1,3} [^{\n]*/gm) ?? []).join('') + '蒙學誦讀 第一冊'; await loadFonts(FONTS, markdown); // the latin files, and Andika's latin-ext for ǎ ǐ ǒ ǔ await Promise.all([loadCjkFonts({ [KAI]: FONTS[KAI] }, markdown), loadCjkFonts({ [HEI]: FONTS[HEI] }, labels), loadSvg('tian.svg', tian), ...VIGNETTES.map((fileId) => loadImage(fileId, asset(fileId)))]); // #endregion // Page 1 is page 36 of the primer: a verso, so the four lessons lie as two spreads. const continuation = { pageIndexOffset: 35, pageNumbering: { startAt: 36 } }; const doc = await withLoadedFonts(() => buildDocument({ markdown, resources: artwork, continuation }, config()), { ...kitFonts(FONTS), text: markdown }); showBook(doc, { title: t({ en: 'A pinyin primer', es: 'Una cartilla con pinyin' }) });Kit · core, fonts, viewer, images, cjk : identique dans toutes les recettes · 459 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 · 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
#Afficher la grille pendant la composition
show: true dessine sur le canvas les douze cases de chaque ligne ; le PDF les omet, sauf si renderToPdf reçoit characterGrid: true.
- grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11 },
+ grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11, show: true },#Placer le zhuyin à côté des caractères
Les lectures en bopomofo se placent en colonne à droite de chaque caractère ; le livre de lecture en zhuyin les compose sur une page verticale.
Erreurs fréquentes
Erreur fréquente
Les lectures s’impriment dans le texte, pas dans les maquettes, les légendes ou les cellules
Les lectures ruby sont dessinées dans les paragraphes, les titres, les éléments de liste, les citations et les encadrés. Un élément de texte de maquette (une ouverture, un titre courant, un badge), une légende, une note de bas de page et une cellule de tableau impriment les caractères de base sans leurs lectures. Un titre qui a besoin de son pinyin est un titre sans maquette propre : dessinez ce qui l’entoure (une bande, un numéro de leçon) avec la maquette du titre qui le précède, dont les éléments avec reserve: false se peignent sous le texte d’une ouverture span: 'page'. Ruby : lectures en pinyin et en zhuyin →
Erreur fréquente
Composez la ponctuation de chaque région dans un caractère de cette région
Les chasses de ponctuation déplacent le blanc à côté de chaque signe du côté où la région du document le place, et non du côté où la police le dessine : en zh-Hans, une virgule Kaiming garde la moitié gauche de son cadre, où un caractère simplifié dessine le glyphe, et abandonne la moitié droite. Un caractère traditionnel centre ,。 dans le cadre : la moitié retirée contient une partie du glyphe, et la virgule serre le caractère suivant. LXGW WenKai TC, le seul caractère de texte Kai sur Fontsource, produit cet effet dans un texte simplifié. Composez le texte, les notes et les citations de Chine continentale en Noto Serif SC ou Noto Sans SC, gardez les polices TC pour zh-Hant, et n’employez un caractère pinceau simplifié (Ma Shan Zheng) que pour les lignes de titrage, où le texte de maquette n’applique aucune chasse de ponctuation. Ce caractère dessine aussi des formes héritées que ni la norme continentale ni celle de Taïwan n’impriment, 為 avec le haut 爫 de 爲 et 令 (dans 冷, 領) avec un pied 卩 : vérifiez les caractères qu’une page y compose. Largeur de la ponctuation chinoise →
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
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 →
Avertissement de mise en page · rubyExceedsLeading
Lectures trop près de la ligne suivante
Pourquoi. Un paragraphe avec des lectures ruby au-dessus ou au-dessous de son texte a un interligne plus étroit que les lectures : elles se logent dans l’interlignage et touchent la ligne voisine.
Correction. Composez le paragraphe avec plus d’interlignage (au moins le corps du texte plus `cjk.ruby.fontSize`), ou réduisez la taille des lectures. Docs →
-
Cette recette ne propose pas de PDF. Le
fontsourceProviderdu kit n’incorpore que le fichier latin d’un caractère, et les lettres à ton ā ǎ ǐ ǒ ǔ sont dans latin-ext : un PDF les perdrait et postext-pdf signalerait chacune commemissingGlyph. S’il vous en faut un, composez les lectures dans un caractère chinois, dontcjkPdfProviderchoisit les fichiers caractère par caractère. -
Vérifiez chaque caractère des cases d’après les formes standard de la région. LXGW WenKai TC dessine 為 avec le haut 爫 de 爲, une forme plus ancienne qui passe dans le texte courant, comme dans 老何為 et 為人子 ici, mais pas dans une case qu’un enfant de Hong Kong recopie. La leçon trois fait plutôt pratiquer 幼.
Crédits
- Recette
- Ignacio Ferro
- Texte
- The Three Character Classic (三字經), lines 1–32: the text of the Harvard-Yenching Library’s 新刊三字經 as transcribed on zh.wikisource (revision 10344699), with today’s punctuation and 隣 written 鄰 · Traditionally attributed to Wang Yinglin (13th century); transcription by Wikisource editors · domaine public
- The pinyin, the notes for families and the translations · Postext Cookbook · CC BY 4.0
- Images
- The writing squares, drawn in code in the page’s palette · Postext Cookbook · CC BY 4.0
- Lesson 1’s vignette: a seedling, a watercolour · Generated With Diffusion Models · original
- Lesson 2’s vignette: the shuttle of a loom, a watercolour · Generated With Diffusion Models · original
- Lesson 3’s vignette: a brush and its first stroke, a watercolour · Generated With Diffusion Models · original
- Lesson 4’s vignette: a jade disc on a red cord, a watercolour · Generated With Diffusion Models · original
- Polices
- LXGW WenKai TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1) · Andika (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


