En bref
Un livre de lecture scolaire de Taïwan, imprimé en colonnes de haut en bas. Chaque caractère chinois a un petit guide de prononciation à côté, pour que les enfants lisent des mots qu’ils n’ont pas encore appris.
Ce que vous allez composer
La leçon 12 du livre 9 de 國語, un livre de lecture taïwanais inventé pour la cinquième année de primaire : deux fables du Han Feizi, le paysan de Song qui attend près d’une souche un autre lièvre et l’homme de Zheng qui se fie davantage à sa mesure qu’à ses pieds. Les livres de lecture de Taïwan se composent à la verticale, dans une écriture Kai, avec le zhuyin de chaque caractère dans une colonne à sa droite, si bien qu’un enfant peut lire un caractère avant de l’apprendre. La leçon s’ouvre sur une double page reliée à droite. En haut, un étage contient les images, les notes, l’auteur et les nouveaux caractères ; en bas, la leçon court en Iansui de 16 pt, 23 caractères par ligne. L’équivalent en écriture latine est le manuel avec une colonne en marge, dont la colonne latérale réservée aux flottants devient ici l’étage supérieur, tournée d’un quart de tour.
Cette recette répond à
- Comment composer le zhuyin à côté de chaque caractère d’un texte chinois vertical ?
- Comment composer un livre chinois à la verticale, relié à droite ?
La réponse courte
const layout = {
writingMode: 'vertical-rl', // lines run down the page, read from the right; bound on the right
layoutType: 'oneAndHalf', // two tiers: the text below, pictures and notes above
sideColumnRole: 'floats', sideColumnSide: 'left', // 'left' is the top tier in vertical text
sideColumnPercent: 34,
gutterWidth: pt(2 * SIZE),
};
const cjk = {
// The lower tier: 23 characters down, 13 lines across the page.
grid: { enabled: true, charsPerLine: 23, linesPerPage: 13 },
// Readings at half the text size; zhuyin sets its symbols at 60 % of that, 0.3 em,
// so three symbols fit beside one character, and beside each of two in a row.
ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) },
};
// The line pitch is twice the size: a gap of one em, which the zhuyin and its tone
// marks half fill. clreq asks for 1.5 em; one em keeps 13 lines of 23 on the page.
const LINE = 2 * SIZE;
const bodyText = {
fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
};
Ingrédients
- Fonctionnalités
- Ruby : lectures en pinyin et en zhuyinTexte verticalLivres reliés à droitePolices chinoises, japonaises et coréennesLargeur de la ponctuation chinoiseGrille de caractèresColonne et demieColonne marginale pour les flottantsNotes marginalesNumération chinoiseListes numérotéesTypes de ressource personnalisésEncadrésStyles de paragrapheStyles de titreTexte, filets et boîtes dans les maquettes de pageTitres courants et foliosExport PDF
- Utilise aussi
- Coupure des lignes en chinoisAttributs de titreTitres numérotésDes figures exactement iciTitres courants selon le rôle de la pagePolices intégrées au PDFFigures et tableaux comme ressourcesNombres droits dans le texte verticalChapitres non numérotés
- Typographie
- Iansui, LXGW WenKai TC, Noto Serif TC, Noto Sans TC (SIL OFL 1.1)
- Ressources
plate-1-1500.jpgplate-2-1500.jpg- The farmer waiting by the stump, a brush painting (Generated With Diffusion Models, original)
- The man of Zheng hurrying home from the shoe stall, a brush painting (Generated With Diffusion Models, original)
Préparation
#1 · Une colonne de zhuyin à côté de chaque caractère
Le code est dans la réponse courte plus haut. {宋人|ㄙㄨㄥˋ|ㄖㄣˊ} donne à chaque caractère sa propre lecture, une par | ; la ponctuation reste hors des accolades, si bien que 。et , n’en reçoivent pas. En texte vertical, une lecture en bopomofo se place à droite de la colonne, ses symboles empilés, la marque de ton à droite du dernier et le point du ton neutre au-dessus du premier. À la moitié du corps du texte, cjk.ruby.fontSize compose les symboles à 0,3 em, si bien que trois symboles tiennent à côté d’un caractère, et deux lectures de ce genre à la suite, comme dans 就像, restent espacées d’au moins un quart de symbole sans élargir aucun des deux caractères ; à 0,6 em, ㄇㄧㄥˊ est plus haut que 明 et écarte 明明. Le pas de ligne de 32 pt laisse un espace d’un cadratin, que le zhuyin et ses marques de ton remplissent à moitié. clreq demande 1,5 cadratin quand le zhuyin se tient entre des lignes verticales ; cela donne un pas de 40 pt et dix lignes par page : cette page garde donc un cadratin pour 13 lignes de 23, et chaque lecture reste à environ 8 pt de la ligne voisine.
#2 · Images et notes dans l’étage supérieur
const resourceTypes = [{ id: 'plate', name: '插圖', shortLabel: '圖', numberingTemplate: '{n}',
resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // no prefix, no caption
const box = (id, title, body) => ({ id, title, background: col('tint'),
padding: { top: mm(3), right: mm(3), bottom: mm(3), left: mm(3) },
titleStyle: { fontFamily: HEI, fontSize: pt(11), fontWeight: 700, color: col('accent') },
body: { color: col('ink'), firstLineIndent: pt(0), textAlign: 'left', boldColor: col('ink'),
italicColor: col('ink'), ...body } });
// Zhuyin is 0.3 em of the text it reads: at 13 pt the notes' readings are 3.9 pt.
const calloutStyles = [ // fenced :::callout{type="notes" span="side"} in the text
box('notes', '注釋', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24) }),
box('author', '作者', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24),
textAlign: 'justify', firstLineIndent: em(2) }),
box('chars', '生字', { fontFamily: KAI, fontSize: pt(22), lineHeight: pt(40),
textAlign: 'center' }),
];
En texte vertical, les colonnes d’une mise en page oneAndHalf sont des étages, et sideColumnSide: 'left' place en haut celui qui est réservé aux flottants. ::resource{id="plate-1"} dans le texte y envoie la première image ; le type plate n’a pas de préfixe de légende et les images n’ont pas de légende : rien ne s’imprime donc sous elles. Un encadré délimité avec span="side" arrive dans l’étage à partir de la ligne où il est délimité, et son texte descend l’étage. Les notes des deux fables forment un seul encadré, numéroté sur toute la leçon et délimité après le premier original, qui se termine en page 86 : elles remplissent l’étage de la page 87, si bien que les notes des deux fables se trouvent sur la double page qui contient leurs originaux. Le zhuyin fait 0,3 em du texte qu’il lit, et cjk.ruby est un réglage unique pour tout le livre : les notes et l’encadré sur l’auteur sont donc composés à 13 pt pour garder leurs lectures à 3,9 pt.
#3 · Quatre polices, chacune chargée avec son propre texte
// {株|ㄓㄨ}: the characters are the text, the readings go to the zhuyin face.
const BOXES = /^:::callout\{type="(?:notes|author)"[^}]*\}\n([\s\S]*?)^:::$/gm;
const bases = (md) => md.replace(/\{([^|{}]+)((?:\|[^|{}]+)+)\}/g, '$1');
const readings = [...markdown.matchAll(/\{[^|{}]+((?:\|[^|{}]+)+)\}/g)].map((m) => m[1]).join('');
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [KAI]: ['400'] }, bases(markdown.replace(BOXES, '')), { vertical: true });
const notesText = [...markdown.matchAll(BOXES)].map((m) => m[1]).join('\n');
await loadCjkFonts({ [MING]: ['400'] }, bases(notesText), { vertical: true });
await loadCjkFonts({ [ZHUYIN]: ['400'] }, readings.replaceAll('|', ''), { vertical: true });
await loadCjkFonts({ [HEI]: ['400', '700'] }, LABELS, { vertical: true });
await Promise.all(Object.values(plates).map((fileId) => loadImage(fileId, asset(fileId))));
// Lesson 12 of a reader: page 86 is a verso, so the lesson opens on a spread.
const continuation = { pageIndexOffset: 1, pageNumbering: { startAt: 86 } };
const doc = await withLoadedFonts(
() => buildDocument({ markdown, resources, continuation }, config()),
{ ...kitFonts(FONTS), text: markdown });
showBook(doc, { title: t({ en: 'A vertical reader with zhuyin',
es: 'Un libro de lectura vertical con zhuyin' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }),
`${RECIPE}.pdf`);
La leçon est en Iansui, une écriture Kai dessinée à partir de Klee One selon les formes standard des caractères de Taïwan, celles que les enfants apprennent à écrire. Les lectures sont en LXGW WenKai TC, dont le point du ton neutre reste rond à 4,8 pt, les notes en Noto Serif TC et les étiquettes en Noto Sans TC. Chaque police charge les fichiers qui contiennent les caractères qu’elle compose : les lectures sont séparées de leurs caractères, et les encadrés de notes vont à la seule police Ming. vertical: true ajoute les formes verticales, si bien que 「」 et 、 se tiennent comme il faut le long de la colonne. pageIndexOffset: 1 fait de la page 86 un verso, si bien que la leçon s’ouvre sur une double page.
#4 · L’onglet et les folios au bord extérieur
// The verso lies on the right of the spread, so even pages carry both at the right edge.
const tab = (parity, edge) => [
{ kind: 'box', id: `tab-${parity}`, parity, pages: 'all',
placement: { anchor: { to: 'page', edge }, offset: { y: mm(24) },
size: { width: mm(9), height: mm(64) } }, style: { backgroundColor: col('accent') } },
{ kind: 'text', id: `unit-${parity}`, content: '第三單元 寓言故事', parity, pages: 'all',
writingMode: 'vertical-rl', fontFamily: HEI, fontSize: pt(9), fontWeight: 700,
color: col('paper'), align: 'center', verticalAlign: 'middle',
placement: { anchor: { to: `#tab-${parity}`, edge: 'align-top' },
size: { width: mm(9), height: mm(64) } } },
];
const foot = (id, parity, edge, x, content, extra = {}) => ({
kind: 'text', id, content, parity, pages: 'all', fontFamily: HEI, fontSize: pt(8),
color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-9) } },
...extra,
});
const folio = { fontSize: pt(9), fontWeight: 700 };
const header = { elements: [...tab('even', 'top-right'), ...tab('odd', 'top-left')] };
const footer = {
elements: [
foot('folio-even', 'even', 'bottom-right', -16, '{pageNumber}', folio),
foot('book', 'even', 'bottom-right', -26, '國語 第九冊'),
foot('folio-odd', 'odd', 'bottom-left', 16, '{pageNumber}', folio),
foot('lesson', 'odd', 'bottom-left', 26, '{chapterNumber} {chapterTitle}'),
],
};
Dans un livre relié à droite, le verso se trouve à droite de la double page : les pages paires portent donc l’onglet de l’unité et le folio au bord droit, et les pages impaires au bord gauche. L’onglet est une boîte surmontée d’un élément de texte ; writingMode: 'vertical-rl' sur l’élément compose 第三單元 寓言故事 le long de l’onglet, tandis que les folios et les lignes de pied restent horizontaux. Les numéros de leçon et de fable viennent du numberingTemplate des titres (第{1:一}課 et {2:一}, une espace idéographique comme numberSeparator, {startAt=12} sur la leçon), séparés du titre pour que le zhuyin de 寓 reste sur 寓 ; la ligne de pied impaire imprime {chapterNumber} {chapterTitle}, puisque {chapterTitle} omet le numéro.
La recette complète
// ═══ Postext Cookbook · Nº 080 · A vertical reader with zhuyin to the right ═════════ // https://postext.dev/en/cookbook/zhuyin-vertical-reader // Code: MIT · Text: Han Feizi, zh.wikisource (CC BY-SA 4.0) · Pictures: diffusion models // Fonts: Iansui, LXGW WenKai TC, Noto Serif TC, Noto Sans TC (SIL OFL 1.1) · Needs postext ≥ 1.25.0 import { buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage, loadVerticalAlternates, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'zhuyin-vertical-reader'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: semantic colours, every one linked by id const palette = { ink: '#2b2520', // text and zhuyin accent: '#b83f28', // lesson title, labels, the unit's tab (4.9:1 on the tint) tint: '#f7efdf', // the boxes of the upper tier muted: '#72675b', // lead, folios, colophon paper: '#ffffff', // the lettering on the tab }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } }, ]; // #endregion const KAI = 'Iansui'; // 楷: the lesson, drawn to Taiwan's standard forms (為, not 爲) const ZHUYIN = 'LXGW WenKai TC'; // the readings: a round dot for the neutral tone const MING = 'Noto Serif TC'; // 明: notes and the author box const HEI = 'Noto Sans TC'; // 黑: labels, folios, the tab const SIZE = 16; // the text size (三號) // #region answer: vertical text with a zhuyin column right of every character const layout = { writingMode: 'vertical-rl', // lines run down the page, read from the right; bound on the right layoutType: 'oneAndHalf', // two tiers: the text below, pictures and notes above sideColumnRole: 'floats', sideColumnSide: 'left', // 'left' is the top tier in vertical text sideColumnPercent: 34, gutterWidth: pt(2 * SIZE), }; const cjk = { // The lower tier: 23 characters down, 13 lines across the page. grid: { enabled: true, charsPerLine: 23, linesPerPage: 13 }, // Readings at half the text size; zhuyin sets its symbols at 60 % of that, 0.3 em, // so three symbols fit beside one character, and beside each of two in a row. ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) }, }; // The line pitch is twice the size: a gap of one em, which the zhuyin and its tone // marks half fill. clreq asks for 1.5 em; one em keeps 13 lines of 23 on the page. const LINE = 2 * SIZE; const bodyText = { fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true, }; // #endregion // #region upper-tier: pictures without a caption line, boxes of notes set down the tier const resourceTypes = [{ id: 'plate', name: '插圖', shortLabel: '圖', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // no prefix, no caption const box = (id, title, body) => ({ id, title, background: col('tint'), padding: { top: mm(3), right: mm(3), bottom: mm(3), left: mm(3) }, titleStyle: { fontFamily: HEI, fontSize: pt(11), fontWeight: 700, color: col('accent') }, body: { color: col('ink'), firstLineIndent: pt(0), textAlign: 'left', boldColor: col('ink'), italicColor: col('ink'), ...body } }); // Zhuyin is 0.3 em of the text it reads: at 13 pt the notes' readings are 3.9 pt. const calloutStyles = [ // fenced :::callout{type="notes" span="side"} in the text box('notes', '注釋', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24) }), box('author', '作者', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24), textAlign: 'justify', firstLineIndent: em(2) }), box('chars', '生字', { fontFamily: KAI, fontSize: pt(22), lineHeight: pt(40), textAlign: 'center' }), ]; // #endregion // #region furniture: the unit's tab and the folios, on the outer edge of a right-bound book // The verso lies on the right of the spread, so even pages carry both at the right edge. const tab = (parity, edge) => [ { kind: 'box', id: `tab-${parity}`, parity, pages: 'all', placement: { anchor: { to: 'page', edge }, offset: { y: mm(24) }, size: { width: mm(9), height: mm(64) } }, style: { backgroundColor: col('accent') } }, { kind: 'text', id: `unit-${parity}`, content: '第三單元 寓言故事', parity, pages: 'all', writingMode: 'vertical-rl', fontFamily: HEI, fontSize: pt(9), fontWeight: 700, color: col('paper'), align: 'center', verticalAlign: 'middle', placement: { anchor: { to: `#tab-${parity}`, edge: 'align-top' }, size: { width: mm(9), height: mm(64) } } }, ]; const foot = (id, parity, edge, x, content, extra = {}) => ({ kind: 'text', id, content, parity, pages: 'all', fontFamily: HEI, fontSize: pt(8), color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-9) } }, ...extra, }); const folio = { fontSize: pt(9), fontWeight: 700 }; const header = { elements: [...tab('even', 'top-right'), ...tab('odd', 'top-left')] }; const footer = { elements: [ foot('folio-even', 'even', 'bottom-right', -16, '{pageNumber}', folio), foot('book', 'even', 'bottom-right', -26, '國語 第九冊'), foot('folio-odd', 'odd', 'bottom-left', 16, '{pageNumber}', folio), foot('lesson', 'odd', 'bottom-left', 26, '{chapterNumber} {chapterTitle}'), ], }; // #endregion const config = () => ({ locale: 'zh-Hant', // Taiwan: full-width punctuation, centred in its cell (gotcha: cjk-locale-tag) colorPalette, resourceTypes, page: { sizePreset: 'custom', width: mm(184), height: mm(260), dpi: 150, // 16開 margins: { top: mm(24), bottom: mm(18), left: mm(18), right: mm(16), mirror: true }, }, layout, cjk, bodyText, headings: { fontFamily: KAI, color: col('ink'), fontWeight: 400, // Iansui has one weight levels: [ // 第十二課 and 一, 二: the number stays outside the title's first reading. { level: 1, fontSize: pt(30), lineHeight: pt(2 * LINE), color: col('accent'), numberingTemplate: '第{1:一}課', numberSeparator: ' ', breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0) }, { level: 2, fontSize: pt(20), lineHeight: pt(2 * LINE), marginTop: pt(LINE), numberingTemplate: '{2:一}', numberSeparator: ' ', marginBottom: pt(0) }, { level: 3, fontFamily: HEI, fontWeight: 700, fontSize: pt(13), lineHeight: pt(LINE), color: col('accent'), marginTop: pt(LINE / 2), marginBottom: pt(0) }, ], }, // 想一想 and 語文天地: exercise heads in the label face, one line tall. headingStyles: [{ id: 'drill', numbered: false, fontFamily: HEI, fontWeight: 700, fontSize: pt(13), lineHeight: pt(LINE), color: col('accent'), marginTop: pt(LINE / 2), marginBottom: pt(0) }], orderedLists: { numberFormat: 'trad-chinese-informal', separator: '、', color: col('accent'), fontFamily: HEI, fontWeight: 700, marginTop: pt(0), marginBottom: pt(0) }, paragraphStyles: [ { id: 'lead', fontFamily: KAI, fontSize: pt(14), lineHeight: pt(LINE), color: col('muted'), textAlign: 'justify', firstLineIndent: em(0) }, { id: 'plain', fontFamily: KAI, fontSize: pt(14), lineHeight: pt(LINE), color: col('ink'), textAlign: 'justify', firstLineIndent: em(2) }, { id: 'idiom', fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'), boldColor: col('accent'), boldFontWeight: 400, textAlign: 'justify', // bold in colour only firstLineIndent: em(0) }, { id: 'colophon', fontFamily: HEI, fontSize: pt(7), lineHeight: pt(11), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LINE) }, ], calloutStyles, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`# {寓言兩則|ㄩˋ|ㄧㄢˊ|ㄌㄧㄤˇ|ㄗㄜˊ} {startAt=12}Exemple Markdown · 91 lignes · content.en.md
:::paragraphs{style="lead"} {寓言用一個短短的故事|ㄩˋ|ㄧㄢˊ|ㄩㄥˋ|ㄧ|˙ㄍㄜ|ㄉㄨㄢˇ|ㄉㄨㄢˇ|˙ㄉㄜ|ㄍㄨˋ|˙ㄕ},{說出一個道理|ㄕㄨㄛ|ㄔㄨ|ㄧ|˙ㄍㄜ|ㄉㄠˋ|ㄌㄧˇ}。{這一課的兩則寓言|ㄓㄜˋ|ㄧ|ㄎㄜˋ|˙ㄉㄜ|ㄌㄧㄤˇ|ㄗㄜˊ|ㄩˋ|ㄧㄢˊ},{都選自戰國時代韓非所寫的|ㄉㄡ|ㄒㄩㄢˇ|ㄗˋ|ㄓㄢˋ|ㄍㄨㄛˊ|ㄕˊ|ㄉㄞˋ|ㄏㄢˊ|ㄈㄟ|ㄙㄨㄛˇ|ㄒㄧㄝˇ|˙ㄉㄜ}《{韓非子|ㄏㄢˊ|ㄈㄟ|ㄗˇ}》。{讀的時候想一想|ㄉㄨˊ|˙ㄉㄜ|ㄕˊ|ㄏㄡˋ|ㄒㄧㄤˇ|ㄧ|ㄒㄧㄤˇ}:{故事裡的人|ㄍㄨˋ|˙ㄕ|ㄌㄧˇ|˙ㄉㄜ|ㄖㄣˊ},{錯在哪裡|ㄘㄨㄛˋ|ㄗㄞˋ|ㄋㄚˇ|ㄌㄧˇ}? ::: ::resource{id="plate-1"} ## {守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ} {宋人有耕田者|ㄙㄨㄥˋ|ㄖㄣˊ|ㄧㄡˇ|ㄍㄥ|ㄊㄧㄢˊ|ㄓㄜˇ},{田中有株|ㄊㄧㄢˊ|ㄓㄨㄥ|ㄧㄡˇ|ㄓㄨ},{兔走觸株|ㄊㄨˋ|ㄗㄡˇ|ㄔㄨˋ|ㄓㄨ},{折頸而死|ㄓㄜˊ|ㄐㄧㄥˇ|ㄦˊ|ㄙˇ},{因釋其耒而守株|ㄧㄣ|ㄕˋ|ㄑㄧˊ|ㄌㄟˇ|ㄦˊ|ㄕㄡˇ|ㄓㄨ},{冀復得兔|ㄐㄧˋ|ㄈㄨˋ|ㄉㄜˊ|ㄊㄨˋ},{兔不可復得|ㄊㄨˋ|ㄅㄨˋ|ㄎㄜˇ|ㄈㄨˋ|ㄉㄜˊ},{而身為宋國笑|ㄦˊ|ㄕㄣ|ㄨㄟˊ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄒㄧㄠˋ}。 :::callout{type="notes" span="side"} ①{株|ㄓㄨ}:露出地面的樹根。 ②{走|ㄗㄡˇ}:跑。 ③{觸|ㄔㄨˋ}:碰,撞。 ④{釋|ㄕˋ}:放下。 ⑤{耒|ㄌㄟˇ}:古代翻土用的農具。 ⑥{冀|ㄐㄧˋ}:希望。 ⑦{為宋國笑|ㄨㄟˊ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄒㄧㄠˋ}:被宋國人譏笑。 ⑧{且置履|ㄑㄧㄝˇ|ㄓˋ|ㄌㄩˇ}:將要買鞋。 ⑨{度|ㄉㄨㄛˋ}:量長短。 ⑩{坐|ㄗㄨㄛˋ}:同「座」,座位。 ⑪{操|ㄘㄠ}:拿,帶著。 ⑫{度|ㄉㄨˋ}:量好的尺碼。 ⑬{反|ㄈㄢˇ}:同「返」,回去。 ⑭{罷|ㄅㄚˋ}:結束,散了。 ⑮{寧|ㄋㄧㄥˊ}:寧可。 ::: ### {語譯|ㄩˇ|ㄧˋ} :::paragraphs{style="plain"} {宋國有一個種田的人|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄧㄡˇ|ㄧ|˙ㄍㄜ|ㄓㄨㄥˋ|ㄊㄧㄢˊ|˙ㄉㄜ|ㄖㄣˊ},{田裡有一截樹樁|ㄊㄧㄢˊ|ㄌㄧˇ|ㄧㄡˇ|ㄧ|ㄐㄧㄝˊ|ㄕㄨˋ|ㄓㄨㄤ}。{有一天|ㄧㄡˇ|ㄧ|ㄊㄧㄢ},{一隻兔子跑過來|ㄧ|ㄓ|ㄊㄨˋ|˙ㄗ|ㄆㄠˇ|ㄍㄨㄛˋ|˙ㄌㄞ},{一頭撞在樹樁上|ㄧ|ㄊㄡˊ|ㄓㄨㄤˋ|ㄗㄞˋ|ㄕㄨˋ|ㄓㄨㄤ|ㄕㄤˋ},{折斷脖子死了|ㄓㄜˊ|ㄉㄨㄢˋ|ㄅㄛˊ|˙ㄗ|ㄙˇ|˙ㄌㄜ}。{這個人就放下手裡的農具|ㄓㄜˋ|˙ㄍㄜ|ㄖㄣˊ|ㄐㄧㄡˋ|ㄈㄤˋ|ㄒㄧㄚˋ|ㄕㄡˇ|ㄌㄧˇ|˙ㄉㄜ|ㄋㄨㄥˊ|ㄐㄩˋ},{天天守在樹樁旁邊|ㄊㄧㄢ|ㄊㄧㄢ|ㄕㄡˇ|ㄗㄞˋ|ㄕㄨˋ|ㄓㄨㄤ|ㄆㄤˊ|ㄅㄧㄢ},{希望再撿到兔子|ㄒㄧ|ㄨㄤˋ|ㄗㄞˋ|ㄐㄧㄢˇ|ㄉㄠˋ|ㄊㄨˋ|˙ㄗ}。{兔子再也沒有來過|ㄊㄨˋ|˙ㄗ|ㄗㄞˋ|ㄧㄝˇ|ㄇㄟˊ|ㄧㄡˇ|ㄌㄞˊ|ㄍㄨㄛˋ},{他自己倒成了宋國人的笑話|ㄊㄚ|ㄗˋ|ㄐㄧˇ|ㄉㄠˋ|ㄔㄥˊ|˙ㄌㄜ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄖㄣˊ|˙ㄉㄜ|ㄒㄧㄠˋ|ㄏㄨㄚˋ}。 ::: ::resource{id="plate-2"} ## {鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ} {鄭人有且置履者|ㄓㄥˋ|ㄖㄣˊ|ㄧㄡˇ|ㄑㄧㄝˇ|ㄓˋ|ㄌㄩˇ|ㄓㄜˇ},{先自度其足而置之其坐|ㄒㄧㄢ|ㄗˋ|ㄉㄨㄛˋ|ㄑㄧˊ|ㄗㄨˊ|ㄦˊ|ㄓˋ|ㄓ|ㄑㄧˊ|ㄗㄨㄛˋ},{至之市而忘操之|ㄓˋ|ㄓ|ㄕˋ|ㄦˊ|ㄨㄤˋ|ㄘㄠ|ㄓ}。{已得履|ㄧˇ|ㄉㄜˊ|ㄌㄩˇ},{乃曰|ㄋㄞˇ|ㄩㄝ}:「{吾忘持度|ㄨˊ|ㄨㄤˋ|ㄔˊ|ㄉㄨˋ}。」{反歸取之|ㄈㄢˇ|ㄍㄨㄟ|ㄑㄩˇ|ㄓ}。{及反|ㄐㄧˊ|ㄈㄢˇ},{市罷|ㄕˋ|ㄅㄚˋ},{遂不得履|ㄙㄨㄟˋ|ㄅㄨˋ|ㄉㄜˊ|ㄌㄩˇ}。{人曰|ㄖㄣˊ|ㄩㄝ}:「{何不試之以足|ㄏㄜˊ|ㄅㄨˋ|ㄕˋ|ㄓ|ㄧˇ|ㄗㄨˊ}?」{曰|ㄩㄝ}:「{寧信度|ㄋㄧㄥˊ|ㄒㄧㄣˋ|ㄉㄨˋ},{無自信也|ㄨˊ|ㄗˋ|ㄒㄧㄣˋ|ㄧㄝˇ}。」 ### {語譯|ㄩˇ|ㄧˋ} :::paragraphs{style="plain"} {鄭國有個人要買鞋子|ㄓㄥˋ|ㄍㄨㄛˊ|ㄧㄡˇ|˙ㄍㄜ|ㄖㄣˊ|ㄧㄠˋ|ㄇㄞˇ|ㄒㄧㄝˊ|˙ㄗ}。{他先在家裡量好自己腳的大小|ㄊㄚ|ㄒㄧㄢ|ㄗㄞˋ|ㄐㄧㄚ|˙ㄌㄧ|ㄌㄧㄤˊ|ㄏㄠˇ|ㄗˋ|ㄐㄧˇ|ㄐㄧㄠˇ|˙ㄉㄜ|ㄉㄚˋ|ㄒㄧㄠˇ},{把量好的尺碼放在座位上|ㄅㄚˇ|ㄌㄧㄤˊ|ㄏㄠˇ|˙ㄉㄜ|ㄔˇ|ㄇㄚˇ|ㄈㄤˋ|ㄗㄞˋ|ㄗㄨㄛˋ|ㄨㄟˋ|ㄕㄤˋ}。{到了市場|ㄉㄠˋ|˙ㄌㄜ|ㄕˋ|ㄔㄤˊ},{卻忘了把尺碼帶在身上|ㄑㄩㄝˋ|ㄨㄤˋ|˙ㄌㄜ|ㄅㄚˇ|ㄔˇ|ㄇㄚˇ|ㄉㄞˋ|ㄗㄞˋ|ㄕㄣ|ㄕㄤˋ}。{他已經挑好了鞋子|ㄊㄚ|ㄧˇ|ㄐㄧㄥ|ㄊㄧㄠ|ㄏㄠˇ|˙ㄌㄜ|ㄒㄧㄝˊ|˙ㄗ},{才說|ㄘㄞˊ|ㄕㄨㄛ}:「{我忘了帶尺碼|ㄨㄛˇ|ㄨㄤˋ|˙ㄌㄜ|ㄉㄞˋ|ㄔˇ|ㄇㄚˇ}。」{就轉身回家去拿|ㄐㄧㄡˋ|ㄓㄨㄢˇ|ㄕㄣ|ㄏㄨㄟˊ|ㄐㄧㄚ|ㄑㄩˋ|ㄋㄚˊ}。{等他再回到市場|ㄉㄥˇ|ㄊㄚ|ㄗㄞˋ|ㄏㄨㄟˊ|ㄉㄠˋ|ㄕˋ|ㄔㄤˊ},{市場已經散了|ㄕˋ|ㄔㄤˊ|ㄧˇ|ㄐㄧㄥ|ㄙㄢˋ|˙ㄌㄜ},{終於沒買到鞋子|ㄓㄨㄥ|ㄩˊ|ㄇㄟˊ|ㄇㄞˇ|ㄉㄠˋ|ㄒㄧㄝˊ|˙ㄗ}。{有人問他|ㄧㄡˇ|ㄖㄣˊ|ㄨㄣˋ|ㄊㄚ}:「{為什麼不用腳試穿呢|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄅㄨˋ|ㄩㄥˋ|ㄐㄧㄠˇ|ㄕˋ|ㄔㄨㄢ|˙ㄋㄜ}?」{他說|ㄊㄚ|ㄕㄨㄛ}:「{我寧可相信尺碼|ㄨㄛˇ|ㄋㄧㄥˊ|ㄎㄜˇ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄔˇ|ㄇㄚˇ},{也不相信自己的腳|ㄧㄝˇ|ㄅㄨˋ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄗˋ|ㄐㄧˇ|˙ㄉㄜ|ㄐㄧㄠˇ}。」 ::: ## {想一想|ㄒㄧㄤˇ|ㄧ|ㄒㄧㄤˇ} {style="drill"} 1. {宋國的農夫為什麼成了別人的笑話|ㄙㄨㄥˋ|ㄍㄨㄛˊ|˙ㄉㄜ|ㄋㄨㄥˊ|ㄈㄨ|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄔㄥˊ|˙ㄌㄜ|ㄅㄧㄝˊ|ㄖㄣˊ|˙ㄉㄜ|ㄒㄧㄠˋ|ㄏㄨㄚˋ}? 2. {鄭國人明明到了市場|ㄓㄥˋ|ㄍㄨㄛˊ|ㄖㄣˊ|ㄇㄧㄥˊ|ㄇㄧㄥˊ|ㄉㄠˋ|˙ㄌㄜ|ㄕˋ|ㄔㄤˊ},{為什麼沒買到鞋子|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄇㄟˊ|ㄇㄞˇ|ㄉㄠˋ|ㄒㄧㄝˊ|˙ㄗ}? 3. {這兩個人做錯的地方|ㄓㄜˋ|ㄌㄧㄤˇ|˙ㄍㄜ|ㄖㄣˊ|ㄗㄨㄛˋ|ㄘㄨㄛˋ|˙ㄉㄜ|ㄉㄧˋ|ㄈㄤ},{有什麼相同|ㄧㄡˇ|ㄕㄣˊ|˙ㄇㄜ|ㄒㄧㄤ|ㄊㄨㄥˊ}? :::callout{type="author" span="side"} {韓非|ㄏㄢˊ|ㄈㄟ},戰國時代韓國的貴族,大約生於西元前二八〇年,死於西元前二三三年。他和{李斯|ㄌㄧˇ|ㄙ}都是{荀子|ㄒㄩㄣˊ|ㄗˇ}的學生,說話口吃,卻很會寫文章。後人把他的文章編成《韓非子》,書中有許多有趣的寓言故事。 ::: ## {語文天地|ㄩˇ|ㄨㄣˊ|ㄊㄧㄢ|ㄉㄧˋ} {style="drill"} 「{守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}」{與|ㄩˇ}「{鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}」{後來都成了成語|ㄏㄡˋ|ㄌㄞˊ|ㄉㄡ|ㄔㄥˊ|˙ㄌㄜ|ㄔㄥˊ|ㄩˇ}。 :::paragraphs{style="idiom"} **{守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}**:{比喻不肯努力|ㄅㄧˇ|ㄩˋ|ㄅㄨˋ|ㄎㄣˇ|ㄋㄨˇ|ㄌㄧˋ},{只想白白得到好處|ㄓˇ|ㄒㄧㄤˇ|ㄅㄞˊ|ㄅㄞˊ|ㄉㄜˊ|ㄉㄠˋ|ㄏㄠˇ|ㄔㄨˋ}。{例|ㄌㄧˋ}:{考試前不讀書|ㄎㄠˇ|ㄕˋ|ㄑㄧㄢˊ|ㄅㄨˋ|ㄉㄨˊ|ㄕㄨ},{只希望考的題目剛好都會|ㄓˇ|ㄒㄧ|ㄨㄤˋ|ㄎㄠˇ|˙ㄉㄜ|ㄊㄧˊ|ㄇㄨˋ|ㄍㄤ|ㄏㄠˇ|ㄉㄡ|ㄏㄨㄟˋ},{這就是守株待兔|ㄓㄜˋ|ㄐㄧㄡˋ|ㄕˋ|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}。 **{鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}**:{比喻只相信死板的規定|ㄅㄧˇ|ㄩˋ|ㄓˇ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄙˇ|ㄅㄢˇ|˙ㄉㄜ|ㄍㄨㄟ|ㄉㄧㄥˋ},{不看實際的情形|ㄅㄨˋ|ㄎㄢˋ|ㄕˊ|ㄐㄧˋ|˙ㄉㄜ|ㄑㄧㄥˊ|ㄒㄧㄥˊ}。{例|ㄌㄧˋ}:{買衣服不試穿|ㄇㄞˇ|ㄧ|˙ㄈㄨ|ㄅㄨˋ|ㄕˋ|ㄔㄨㄢ},{只看尺寸|ㄓˇ|ㄎㄢˋ|ㄔˊ|˙ㄘㄨㄣ},{就像鄭人買履|ㄐㄧㄡˋ|ㄒㄧㄤˋ|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}。 ::: :::callout{type="chars" span="side"} {株|ㄓㄨ} {耒|ㄌㄟˇ} {冀|ㄐㄧˋ} {履|ㄌㄩˇ} {罷|ㄅㄚˋ} {遂|ㄙㄨㄟˋ} ::: :::paragraphs{style="colophon"} Set in Iansui, LXGW WenKai TC, Noto Serif TC and Noto Sans TC (SIL OFL). Text: Han Feizi, chapters :sideways[49] and :sideways[32], Chinese Wikisource, revisions 2642850 and 2327662 (CC BY-SA 4.0). Zhuyin checked against the Revised Mandarin Chinese Dictionary (Ministry of Education, Taiwan); modern versions, notes and exercises written for this recipe (CC BY 4.0). :::`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Iansui: ['400'], 'LXGW WenKai TC': ['400'], 'Noto Serif TC': ['400'], 'Noto Sans TC': ['400', '700'], }; // What the label face sets: the tab, the foot of the page, box titles and exercise heads. const LABELS = '第三單元寓言故事國語第九冊十二課兩則注釋作者生字語譯想一文天地、0123456789'; // #region art: two brush paintings for the upper tier, JPEGs in assets/ cut to 15 : 7 const plates = { 'plate-1': 'plate-1-1500.jpg', 'plate-2': 'plate-2-1500.jpg' }; // #endregion const altText = { 'plate-1': '守株待兔:農夫坐在樹樁旁,農具丟在田裡,一隻兔子跑遠了。', 'plate-2': '鄭人買履:鄭國人從鞋攤走回家,量好的尺碼還放在家門口的凳子上。', }; const resources = Object.entries(plates).map(([id, fileId]) => ({ id, typeId: 'plate', kind: 'bitmap', createdAt: 0, updatedAt: 0, altText: altText[id], placement: { span: 'side' }, bitmap: { fileId, format: 'jpeg', width: 1500, height: 700 } })); // declared at their pixels // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: each voice loads with the text it sets; vertical forms for the canvas // {株|ㄓㄨ}: the characters are the text, the readings go to the zhuyin face. const BOXES = /^:::callout\{type="(?:notes|author)"[^}]*\}\n([\s\S]*?)^:::$/gm; const bases = (md) => md.replace(/\{([^|{}]+)((?:\|[^|{}]+)+)\}/g, '$1'); const readings = [...markdown.matchAll(/\{[^|{}]+((?:\|[^|{}]+)+)\}/g)].map((m) => m[1]).join(''); await loadFonts(FONTS, markdown); await loadCjkFonts({ [KAI]: ['400'] }, bases(markdown.replace(BOXES, '')), { vertical: true }); const notesText = [...markdown.matchAll(BOXES)].map((m) => m[1]).join('\n'); await loadCjkFonts({ [MING]: ['400'] }, bases(notesText), { vertical: true }); await loadCjkFonts({ [ZHUYIN]: ['400'] }, readings.replaceAll('|', ''), { vertical: true }); await loadCjkFonts({ [HEI]: ['400', '700'] }, LABELS, { vertical: true }); await Promise.all(Object.values(plates).map((fileId) => loadImage(fileId, asset(fileId)))); // Lesson 12 of a reader: page 86 is a verso, so the lesson opens on a spread. const continuation = { pageIndexOffset: 1, pageNumbering: { startAt: 86 } }; const doc = await withLoadedFonts( () => buildDocument({ markdown, resources, continuation }, config()), { ...kitFonts(FONTS), text: markdown }); showBook(doc, { title: t({ en: 'A vertical reader with zhuyin', es: 'Un libro de lectura vertical con zhuyin' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // #endregionKit · core, fonts, viewer, pdf, images, cjk : identique dans toutes les recettes · 504 lignes
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── Kit · cjk v2 ── Chinese, Japanese and Korean books · postext.dev/cookbook // Fontsource ships a CJK family as about a hundred files per weight, each // declared in its stylesheet with the unicode-range it covers. The screen // loads the files the sample touches; the PDF gets the same files for the // characters its pages set in each face, and embeds each as a subset. // A book bound on the right (vertical text) is shown with its spreads // mirrored: page 1 alone on the left of the spine, then [3 | 2]. /** The files of a Fontsource face, read from its stylesheet: { url, range, * ranges }, the last declared first (the order the browser tries them in). */ function cjkSlices(family, weight, style) { cjkSlices.cache ??= new Map(); const id = fontsourceId(family); const css = `${id}/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { let at; cjkSlices.cache.set(css, fontsourceAt(id).then((a) => fetch(at = a + css.slice(id.length + 1))) .then((res) => { if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style} (${res.status})`); return res.text(); }) .then((text) => [...text.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => { const range = /unicode-range:\s*([^;]+);/.exec(rule)?.[1].trim() ?? 'U+0-10FFFF'; const ranges = range.split(',').map((part) => { const [lo, hi = lo] = part.trim().slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return { url: new URL(/url\(([^)]+?\.woff2)\)/.exec(rule)[1], at).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` the screen sets code point `cp` in, if any: the * last one loadCjkFonts adds whose range holds it. A numbered file that * repeats a letter of latin is added after latin, and wins it. */ function cjkSliceFor(slices, cp) { const holds = (slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi); return slices.reduce((last, slice) => (holds(slice) ? slice : last), undefined); } /** Whether Fontsource serves `family` as a Chinese, Japanese or Korean * family (its subsets name the script). Fails when the API does not * answer: a CJK face taken for a Latin one would paint in a system face. */ async function isCjkFamily(family) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); return !!meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset)); } /** faces = { 'Noto Serif TC': ['400', '700'] }, as for loadFonts: the * whole FONTS object may be passed, its other families are left to * loadFonts. Adds one FontFace per file of each CJK face with its * unicodeRange, then loads the files `text` touches. `text` is what the * faces set: the sample for the text face; a book in several voices calls * it once per voice (loadCjkFonts({ 'LXGW WenKai TC': ['400'] }, quotes)), * so the heading and quotation faces fetch and check only their own * characters. Fails when a character of `text` is in no file of a face. * List every weight the pages use: a weight left to the engine's later round * gets the latin file only. With { vertical: true } it also loads each * family's vertical forms (brackets, quotes, pause marks) for the canvas, * which needs loadVerticalAlternates imported from postext. Resolves to * the number of files loaded. */ async function loadCjkFonts(faces, text, { vertical = false } = {}) { kitStatus('Loading fonts…'); let loaded = 0; try { if (vertical && typeof loadVerticalAlternates !== 'function') { throw new Error('loadCjkFonts(…, { vertical: true }) needs loadVerticalAlternates imported from postext'); } for (const [family, specs] of Object.entries(faces)) { if (!(await isCjkFamily(family))) continue; const twin = []; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const slices = await cjkSlices(family, weight, style); const missing = [...new Set(text)].filter((ch) => /\S/.test(ch) && !cjkSliceFor(slices, ch.codePointAt(0))); if (missing.length) { throw new Error(`${family} ${spec} has no file for ${missing.slice(0, 12).join(' ')}: ` + `give each face the text it sets (loadCjkFonts({ '${family}': ['${spec}'] }, text))`); } for (const slice of slices) { document.fonts.add(new FontFace(family, `url(${slice.url}) format('woff2')`, { weight: String(weight), style, unicodeRange: slice.range })); twin.push({ source: slice.url, weight: String(weight), style, unicodeRange: slice.range }); } const font = `${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`; loaded += (await document.fonts.load(font, text)).length; if (!document.fonts.check(font, text)) throw new Error(`${family} ${spec} did not load for the sample`); } // The same files under a twin name with the `vert` feature on: the // canvas paints the punctuation of vertical lines with it. if (vertical && twin.length) await loadVerticalAlternates(family, twin); } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with CJK faces: a family whose * Fontsource subsets are Chinese, Japanese or Korean gets the files the * screen set its pages' characters in (`request.codePoints`), each marked * with its unicode-range and its place in the order loadCjkFonts added * them: postext-pdf sets a character two files hold in the one added * last, as the browser does. Any other family gets the latin file * fontsourceProvider fetches (the "pdf" block) and, when the face sets * letters only latin-ext has, that file too. */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return cjkLatinPdfFiles(family, weight, style, request); const meta = await fontsourceMeta(family); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const slices = await cjkSlices(family, w, s); const picked = new Set(); for (const cp of request?.codePoints ?? []) { const slice = cjkSliceFor(slices, cp); if (slice) picked.add(slice); } if (!picked.size) picked.add(slices[0]); return Promise.all(slices.map(async (slice, order) => { if (!picked.has(slice)) return null; const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer())); return Object.assign(bytes, { unicodeRange: slice.range, order }); })).then((files) => files.filter(Boolean)); } /** A Latin family set next to the CJK faces: its latin file, then its * latin-ext file when the face sets letters only latin-ext has (ō ū in * Hepburn rōmaji, ǎ in pinyin), the file loadFonts adds on screen for * them, each marked with its unicode-range and its place in that order: * the PDF sets a character in the file the browser took it from. A face * Fontsource ships without latin-ext, or whose file does not come, gets * latin alone, and the PDF names the letters it lacks. */ async function cjkLatinPdfFiles(family, weight, style, request) { const meta = await fontsourceMeta(family); const beyond = [...(request?.codePoints ?? [])].some(cjkLatinExtOnly); if (!beyond || !meta?.subsets?.includes('latin-ext')) return fontsourceProvider(family, weight, style); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const id = fontsourceId(family); const url = `${await fontsourceAt(id)}files/${id}-latin-ext-${w}-${s}.woff2`; const [latin, ext] = await Promise.all([fontsourceProvider(family, weight, style), fetch(url) .then(async (res) => (res.ok ? decompressWoff2(new Uint8Array(await res.arrayBuffer())) : null), () => null)]); const ranges = kitRanges(); return ext ? [Object.assign(latin, { unicodeRange: ranges.latin, order: 0 }), Object.assign(ext, { unicodeRange: ranges['latin-ext'], order: 1 })] : latin; } /** Whether code point `cp` is in Fontsource's latin-ext file and not in * its latin file: Latin Extended-A and -B, IPA, the spacing modifiers and * Latin Extended Additional (loadFonts's test for latin-ext), less the * few latin holds too (ı Œ œ ʻ ʼ ˆ ˚ ˜). */ function cjkLatinExtOnly(cp) { if (!((cp >= 0x100 && cp <= 0x2ff) || (cp >= 0x1e00 && cp <= 0x1eff))) return false; return ![0x131, 0x152, 0x153, 0x2bb, 0x2bc, 0x2c6, 0x2da, 0x2dc].includes(cp); } /** showPages for a book bound on either edge. A right-bound book (the * document says so: doc.binding is 'right' for page.binding 'right' and * for vertical text) lies on the desk as it opens: page 1 alone on the * left of the spine, then [3 | 2], the spine shade on each page's inner * edge. `binding` ('left' | 'right') overrides the document's. */ function showBook(docs, { binding, ...options } = {}) { const count = showPages(docs, options); const right = (binding ?? [docs].flat()[0]?.binding) === 'right'; if (!document.getElementById('pt-kit-cjk')) { // The pages keep direction ltr: a canvas draws text in the direction its // element inherits, and under rtl each run would end where the engine // starts it, its brackets mirrored. document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk"> .pt-spread[dir="rtl"] canvas { direction: ltr; } .pt-spread[dir="rtl"] figure:first-child canvas { box-shadow: inset 14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } </style>`); } // Each pair stays [verso, recto] in the page; right to left, the verso // sits on the right. Phones stack the pages in reading order either way. for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr'; document.getElementById('pages').dataset.binding = right ? 'right' : 'left'; return count; } // ─── /Kit ───────────────────────────────────────────────────────────────────────
Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)
Variantes
#Imprimer le zhuyin dans une seconde couleur
Certains livres de lecture impriment les lectures plus claires que le texte, pour que les caractères ressortent.
- ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) },
+ ruby: { fontFamily: ZHUYIN, fontSize: em(0.5), color: col('muted') },#Placer les lectures au-dessus d’un texte horizontal
L’abécédaire en pinyin place une syllabe au-dessus de chaque caractère sur une ligne horizontale, comme le font les premiers livres de lecture du continent et de Hong Kong.
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
En texte vertical, la colonne latérale est un registre, et 'left' est celui du haut
Avec layout.writingMode 'vertical-rl', la page est une page horizontale tournée d’un quart de tour : la colonne latérale d’une mise en page oneAndHalf devient un registre en travers de la page. sideColumnSide 'left' la place en tête, à l’endroit des commentaires de marge de tête (眉批), et 'right' en pied. 'outer' et 'inner' se lisent comme 'right' et 'left' (un registre n’a pas de bord extérieur) : le 'outer' de la colonne de marge d’un manuel envoie donc les notes en pied. sideColumnPercent est une part de la hauteur de la page. Colonne marginale pour les flottants →
Erreur fréquente
Les polices chinoises se chargent par tranches, avec le bloc cjk
Fontsource sert une famille chinoise, japonaise ou coréenne sous la forme d’une centaine de fichiers par graisse, chacun couvrant une plage de caractères. loadFonts ne récupère que le fichier latin : à l’écran, les caractères han viennent d’une police système et sont mal mesurés, et fontsourceProvider remet ce fichier latin au PDF, qui les imprime en cases vides. Listez le bloc cjk du kit, appelez loadCjkFonts(FONTS, markdown) après loadFonts (une fois par voix, avec le texte qu’elle compose, quand le livre utilise plusieurs polices CJK) et donnez à renderToPdf fontProvider: cjkPdfProvider : les deux prennent les fichiers qui contiennent les caractères du texte. Polices chinoises, japonaises et coréennes →
Erreur fréquente
Un livre relié à droite montre ses doubles pages avec showBook
Dans un livre relié à droite (texte arabe, hébreu ou persan, chinois vertical, ou page.binding 'right'), la page 1 reste le recto, mais elle se trouve à gauche du dos, et les paires se lisent [3 | 2]. showPages présente tout livre relié à gauche ; showBook, du bloc book (le bloc cjk contient la même fonction), lit doc.binding et inverse les paires. capture.hero désigne toujours une double page dans l’ordre de lecture, [verso, recto] : [2, 3]. Livres reliés à droite →
Erreur fréquente
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 →
-
Prenez les lectures dans le dictionnaire de Taïwan. Les outils construits sur les dictionnaires du continent, dont pypinyin, donnent 度 duó, 寧 nìng, 時候 shíhou et 市場 shìchǎng ; le Revised Mandarin Chinese Dictionary du ministère de l’Éducation, que suivent les livres de lecture de Taïwan, lit ㄉㄨㄛˋ, ㄋㄧㄥˊ, ㄕˊ ㄏㄡˋ et ㄕˋ ㄔㄤˊ, et compose 故事, 家裡 et 過來 avec une seconde syllabe au ton neutre. moedict.tw le sert mot par mot. Il garde 一 et 不 dans leur ton propre, et cette leçon aussi.
-
Dessinez quelques caractères dans chaque police avant de les mélanger. LXGW WenKai TC suit les formes héritées et dessine 為 (U+70BA) comme 爲 : une leçon composée avec elle imprimait 爲宋國笑 dans le texte et 為宋國笑 dans la note en Ming sur la même double page. Iansui dessine le 為 standard.
-
Iansui n’a qu’une graisse. Les titres ressortent par leur corps et les entrées d’expression par leur couleur : le style
idiomfixeboldFontWeight: 400avec l’accent commeboldColor, si bien que**…**change la couleur et non la police.
Crédits
- Recette
- Ignacio Ferro
- Texte
- Han Feizi, chapter 49, “Wu du” (The five vermin): the man of Song who waited by the stump · Han Fei; transcription and punctuation by Wikisource editors (爲 set as 為, the Taiwan standard form) · CC BY-SA 4.0
- Han Feizi, chapter 32, “Wai chu shuo zuo shang”: the man of Zheng who went to buy shoes · Han Fei; transcription and punctuation by Wikisource editors · CC BY-SA 4.0
- The zhuyin (checked against the Ministry of Education's Revised Mandarin Chinese Dictionary), the modern Chinese versions, the notes, the author box, the lead and the exercises, written for this recipe · Postext Cookbook · CC BY 4.0
- Images
- The farmer waiting by the stump, a brush painting · Generated With Diffusion Models · original
- The man of Zheng hurrying home from the shoe stall, a brush painting · Generated With Diffusion Models · original
- Polices
- Iansui (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1) · Noto Serif TC (SIL OFL 1.1) · Noto Sans TC (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)


