Aller au contenu principal
Recette numéro 130

Recettes · Chapitre 2 · Caractères et texte

Le japonais en lettres latines : le Journal en rōmaji

Un texte en rōmaji étiqueté ja-Latn : règles japonaises pour ses kana, aucune césure pour son latin, et les macrons d’un caractère latin jusque dans le PDF.

Sur cette page
Sortie
Canvas · PDF
Postext
Testé avec Postext 1.31.0
Nécessite ≥ 1.25.0 · postext-pdf ≥ 1.25.0
Licence
Mis à jour le 10 oct. 2026
Code MIT · Texte CC BY 4.0
  • Exemple en anglais : pas encore d’édition française
  • Format 148 × 210 mm
  • 1 colonne
  • Source Serif 4 10,5/15
  • Noto Serif JP
  • Source Sans 3
  • 4 pages
  • Niveau
  • Postext 1.31.0
  • Mis en page en 75 ms
  • 131 lignes de code

En bref

La première entrée d’un journal de 1909 que le poète Takuboku a écrit en japonais avec des lettres latines, avec sous chaque paragraphe sa lecture en écriture japonaise.

Ce que vous allez composer

Quatre pages du Rōmaji nikki, le journal qu’Ishikawa Takuboku tint à Tokyo au printemps 1909 en lettres latines, pour que sa femme ne puisse pas le lire : les lignes de titre de son carnet et la première entrée, le 7 avril, jusqu’à Kanasii koto da! Le journal est composé en édition d’étude sur une page A5 : le rōmaji en Source Serif 4, justifié et jamais coupé, et sous chaque paragraphe sa lecture en kana et en kanji en Noto Serif JP, plus petite, plus claire et en retrait. Takuboku écrit dans le système Nippon-shiki, si, tu, hu, avec des accents circonflexes sur les voyelles longues, Tôkyô ; les lignes de l’éditeur suivent le système Hepburn avec macrons, Rōmaji, et les deux arrivent jusqu’au PDF.

Cette recette répond à

  • Comment composer du japonais écrit en rōmaji : quelle balise de langue, quelle césure, et les macrons dans le PDF ?
  • Quelles polices composent le japonais, avec les kanji sous leurs formes japonaises, à l’écran et dans le PDF ?

La réponse courte

script.js · lignes 35–50dans le code complet
// ja-Latn is Japanese written in Latin script (BCP 47). Its language is ja, so the kana
// under each paragraph get the Japanese rules (kinsoku, 、。 spacing, the space after !)
// and the built-in strings are Japanese; and Japanese has no hyphenation patterns, so the
// rōmaji is never divided at a line end. English patterns would divide tatinobotta or
// Surigarasu by English syllables, not by the morae a Japanese reader hears.
const language = { locale: 'ja-Latn' }; // spread into the config
const bodyText = {
  fontFamily: SERIF, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: em(1.2), indentAfterHeading: false,
  optimalLineBreaking: true, maxWordSpacing: 1.8, // no hyphens: the spaces take the slack
};
// The reading in kana, under its paragraph: smaller, a shade lighter, indented.
const kana = { id: 'kana', fontFamily: MINCHO, fontSize: pt(8.6), lineHeight: pt(LEAD),
  color: col('kana'), textAlign: 'justify', indent: em(1.5), firstLineIndent: em(1),
  marginTop: pt(LEAD / 4), marginBottom: pt(LEAD * 0.75) };

Ingrédients

Typographie
Source Serif 4, Source Sans 3, Noto Serif JP (SIL OFL 1.1)
Ressources
Aucune : chaque image est dessinée en code

Préparation

#1 · Étiqueter le texte ja-Latn

Le code est dans la réponse courte plus haut. ja-Latn est l’étiquette BCP 47 du japonais écrit en alphabet latin. Sa langue est ja : la lecture sous chaque paragraphe reçoit donc les règles japonaises, le kinsoku, l’espacement de 、。 et l’espace cadratin que le moteur ajoute après ! à l’intérieur d’une phrase, comme dans 「ええ! ええ!」. Le japonais n’a pas de motifs de césure, si bien que le rōmaji n’est jamais coupé ; avec en, les motifs anglais couperaient des mots comme tatinobotta ou Surigarasu selon les syllabes anglaises, et non selon les mores qu’entend un lecteur japonais. Sans traits d’union, ce sont les espaces qui absorbent le jeu : la justification compte donc environ 70 caractères et maxWordSpacing vaut 1,8 (césure).

Page 2 : le jour, l’adresse, les deux premiers paragraphes en rōmaji et leurs lectures en kana, plus petites et en retrait.

#2 · Faire entrer les macrons dans le PDF

script.js · lignes 201–203dans le code complet
// Fontsource keeps ō ū ā in a latin-ext file. cjkPdfProvider serves Noto Serif JP from its
// slices, and adds that file after the latin one for a Latin face whose text needs it.
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);

Fontsource range ō ū ā dans un fichier latin-ext à part. L’écran le charge (loadFonts voit le ō de l’exemple), et le cjkPdfProvider du kit fait de même pour le PDF. Il sert Noto Serif JP à partir de ses tranches japonaises et, quand un caractère latin compose une lettre que seul latin-ext contient, il répond avec deux fichiers, latin puis latin-ext ; postext-pdf prend chaque glyphe dans le premier fichier qui l’a. Le simple fontsourceProvider du kit ne donne au PDF que le fichier latin : avec lui, le PDF n’aurait aucun glyphe pour ō.

#3 · Ouvrir sur les lignes du journal lui-même

script.js · lignes 54–86dans le code complet
const onField = { color: col('paper'), align: 'center', overflow: 'wrap' };
const title = {
  id: 'title', numbered: false, toc: false, span: 'page', runningChapter: false,
  breakBefore: { enabled: true, parity: 'any' }, // its page is an opener: no running heads
  advancedDesign: { enabled: true, minHeight: mm(166), slot: { elements: [
    { kind: 'box', id: 'field', style: { backgroundColor: col('indigo') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill',
        height: 'fill' } } },
    { kind: 'text', id: 'lines', content: '{attr.lines}', ...onField, fontFamily: SERIF,
      fontWeight: 600, fontSize: pt(20), lineHeight: 1.9, letterSpacing: pt(5),
      placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(18) } } },
    { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('paper'),
      placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(122) },
        size: { width: mm(24) } } },
    { kind: 'text', id: 'note', content: '{attr.note}', inlineMarks: true, ...onField,
      fontFamily: SANS, fontSize: pt(8.5), lineHeight: 1.5, placement: { anchor: { to:
        'container', edge: 'top' }, offset: { y: mm(130) }, size: { width: mm(84) } } },
  ] } },
};
// The entry: its day as a heading, the address under it from an attribute.
const entry = {
  level: 2, fontFamily: SANS, fontWeight: 600, fontSize: pt(10), letterSpacing: pt(2.5),
  color: col('indigo'), marginTop: pt(LEAD), marginBottom: pt(LEAD), advancedDesign: {
    enabled: true, minHeight: pt(2 * LEAD), slot: { elements: [
      { kind: 'text', id: 'day', content: '{titleText}', fontFamily: SANS, fontWeight: 600,
        fontSize: pt(10), letterSpacing: pt(2.5), color: col('indigo'),
        placement: { anchor: { to: 'container', edge: 'top-left' } } },
      { kind: 'text', id: 'place', content: '{attr.place}', fontFamily: SANS, fontSize: pt(7.5),
        letterSpacing: pt(1), color: col('muted'), overflow: 'wrap', align: 'left',
        placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: pt(LEAD) },
          size: { width: 'fill' } } },
    ] } },
};

Le carnet commence par « NIKKI. I. MEIDI 42 NEN. 1909. APRIL. TOKYO. », un élément par ligne. Le titre les prend dans un attribut, chaque \n ouvrant une nouvelle ligne, et les compose en capitales interlettrées sur un fond indigo, la toile d’une couverture de carnet. Sa page est une ouverture : les titres courants la sautent.

#4 · Des titres courants selon la parité

script.js · lignes 90–99dans le code complet
const head = (id, content, parity, edge) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: SANS, fontSize: pt(7.5), letterSpacing: pt(1.5), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { y: mm(13), // 'top' centres on the page
    x: mm(edge.endsWith('left') ? 16 : edge.endsWith('right') ? -16 : 0) } } });
const header = { elements: [
  head('folio-even', '{pageNumber}', 'even', 'top-left'),
  head('book', 'ROMAZI NIKKI · 1909', 'even', 'top'),
  head('place', 'TOKYO · APRIL', 'odd', 'top'),
  head('folio-odd', '{pageNumber}', 'odd', 'top-right'),
] };

Le verso porte le nom du journal et le recto le lieu et le mois, avec les folios aux angles extérieurs, en Source Sans 3 de 7,5 pt.

La recette complète

Sandbox
// ═══ Postext Cookbook · Nº 130 · Japanese in Latin letters: Takuboku's Rōmaji Diary ════
// https://postext.dev/en/cookbook/romaji-nikki
// Code: MIT · Text: Ishikawa Takuboku, 1909 (public domain); transcription (CC BY 4.0)
// Fonts: Source Serif 4, Source Sans 3, Noto Serif JP (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// A diary written in Japanese with Latin letters, and its reading in kana under each paragraph.
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 note; the diary is Japanese in both editions
const RECIPE = 'romaji-nikki';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: diary ink, a faded indigo, the cream of a notebook
const palette = {
  ink: '#22201d', // the rōmaji
  indigo: '#34497a', // the date and the title (8.4:1 on the paper)
  kana: '#5a554e', // the transcription, a step lighter than the text (7:1)
  rule: '#c9c1b2',
  muted: '#6f6a62', // running heads, folios, the note
  paper: '#fcfaf4',
};
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: 'indigo (defaults)', value: { hex: palette.indigo, model: 'hex' } },
];
// #endregion

const SERIF = 'Source Serif 4'; // the rōmaji: ô û â ê in latin, ō ū in latin-ext
const SANS = 'Source Sans 3'; // labels, running heads, folios, the note
const MINCHO = 'Noto Serif JP'; // the kana: Noto Serif JP's Latin is Source Serif's
const [BODY, LEAD] = [10.5, 15]; // pt

// #region answer: a Japanese text in Latin letters is tagged ja-Latn
// ja-Latn is Japanese written in Latin script (BCP 47). Its language is ja, so the kana
// under each paragraph get the Japanese rules (kinsoku, 、。 spacing, the space after !)
// and the built-in strings are Japanese; and Japanese has no hyphenation patterns, so the
// rōmaji is never divided at a line end. English patterns would divide tatinobotta or
// Surigarasu by English syllables, not by the morae a Japanese reader hears.
const language = { locale: 'ja-Latn' }; // spread into the config
const bodyText = {
  fontFamily: SERIF, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: em(1.2), indentAfterHeading: false,
  optimalLineBreaking: true, maxWordSpacing: 1.8, // no hyphens: the spaces take the slack
};
// The reading in kana, under its paragraph: smaller, a shade lighter, indented.
const kana = { id: 'kana', fontFamily: MINCHO, fontSize: pt(8.6), lineHeight: pt(LEAD),
  color: col('kana'), textAlign: 'justify', indent: em(1.5), firstLineIndent: em(1),
  marginTop: pt(LEAD / 4), marginBottom: pt(LEAD * 0.75) };
// #endregion

// #region title: the diary's own heading lines on an indigo field, like a cloth notebook
const onField = { color: col('paper'), align: 'center', overflow: 'wrap' };
const title = {
  id: 'title', numbered: false, toc: false, span: 'page', runningChapter: false,
  breakBefore: { enabled: true, parity: 'any' }, // its page is an opener: no running heads
  advancedDesign: { enabled: true, minHeight: mm(166), slot: { elements: [
    { kind: 'box', id: 'field', style: { backgroundColor: col('indigo') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill',
        height: 'fill' } } },
    { kind: 'text', id: 'lines', content: '{attr.lines}', ...onField, fontFamily: SERIF,
      fontWeight: 600, fontSize: pt(20), lineHeight: 1.9, letterSpacing: pt(5),
      placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(18) } } },
    { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('paper'),
      placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(122) },
        size: { width: mm(24) } } },
    { kind: 'text', id: 'note', content: '{attr.note}', inlineMarks: true, ...onField,
      fontFamily: SANS, fontSize: pt(8.5), lineHeight: 1.5, placement: { anchor: { to:
        'container', edge: 'top' }, offset: { y: mm(130) }, size: { width: mm(84) } } },
  ] } },
};
// The entry: its day as a heading, the address under it from an attribute.
const entry = {
  level: 2, fontFamily: SANS, fontWeight: 600, fontSize: pt(10), letterSpacing: pt(2.5),
  color: col('indigo'), marginTop: pt(LEAD), marginBottom: pt(LEAD), advancedDesign: {
    enabled: true, minHeight: pt(2 * LEAD), slot: { elements: [
      { kind: 'text', id: 'day', content: '{titleText}', fontFamily: SANS, fontWeight: 600,
        fontSize: pt(10), letterSpacing: pt(2.5), color: col('indigo'),
        placement: { anchor: { to: 'container', edge: 'top-left' } } },
      { kind: 'text', id: 'place', content: '{attr.place}', fontFamily: SANS, fontSize: pt(7.5),
        letterSpacing: pt(1), color: col('muted'), overflow: 'wrap', align: 'left',
        placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: pt(LEAD) },
          size: { width: 'fill' } } },
    ] } },
};
// #endregion

// #region heads: the diary's name on the verso, the place on the recto, folios outside
const head = (id, content, parity, edge) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: SANS, fontSize: pt(7.5), letterSpacing: pt(1.5), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { y: mm(13), // 'top' centres on the page
    x: mm(edge.endsWith('left') ? 16 : edge.endsWith('right') ? -16 : 0) } } });
const header = { elements: [
  head('folio-even', '{pageNumber}', 'even', 'top-left'),
  head('book', 'ROMAZI NIKKI · 1909', 'even', 'top'),
  head('place', 'TOKYO · APRIL', 'odd', 'top'),
  head('folio-odd', '{pageNumber}', 'odd', 'top-right'),
] };
// #endregion

const config = () => ({
  ...language, // ja-Latn, written out (gotcha: ja-locale-tag)
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(148), height: mm(210), dpi: 150, // A5
    backgroundColor: col('paper'),
    // A measure of about 70 characters: with no hyphens, a narrower one sets loose lines.
    margins: { top: mm(22), bottom: mm(22), left: mm(17), right: mm(17), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText,
  headings: { fontFamily: SANS, fontWeight: 600, color: col('indigo'),
    levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }, entry] },
  headingStyles: [title],
  paragraphStyles: [kana,
    { id: 'note', fontFamily: SANS, fontSize: pt(7.8), lineHeight: pt(11.5), color: col('muted'),
      boldColor: col('ink'), boldFontWeight: 600, textAlign: 'left', firstLineIndent: em(0),
      marginTop: pt(2 * LEAD) }],
  header,
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Exemple Markdown · 61 lignes · content.en.mdtitle: "ROMAZI NIKKI" author: "Isikawa Takuboku" --- # ROMAZI NIKKI {style="title" lines="NIKKI.\nI.\nMEIDI 42 NEN.\n1909.\nAPRIL.\nTOKYO." note="Ishikawa Takuboku, *Rōmaji nikki* (the Rōmaji Diary), the first entry, 7 April 1909, with a transcription into kana and kanji under each paragraph"} ## 7TH, WEDNESDAY. {place="HONGO-KU MORIKAWA-TYO 1 BANTI, SINSAKA 359 GO, GAIHEI-KAN-BESSO NITE."} Hareta Sora ni susamajii Oto wo tatete, hagesii Nisi-kaze ga huki areta. Sangai no Mado to yû Mado wa Taema mo naku gata-gata naru, sono Sukima kara wa, haruka Sita kara tatinobotta Suna-hokori ga sara-sara to hukikomu: sono kuse Sora ni tirabatta siroi Kumo wa titto mo ugokanu. Gogo ni natte Kaze wa yô-yô otituita. :::paragraphs{style="kana"} 晴れた空にすさまじい音を立てて、激しい西風が吹き荒れた。三階の窓という窓は絶え間もなくがたがた鳴る、そのすき間からは、はるか下から立ちのぼった砂ぼこりがさらさらと吹き込む。そのくせ空に散らばった白い雲はちっとも動かぬ。午後になって風はようよう落ち着いた。 ::: Haru rasii Hikage ga Mado no Surigarasu wo atataka ni somete, Kaze sae nakuba Ase de mo nagare sô na Hi de atta. Itu mo kuru Kasihonya no Oyadi, Te-no-hira de Hana wo kosuriage nagara, “Hidoku huki masu nâ.” to itte haitte kita. “Desuga, Kyô-dyû nya Tôkyô-dyû no Sakura ga nokorazu saki masu ze. Kaze ga attatte, anata, kono Tenki de gozai masu mono.” :::paragraphs{style="kana"} 春らしい日影が窓のすりガラスを暖かに染めて、風さえなくば汗でも流れそうな日であった。いつも来る貸本屋のおやじ、手のひらで鼻をこすり上げながら、「ひどく吹きますなあ。」と言って入って来た。「ですが、今日じゅうにゃ東京じゅうの桜が残らず咲きますぜ。風があったって、あなた、この天気でございますもの。」 ::: “Tôtô Haru ni nattyatta nê!” to Yo wa itta. Muron kono Kangai wa Oyadi ni wakarikko wa nai. “Eh! eh!” to Oyadi wa kotaeta: “Haru wa anata, watasidomo ni wa Kinmotu de gozai masu ne. Kasihon wa, moh, kara Dame de gasu: Hon nanka yomu yorya mata asonde aruita hô ga yô-gasu kara, Muri mo nai-ndesu ga, yonde kudasaru kata mo sizen to kô nagaku bakari narimasunde ne.” :::paragraphs{style="kana"} 「とうとう春になっちゃったねえ!」と予は言った。むろんこの感慨はおやじにわかりっこはない。「ええ!ええ!」とおやじは答えた。「春はあなた、わたしどもには禁物でございますね。貸本は、もう、からだめでがす。本なんか読むよりゃまた遊んで歩いたほうがようがすから、無理もないんですが、読んでくださる方も自然とこう長くばかりなりますんでね。」 ::: Kinô Sya kara Zensyaku sita Kane no nokori, 5 yen Sihei ga iti-mai Saihu no naka ni aru; Gozen-tyû wa sore bakkari Ki ni natte, Siyô ga nakatta. Kono Kimoti wa, heizei Kane no aru Hito ga kyû ni motanaku natta toki to onaji yô na Kigakari ka mo sirenu: dotira mo okasii koto da, onaji yô ni okasii ni wa tigai nai ga, sono Kô-hukô ni wa taisita Tigai ga aru. :::paragraphs{style="kana"} 昨日社から前借りした金の残り、五円紙幣が一枚財布の中にある。午前中はそればっかり気になって、しようがなかった。この気持ちは、平生金のある人が急に持たなくなったときと同じような気がかりかもしれぬ。どちらもおかしいことだ、同じようにおかしいには違いないが、その幸不幸には大した違いがある。 ::: Siyô koto nasi ni, Rôma-ji no Hyô nado wo tukutte mita. Hyô no naka kara, toki-doki, Tugaru-no-Umi no kanata ni iru Haha ya Sai no koto ga ukande Yo no Kokoro wo kasumeta. “Haru ga kita, Si-gatu ni natta. Haru ! Haru ! Hana mo saku ! Tokyô e kite mô iti-nen da! ...........Ga, Yo wa mada Yo no Kazoku wo yobiyosete yasinau Junbi ga dekinu !” Tika-goro, Hi ni nankwai to naku, Yo no Kokoro no naka wo atira e yuki, kotira e yuki siteru, Mondai wa kore da ........... :::paragraphs{style="kana"} しようことなしに、ローマ字の表などを作ってみた。表の中から、ときどき、津軽の海の彼方にいる母や妻のことが浮かんで予の心をかすめた。「春が来た、四月になった。春!春!花も咲く!東京へ来てもう一年だ!……が、予はまだ予の家族を呼び寄せて養う準備ができぬ!」近ごろ、日に何回となく、予の心の中をあちらへ行き、こちらへ行きしてる問題はこれだ…… ::: Sonnara naze kono Nikki wo Rômaji de kaku koto ni sitaka ? Naze da ? Yo wa Sai wo aisiteru ; aisiteru kara koso kono Nikki wo yomase taku nai no da. ------ Sikasi kore wa Uso da ! Aisiteru no mo Jijitu, yomase taku nai no mo Jijitu da ga, kono Hutatu wa kanarazu simo Kwankei site inai. :::paragraphs{style="kana"} そんならなぜこの日記をローマ字で書くことにしたか?なぜだ?予は妻を愛してる。愛してるからこそこの日記を読ませたくないのだ。――しかしこれはうそだ!愛してるのも事実、読ませたくないのも事実だが、この二つは必ずしも関係していない。 ::: Sonnara Yo wa Jakusya ka ? Ina, Tumari kore wa Hûhu-kwankei to yû matigatta Seido ga aru tame ni okoru no da. Hûhu ! nan to yû Baka na Seido darô ! Sonnara dô sureba yoi ka ? :::paragraphs{style="kana"} そんなら予は弱者か?否、つまりこれは夫婦関係という間違った制度があるために起こるのだ。夫婦!なんというばかな制度だろう!そんならどうすればよいか? ::: Kanasii koto da ! :::paragraphs{style="kana"} 悲しいことだ! ::: :::paragraphs{style="note"} **A note on the text.** Takuboku kept this diary in Tokyo from 7 April to 16 June 1909, in Latin letters, so that his wife Setsuko could not read it. He spells it in the Nippon-shiki system of 1885, not in Hepburn: *si, ti, tu, hu* for shi, chi, tsu, fu, *dy* for the voiced *t*-row kana (*Kyô-dyû*), a circumflex on long vowels (*Tôkyô*) and, his own habit, a capital on most nouns. The text follows the Iwanami Bunko edition of 1977; the transcription under each paragraph was made for this recipe, in modern kana. Set in Source Serif 4, Source Sans 3 and Noto Serif JP (SIL OFL) · Diary: public domain · Transcription and note: CC BY 4.0 :::
`; // content.<lang>.md: the same diary in both // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Source Serif 4': ['400', '400i', '600'], 'Source Sans 3': ['400', '400i', '600'], 'Noto Serif JP': ['400'] }; const kanaText = (markdown.match(/:::paragraphs\{style="kana"\}[\s\S]*?:::/g) ?? []).join(''); // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); // and the latin-ext files, for the ō of the note await loadCjkFonts({ [MINCHO]: FONTS[MINCHO] }, kanaText); const doc = await withLoadedFonts(() => buildDocument({ markdown }, config()), { ...kitFonts(FONTS), text: markdown }); showPages(doc, { title: t({ en: 'The Rōmaji Diary', es: 'El diario en rōmaji' }) }); // #region macrons: the PDF gets each Latin face's latin-ext file too // Fontsource keeps ō ū ā in a latin-ext file. cjkPdfProvider serves Noto Serif JP from its // slices, and adds that file after the latin one for a Latin face whose text needs it. offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`); // #endregion
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

#Écrire le rōmaji en Hepburn

Une édition moderne peut réécrire le journal en Hepburn avec macrons, Tōkyō pour Tôkyô. Le PDF porte déjà ō : seul le texte change ; gardez une note qui précise que la graphie est celle de l’éditeur.

#Couper entre les mores

Si une colonne étroite demande des traits d’union, placez dans le rōmaji des traits d’union conditionnels (U+00AD) entre les mores, Su\u00adna-ho\u00adko\u00adri dans le code ou un U+00AD saisi dans le texte : un document étiqueté ja-Latn ne fait jamais de césure de lui-même, mais il coupe à un trait d’union conditionnel quand un mot ne tient pas.

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 →

  • Shippori Mincho n’a ni ō ni ū ; Noto Serif JP et Source Serif 4 les ont dans leurs fichiers latin-ext. Vérifiez le caractère avant de le choisir pour un texte en Hepburn.
  • Le chinois écrit en lettres latines, c’est le pinyin, composé en ruby au-dessus des caractères dans Un premier livre de lecture avec le pinyin sur chaque caractère ; le rōmaji de Takuboku est le texte lui-même : il est donc composé comme un texte latin, avec son propre espacement, et non comme une annotation.

Crédits

Texte
  • Ishikawa Takuboku, ROMAZI NIKKI (the Rōmaji Diary), the entry of 7 April 1909 to “Kanasii koto da!”, in Latin letters as he wrote it, after the Iwanami Bunko edition (ed. Kuwabara Takeo, 1977) as transcribed on the blog 啄木の息 · Ishikawa Takuboku (1886–1912) · domaine public
  • The transcription into kana and kanji under each paragraph and the note on the text, written for this recipe · Postext Cookbook · CC BY 4.0
Polices
Source Serif 4 (SIL OFL 1.1) · Source Sans 3 (SIL OFL 1.1) · Noto Serif JP (SIL OFL 1.1)
SandboxPDF