Zum Hauptinhalt springen
Rezept Nummer 124

Rezepte · Kapitel 2 · Schrift und Text

Ein Schullesebuch mit Furigana über jedem Kanji

Niimi Nankichis „Gon, der Fuchs“ auf einer Lesebuchseite: Furigana pro Zeichen, pro Wort und als Jukugo, im Verhältnis 1:2:1, Überhang auf Kana, nie auf Kanji.

Auf dieser Seite
Ausgabe
Canvas · PDF
Stufe
Mittel
Postext
Getestet mit Postext 1.31.0
Erfordert ≥ 1.25.0 · postext-pdf ≥ 1.25.0
Lizenz
Aktualisiert am 11. Okt. 2026
Code MIT · Text CC BY 4.0

S. 24–25 · 1–2 von 3

  • Englisches Beispiel: noch keine deutsche Ausgabe
  • Endformat 182 × 257 mm
  • 1 Spalte
  • Klee One 14/28
  • Zen Maru Gothic
  • 3 Seiten
  • Stufe
  • Postext 1.30.0
  • In 155 ms gesetzt
  • 136 Zeilen Code

Kurz gesagt

Eine Seite aus einem japanischen Schullesebuch, mit kleinen Kana über jedem Kanji, damit ein Kind laut vorlesen kann. Postext setzt jede Lesung über ihre Zeichen und lässt eine lange auf das Kana daneben überhängen, nie auf Kanji.

Was Sie setzen

Die ersten Seiten von Niimi Nankichis „Gon, der Fuchs“ (ごんぎつね, 1932), wie ein Lesebuch für die vierte Klasse sie druckt: die Geschichte, der jedes japanische Kind mit neun oder zehn Jahren begegnet. Eine B5-Seite fasst 28 Zeichen auf 21 Zeilen in Klee One, einer Schrift nach der Hand der Schulbücher, zu 14 pt auf einer Zeile von 28 pt, mit einer Lesung in kleinen Kana über jedem Kanji. Die Lesungen sind das eigentliche Thema: eine pro Zeichen über 村 und 森, eine für das ganze Wort über 百舌鳥 (mozu), dessen Kanji keine eigene Lesung haben, und Jukugo-Lesungen über 兵十 (Hyōjū), jede über ihrem eigenen Kanji, bis eine zu lang ist. Ein Kasten am Ende zeigt der Lehrkraft die drei Fälle. Die Pinyin-Fibel setzt chinesische Lesungen auf dieselbe Weise über jedes Zeichen.

Dieses Rezept beantwortet

  • Wie füge ich Furigana über Kanji hinzu, eine Lesung pro Zeichen oder eine für das ganze Wort?

Die kurze Antwort

script.js · Zeilen 30–51im vollständigen Code
// 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
};

Zutaten

Schrift
Klee One, Zen Maru Gothic (SIL OFL 1.1)
Dateien
Keine: Jedes Bild wird im Code gezeichnet

Zubereitung

#1 · Drei Arten, eine Lesung zu schreiben

Den Code finden Sie oben unter Die kurze Antwort. Im Markdown setzt {村|むら} むら über 村; {兵十|ひょう|じゅう} gibt jedem Kanji seine eigene Lesung, und in einem mit 'ja' ausgezeichneten Dokument ist das Jukugo-Ruby: Die Lesungen bleiben über ihren Kanji, wenn sie passen, und eine längere darf auf das nächste Kanji desselben Wortes überhängen, nie auf das eines anderen Wortes. {百舌鳥|もず}, eine Lesung für drei Kanji, ist Gruppen-Ruby, als Ganzes über das Wort verteilt. :ruby[菜種]{rt="な たね" mode=jukugo} schreibt das letzte Beispiel des Kastens in der Direktivenform, die auch mode=mono und align annimmt (Zeichen, Ruby und Warichu).

Seite 26: eine Lesung pro Kanji, eine für das Wort und Jukugo-Lesungen, die bei ihren Kanji bleiben.

#2 · 1:2:1 und Lesungen, die nur auf Kana überhängen

Die beiden Einstellungen unter cjk.ruby wählt 'ja' von selbst. align: 'jis' setzt eine Lesung, die kürzer als ihre Basis ist, nach JIS X 4051: eine halbe Einheit Abstand an jedem Ende und eine ganze zwischen den Kana, sodass も und ず auf Seite 25 über den Fugen von 百舌鳥 stehen, statt sich in seiner Mitte zu drängen. overhang: 'kana' lässt eine Lesung, die länger als ihre Basis ist, um bis zu ein Ruby-Zeichen auf ein Kana oder den Leerraum eines Satzzeichens daneben überhängen, nie auf ein Kanji: ぎつね über dem 狐 von 「ごん狐」 hängt auf ん und auf die Klammer danach über, und das じゅう von 兵十だな auf だ. Wo kein Kana zum Überhängen da ist, werden die Kanji auf die Breite der Lesung gesperrt. Am Zeilenrand bleibt die Lesung innerhalb der Satzbreite.

Seite 24: ぎつね über 狐 hängt auf ん und 」 über, und こぎつね über 小狐 bleibt bei seinen zwei Kanji.

#3 · Ein Zeilenzwischenraum, der die Lesungen aufnimmt

Die Lesungen haben 7 pt, die Hälfte der 14 pt des Textes, und die Zeile hat 28 pt, also nehmen die 14 pt zwischen zwei Zeilen sie mit reichlich Platz auf, und jede Rasterzeile bleibt, wo sie ist. Setzen Sie den Zeilenabstand enger als Text plus Lesungen, warnt der Build mit rubyExceedsLeading.

#4 · Der Auftakt setzt keine Lesungen

script.js · Zeilen 55–80im vollständigen Code
// 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) } } },
] } };

Titel und Name des Autors sind Design-Text, der kein Ruby setzt, daher ist die Lesung von 新美南吉 eine eigene Zeile über dem Namen, in Kana, wie Lesebücher sie drucken. Der Titel selbst steht in Kana, ごんぎつね, wie die Lesebücher ihn schreiben, obwohl der Aozora-Text ihn ごん狐 überschreibt.

#5 · Eine Akzentfarbe und Lesungen eine Spur heller

script.js · Zeilen 13–23im vollständigen Code
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' } }));

Die Lesungen nehmen reading, ein Braun eine Stufe heller als die Schrift, sodass das Auge die beiden Ebenen unterscheiden kann, ohne dass die Kana blass werden. Das Rot der Higanbana, die in der Geschichte blühen, kennzeichnet das Badge, die Abschnittsnummer und den Titel des Kastens.

Das ganze Rezept

Sandbox
// ═══ 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`---
Markdown-Beispiel · 38 Zeilen · content.en.mdtitle: "よみもの 四年" --- # ごんぎつね {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: in jedem Rezept gleich · 458 Zeilen// ─── 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 ───────────────────────────────────────────────────────────────────────

Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)

Varianten

#Kana in voller Größe in den Lesungen

Ältere Bücher druckten っ und ょ in der Ruby in voller Größe, がつこう für がっこう; smallKana: 'full' tut dasselbe und lässt den vorgelesenen und kopierten Text unverändert.

-    smallKana: 'keep', // ひょう with its small ょ, as today's books print it
+    smallKana: 'full', // ひよう: the small kana at full size, as letterpress books printed them

#Jede Lesung zentrieren

align: 'center' setzt eine kurze Lesung in die Mitte ihrer Basis, wie es die meisten chinesischen Bücher tun; 1:2:1 ist die japanische Vorgabe.

-    align: 'jis', // a reading shorter than its base: ½ unit at each end, 1 between (1:2:1)
+    align: 'center', // も ず together in the middle of 百舌鳥

Häufige Fehler

Häufiger Fehler

Zeichnen Sie japanischen Text mit 'ja' aus, nie mit zh-Hans oder LANG

Die Ausgaben eines Rezepts sind en und es, ein japanisches Beispiel ist aber in beiden Japanisch: `locale: LANG` würde es als Englisch oder Spanisch auszeichnen, und eine chinesische Auszeichnung würde es nach chinesischen Regeln setzen (Kaiming-Interpunktion, kleine Kana dürfen eine Zeile beginnen, 图 statt 図, chinesische Glyphenformen im PDF). Seit postext 1.30 ist die Auszeichnung auch die Sprache, in der der Text geformt wird (gemessen, gezeichnet und eingebettet). Jede Auszeichnung außer 'ja' liefert daher andere Glyphenformen als die japanischen (unter einer chinesischen Auszeichnung die chinesischen) und andere Zeilenumbrüche. Schreiben Sie 'ja': Das wählt die Region Japan (JLReq-Zeilenumbruch und -Interpunktion, Sesam-Betonungszeichen, Furigana-Abstände, die Beschriftungen 図 und 表) und schaltet die Silbentrennung ab. Der Lint schlägt bei einem Text mit Kana unter einer zh- oder ko-Auszeichnung fehl. Japanischer Zeilenumbruch (Kinsoku) →

Häufiger Fehler

Setzen Sie Japanisch in einer japanischen Schrift

Noto Serif SC und TC haben Kana, zeichnen die Kanji aber in chinesischen Formen (直, 骨 und 角 unterscheiden sich) und die Kana in chinesischer Gestaltung. Setzen Sie den Text in Noto Serif JP oder Shippori Mincho B1 und die Überschriften in Noto Sans JP, geladen mit loadCjkFonts. Die japanischen Dateien von Fontsource enthalten keine Hentaigana oder andere historische Kana (U+1B000–1B16F): loadCjkFonts scheitert daran und das PDF druckt Kästchen. Schreiben Sie also die modernen Kana oder liefern Sie eine Schrift, die sie hat, in den Assets des Rezepts mit. Shippori Mincho fehlen außerdem die Makronvokale ō und ū: Setzen Sie Rōmaji in einer lateinischen Schrift. Kana, Kanji und Rōmaji →

Häufiger Fehler

Chinesische Schnitte werden in Teilstücken geladen, über den Block cjk

Fontsource liefert eine chinesische, japanische oder koreanische Familie als etwa hundert Dateien pro Schriftstärke aus, jede für einen Zeichenbereich. loadFonts holt nur die Datei latin, am Bildschirm stammen die Han-Zeichen also aus einer Systemschrift und werden falsch gemessen, und fontsourceProvider gibt dem PDF diese latin-Datei, die sie als leere Kästchen druckt. Führen Sie den Kit-Block cjk auf, rufen Sie loadCjkFonts(FONTS, markdown) nach loadFonts auf (einmal pro Stimme, mit dem Text, den sie setzt, wenn das Buch mehrere CJK-Schriften verwendet), und geben Sie renderToPdf fontProvider: cjkPdfProvider: Beide nehmen die Dateien, die die Zeichen des Textes enthalten. Chinesische, japanische und koreanische Schriften →

Häufiger Fehler

Lesehilfen werden im Text gedruckt, nicht in Gestaltungen, Bildunterschriften oder Zellen

Ruby-Lesehilfen werden in Absätzen, Überschriften, Listeneinträgen, Zitaten und Kästen gezeichnet. Ein Gestaltungstextelement (ein Auftakt, ein Kolumnentitel, ein Abzeichen), eine Bildunterschrift, eine Fußnote und eine Tabellenzelle drucken die Basiszeichen ohne ihre Lesehilfen. Ein Titel, der sein Pinyin braucht, ist eine Überschrift ohne eigene Gestaltung: Zeichnen Sie, was ihn umgibt (ein Band, eine Lektionsnummer), mit der Gestaltung der Überschrift davor, deren Elemente mit reserve: false unter den Text eines Auftakts mit span: 'page' gezeichnet werden. Ruby: Pinyin- und Zhuyin-Lesungen →

Häufiger Fehler

Lesen Sie Aozora-Dateien als CP932, nicht als Shift_JIS

Die Textdateien von Aozora Bunko sind Windows-31J (CP932). Als reines Shift_JIS dekodiert, wird der Strich ―― zu Geviertstrichen (U+2014), ~ zum Wellenstrich 〜, und einige NEC- und IBM-Zeichen schlagen fehl. Lesen Sie sie als cp932 und wandeln Sie dann das Markup um: Ruby in 《》, die Anmerkungen [#…] und die Gaiji ※[#…] sind kein Markdown und werden unverändert gedruckt, wenn sie stehen bleiben. Das Skript aozora.py des Skills erledigt beides. Import aus Aozora Bunko →

Layoutwarnung · rubyExceedsLeading

Lesehilfen bedrängen die nächste Zeile

Ursache. Ein Absatz mit Ruby-Lesehilfen über oder unter seinem Text hat einen Zeilenzwischenraum, der schmaler ist als die Lesehilfen: Sie liegen im Durchschuss und berühren die benachbarte Zeile.

Lösung. Setzen Sie den Absatz mit größerem Zeilenabstand (mindestens Schriftgröße plus `cjk.ruby.fontSize`) oder verkleinern Sie die Lesehilfen. Doku →

  • Aozora Bunko schreibt eine Lesung für ein ganzes Wort, 兵十《ひょうじゅう》, und der Konverter behält sie als Gruppen-Ruby bei, {兵十|ひょうじゅう}. Für Jukugo-Ruby teilen Sie die Lesung von Hand nach Kanji auf: {兵十|ひょう|じゅう}.
  • Eine Lesung wird am senkrechten Strich nur dann in einen Teil pro Zeichen geteilt, wenn die Teile zur Basis passen; {一人|ひ|と|り} hat drei Teile für zwei Kanji und wird als eine Lesung für das Wort gesetzt, was 一人 ohnehin braucht.

Nachweise

Text
  • “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) · gemeinfrei
  • The readings over every kanji, the box on furigana and the opener’s labels · Postext Cookbook · CC BY 4.0
Schriften
Klee One (SIL OFL 1.1) · Zen Maru Gothic (SIL OFL 1.1)
SandboxPDF