En bref
Une page d’un livre de lecture japonais, avec de petits kana sur chaque kanji pour qu’un enfant le lise à voix haute. Postext place chaque lecture sur ses caractères ; une lecture longue s’appuie sur le kana voisin, jamais sur un kanji.
Ce que vous allez composer
Les premières pages de « Gon le renard » (ごんぎつね, 1932) de Niimi Nankichi, telles que les imprime un livre de lecture de quatrième année, le récit que tout enfant japonais rencontre vers neuf ou dix ans. Une page B5 contient 28 caractères sur 21 lignes de Klee One, un caractère dessiné d’après l’écriture des manuels scolaires, en 14 pt sur une ligne de 28 pt, avec une lecture en petits kana au-dessus de chaque kanji. Les lectures sont le sujet : une par caractère au-dessus de 村 et de 森, une pour tout le mot au-dessus de 百舌鳥 (mozu), dont les kanji n’ont pas de lecture propre, et des lectures jukugo au-dessus de 兵十 (Hyōjū), chacune sur son kanji tant qu’aucune n’est trop longue. Un encadré à la fin montre les trois cas à l’enseignant. L’abécédaire en pinyin place de la même façon des lectures chinoises au-dessus de chaque caractère.
Cette recette répond à
- Comment ajouter des furigana au-dessus des kanji, une lecture par caractère ou une pour le mot entier ?
La réponse courte
// In a document tagged 'ja' the compact {漢字|かん|じ} (one reading per character) is jukugo
// ruby: each reading stays over its own kanji when it fits, and when one is longer it may run
// onto the next kanji of the same word; a break may fall between the two. {百舌鳥|もず} (one
// reading for the word) is group ruby, spread over the word 1:2:1. :ruby[菜種]{rt="な たね"
// mode=jukugo} says the same as the compact form; mode=mono keeps each reading to its kanji.
const cjk = {
grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
ruby: {
fontSize: em(0.5), // 7 pt: half the text, the size JLReq gives furigana
color: col('reading'),
align: 'jis', // a reading shorter than its base: ½ unit at each end, 1 between (1:2:1)
overhang: 'kana', // a longer reading may run one ruby character onto a kana, never a kanji
smallKana: 'keep', // ひょう with its small ょ, as today's books print it
},
};
// The line gap (28 − 14 = 14 pt) holds a 7 pt reading with room to spare: no line moves for one.
const bodyText = {
fontFamily: TEXT, fontSize: pt(SIZE), lineHeight: pt(PITCH), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true,
avoidRunts: true, // no paragraph ends on one character
};
Ingrédients
- Fonctionnalités
- Furigana : lectures en kana au-dessus des kanjiRuby : lectures en pinyin et en zhuyinKana, kanji et rōmajiMarques d’emphase japonaises (bōten)Polices chinoises, japonaises et coréennesGrille de caractèresEspacement de la ponctuation japonaise (yakumono)Coupure des lignes en japonais (kinsoku)Titres sur plusieurs lignes du texte (gyōdori)Ouvertures composéesAttributs de titreEncadrésTitres courants et foliosMarges en miroirCouleur du papierStyles de paragraphePalette de couleurs sémantiqueImport depuis Aozora BunkoExport PDF
- Utilise aussi
- Coupure des lignes en chinoisÉquilibrage des colonnesPoints d’emphase, traits des noms propres et des titresSortir de la grille volontairementPolices intégrées au PDF
- Typographie
- Klee One, Zen Maru Gothic (SIL OFL 1.1)
- Ressources
- Aucune : chaque image est dessinée en code
Préparation
#1 · Trois façons d’écrire une lecture
Le code est dans la réponse courte plus haut. Dans le Markdown, {村|むら} place むら au-dessus de 村 ; {兵十|ひょう|じゅう} donne à chaque kanji sa propre lecture, et dans un document balisé 'ja' c’est un ruby jukugo : les lectures restent au-dessus de leurs kanji quand elles tiennent, et une lecture plus longue peut s’appuyer sur le kanji suivant du même mot, jamais sur celui d’un autre mot. {百舌鳥|もず}, une lecture pour trois kanji, est un ruby de groupe, réparti sur le mot comme un tout. :ruby[菜種]{rt="な たね" mode=jukugo} écrit le dernier exemple de l’encadré sous forme de directive, qui accepte aussi mode=mono et align (Signes, ruby et warichu).

#2 · 1:2:1, et des lectures qui ne s’appuient que sur des kana
Les deux réglages sous cjk.ruby sont ceux que 'ja' choisit de lui-même. align: 'jis' dispose une lecture plus courte que sa base à la manière de JIS X 4051 : une demi-unité d’espace à chaque bout et une unité entière entre les kana, si bien que も et ず se placent au-dessus des jointures de 百舌鳥 sur la page 25 au lieu de se tasser au milieu. overhang: 'kana' laisse une lecture plus longue que sa base déborder d’au plus un caractère de ruby sur un kana ou sur le blanc d’un signe voisin, jamais sur un kanji : ぎつね au-dessus du 狐 de 「ごん狐」 s’appuie sur ん et sur le crochet qui suit, et le じゅう de 兵十だな sur だ. Là où il n’y a pas de kana sur lequel s’appuyer, les kanji s’espacent jusqu’à la largeur de la lecture. Au bord d’une ligne, la lecture reste dans la justification.

#3 · Un interligne qui loge les lectures
Les lectures font 7 pt, la moitié du texte de 14 pt, et la ligne fait 28 pt : les 14 pt entre deux lignes les accueillent largement, et chaque ligne de la grille reste à sa place. Réglez un pas plus serré que le texte plus ses lectures, et la composition émet l’avertissement rubyExceedsLeading.
#4 · L’ouverture n’imprime aucune lecture
// Design text sets no furigana (gotcha: ruby-not-in-designs): the author's reading is a
// line of its own.
const BAND = 92; // mm from the trim's top
const opener = { enabled: true, minHeight: pt(PITCH * 6), slot: { elements: [
{ kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('straw') },
placement: { anchor: { to: 'page', edge: 'top-left' },
size: { width: 'fill', height: mm(BAND) } } },
{ kind: 'text', id: 'unit', content: '{attr.unit}', fontFamily: ROUND, fontSize: pt(16),
fontWeight: 700, color: col('paper'), align: 'center', verticalAlign: 'middle',
overflow: 'clip', box: { backgroundColor: col('red'), borderRadius: mm(6) },
placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(-2) },
size: { width: mm(12), height: mm(12) } } },
{ kind: 'text', id: 'series', content: '{title}', fontFamily: ROUND, fontSize: pt(9),
fontWeight: 700, letterSpacing: pt(2), color: col('red'), align: 'left',
verticalAlign: 'middle', placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { x: mm(16), y: mm(-2) }, size: { width: mm(80), height: mm(12) } } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: ROUND, fontSize: pt(48),
lineHeight: 1.1, fontWeight: 700, color: col('ink'), align: 'left',
placement: { anchor: { to: '#unit', edge: 'below' }, offset: { y: mm(7) } } },
{ kind: 'text', id: 'kana', content: '{attr.kana}', fontFamily: ROUND, fontSize: pt(8),
letterSpacing: pt(1), color: col('muted'), align: 'left',
placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(5) } } },
{ kind: 'text', id: 'author', content: '{attr.author} 作', fontFamily: ROUND,
fontSize: pt(13), fontWeight: 500, color: col('ink'), align: 'left',
placement: { anchor: { to: '#kana', edge: 'below' }, offset: { y: mm(1) } } },
] } };
Le titre et le nom de l’auteur sont du texte de maquette, qui ne compose pas de ruby : la lecture de 新美南吉 est donc une ligne à part au-dessus du nom, en kana, comme l’impriment les livres de lecture. Le titre lui-même est en kana, ごんぎつね, comme l’écrivent ces livres, bien que le texte d’Aozora l’intitule ごん狐.
#5 · Une seule couleur d’accent, et des lectures un ton plus clair
const palette = {
ink: '#2a2420', // text: a warm near-black
red: '#b2402c', // the one accent: the unit badge, the section numbers, the box title
reading: '#5a4e45', // the furigana, a shade off the ink so the two layers part
straw: '#f6ecd6', // the opener band and the box
muted: '#7a6d62', // folios, the series line, the colophon
paper: '#fffdf8',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.red })
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
Les lectures prennent reading, un brun d’un cran plus clair que l’encre, pour que l’œil distingue les deux couches sans que les kana pâlissent. Le rouge des higanbana qui fleurissent dans le récit marque la pastille, le numéro de section et le titre de l’encadré.
La recette complète
// ═══ Postext Cookbook · Nº 124 · A school reader with furigana: per character, per word ═══ // https://postext.dev/en/cookbook/jukugo-furigana-textbook // Code: MIT · Text: 新美南吉「ごん狐」(1932), Aozora Bunko 628 (PD); readings: CC BY 4.0 // Fonts: Klee One, Zen Maru Gothic (SIL OFL 1.1) · Needs postext ≥ 1.25.0 import { buildDocument, withLoadedFonts, renderPageToCanvas } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the frame; the story is Japanese in both const RECIPE = 'jukugo-furigana-textbook'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: an autumn reader: ink, the red of the higanbana, a pale straw ground const palette = { ink: '#2a2420', // text: a warm near-black red: '#b2402c', // the one accent: the unit badge, the section numbers, the box title reading: '#5a4e45', // the furigana, a shade off the ink so the two layers part straw: '#f6ecd6', // the opener band and the box muted: '#7a6d62', // folios, the series line, the colophon paper: '#fffdf8', }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.red }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion const [TEXT, ROUND] = ['Klee One', 'Zen Maru Gothic']; // 教科書体-like text, rounded gothic const [SIZE, PITCH] = [14, 28]; // pt: a fourth-year reader's size, on a line twice as tall const [CHARS, LINES] = [28, 21]; // the type area in characters // #region answer: readings over every kanji: by character, by word, jukugo, JIS 1:2:1 // In a document tagged 'ja' the compact {漢字|かん|じ} (one reading per character) is jukugo // ruby: each reading stays over its own kanji when it fits, and when one is longer it may run // onto the next kanji of the same word; a break may fall between the two. {百舌鳥|もず} (one // reading for the word) is group ruby, spread over the word 1:2:1. :ruby[菜種]{rt="な たね" // mode=jukugo} says the same as the compact form; mode=mono keeps each reading to its kanji. const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES }, ruby: { fontSize: em(0.5), // 7 pt: half the text, the size JLReq gives furigana color: col('reading'), align: 'jis', // a reading shorter than its base: ½ unit at each end, 1 between (1:2:1) overhang: 'kana', // a longer reading may run one ruby character onto a kana, never a kanji smallKana: 'keep', // ひょう with its small ょ, as today's books print it }, }; // The line gap (28 − 14 = 14 pt) holds a 7 pt reading with room to spare: no line moves for one. const bodyText = { fontFamily: TEXT, fontSize: pt(SIZE), lineHeight: pt(PITCH), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true, avoidRunts: true, // no paragraph ends on one character }; // #endregion // #region opener: a straw band with the unit badge, the title and the author's name in kana // Design text sets no furigana (gotcha: ruby-not-in-designs): the author's reading is a // line of its own. const BAND = 92; // mm from the trim's top const opener = { enabled: true, minHeight: pt(PITCH * 6), slot: { elements: [ { kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('straw') }, placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } }, { kind: 'text', id: 'unit', content: '{attr.unit}', fontFamily: ROUND, fontSize: pt(16), fontWeight: 700, color: col('paper'), align: 'center', verticalAlign: 'middle', overflow: 'clip', box: { backgroundColor: col('red'), borderRadius: mm(6) }, placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(-2) }, size: { width: mm(12), height: mm(12) } } }, { kind: 'text', id: 'series', content: '{title}', fontFamily: ROUND, fontSize: pt(9), fontWeight: 700, letterSpacing: pt(2), color: col('red'), align: 'left', verticalAlign: 'middle', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(16), y: mm(-2) }, size: { width: mm(80), height: mm(12) } } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: ROUND, fontSize: pt(48), lineHeight: 1.1, fontWeight: 700, color: col('ink'), align: 'left', placement: { anchor: { to: '#unit', edge: 'below' }, offset: { y: mm(7) } } }, { kind: 'text', id: 'kana', content: '{attr.kana}', fontFamily: ROUND, fontSize: pt(8), letterSpacing: pt(1), color: col('muted'), align: 'left', placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(5) } } }, { kind: 'text', id: 'author', content: '{attr.author} 作', fontFamily: ROUND, fontSize: pt(13), fontWeight: 500, color: col('ink'), align: 'left', placement: { anchor: { to: '#kana', edge: 'below' }, offset: { y: mm(1) } } }, ] } }; // #endregion // Folios at the outer foot, the series beside them. const foot = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity, fontFamily: ROUND, fontSize: pt(9), color: col('muted'), align: edge.endsWith('left') ? 'left' : 'right', placement: { anchor: { to: 'container', edge }, offset: { x: mm(x), y: mm(-12) } }, ...extra }); const folio = { fontWeight: 700, color: col('red') }; const config = () => ({ locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag) colorPalette, page: { sizePreset: 'custom', width: mm(182), height: mm(257), dpi: 150, // B5 backgroundColor: col('paper'), // Minimums: the grid centres its 28 × 21 area (138 × 207 mm) in what they leave. margins: { top: mm(26), bottom: mm(22), left: mm(20), right: mm(20), mirror: true } }, layout: { layoutType: 'single' }, cjk, bodyText, headings: { fontFamily: ROUND, fontWeight: 700, color: col('red'), balancing: { enabled: false }, // nothing added above heads: the grid holds levels: [ // parity 'any': the H1 opens on the next page, either side (the default waits for a recto). { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener }, { level: 2, fontSize: pt(16), lineSpan: 2 }, // 一, 二…: two lines of the grid ] }, calloutStyles: [{ id: 'words', background: col('straw'), borderRadius: mm(3), snapToGrid: false, marginTop: pt(PITCH), marginBottom: pt(0), padding: { top: mm(4), right: mm(6), bottom: mm(3), left: mm(6) }, titleStyle: { fontFamily: ROUND, fontSize: pt(11), fontWeight: 700, color: col('red') }, body: { fontFamily: TEXT, fontSize: pt(13), lineHeight: pt(28), color: col('ink'), firstLineIndent: pt(0), textAlign: 'left' } }], paragraphStyles: [{ id: 'colophon', fontFamily: ROUND, fontSize: pt(6.5), lineHeight: pt(9), color: col('muted'), firstLineIndent: pt(0), textAlign: 'left', marginTop: mm(4) }], header: { elements: [] }, footer: { elements: [ foot('v-folio', '{pageNumber}', 'even', 'bottom-left', 0, folio), foot('v-series', '{title}', 'even', 'bottom-left', 9), foot('r-series', '{chapterTitle}', 'odd', 'bottom-right', -9), foot('r-folio', '{pageNumber}', 'odd', 'bottom-right', 0, folio), ] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Exemple Markdown · 38 lignes · content.en.md
title: "よみもの 四年" --- # ごんぎつね {author="新美南吉" kana="にいみ なんきち" unit="4"} ## 一 これは、{私|わたし}が{小|ちい}さいときに、{村|むら}の{茂平|も|へい}というおじいさんからきいたお{話|はなし}です。 むかしは、{私|わたし}たちの{村|むら}のちかくの、{中山|なか|やま}というところに{小|ちい}さなお{城|しろ}があって、{中山|なか|やま}さまというおとのさまが、おられたそうです。 その{中山|なか|やま}から、{少|すこ}しはなれた{山|やま}の{中|なか}に、「ごん{狐|ぎつね}」という{狐|きつね}がいました。ごんは、{一人|ひとり}ぼっちの{小狐|こ|ぎつね}で、:dots[しだ]{style=sesame}の{一|いっ}ぱいしげった{森|もり}の{中|なか}に{穴|あな}をほって{住|す}んでいました。そして、{夜|よる}でも{昼|ひる}でも、あたりの{村|むら}へ{出|で}てきて、いたずらばかりしました。はたけへ{入|はい}って{芋|いも}をほりちらしたり、{菜種|な|たね}:dots[がら]{style=sesame}の、ほしてあるのへ{火|ひ}をつけたり、{百姓家|ひゃく|しょう|や}の{裏手|うら|て}につるしてあるとんがらしをむしりとって、いったり、いろんなことをしました。 {或|ある}{秋|あき}のことでした。{二|に}、{三日|さん|にち}{雨|あめ}がふりつづいたその{間|あいだ}、ごんは、{外|そと}へも{出|で}られなくて{穴|あな}の{中|なか}にしゃがんでいました。 {雨|あめ}があがると、ごんは、ほっとして{穴|あな}からはい{出|で}ました。{空|そら}はからっと{晴|は}れていて、{百舌鳥|もず}の{声|こえ}がきんきん、ひびいていました。 ごんは、{村|むら}の{小川|お|がわ}の{堤|つつみ}まで{出|で}て{来|き}ました。あたりの、すすきの{穂|ほ}には、まだ{雨|あめ}のしずくが{光|ひか}っていました。{川|かわ}は、いつもは{水|みず}が{少|すくな}いのですが、{三日|みっ|か}もの{雨|あめ}で、{水|みず}が、どっとましていました。ただのときは{水|みず}につかることのない、{川|かわ}べりのすすきや、{萩|はぎ}の{株|かぶ}が、{黄|き}いろくにごった{水|みず}に{横|よこ}だおしになって、もまれています。ごんは{川下|かわ|しも}の{方|ほう}へと、ぬかるみみちを{歩|ある}いていきました。 ふと{見|み}ると、{川|かわ}の{中|なか}に{人|ひと}がいて、{何|なに}かやっています。ごんは、{見|み}つからないように、そうっと{草|くさ}の{深|ふか}いところへ{歩|ある}きよって、そこからじっとのぞいてみました。 「{兵十|ひょう|じゅう}だな」と、ごんは{思|おも}いました。{兵十|ひょう|じゅう}はぼろぼろの{黒|くろ}いきものをまくし{上|あ}げて、{腰|こし}のところまで{水|みず}にひたりながら、{魚|さかな}をとる、:dots[はりきり]{style=sesame}という、{網|あみ}をゆすぶっていました。はちまきをした{顔|かお}の{横|よこ}っちょうに、まるい{萩|はぎ}の{葉|は}が{一|いち}まい、{大|おお}きな{黒子|ほくろ}みたいにへばりついていました。 しばらくすると、{兵十|ひょう|じゅう}は、:dots[はりきり]{style=sesame}{網|あみ}の{一|いち}ばんうしろの、{袋|ふくろ}のようになったところを、{水|みず}の{中|なか}からもちあげました。その{中|なか}には、{芝|しば}の{根|ね}や、{草|くさ}の{葉|は}や、くさった{木|き}ぎれなどが、ごちゃごちゃはいっていましたが、でもところどころ、{白|しろ}いものがきらきら{光|ひか}っています。それは、ふとい:dots[うなぎ]{style=sesame}の{腹|はら}や、{大|おお}きな:dots[きす]{style=sesame}の{腹|はら}でした。{兵十|ひょう|じゅう}は、びくの{中|なか}へ、そのうなぎやきすを、ごみと{一|いっ}しょにぶちこみました。そして、また、{袋|ふくろ}の{口|くち}をしばって、{水|みず}の{中|なか}へ{入|い}れました。 {兵十|ひょう|じゅう}はそれから、びくをもって{川|かわ}から{上|あが}りびくを{土手|ど|て}においといて、{何|なに}をさがしにか、{川上|かわ|かみ}の{方|ほう}へかけていきました。 :::callout{type="words" title="ふりがなの つけかた"} 一字に 一つずつ {村|むら} {城|しろ} {森|もり} {穴|あな} ことばに 一つ {百舌鳥|もず} {一人|ひとり} {黒子|ほくろ} 一字ずつ、ことばとして {兵十|ひょう|じゅう} {小川|お|がわ} :ruby[菜種]{rt="な たね" mode=jukugo} ::: :::paragraphs{style="colophon"} Text: Niimi Nankichi, “Gon gitsune” (1932), section 1, from Aozora Bunko card 628 (base text 新美南吉童話集, Iwanami Bunko, 1996; input 林裕司, proofing 浜野智), public domain; readings over every kanji added for the Postext Cookbook. Set in Klee One and Zen Maru Gothic (SIL OFL). :::`; // content.<lang>.md: the same Japanese page in both // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build 'Klee One': ['400'], // the text and its readings: the hand of a school textbook 'Zen Maru Gothic': ['400', '500', '700'], // the title, heads, badge, box title, folios }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // Each face loads the files of what it sets (gotcha: cjk-fonts-slices). const all = (re) => (markdown.match(re) ?? []).join(''); await loadFonts(FONTS, markdown); await loadCjkFonts({ [TEXT]: FONTS[TEXT] }, markdown); await loadCjkFonts({ [ROUND]: FONTS[ROUND] }, `${all(/^#.*$/gm)}${all(/title="[^"]*"/g)}${all(/colophon"\}\n[^\n]*/g)}よみもの四年作0123456789`); // Page 1 is page 24 of the reader, a verso: the lesson opens on a spread. const continuation = { pageIndexOffset: 23, pageNumbering: { startAt: 24 } }; const doc = await withLoadedFonts(() => buildDocument({ markdown, continuation }, config()), { ...kitFonts(FONTS), text: markdown }); showPages(doc, { title: t({ en: 'A school reader with furigana', es: 'Un libro de lectura con furigana' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);Kit · core, fonts, viewer, pdf, cjk : identique dans toutes les recettes · 458 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 · 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
#Des kana pleine taille dans les lectures
Les livres anciens imprimaient っ et ょ en pleine taille dans le ruby, がつこう pour がっこう ; smallKana: 'full' fait de même et laisse inchangé le texte lu à voix haute ou copié.
- smallKana: 'keep', // ひょう with its small ょ, as today's books print it
+ smallKana: 'full', // ひよう: the small kana at full size, as letterpress books printed them#Centrer chaque lecture
align: 'center' place une lecture courte au milieu de sa base, comme la plupart des livres chinois ; 1:2:1 est la valeur par défaut japonaise.
- align: 'jis', // a reading shorter than its base: ½ unit at each end, 1 between (1:2:1)
+ align: 'center', // も ず together in the middle of 百舌鳥Erreurs fréquentes
Erreur fréquente
Balisez un texte japonais 'ja', jamais zh-Hans ni LANG
Les éditions d’une recette sont en et es, mais un exemple japonais est en japonais dans les deux : `locale: LANG` le baliserait en anglais ou en espagnol, et une balise chinoise le composerait selon les règles chinoises (ponctuation Kaiming, petits kana libres de commencer une ligne, 图 pour 図, formes de glyphes chinoises dans le PDF). Depuis postext 1.30, la balise est aussi la langue dans laquelle le texte est mis en forme (mesuré, peint et incorporé) : toute balise autre que 'ja' donne d’autres formes de glyphes que les japonaises (les chinoises sous une balise chinoise) et d’autres coupures de ligne. Écrivez 'ja' : elle choisit la région Japon (coupure des lignes et ponctuation JLReq, points d’emphase sésame, espacement des furigana, étiquettes 図 et 表) et désactive la césure. Le lint fait échouer un texte contenant des kana sous une balise zh ou ko. Coupure des lignes en japonais (kinsoku) →
Erreur fréquente
Composez le japonais dans un caractère japonais
Noto Serif SC et TC ont des kana, mais ils dessinent les kanji sous leurs formes chinoises (直, 骨 et 角 diffèrent) et les kana dans un dessin chinois. Composez le texte en Noto Serif JP ou Shippori Mincho B1 et les titres en Noto Sans JP, chargés avec loadCjkFonts. Les fichiers japonais de Fontsource ne contiennent ni hentaigana ni autres kana historiques (U+1B000–1B16F) : loadCjkFonts échoue sur eux et le PDF imprime des cases. Écrivez donc les kana modernes, ou livrez dans les ressources de la recette un caractère qui les possède. Shippori Mincho n’a pas non plus les voyelles à macron ō et ū : composez le rōmaji dans un caractère latin. Kana, kanji et rōmaji →
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
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
Lisez les fichiers Aozora en CP932, pas en Shift_JIS
Les fichiers texte d’Aozora Bunko sont en Windows-31J (CP932). Décodés en Shift_JIS simple, le tiret ―― devient des tirets cadratin (U+2014), ~ le tiret ondulé 〜, et quelques caractères NEC et IBM échouent. Lisez-les en cp932, puis convertissez le balisage : les ruby entre 《》, les notes [#…] et les gaiji ※[#…] ne sont pas du Markdown et s’impriment tels quels si on les laisse. Le script aozora.py du skill fait les deux. Import depuis Aozora Bunko →
Avertissement de mise en page · rubyExceedsLeading
Lectures trop près de la ligne suivante
Pourquoi. Un paragraphe avec des lectures ruby au-dessus ou au-dessous de son texte a un interligne plus étroit que les lectures : elles se logent dans l’interlignage et touchent la ligne voisine.
Correction. Composez le paragraphe avec plus d’interlignage (au moins le corps du texte plus `cjk.ruby.fontSize`), ou réduisez la taille des lectures. Docs →
- Aozora Bunko écrit une seule lecture pour tout un mot, 兵十《ひょうじゅう》, et le convertisseur la garde en ruby de groupe,
{兵十|ひょうじゅう}. Pour un ruby jukugo, découpez vous-même la lecture par kanji :{兵十|ひょう|じゅう}. - Une lecture n’est découpée à la barre en une partie par caractère que lorsque les parties correspondent à la base ;
{一人|ひ|と|り}a trois parties pour deux kanji et se compose comme une seule lecture pour le mot, ce dont 一人 a besoin de toute façon.
Crédits
- Recette
- Ignacio Ferro
- Texte
- “Gon gitsune” (ごん狐), section 1, by Niimi Nankichi (1932): Aozora Bunko card 628, base text 新美南吉童話集 (Iwanami Bunko, 1996, second printing 1997), input by 林裕司, proofing by 浜野智 · Niimi Nankichi (1913–1943) · domaine public
- The readings over every kanji, the box on furigana and the opener’s labels · Postext Cookbook · CC BY 4.0
- Polices
- Klee One (SIL OFL 1.1) · Zen Maru Gothic (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)


