In breve
Pagine di un primo libro di lettura per bambini di Hong Kong. Mostra come stampare la pronuncia in lettere latine sopra ogni carattere cinese, con i quadretti per esercitarsi a scrivere.
Che cosa comporrai
Due doppie pagine di 《蒙學誦讀》, un primo libro di lettura inventato di Hong Kong, dove i bambini leggono ad alta voce in putonghua il Classico dei tre caratteri. Ogni pagina è una lezione di quattro distici in Kai 一号 (26 pt), ogni carattere sotto la sua sillaba pinyin. Le sillabe sono composte in Andika perché disegna la a e la g a un solo occhiello che stampano i libri di scuola cinesi. Una fascia chiara porta un distintivo rosso con il numero della lezione, un piccolo acquerello e il titolo in 初号 (42 pt) con le sue letture. Sotto il testo, sei quadretti per la scrittura (田字格) contengono i caratteri da copiare, e una nota spiega alla famiglia che cosa vogliono dire i versi. La controparte spagnola è il sillabario con le sillabe nei chip, dove ogni sillaba è un chip colorato invece di una lettura sopra un carattere.
Un sillabario abbandona di proposito le regole di un libro cinese. Un bambino legge pochi caratteri grandi alla volta, quindi una riga ne contiene dodici a 26 pt, dove un libro ne compone da 25 a 40 a 10,5 pt, e l’interlinea è poco più del doppio del corpo per accogliere le letture. Ogni distico sta centrato su una riga tutta sua, né giustificato né rientrato. Il testo è in Kai, il carattere che segue il pennello, perché mostra i tratti che un bambino impara a scrivere; un libro lo comporrebbe in Song.
Questa ricetta risponde a
- Come aggiungo il pinyin sopra i caratteri cinesi?
La risposta breve
// {人之初|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
};
Ingredienti
- Funzionalità
- Ruby: letture in pinyin e zhuyinFont cinesi, giapponesi e coreaniGriglia dei caratteriLarghezze della punteggiatura cineseCarattere del testo correnteAperture progettateAttributi dei titoliImmagini nei progetti di paginaTesto, filetti e riquadri nei progetti di paginaRiquadriTavolozza di colori semanticaTestatine e folio
- Usa anche
- Divisione in righe del cineseUscire dalla griglia di propositoFascia di capitolo a tutta larghezzaStili di paragrafoFigure e tabelle come risorse
- Tipo
- LXGW WenKai TC, Noto Sans TC, Andika (SIL OFL 1.1)
- Risorse
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, originale)
- Lesson 2’s vignette: the shuttle of a loom, a watercolour (Generated With Diffusion Models, originale)
- Lesson 3’s vignette: a brush and its first stroke, a watercolour (Generated With Diffusion Models, originale)
- Lesson 4’s vignette: a jade disc on a red cord, a watercolour (Generated With Diffusion Models, originale)
Preparazione
#1 · Una sillaba sopra ogni carattere
Il codice è nella risposta breve qui sopra. {人之初|rén zhī chū} dà a tre caratteri tre letture, separate dagli spazi: ogni sillaba è centrata sopra il proprio carattere, e una riga può andare a capo tra l’una e l’altra. Una lettura non occupa spazio proprio. Sta nello spazio interlineare, qui 54 − 26 = 28 pt, e uno spazio più stretto della lettura viene segnalato come rubyExceedsLeading. Una sillaba più larga del suo carattere, invece, occupa spazio: allarga la casella di quel carattere, e in una riga centrata i caratteri che seguono escono dalla griglia. Le letture sono composte al corpo a cui le sillabe più larghe di questo testo, xiāng e zhuān, stanno ancora sopra un solo carattere, 0,38 em (9,9 pt): ogni distico è lungo otto caratteri e ogni carattere mantiene il suo quadrato. A 0,45 em, 性相近,習相遠 si allargherebbe a otto caratteri e mezzo e aprirebbe spazi attorno a 相.
#2 · Il titolo mantiene le sue letture
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) } } },
] } };
Il testo di design non stampa letture, quindi il titolo non può venire dal design dell’apertura. Il titolo di livello 1, 第一課, disegna la fascia, l’immagine e il distintivo rosso; il titolo vero è il titolo di livello 2 sotto di esso, un titolo semplice in 初号 che il compositore del testo compone con il suo pinyin. reserve: false lascia la fascia e l’immagine fuori dall’altezza del titolo, e span: 'page' le dipinge sotto il testo.
Il design di un titolo ignora parity, quindi l’immagine ha un elemento per ogni lato della pagina: {attr.left} a 14 mm dal bordo sinistro e {attr.right} a 14 mm dal bordo destro. Le lezioni sui versi scrivono # 第一課 {left="sprout"} e quelle sui recto {right="shuttle"}, così ogni immagine sta sul lato esterno della doppia pagina; l’elemento a cui manca l’attributo non disegna nulla.
#3 · Sei quadretti da un solo attributo
// 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="人之本不相以"} dà al design i sei caratteri in un solo attributo. Ogni carattere Han di LXGW WenKai TC è largo un em, quindi una spaziatura tra lettere pari al passo dei quadretti meno un em (18,4 − 10,6 mm) sposta i caratteri da un quadretto all’altro, e un solo elemento di testo riempie la fila. Il quadretto è un SVG disegnato in codice: una cornice rossa e una croce tratteggiata.
#4 · Ogni carattere carica i propri glifi
// 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 taglia ogni carattere cinese in circa cento file per intervallo di caratteri. Il carattere Kai compone tutto l’esempio e carica i file che contengono i suoi glifi. Il carattere Hei compone solo i distintivi, le etichette e il piè di pagina, quindi riceve quel testo e scarica pochi file. Andika viene da loadFonts, che aggiunge il file latin-ext quando il testo ha lettere oltre il Latin-1, come ǎ e ǐ.
#5 · La punteggiatura di Hong Kong dal 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 applica le regole di Hong Kong: punteggiatura a larghezza piena e le regole di base per gli a capo, di cui nessun distico qui ha bisogno. LXGW WenKai TC disegna le virgole e i punti al centro del quadrato, come li stampano Hong Kong e Taiwan, ed è per questo che il sillabario viene da Hong Kong. Anche un primo libro di lettura della Cina continentale si compone in Kai, in caratteri semplificati con la virgola e il punto in basso a sinistra del quadrato. Fontsource però non ha un Kai da testo per il cinese semplificato, e quello tradizionale ammasserebbe quei segni contro il carattere successivo, quindi una pagina continentale in questo Ricettario usa Noto Serif SC, il carattere Song della pagina di romanzo continentale.
La ricetta completa
// ═══ 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`---Esempio Markdown · 86 righe · 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: uguale in ogni ricetta · 459 righe
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── Kit · cjk v2 ── Chinese, Japanese and Korean books · postext.dev/cookbook // Fontsource ships a CJK family as about a hundred files per weight, each // declared in its stylesheet with the unicode-range it covers. The screen // loads the files the sample touches; the PDF gets the same files for the // characters its pages set in each face, and embeds each as a subset. // A book bound on the right (vertical text) is shown with its spreads // mirrored: page 1 alone on the left of the spine, then [3 | 2]. /** The files of a Fontsource face, read from its stylesheet: { url, range, * ranges }, the last declared first (the order the browser tries them in). */ function cjkSlices(family, weight, style) { cjkSlices.cache ??= new Map(); const id = fontsourceId(family); const css = `${id}/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { let at; cjkSlices.cache.set(css, fontsourceAt(id).then((a) => fetch(at = a + css.slice(id.length + 1))) .then((res) => { if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style} (${res.status})`); return res.text(); }) .then((text) => [...text.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => { const range = /unicode-range:\s*([^;]+);/.exec(rule)?.[1].trim() ?? 'U+0-10FFFF'; const ranges = range.split(',').map((part) => { const [lo, hi = lo] = part.trim().slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return { url: new URL(/url\(([^)]+?\.woff2)\)/.exec(rule)[1], at).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` the screen sets code point `cp` in, if any: the * last one loadCjkFonts adds whose range holds it. A numbered file that * repeats a letter of latin is added after latin, and wins it. */ function cjkSliceFor(slices, cp) { const holds = (slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi); return slices.reduce((last, slice) => (holds(slice) ? slice : last), undefined); } /** Whether Fontsource serves `family` as a Chinese, Japanese or Korean * family (its subsets name the script). Fails when the API does not * answer: a CJK face taken for a Latin one would paint in a system face. */ async function isCjkFamily(family) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); return !!meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset)); } /** faces = { 'Noto Serif TC': ['400', '700'] }, as for loadFonts: the * whole FONTS object may be passed, its other families are left to * loadFonts. Adds one FontFace per file of each CJK face with its * unicodeRange, then loads the files `text` touches. `text` is what the * faces set: the sample for the text face; a book in several voices calls * it once per voice (loadCjkFonts({ 'LXGW WenKai TC': ['400'] }, quotes)), * so the heading and quotation faces fetch and check only their own * characters. Fails when a character of `text` is in no file of a face. * List every weight the pages use: a weight left to the engine's later round * gets the latin file only. With { vertical: true } it also loads each * family's vertical forms (brackets, quotes, pause marks) for the canvas, * which needs loadVerticalAlternates imported from postext. Resolves to * the number of files loaded. */ async function loadCjkFonts(faces, text, { vertical = false } = {}) { kitStatus('Loading fonts…'); let loaded = 0; try { if (vertical && typeof loadVerticalAlternates !== 'function') { throw new Error('loadCjkFonts(…, { vertical: true }) needs loadVerticalAlternates imported from postext'); } for (const [family, specs] of Object.entries(faces)) { if (!(await isCjkFamily(family))) continue; const twin = []; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const slices = await cjkSlices(family, weight, style); const missing = [...new Set(text)].filter((ch) => /\S/.test(ch) && !cjkSliceFor(slices, ch.codePointAt(0))); if (missing.length) { throw new Error(`${family} ${spec} has no file for ${missing.slice(0, 12).join(' ')}: ` + `give each face the text it sets (loadCjkFonts({ '${family}': ['${spec}'] }, text))`); } for (const slice of slices) { document.fonts.add(new FontFace(family, `url(${slice.url}) format('woff2')`, { weight: String(weight), style, unicodeRange: slice.range })); twin.push({ source: slice.url, weight: String(weight), style, unicodeRange: slice.range }); } const font = `${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`; loaded += (await document.fonts.load(font, text)).length; if (!document.fonts.check(font, text)) throw new Error(`${family} ${spec} did not load for the sample`); } // The same files under a twin name with the `vert` feature on: the // canvas paints the punctuation of vertical lines with it. if (vertical && twin.length) await loadVerticalAlternates(family, twin); } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with CJK faces: a family whose * Fontsource subsets are Chinese, Japanese or Korean gets the files the * screen set its pages' characters in (`request.codePoints`), each marked * with its unicode-range and its place in the order loadCjkFonts added * them: postext-pdf sets a character two files hold in the one added * last, as the browser does. Any other family gets the latin file * fontsourceProvider fetches (the "pdf" block) and, when the face sets * letters only latin-ext has, that file too. */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return cjkLatinPdfFiles(family, weight, style, request); const meta = await fontsourceMeta(family); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const slices = await cjkSlices(family, w, s); const picked = new Set(); for (const cp of request?.codePoints ?? []) { const slice = cjkSliceFor(slices, cp); if (slice) picked.add(slice); } if (!picked.size) picked.add(slices[0]); return Promise.all(slices.map(async (slice, order) => { if (!picked.has(slice)) return null; const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer())); return Object.assign(bytes, { unicodeRange: slice.range, order }); })).then((files) => files.filter(Boolean)); } /** A Latin family set next to the CJK faces: its latin file, then its * latin-ext file when the face sets letters only latin-ext has (ō ū in * Hepburn rōmaji, ǎ in pinyin), the file loadFonts adds on screen for * them, each marked with its unicode-range and its place in that order: * the PDF sets a character in the file the browser took it from. A face * Fontsource ships without latin-ext, or whose file does not come, gets * latin alone, and the PDF names the letters it lacks. */ async function cjkLatinPdfFiles(family, weight, style, request) { const meta = await fontsourceMeta(family); const beyond = [...(request?.codePoints ?? [])].some(cjkLatinExtOnly); if (!beyond || !meta?.subsets?.includes('latin-ext')) return fontsourceProvider(family, weight, style); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const id = fontsourceId(family); const url = `${await fontsourceAt(id)}files/${id}-latin-ext-${w}-${s}.woff2`; const [latin, ext] = await Promise.all([fontsourceProvider(family, weight, style), fetch(url) .then(async (res) => (res.ok ? decompressWoff2(new Uint8Array(await res.arrayBuffer())) : null), () => null)]); const ranges = kitRanges(); return ext ? [Object.assign(latin, { unicodeRange: ranges.latin, order: 0 }), Object.assign(ext, { unicodeRange: ranges['latin-ext'], order: 1 })] : latin; } /** Whether code point `cp` is in Fontsource's latin-ext file and not in * its latin file: Latin Extended-A and -B, IPA, the spacing modifiers and * Latin Extended Additional (loadFonts's test for latin-ext), less the * few latin holds too (ı Œ œ ʻ ʼ ˆ ˚ ˜). */ function cjkLatinExtOnly(cp) { if (!((cp >= 0x100 && cp <= 0x2ff) || (cp >= 0x1e00 && cp <= 0x1eff))) return false; return ![0x131, 0x152, 0x153, 0x2bb, 0x2bc, 0x2c6, 0x2da, 0x2dc].includes(cp); } /** showPages for a book bound on either edge. A right-bound book (the * document says so: doc.binding is 'right' for page.binding 'right' and * for vertical text) lies on the desk as it opens: page 1 alone on the * left of the spine, then [3 | 2], the spine shade on each page's inner * edge. `binding` ('left' | 'right') overrides the document's. */ function showBook(docs, { binding, ...options } = {}) { const count = showPages(docs, options); const right = (binding ?? [docs].flat()[0]?.binding) === 'right'; if (!document.getElementById('pt-kit-cjk')) { // The pages keep direction ltr: a canvas draws text in the direction its // element inherits, and under rtl each run would end where the engine // starts it, its brackets mirrored. document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk"> .pt-spread[dir="rtl"] canvas { direction: ltr; } .pt-spread[dir="rtl"] figure:first-child canvas { box-shadow: inset 14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } </style>`); } // Each pair stays [verso, recto] in the page; right to left, the verso // sits on the right. Phones stack the pages in reading order either way. for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr'; document.getElementById('pages').dataset.binding = right ? 'right' : 'left'; return count; } // ─── /Kit ───────────────────────────────────────────────────────────────────────
Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)
Varianti
#Mostra la griglia mentre componi la pagina
show: true disegna sul canvas i dodici quadrati di ogni riga; il PDF li omette, a meno che renderToPdf non riceva characterGrid: true.
- grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11 },
+ grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11, show: true },#Metti lo zhuyin accanto ai caratteri
Le letture in bopomofo stanno in una colonna a destra di ogni carattere; il libro di lettura in zhuyin le compone lungo una pagina verticale.
Errori frequenti
Errore frequente
Le letture si stampano nel testo, non nella grafica, nelle didascalie o nelle celle
Le letture ruby si disegnano nei paragrafi, nei titoli, nelle voci di elenco, nelle citazioni e nei riquadri. Un elemento di testo grafico (un’apertura, una testatina, un badge), una didascalia, una nota a piè di pagina e una cella di tabella stampano i caratteri di base senza le loro letture. Un titolo che ha bisogno del suo pinyin è un titolo senza grafica propria: disegna ciò che lo circonda (una fascia, un numero di lezione) con la grafica del titolo che lo precede, i cui elementi con reserve: false si disegnano sotto il testo di un’apertura span: 'page'. Ruby: letture in pinyin e zhuyin →
Errore frequente
Componi la punteggiatura di ogni regione in un carattere di quella regione
Le larghezze della punteggiatura spostano il bianco accanto a ogni segno dal lato in cui lo mette la regione del documento, non da quello in cui lo disegna il font: con zh-Hans una virgola Kaiming conserva la metà sinistra del suo quadratino, dove un carattere semplificato disegna il glifo, e cede la metà destra. Un carattere tradizionale centra ,。 nel quadratino, quindi la metà che se ne va contiene parte del glifo e la virgola si schiaccia sul carattere successivo. LXGW WenKai TC, l’unico carattere Kai da testo su Fontsource, fa questo nel testo semplificato. Componi il testo, le note e le citazioni della Cina continentale in Noto Serif SC o Noto Sans SC, tieni i caratteri TC per zh-Hant e usa un carattere a pennello semplificato (Ma Shan Zheng) solo per le righe di titolazione, dove il testo grafico non applica le larghezze della punteggiatura. Disegna anche forme ereditate che né lo standard continentale né quello di Taiwan stampano, 為 con la parte alta 爫 di 爲 e 令 (in 冷, 領) con un piede 卩: controlla i caratteri che una pagina compone con esso. Larghezze della punteggiatura cinese →
Errore frequente
I caratteri cinesi si caricano a fette, tramite il blocco cjk
Fontsource serve una famiglia cinese, giapponese o coreana come un centinaio di file per peso, ognuno dei quali copre un intervallo di caratteri. loadFonts scarica solo il file latin, così a schermo i caratteri Han vengono da un font di sistema e sono misurati male, e fontsourceProvider passa al PDF quel file latin, che li stampa come riquadri vuoti. Elenca il blocco cjk del kit, chiama loadCjkFonts(FONTS, markdown) dopo loadFonts (una volta per voce tipografica, con il testo che compone, quando il libro usa più caratteri CJK) e passa a renderToPdf fontProvider: cjkPdfProvider: entrambi prendono i file che contengono i caratteri del testo. Font cinesi, giapponesi e coreani →
Errore frequente
Assegna al documento il tag zh-Hans o zh-Hant, non LANG
Le edizioni di una ricetta sono en ed es, ma un esempio cinese è cinese in entrambe: `locale: LANG` gli darebbe il tag inglese o spagnolo, sillaberebbe le sue parole latine, etichetterebbe le figure Figure o Figura e darebbe al PDF la lingua sbagliata. Da postext 1.30 il tag è anche la lingua usata per lo shaping del testo (misura, disegno e incorporamento), quindi con un tag inglese o spagnolo un carattere cinese compone in altre forme le cifre e la punteggiatura che condivide con il testo latino, e le righe vanno a capo altrove. Scrivi tu il tag: 'zh-Hans' (convenzioni della Cina continentale: divisione in righe GB, punteggiatura Kaiming) o 'zh-Hant' (Taiwan: punteggiatura centrata a tutta larghezza); 'zh-HK' per Hong Kong. Un 'zh' da solo vale come semplificato, Cina continentale. Un esempio giapponese prende 'ja' (errore frequente ja-locale-tag). Divisione in righe del cinese →
Avviso di impaginazione · rubyExceedsLeading
Letture troppo vicine alla riga successiva
Perché. Un paragrafo con letture ruby sopra o sotto il testo ha uno spazio tra le righe più stretto delle letture: queste stanno nell’interlinea e toccano la riga accanto.
Soluzione. Componi il paragrafo con più interlinea (almeno il corpo del testo più `cjk.ruby.fontSize`), oppure rimpicciolisci le letture. Documentazione →
-
Questa ricetta non offre un PDF. Il
fontsourceProviderdel kit incorpora solo il file latin di un carattere, e le lettere con i toni ā ǎ ǐ ǒ ǔ sono in latin-ext, quindi un PDF le perderebbe e postext-pdf le segnalerebbe ciascuna comemissingGlyph. Se ne hai bisogno, componi le letture in un carattere cinese, i cui filecjkPdfProvidersceglie carattere per carattere. -
Controlla ogni carattere dei quadretti rispetto alle forme standard della regione. LXGW WenKai TC disegna 為 con la parte superiore 爫 di 爲, una forma più antica che passa nel testo corrente, come qui in 老何為 e 為人子, ma non in un quadretto che un bambino di Hong Kong copia. La lezione tre fa invece esercitare 幼.
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- 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 · pubblico dominio
- The pinyin, the notes for families and the translations · Postext Cookbook · CC BY 4.0
- Immagini
- 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 · originale
- Lesson 2’s vignette: the shuttle of a loom, a watercolour · Generated With Diffusion Models · originale
- Lesson 3’s vignette: a brush and its first stroke, a watercolour · Generated With Diffusion Models · originale
- Lesson 4’s vignette: a jade disc on a red cord, a watercolour · Generated With Diffusion Models · originale
- Font
- LXGW WenKai TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1) · Andika (SIL OFL 1.1)
- Codice
- MIT, come Postext
Modifica questo testo ↗ (si apre in una nuova scheda)Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)


