Vai al contenuto principale
Ricetta numero 117

Ricettario · Capitolo 1 · Pagina e griglia

Un romanzo bunko in verticale, con furigana

Sogni di dieci notti di Sōseki su una pagina bunko A6: 38 caratteri per 15 righe, lettura da destra, letture accanto ai kanji rari, titoli su tre righe.

In questa pagina
Output
Canvas · PDF
Livello
Intermedio
Postext
Provata con Postext 1.31.0
Richiede ≥ 1.28.0 · postext-pdf ≥ 1.28.0
Licenza
Aggiornata il 11 ott 2026
Codice MIT · Testo CC BY 4.0

pp. 二–三 · 2–3 di 9

  • Esempio in inglese: non c’è ancora un’edizione italiana
  • Formato 105 × 148 mm
  • 1 colonna
  • Shippori Mincho B1 9/15,8
  • Noto Sans JP
  • 9 pagine
  • Livello
  • Postext 1.30.0
  • Impaginato in 62 ms
  • 145 righe di codice

In breve

L’inizio di un classico giapponese come tascabile: il testo scorre in colonne dall’alto in basso, da leggere da destra a sinistra, con piccoli kana accanto ai caratteri più difficili.

Che cosa comporrai

Nove pagine di un tascabile giapponese (文庫, bunko) dei Sogni di dieci notti di Natsume Sōseki, la prima e la seconda notte, su una pagina A6 di 105 × 148 mm rilegata a destra. Il frontespizio sta da solo a sinistra del dorso; dalla pagina 2 il testo scorre dall’alto in basso in righe lette da destra, 38 caratteri in verticale e 15 righe in orizzontale, Shippori Mincho B1 a 9 pt con un passo di 1,75 em. Le letture che Aozora Bunko dà per i kanji più difficili stanno accanto a essi in kana di metà corpo, il titolo di ogni notte occupa tre righe della griglia, e le pagine sinistre portano il titolo del libro lungo il margine esterno sopra un folio in numeri kanji. Il tascabile europeo con la stessa struttura è Nº 104, e il romanzo cinese verticale è Nº 075.

Questa ricetta risponde a

  • Come compongo un romanzo giapponese in verticale, rilegato a destra, come un tascabile bunko?
  • Come aggiungo i furigana sopra i kanji, una lettura per carattere o una per l’intera parola?
  • Come trasformo un testo di Aozora Bunko in un libro Postext?
  • Come faccio occupare a un titolo giapponese tre righe di testo (3行取り), così che il testo resti sulla griglia?

La risposta breve

script.js · righe 33–52nel codice completo
// 'vertical-rl' sets each line top to bottom and the lines from right to left; with the
// binding left to 'auto' the book opens from the right, so page 1 is a left-hand page and
// the spreads read [3 | 2]. locale 'ja' gives the cjk settings Japan's values: they are
// written out below so you can see them, and leaving them out sets the same page.
const page = {
  sizePreset: 'custom', width: mm(105), height: mm(148), dpi: 200, // 文庫判 A6
  backgroundColor: col('paper'),
  // 天 above 地; left is the spine side (the right edge of a left-hand page). The grid
  // grows the margins of each pair alike, so the head stays deeper than the foot.
  margins: { top: mm(15), bottom: mm(11), left: mm(9), right: mm(12), mirror: true },
  pageNumbering: { format: 'japanese-informal' }, // folios 一, 二 … 十, 十一
};
const layout = { layoutType: 'single', writingMode: 'vertical-rl' };
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  lineBreak: 'ja-very-strict', // 行頭禁則: no 、。」ー or small kana at the head of a line
  hangingPunctuation: 'allow', // ぶら下げ: a 、。 that would open a line hangs below the last
  paragraphStartBracket: 'half', // ③: a 「 that opens a paragraph fills the indent cell
  ruby: { align: 'jis', overhang: 'kana' }, // JIS 1:2:1, a reading may run onto kana only
};

Ingredienti

Tipo
Shippori Mincho B1, Noto Sans JP (SIL OFL 1.1)
Risorse
Nessuna: ogni immagine è disegnata nel codice

Preparazione

#1 · Ruota la pagina, contala in caratteri e marcala ja

Il codice è la risposta breve qui sopra. layout.writingMode: 'vertical-rl' impagina ogni pagina come una pagina orizzontale ruotata di un quarto di giro in senso orario, e page.binding, lasciato su 'auto', rilega un libro verticale a destra: la pagina 1 è sempre il recto, ma sta a sinistra del dorso, e mirror: true mette il suo margine interno a destra (Vertical writing, Binding). La griglia dei caratteri fa una gabbia del testo di 38 caratteri da 9 pt in verticale (120,7 mm) e 15 righe da 15,75 pt in orizzontale (83,3 mm); i margini in page sono valori minimi, e la griglia allarga ogni coppia della stessa quantità, così la testa resta 4 mm più profonda del piede. Se chiedi più righe di quante ne lascino i margini, la griglia ne toglie una senza dirlo (cjkGridClamped in doc.configWarnings): sono i 9 mm al dorso e i 12 mm al margine esterno a far entrare 15 righe.

locale: 'ja' imposta il resto come si compongono i libri giapponesi, quindi le quattro righe cjk scritte qui non cambiano nulla; mostrano che cosa sceglie il tag. ja-very-strict è la regola d’inizio riga di JIS X 4051 (行頭禁則): nessuna riga si apre con 、。」, ー o un kana piccolo come っ. hangingPunctuation: 'allow' lascia che un 、 o un 。 che altrimenti aprirebbe una riga sporga sotto l’ultimo carattere (ぶら下げ), come fanno i bunko invece di comprimere la riga. paragraphStartBracket: 'half' è lo schema ③ di JLReq: un paragrafo che si apre con 「 mette la parentesi nella seconda metà del suo rientro di un carattere, così il primo carattere del discorso sta dove sta quello di ogni altro paragrafo (East Asian typography). I libri cinesi differiscono su entrambi i punti: per impostazione predefinita non fanno sporgere alcun segno e rientrano un paragrafo di due caratteri, con o senza parentesi.

#2 · Dai al titolo di ogni notte tre righe della griglia

script.js · righe 56–63nel codice completo
// lineSpan sets the heading in a band of three body lines, its characters centred in it, so
// the text after it stands on the grid's lines. Aozora marks the titles 5字下げ (中見出し);
// indent counts body characters, whatever the heading's own size.
const night = {
  level: 1, fontFamily: MINCHO, fontWeight: 600, fontSize: pt(12.5),
  lineSpan: 3, indent: em(5), // JLReq §4.1.3
  breakBefore: { enabled: false }, // the nights run on
};

Un titolo giapponese occupa un numero intero di righe di testo, 行取り (gyōdori), così le righe successive restano sulla stessa griglia delle precedenti. lineSpan: 3 compone 第一夜 in una fascia di tre righe da 15,75 pt con i caratteri centrati su di esse; i 12,5 pt del titolo non spostano la griglia, e a pagina 6 第二夜 occupa le prime tre righe della pagina, quindi il testo che segue comincia sulla quarta riga della griglia. indent: em(5) si legge in caratteri del testo (五字下げ), che è come il testo di Aozora marca un 中見出し e come JLReq conta i rientri dei titoli (Per-level overrides).

#3 · Prendi il testo e le sue letture da Aozora Bunko

Le due notti vengono dalla scheda 799 di Aozora Bunko, convertita con aozora.py della skill postext-port. Il convertitore legge il file come CP932, trasforma ogni lettura scritta 仰向《あおむき》 in una lettura di gruppo {仰向|あおむき}, una sola sequenza di kana sull’intera parola, e ogni [#5字下げ]…[#「第一夜」は中見出し] in un titolo. In un documento ja le letture seguono JIS X 4051: una lettura più corta della sua base si distribuisce lungo di essa 1:2:1, e una più lunga può sconfinare su un kana vicino ma mai su un kanji (Marks, ruby and warichu). L’interlinea di 1,75 em lascia 6,75 pt tra le righe, lo spazio per le letture da 4,5 pt.

#4 · Metti lo hashira lungo il margine esterno, i folio in kanji

script.js · righe 67–78nel codice completo
// anchor 'outer' is the fore-edge: the left margin of an odd page, the right of an even one
// in a book bound on the right. The 柱 starts two characters below the head of the text.
const foreEdge = (id, content, parity, edge, y, pages) => ({
  kind: 'text', id, content, parity, pages, writingMode: 'vertical-rl',
  fontFamily: GOTHIC, fontSize: pt(7), letterSpacing: pt(1), color: col('muted'),
  overflow: 'clip', placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } },
});
const header = { elements: [
  foreEdge('hashira', '{title}', 'odd', 'top', 2, 'body'), // never on the opening page
  foreEdge('folio', '{pageNumber}', 'all', 'bottom', -2, 'all'),
] };
const none = { elements: [] };

In un bunko verticale la testatina (柱, hashira) è piccola e sta nel margine esterno. anchor.to: 'outer' vi colloca un elemento, nel margine sinistro di una pagina dispari e in quello destro di una pagina pari in un libro rilegato a destra, e writingMode: 'vertical-rl' lo compone dall’alto in basso (Vertical text elements). Il titolo 夢十夜 compare solo sulle pagine sinistre (parity: 'odd'), e pages: 'body' lo terrebbe lontano da ogni pagina che apre un capitolo. pageNumbering.format: 'japanese-informal' stampa i folio 二, 三 … 九 (Numbering).

#5 · Centra il frontespizio sul formato

script.js · righe 82–110nel codice completo
// A design on a vertical page is laid out in the flow frame: x runs down the page and y
// across it, leftwards from the right edge. Anchored to the page (the trim), the frame and
// the title stand on the page's axis, 52.5 mm in, whichever side the spine is on.
const at = (down, across) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(down), y: mm(across) } });
const AXIS = 105 / 2; // mm
const TITLE = 28 * PT; // mm: 夢十夜 in 28 pt, three characters and two 8 pt gaps down
const frame = { kind: 'box', id: 'frame', placement: { ...at(30, AXIS - 23),
  size: { width: mm(88), height: mm(46) } }, // 88 mm down, 46 mm across
  style: { borderColor: col('vermilion'), borderWidth: pt(0.75) } };
const titlePage = {
  id: 'title', numbered: false, toc: false, span: 'page', header: none, footer: { elements: [
    // The colophon across the foot, in the edition's language: each \n in the attribute
    // starts a line; the box hangs from the foot of the page, centred.
    // Noto Sans JP: Shippori Mincho B1 has no ō or ū for the rōmaji.
    { kind: 'text', id: 'colophon', content: '{attr.colophon}', fontFamily: GOTHIC,
      fontSize: pt(5.25), lineHeight: 1.45, color: col('muted'), overflow: 'wrap',
      align: 'center', placement: { anchor: { to: 'page', edge: 'bottom' },
        offset: { y: mm(-4) }, size: { width: mm(84) } } }] },
  breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: lines(LINES), slot: { elements: [
    frame,
    { kind: 'text', id: 'book', content: '{titleText}', fontFamily: MINCHO, fontWeight: 800,
      fontSize: pt(28), lineHeight: 1, letterSpacing: pt(8), color: col('ink'),
      placement: at(44, AXIS - TITLE / 2) },
    { kind: 'text', id: 'author', content: '{author}', fontFamily: MINCHO, fontSize: pt(11),
      letterSpacing: pt(4), color: col('ink'), placement: at(92, AXIS + TITLE / 2 + 4) },
  ] } },
};

Il frontespizio è un titolo di livello 1 con uno stile tutto suo, # 夢十夜 {style="title" …}, il cui design riempie la pagina. I suoi elementi sono ancorati alla pagina stessa, non alla gabbia del testo, perché la gabbia di una pagina speculare sta 1,5 mm fuori centro: x scorre lungo la pagina in verticale e y in orizzontale dal bordo destro, quindi AXIS è metà della larghezza della pagina su entrambi i lati del dorso. Il colophon nel piè di pagina è composto in Noto Sans JP: Shippori Mincho B1 non ha né ō né ū, e il PDF disegnerebbe riquadri vuoti per Sōseki.

#6 · Carica ogni peso con i caratteri che compone

script.js · righe 219–228nel codice completo
// Fontsource cuts a Japanese face into about 120 files; loadCjkFonts fetches the ones the
// text touches (gotcha: cjk-fonts-slices). vertical: true loads the face's vertical forms
// for the canvas: 、。「」ー and small kana such as っ stand differently down a line.
const title = markdown.match(/^title: "(.*)"$/m)[1];
const author = markdown.match(/^author: "(.*)"$/m)[1];
const heads = markdown.match(/^# [^{\n]*/gm).join('');
await loadFonts(FONTS, markdown); // the Latin files: the colophon, with ō and ū
await loadCjkFonts({ [MINCHO]: ['400'] }, markdown, { vertical: true });
await loadCjkFonts({ [MINCHO]: ['600', '800'] }, `${heads}${title}${author}`);
await loadCjkFonts({ [GOTHIC]: ['400'] }, `${title}一二三四五六七八九十`, { vertical: true });

Fontsource serve ogni peso di un carattere giapponese come circa 120 file, ognuno per un intervallo di caratteri, e loadCjkFonts scarica i file toccati dal testo che riceve. Il peso del testo riceve l’intero esempio con vertical: true, che carica anche le forme verticali del carattere per il canvas: 、。「」 e ー, e i kana piccoli, che nel testo verticale stanno in alto a destra della loro cella. renderToPdf prende cjkPdfProvider, che incorpora gli stessi file come sottoinsiemi.

La ricetta completa

Sandbox
// ═══ Postext Cookbook · Nº 117 · A bunko novel set vertically, with furigana ════════
// https://postext.dev/en/cookbook/bunko-novel-furigana
// Code: MIT · Text: 夏目漱石『夢十夜』, Aozora Bunko 799 (public domain) · Pictures: none
// Fonts: Shippori Mincho B1, Noto Sans JP (SIL OFL 1.1) · Needs postext ≥ 1.28.0
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the colophon; the novel is Japanese in both
const RECIPE = 'bunko-novel-furigana';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: sumi ink on a cream bunko stock, one vermilion
const palette = {
  ink: '#1f1b18', // the text: a warm sumi black
  vermilion: '#a8392b', // 朱: the frame of the title page
  muted: '#6b635b', // the hashira and the folios
  paper: '#fbf7ee', // bunko paper is cream, not white
};
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: 'defaults', value: { hex: palette.vermilion, model: 'hex' } },
];
// #endregion
const [MINCHO, GOTHIC] = ['Shippori Mincho B1', 'Noto Sans JP'];
const [BODY, LEAD, CHARS, LINES] = [9, 15.75, 38, 15]; // pt, pt: 38字 × 15行 at 1.75 em
const lines = (n) => pt(n * LEAD); // n lines across the page
const PT = 25.4 / 72; // mm in a point

// #region answer: a bunko page, 38 characters down 15 lines, bound on the right
// 'vertical-rl' sets each line top to bottom and the lines from right to left; with the
// binding left to 'auto' the book opens from the right, so page 1 is a left-hand page and
// the spreads read [3 | 2]. locale 'ja' gives the cjk settings Japan's values: they are
// written out below so you can see them, and leaving them out sets the same page.
const page = {
  sizePreset: 'custom', width: mm(105), height: mm(148), dpi: 200, // 文庫判 A6
  backgroundColor: col('paper'),
  // 天 above 地; left is the spine side (the right edge of a left-hand page). The grid
  // grows the margins of each pair alike, so the head stays deeper than the foot.
  margins: { top: mm(15), bottom: mm(11), left: mm(9), right: mm(12), mirror: true },
  pageNumbering: { format: 'japanese-informal' }, // folios 一, 二 … 十, 十一
};
const layout = { layoutType: 'single', writingMode: 'vertical-rl' };
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  lineBreak: 'ja-very-strict', // 行頭禁則: no 、。」ー or small kana at the head of a line
  hangingPunctuation: 'allow', // ぶら下げ: a 、。 that would open a line hangs below the last
  paragraphStartBracket: 'half', // ③: a 「 that opens a paragraph fills the indent cell
  ruby: { align: 'jis', overhang: 'kana' }, // JIS 1:2:1, a reading may run onto kana only
};
// #endregion

// #region nights: each night's title takes three body lines (3行取り), five characters down
// lineSpan sets the heading in a band of three body lines, its characters centred in it, so
// the text after it stands on the grid's lines. Aozora marks the titles 5字下げ (中見出し);
// indent counts body characters, whatever the heading's own size.
const night = {
  level: 1, fontFamily: MINCHO, fontWeight: 600, fontSize: pt(12.5),
  lineSpan: 3, indent: em(5), // JLReq §4.1.3
  breakBefore: { enabled: false }, // the nights run on
};
// #endregion

// #region hashira: the title down the fore-edge of left-hand pages, kanji folios below
// anchor 'outer' is the fore-edge: the left margin of an odd page, the right of an even one
// in a book bound on the right. The 柱 starts two characters below the head of the text.
const foreEdge = (id, content, parity, edge, y, pages) => ({
  kind: 'text', id, content, parity, pages, writingMode: 'vertical-rl',
  fontFamily: GOTHIC, fontSize: pt(7), letterSpacing: pt(1), color: col('muted'),
  overflow: 'clip', placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } },
});
const header = { elements: [
  foreEdge('hashira', '{title}', 'odd', 'top', 2, 'body'), // never on the opening page
  foreEdge('folio', '{pageNumber}', 'all', 'bottom', -2, 'all'),
] };
const none = { elements: [] };
// #endregion

// #region title-page: the 扉, a vermilion frame round the title and the author
// A design on a vertical page is laid out in the flow frame: x runs down the page and y
// across it, leftwards from the right edge. Anchored to the page (the trim), the frame and
// the title stand on the page's axis, 52.5 mm in, whichever side the spine is on.
const at = (down, across) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(down), y: mm(across) } });
const AXIS = 105 / 2; // mm
const TITLE = 28 * PT; // mm: 夢十夜 in 28 pt, three characters and two 8 pt gaps down
const frame = { kind: 'box', id: 'frame', placement: { ...at(30, AXIS - 23),
  size: { width: mm(88), height: mm(46) } }, // 88 mm down, 46 mm across
  style: { borderColor: col('vermilion'), borderWidth: pt(0.75) } };
const titlePage = {
  id: 'title', numbered: false, toc: false, span: 'page', header: none, footer: { elements: [
    // The colophon across the foot, in the edition's language: each \n in the attribute
    // starts a line; the box hangs from the foot of the page, centred.
    // Noto Sans JP: Shippori Mincho B1 has no ō or ū for the rōmaji.
    { kind: 'text', id: 'colophon', content: '{attr.colophon}', fontFamily: GOTHIC,
      fontSize: pt(5.25), lineHeight: 1.45, color: col('muted'), overflow: 'wrap',
      align: 'center', placement: { anchor: { to: 'page', edge: 'bottom' },
        offset: { y: mm(-4) }, size: { width: mm(84) } } }] },
  breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: lines(LINES), slot: { elements: [
    frame,
    { kind: 'text', id: 'book', content: '{titleText}', fontFamily: MINCHO, fontWeight: 800,
      fontSize: pt(28), lineHeight: 1, letterSpacing: pt(8), color: col('ink'),
      placement: at(44, AXIS - TITLE / 2) },
    { kind: 'text', id: 'author', content: '{author}', fontFamily: MINCHO, fontSize: pt(11),
      letterSpacing: pt(4), color: col('ink'), placement: at(92, AXIS + TITLE / 2 + 4) },
  ] } },
};
// #endregion

const config = () => ({
  locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag)
  colorPalette,
  page,
  layout,
  cjk,
  bodyText: {
    fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true, // 一字下げ
  },
  headings: { fontFamily: MINCHO, color: col('ink'), levels: [night] },
  headingStyles: [titlePage],
  header,
  footer: none,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Esempio Markdown · 77 righe · content.en.mdtitle: "夢十夜" author: "夏目漱石" --- # 夢十夜 {style="title" colophon="Natsume Sōseki, 夢十夜 (Ten Nights of Dreams, 1908), nights one and two.\nText: Aozora Bunko 799, public domain.\nSet in Shippori Mincho B1 and Noto Sans JP (SIL OFL)."} # 第一夜 こんな夢を見た。 腕組をして枕元に{坐|すわ}っていると、{仰向|あおむき}に寝た女が、静かな声でもう死にますと云う。女は長い髪を枕に敷いて、{輪郭|りんかく}の{柔|やわ}らかな{瓜実|うりざね}{顔|がお}をその中に横たえている。真白な頬の底に温かい血の色がほどよく差して、{唇|くちびる}の色は無論赤い。とうてい死にそうには見えない。しかし女は静かな声で、もう死にますと{判然|はっきり}云った。自分も{確|たしか}にこれは死ぬなと思った。そこで、そうかね、もう死ぬのかね、と上から{覗|のぞ}き込むようにして聞いて見た。死にますとも、と云いながら、女はぱっちりと眼を{開|あ}けた。大きな{潤|うるおい}のある眼で、長い{睫|まつげ}に包まれた中は、ただ一面に真黒であった。その真黒な{眸|ひとみ}の奥に、自分の姿が{鮮|あざやか}に浮かんでいる。 自分は{透|す}き{徹|とお}るほど深く見えるこの黒眼の{色沢|つや}を眺めて、これでも死ぬのかと思った。それで、ねんごろに枕の{傍|そば}へ口を付けて、死ぬんじゃなかろうね、大丈夫だろうね、とまた聞き返した。すると女は黒い眼を眠そうに{睜|みはっ}たまま、やっぱり静かな声で、でも、死ぬんですもの、仕方がないわと云った。 じゃ、{私|わたし}の顔が見えるかいと{一心|いっしん}に聞くと、見えるかいって、そら、そこに、写ってるじゃありませんかと、にこりと笑って見せた。自分は黙って、顔を枕から離した。腕組をしながら、どうしても死ぬのかなと思った。 しばらくして、女がまたこう云った。 「死んだら、{埋|う}めて下さい。大きな真珠貝で穴を掘って。そうして天から落ちて来る星の{破片|かけ}を{墓標|はかじるし}に置いて下さい。そうして墓の傍に待っていて下さい。また{逢|あ}いに来ますから」 自分は、いつ逢いに来るかねと聞いた。 「日が出るでしょう。それから日が沈むでしょう。それからまた出るでしょう、そうしてまた沈むでしょう。――赤い日が東から西へ、東から西へと落ちて行くうちに、――あなた、待っていられますか」 自分は黙って{首肯|うなず}いた。女は静かな調子を一段張り上げて、 「百年待っていて下さい」と思い切った声で云った。 「百年、私の墓の{傍|そば}に坐って待っていて下さい。きっと逢いに来ますから」 自分はただ待っていると答えた。すると、黒い{眸|ひとみ}のなかに{鮮|あざやか}に見えた自分の姿が、ぼうっと{崩|くず}れて来た。静かな水が動いて写る影を乱したように、流れ出したと思ったら、女の眼がぱちりと閉じた。長い{睫|まつげ}の間から涙が頬へ垂れた。――もう死んでいた。 自分はそれから庭へ下りて、真珠貝で穴を掘った。真珠貝は大きな{滑|なめら}かな{縁|ふち}の{鋭|する}どい貝であった。土をすくうたびに、貝の裏に月の光が差してきらきらした。{湿|しめ}った土の{匂|におい}もした。穴はしばらくして掘れた。女をその中に入れた。そうして柔らかい土を、上からそっと掛けた。掛けるたびに真珠貝の裏に月の光が差した。 それから星の{破片|かけ}の落ちたのを拾って来て、かろく土の上へ乗せた。星の破片は丸かった。長い間大空を落ちている{間|ま}に、{角|かど}が取れて{滑|なめら}かになったんだろうと思った。{抱|だ}き{上|あ}げて土の上へ置くうちに、自分の胸と手が少し暖くなった。 自分は{苔|こけ}の上に坐った。これから百年の間こうして待っているんだなと考えながら、腕組をして、丸い{墓石|はかいし}を眺めていた。そのうちに、女の云った通り日が東から出た。大きな赤い日であった。それがまた女の云った通り、やがて西へ落ちた。赤いまんまでのっと落ちて行った。一つと自分は{勘定|かんじょう}した。 しばらくするとまた{唐紅|からくれない}の{天道|てんとう}がのそりと{上|のぼ}って来た。そうして黙って沈んでしまった。二つとまた勘定した。 自分はこう云う風に一つ二つと勘定して行くうちに、赤い日をいくつ見たか分らない。勘定しても、勘定しても、しつくせないほど赤い日が頭の上を通り越して行った。それでも百年がまだ来ない。しまいには、{苔|こけ}の{生|は}えた丸い石を眺めて、自分は女に{欺|だま}されたのではなかろうかと思い出した。 すると石の下から{斜|はす}に自分の方へ向いて青い{茎|くき}が伸びて来た。見る間に長くなってちょうど自分の胸のあたりまで来て留まった。と思うと、すらりと{揺|ゆら}ぐ{茎|くき}の{頂|いただき}に、心持首を{傾|かたぶ}けていた細長い一輪の{蕾|つぼみ}が、ふっくらと{弁|はなびら}を開いた。真白な{百合|ゆり}が鼻の先で骨に{徹|こた}えるほど匂った。そこへ{遥|はるか}の上から、ぽたりと{露|つゆ}が落ちたので、花は自分の重みでふらふらと動いた。自分は首を前へ出して冷たい露の{滴|したた}る、白い{花弁|はなびら}に{接吻|せっぷん}した。自分が百合から顔を離す{拍子|ひょうし}に思わず、遠い空を見たら、{暁|あかつき}の星がたった一つ{瞬|またた}いていた。 「百年はもう来ていたんだな」とこの時始めて気がついた。 # 第二夜 こんな夢を見た。 {和尚|おしょう}の室を{退|さ}がって、{廊下|ろうか}{伝|づた}いに自分の部屋へ帰ると{行灯|あんどう}がぼんやり{点|とも}っている。{片膝|かたひざ}を{座蒲団|ざぶとん}の上に突いて、灯心を{掻|か}き立てたとき、花のような{丁子|ちょうじ}がぱたりと朱塗の台に落ちた。同時に部屋がぱっと明かるくなった。 {襖|ふすま}の{画|え}は{蕪村|ぶそん}の筆である。黒い柳を濃く薄く、{遠近|おちこち}とかいて、{寒|さ}むそうな漁夫が{笠|かさ}を{傾|かたぶ}けて土手の上を通る。{床|とこ}には{海中文殊|かいちゅうもんじゅ}の{軸|じく}が{懸|かか}っている。{焚|た}き残した線香が暗い方でいまだに{臭|にお}っている。広い寺だから{森閑|しんかん}として、{人気|ひとけ}がない。黒い{天井|てんじょう}に差す{丸行灯|まるあんどう}の丸い影が、{仰向|あおむ}く{途端|とたん}に生きてるように見えた。 {立膝|たてひざ}をしたまま、左の手で{座蒲団|ざぶとん}を{捲|めく}って、右を差し込んで見ると、思った所に、ちゃんとあった。あれば安心だから、蒲団をもとのごとく{直|なお}して、その上にどっかり{坐|すわ}った。 お前は{侍|さむらい}である。侍なら悟れぬはずはなかろうと{和尚|おしょう}が云った。そういつまでも悟れぬところをもって見ると、御前は侍ではあるまいと言った。人間の{屑|くず}じゃと言った。ははあ怒ったなと云って笑った。{口惜|くや}しければ悟った証拠を持って来いと云ってぷいと{向|むこう}をむいた。{怪|け}しからん。 隣の広間の床に{据|す}えてある置時計が次の{刻|とき}を打つまでには、きっと悟って見せる。悟った上で、今夜また{入室|にゅうしつ}する。そうして和尚の首と悟りと{引替|ひきかえ}にしてやる。悟らなければ、和尚の命が取れない。どうしても悟らなければならない。自分は侍である。 もし悟れなければ{自刃|じじん}する。侍が{辱|はずか}しめられて、生きている訳には行かない。{綺麗|きれい}に死んでしまう。 こう考えた時、自分の手はまた思わず{布団|ふとん}の下へ{這入|はい}った。そうして{朱鞘|しゅざや}の短刀を{引|ひ}き{摺|ず}り出した。ぐっと{束|つか}を握って、赤い鞘を向へ払ったら、冷たい{刃|は}が一度に暗い部屋で光った。{凄|すご}いものが手元から、すうすうと逃げて行くように思われる。そうして、ことごとく{切先|きっさき}へ集まって、{殺気|さっき}を一点に{籠|こ}めている。自分はこの鋭い刃が、無念にも針の頭のように{縮|ちぢ}められて、{九寸|くすん}{五分|ごぶ}の先へ来てやむをえず{尖|とが}ってるのを見て、たちまちぐさりとやりたくなった。{身体|からだ}の血が右の手首の方へ流れて来て、握っている束がにちゃにちゃする。{唇|くちびる}が{顫|ふる}えた。 短刀を鞘へ収めて右脇へ引きつけておいて、それから{全伽|ぜんが}を組んだ。――{趙州|じょうしゅう}曰く{無|む}と。無とは何だ。{糞坊主|くそぼうず}めとはがみをした。 奥歯を強く{咬|か}み{締|し}めたので、鼻から熱い息が荒く出る。こめかみが釣って痛い。眼は普通の倍も大きく開けてやった。 {懸物|かけもの}が見える。行灯が見える。{畳|たたみ}が見える。和尚の{薬缶頭|やかんあたま}がありありと見える。{鰐口|わにぐち}を{開|あ}いて{嘲笑|あざわら}った声まで聞える。{怪|け}しからん坊主だ。どうしてもあの薬缶を首にしなくてはならん。悟ってやる。無だ、無だと舌の根で念じた。無だと云うのにやっぱり線香の{香|におい}がした。何だ線香のくせに。 自分はいきなり{拳骨|げんこつ}を固めて自分の頭をいやと云うほど{擲|なぐ}った。そうして奥歯をぎりぎりと{噛|か}んだ。{両腋|りょうわき}から汗が出る。背中が棒のようになった。{膝|ひざ}の{接目|つぎめ}が急に痛くなった。膝が折れたってどうあるものかと思った。けれども痛い。苦しい。{無|む}はなかなか出て来ない。出て来ると思うとすぐ痛くなる。腹が立つ。無念になる。非常に{口惜|くや}しくなる。涙がほろほろ出る。ひと{思|おもい}に身を{巨巌|おおいわ}の上にぶつけて、骨も肉もめちゃめちゃに{砕|くだ}いてしまいたくなる。 それでも我慢してじっと坐っていた。{堪|た}えがたいほど切ないものを胸に{盛|い}れて忍んでいた。その切ないものが{身体|からだ}中の筋肉を下から持上げて、毛穴から外へ吹き出よう吹き出ようと{焦|あせ}るけれども、どこも一面に{塞|ふさ}がって、まるで出口がないような残刻極まる状態であった。 そのうちに頭が変になった。{行灯|あんどう}も{蕪村|ぶそん}の{画|え}も、畳も、{違棚|ちがいだな}も有って無いような、無くって有るように見えた。と云って{無|む}はちっとも{現前|げんぜん}しない。ただ{好加減|いいかげん}に坐っていたようである。ところへ{忽然|こつぜん}隣座敷の時計がチーンと鳴り始めた。 はっと思った。右の手をすぐ短刀にかけた。時計が二つ目をチーンと打った。
`; // content.<lang>.md: the same Japanese text in both // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Shippori Mincho B1': ['400', '600', '800'], // the text and furigana; 一; the title 'Noto Sans JP': ['400'], // the hashira, the folios and the colophon }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region voices: each face loads the files that hold the characters it sets // Fontsource cuts a Japanese face into about 120 files; loadCjkFonts fetches the ones the // text touches (gotcha: cjk-fonts-slices). vertical: true loads the face's vertical forms // for the canvas: 、。「」ー and small kana such as っ stand differently down a line. const title = markdown.match(/^title: "(.*)"$/m)[1]; const author = markdown.match(/^author: "(.*)"$/m)[1]; const heads = markdown.match(/^# [^{\n]*/gm).join(''); await loadFonts(FONTS, markdown); // the Latin files: the colophon, with ō and ū await loadCjkFonts({ [MINCHO]: ['400'] }, markdown, { vertical: true }); await loadCjkFonts({ [MINCHO]: ['600', '800'] }, `${heads}${title}${author}`); await loadCjkFonts({ [GOTHIC]: ['400'] }, `${title}一二三四五六七八九十`, { vertical: true }); // #endregion const doc = await withLoadedFonts(() => buildDocument({ markdown }, config()), { ...kitFonts(FONTS), text: markdown }); showBook(doc, { title: t({ en: 'A bunko novel set vertically, with furigana', es: 'Una novela en formato bunko, en vertical y con furigana' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);
Kit · core, fonts, viewer, pdf, cjk: uguale in ogni ricetta · 458 righe// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · 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 ───────────────────────────────────────────────────────────────────────

Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)

Varianti

#Segna le parole che la fonte mette in rilievo

Le prime due notti di Sōseki non hanno 傍点, ma molti testi di Aozora sì, e il convertitore li scrive come :dots[…]{style=sesame}. In un documento ja, *…* dà gli stessi segni a sesamo accanto alla riga, mai il corsivo.

-こんな夢を見た。
+こんな*夢*を見た。

#Metti ogni parentesi un carattere intero più in basso

Lo schema ① mantiene il rientro di un carattere e mette la parentesi dopo di esso, come fanno molti romanzi per lettori più giovani.

-  paragraphStartBracket: 'half', // ③: a 「 that opens a paragraph fills the indent cell
+  paragraphStartBracket: 'indent', // ①: the bracket after the one-character indent

#Comprimi invece di far sporgere

Con hangingPunctuation: 'none' un 、 o un 。 che aprirebbe una riga viene accolto nella riga precedente comprimendo parentesi e virgole (追い込み), oppure l’ultimo carattere della riga scende con esso (追い出し), e nessun segno sporge sotto la riga.

-  hangingPunctuation: 'allow', // ぶら下げ: a 、。 that would open a line hangs below the last
+  hangingPunctuation: 'none',

Errori frequenti

Errore frequente

Assegna a un testo giapponese il tag 'ja', mai zh-Hans o LANG

Le edizioni di una ricetta sono en ed es, ma un esempio giapponese è giapponese in entrambe: `locale: LANG` gli darebbe il tag inglese o spagnolo, e un tag cinese lo comporrebbe con le regole cinesi (punteggiatura Kaiming, kana piccoli liberi di aprire una riga, 图 per 図, forme cinesi dei glifi nel PDF). Da postext 1.30 il tag è anche la lingua usata per lo shaping del testo (misura, disegno e incorporamento), quindi qualunque tag diverso da 'ja' dà forme dei glifi diverse da quelle giapponesi (quelle cinesi con un tag cinese) e altri a capo. Scrivi 'ja': sceglie la regione Giappone (divisione in righe e punteggiatura JLReq, segni d’enfasi a sesamo, spaziatura dei furigana, etichette 図 e 表) e disattiva la sillabazione. Il lint fa fallire un testo con kana sotto un tag zh o ko. Divisione in righe del giapponese (kinsoku) →

Errore frequente

I caratteri cinesi si caricano a fette, tramite il blocco cjk

Fontsource serve una famiglia cinese, giapponese o coreana come un centinaio di file per peso, ognuno dei quali copre un intervallo di caratteri. loadFonts scarica solo il file latin, così a schermo i caratteri Han vengono da un font di sistema e sono misurati male, e fontsourceProvider passa al PDF quel file latin, che li stampa come riquadri vuoti. Elenca il blocco cjk del kit, chiama loadCjkFonts(FONTS, markdown) dopo loadFonts (una volta per voce tipografica, con il testo che compone, quando il libro usa più caratteri CJK) e passa a renderToPdf fontProvider: cjkPdfProvider: entrambi prendono i file che contengono i caratteri del testo. Font cinesi, giapponesi e coreani →

Errore frequente

Un libro rilegato a destra mostra le doppie pagine con showBook

In un libro rilegato a destra (testo arabo, ebraico o persiano, cinese verticale, o page.binding 'right') la pagina 1 resta il recto, ma sta a sinistra del dorso, e le coppie si leggono [3 | 2]. showPages dispone ogni libro come rilegato a sinistra; showBook del blocco book (il blocco cjk porta la stessa funzione) legge doc.binding e rispecchia le coppie. capture.hero indica comunque una doppia pagina in ordine di lettura, [verso, recto]: [2, 3]. Libri rilegati a destra →

Errore frequente

Leggi i file di Aozora come CP932, non come Shift_JIS

I file di testo di Aozora Bunko sono in Windows-31J (CP932). Decodificati come semplice Shift_JIS, il trattino ―― diventa trattini lunghi (U+2014), ~ diventa il trattino ondulato 〜, e alcuni caratteri NEC e IBM falliscono. Leggili come cp932, poi converti il markup: il ruby in 《》, le note [#…] e i gaiji ※[#…] non sono Markdown e, se restano, si stampano tali e quali. Lo script aozora.py della skill fa entrambe le cose. Importazione da Aozora Bunko →

Errore frequente

Un intervallo di Fontsource può indicare caratteri che il suo file non ha

Fontsource assegna a ogni file un unicode-range, e loadCjkFonts carica i file i cui intervalli coprono l’esempio, ma un intervallo può dichiarare più di quanto contiene il file. Il frammento di Zen Old Mincho per U+3028–303F non ha 〻 né i segni di ripetizione lunghi 〳〴〵, e il carattere non ha nemmeno le vocali con macron ō ū ā; il file latin-ext di Shippori Mincho B1 ha una manciata di caratteri e nessuno di essi è ō, ū o ā. Mentre il pen gira non fallisce nulla: a schermo quei caratteri vengono da un font di sistema e sono misurati con esso, e il PDF stampa il riquadro .notdef del font, che solo la verifica C25 segnala in seguito. Cerca un segno raro o una parola in rōmaji nel font prima di scegliere il carattere (fontTools sul file di Fontsource), e componila in uno che ce l’abbia: Shippori Mincho B1 e Noto Serif JP hanno 〻 e 〳〴〵, Noto Serif JP e Noto Sans JP le vocali con macron. Font cinesi, giapponesi e coreani →

Errore frequente

Una pagina verticale ha due sistemi di riferimento: quello del testo ruotato e quello del foglio

Un documento vertical-rl impagina il testo come una pagina orizzontale ruotata di un quarto di giro in senso orario, e la grafica di un titolo (gli elementi di advancedDesign.slot) si colloca in quel riferimento ruotato: x scorre giù lungo la pagina, y la attraversa verso sinistra dal bordo destro, e la larghezza di un elemento si misura giù lungo la pagina. Gli elementi di intestazione e piè di pagina restano sul foglio: x scorre verso destra dal bordo sinistro e y verso il basso dalla cima. Una cornice disegnata nell’intestazione e un titolo collocato nella grafica del titolo prendono quindi i loro scostamenti su assi diversi. In un frontespizio verticale, ancora gli elementi a `page` e misura dai suoi bordi: ancorati a `container`, gli elementi del piè di pagina stanno qualche millimetro (circa 5 mm) fuori dal centro della gabbia. Testo verticale →

Crediti

Testo
  • 夏目漱石『坊っちゃん』 (Botchan, 1906), the opening of chapter one; Aozora Bunko card 752, from 「ちくま日本文学全集 夏目漱石」 (Chikuma Shobō, 1992), input 真先芳秋, proofreading 柳沢成雄, converted with aozora.py · Natsume Sōseki; Aozora Bunko volunteers · pubblico dominio
Font
Shippori Mincho B1 (SIL OFL 1.1) · Noto Sans JP (SIL OFL 1.1)
SandboxPDF