Vai al contenuto principale
Ricetta numero 118

Ricettario · Capitolo 1 · Pagina e griglia

Un racconto giapponese a due fasce, il titolo su entrambe

Due racconti di Akutagawa in un’opera omnia: due fasce di 25 caratteri su una pagina A5, ogni titolo su entrambe in pagina dispari, ultime pagine come cadono.

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

pp. 214–215 · 4–5 di 7

  • Esempio in inglese: non c’è ancora un’edizione italiana
  • Formato 148 × 210 mm
  • 2 colonne, Intercolonna di 6,3 mm
  • Noto Serif JP 9/15,8
  • Noto Sans JP
  • 7 pagine
  • Livello
  • Postext 1.30.0
  • Impaginato in 84 ms
  • 136 righe di codice

In breve

Due racconti come li stampa un’opera omnia giapponese: il testo scende in due fasce sovrapposte, ogni racconto si apre su una pagina sinistra con il titolo alto quanto la pagina, e l’ultima pagina non viene pareggiata.

Che cosa comporrai

Sette pagine da un volume di opera omnia (全集) su una pagina A5 rilegata a destra: Il filo del ragno e La fede di Bisei di Akutagawa Ryūnosuke, composti in due fasce (二段組) di 25 caratteri in verticale e 21 righe in orizzontale, Noto Serif JP a 9 pt con un filetto sottile tra le fasce. Ogni racconto si apre su una pagina dispari, quella sinistra, con il titolo a 20 pt che scende attraverso entrambe le fasce e il nome dell’autore vicino al piede; i folio proseguono da pagina 211. Le sezioni 一 二 三 occupano tre righe della loro fascia. Quando un racconto finisce, la sua ultima pagina resta come cade il testo, il 成り行き (nariyuki) giapponese: a pagina 214 il racconto finisce nella fascia superiore con la sua data, una riga prima che la fascia sia piena, e la fascia inferiore resta vuota. Il giornale cinese in fasce verticali è Nº 084, e un titolo di capitolo su due colonne orizzontali è Nº 017.

Questa ricetta risponde a

  • Come compongo il titolo di un capitolo a tutta larghezza mentre il testo scorre sotto su due colonne?
  • Come forzo un’interruzione di pagina o di colonna, e faccio cominciare ogni capitolo su una pagina destra?
  • Come imposto la gabbia in caratteri, con tanti caratteri per riga e tante righe per pagina?

La risposta breve

script.js · righe 34–51nel codice completo
// On a vertical page two columns are two tiers (段), filled from the upper right: the
// gutter is the gap between them and the column rule a hairline across the page. The grid
// counts characters down one tier, so each tier is exactly 25 em long and the gutter 2 em.
const layout = {
  layoutType: 'double', writingMode: 'vertical-rl', // binding 'auto' becomes 'right'
  gutterWidth: pt(2 * BODY),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) },
};
const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } };
// 成り行き (nariyuki): the text fills the upper tier across the whole page before the
// lower one, and a work's last page is not levelled: its lower tier ends where the text
// does. trailing: false is that choice (vertical tiers are not balanced by default either).
const balancing = { trailing: false };
// Each work opens on an odd page, the left-hand one in a book bound on the right, with a
// blank page before it when the last work ended on an odd page.
const work = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' } };
// 一 二 三 take three lines of their tier (3行取り), eight characters down.
const section = { level: 2, fontSize: pt(11), fontWeight: 600, lineSpan: 3, indent: em(8) };

Ingredienti

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

Preparazione

#1 · Due colonne ruotate di un quarto di giro sono due fasce

Il codice è la risposta breve qui sopra. In una pagina con writingMode: 'vertical-rl', layoutType: 'double' sovrappone le due colonne: la prima riempie la fascia superiore da destra, poi quella inferiore, e l’intercolonna è la banda tra le due (Vertical writing). La griglia dei caratteri conta i caratteri lungo una fascia, quindi ogni fascia è di 25 caratteri da 9 pt e l’intercolonna si arrotonda a due caratteri interi; le 21 righe da 15,75 pt in orizzontale occupano 116,7 mm dei 148 mm della pagina. Il filetto tra le fasce attraversa la pagina, 0,4 pt in un grigio caldo.

Il bilanciatore di Postext chiuderebbe entrambe le fasce dell’ultima pagina di un racconto alla stessa riga, che è il modo in cui i giornali cinesi e la maggior parte dei libri europei a due colonne chiudono un capitolo. Un libro giapponese a fasce lascia cadere il testo: la fascia superiore percorre tutta la pagina prima che cominci quella inferiore. balancing.trailing: false è questa scelta, e le fasce verticali non si bilanciano affatto a meno che enabled: true non attivi il bilanciatore (Column balancing).

#2 · Fai scendere il titolo dell’opera su entrambe le fasce

script.js · righe 55–68nel codice completo
// A page-span heading is laid out across the whole height of the page, both tiers and the
// gutter, in the flow frame: x runs down the page, y across it leftwards from the right
// edge of the type area. The design reserves seven lines (七行取り); the tiers start after it.
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: pt(down), y: pt(across) } });
const TITLE = 20; // pt
const opener = { enabled: true, minHeight: lines(7), slot: { elements: [
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 600,
    fontSize: pt(TITLE), lineHeight: 1, letterSpacing: pt(TITLE / 2), color: col('ink'),
    placement: at(5 * BODY, 2.5 * LEAD - TITLE / 2) },
  { kind: 'text', id: 'author', content: '{author}', fontFamily: MINCHO, fontSize: pt(10.5),
    letterSpacing: pt(3), color: col('ink'),
    placement: at(HEIGHT - 5 * 10.5 - 4 * 3 - 3 * BODY, 4.5 * LEAD - 10.5 / 2) },
] } };

Un livello di titolo con span: 'page' si impagina su tutta l’altezza della pagina, entrambe le fasce e l’intercolonna, in testa al flusso, cioè a destra di una pagina verticale; le fasce cominciano dopo le sette righe che il suo design riserva. Un titolo così apre sempre una pagina, e breakBefore.parity: 'odd' ne fa quella sinistra: Il filo del ragno finisce a pagina 214, una pagina pari, quindi La fede di Bisei si apre a 215 senza pagina bianca in mezzo (Span and advanced design). Nel design x scorre lungo la pagina in verticale e y in orizzontale dal bordo destro della gabbia del testo; il nome dell’autore si colloca a partire dal piede della fascia inferiore, tre caratteri più su.

#3 · Numera le sezioni su tre righe della loro fascia

Il secondo livello, lineSpan: 3 con indent: em(8), compone 一 二 三 otto caratteri più in basso in una fascia, in una banda di tre righe, come li marca il testo di Aozora (八字下げ, 中見出し), così il testo dopo un numero di sezione resta sulle righe della fascia accanto (Per-level overrides). Le date che chiudono i racconti, (大正七年四月十六日), sono sollevate di un carattere dal piede della loro fascia (地から一字上げ) con :::paragraphs{align=end endIndent=1}; il convertitore di Aozora le scrive come uno stile aozora-end1 che le allinea al piede, e gli attributi del blocco aggiungono il sollevamento di un carattere.

#4 · Metti il volume e l’opera lungo il margine esterno

script.js · righe 72–96nel codice completo
// anchor 'outer' is the fore-edge: the left margin of an odd page and the right one of an
// even page in a book bound on the right. The volume's name on the right-hand page, the
// work on the left; the folios count on from the volume's page 211.
const foreEdge = (id, content, parity, edge, y, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', 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) } },
  ...extra,
});
const header = { elements: [
  foreEdge('volume', '{title}', 'even', 'top', 2),
  foreEdge('work', '{chapterTitle}', 'odd', 'top', 2),
  // The folio in Arabic digits, set across the margin: down the fore-edge they would lie
  // on their side.
  foreEdge('folio', '{pageNumber}', 'all', 'bottom', -1, { pages: 'all', writingMode: undefined,
    letterSpacing: pt(0.5) }),
] };
const none = { elements: [] };
// The first work's opening page carries the colophon across the foot, in the edition's
// language (a style's footer serves every page of its section: gotcha style-header-whole-section).
const first = { id: 'first', footer: { elements: [{ kind: 'text', id: 'colophon', pages: 'opener',
  content: '{attr.colophon}', fontFamily: GOTHIC, fontSize: pt(5.5), lineHeight: 1.45,
  color: col('muted'), overflow: 'wrap', align: 'center',
  placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-6) },
    size: { width: mm(118) } } }] } };

anchor.to: 'outer' mette lo hashira nel margine esterno, il nome del volume 芥川龍之介集 sulle pagine destre e il titolo dell’opera su quelle sinistre, e pages: 'body' li tiene entrambi lontani dalle pagine in cui si apre un’opera (Vertical text elements). Il folio si compone di traverso nel margine in cifre arabe, come lo stampano molte opere omnia: lungo il margine esterno tre cifre starebbero coricate. pageNumbering.startAt: 211 numera le pagine come fa il volume. Lo stile di titolo della prima opera porta il colophon con pages: 'opener', perché il piè di pagina di uno stile serve ogni pagina della sua sezione.

La ricetta completa

Sandbox
// ═══ Postext Cookbook · Nº 118 · A Japanese story in two tiers, its title across both ═══
// https://postext.dev/en/cookbook/two-tier-tankobon
// Code: MIT · Text: 芥川龍之介『蜘蛛の糸』『尾生の信』, Aozora Bunko 92, 24 (PD) · Pictures: none
// Fonts: Noto Serif JP, Noto Sans JP (SIL OFL 1.1) · Needs postext ≥ 1.25.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 stories are Japanese in both
const RECIPE = 'two-tier-tankobon';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink, one deep indigo, a warm book paper
const palette = {
  ink: '#1d1b19', // the text
  indigo: '#2f3d63', // 藍: the cloth of the case; the engine's defaults
  rule: '#8c857b', // the hairline between the two tiers
  muted: '#655e56', // the hashira and the folios
  paper: '#fcfaf4',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'defaults', value: { hex: palette.indigo, model: 'hex' } },
];
// #endregion
const [MINCHO, GOTHIC] = ['Noto Serif JP', 'Noto Sans JP'];
const [BODY, LEAD, CHARS, LINES] = [9, 15.75, 25, 21]; // pt, pt: 2 段 × 25字 × 21行
const HEIGHT = 2 * CHARS * BODY + 2 * BODY; // pt: two tiers and a gutter of two characters
const lines = (n) => pt(n * LEAD); // n lines across the page

// #region answer: two tiers of 25 characters, the last page of a work left as it falls
// On a vertical page two columns are two tiers (段), filled from the upper right: the
// gutter is the gap between them and the column rule a hairline across the page. The grid
// counts characters down one tier, so each tier is exactly 25 em long and the gutter 2 em.
const layout = {
  layoutType: 'double', writingMode: 'vertical-rl', // binding 'auto' becomes 'right'
  gutterWidth: pt(2 * BODY),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) },
};
const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } };
// 成り行き (nariyuki): the text fills the upper tier across the whole page before the
// lower one, and a work's last page is not levelled: its lower tier ends where the text
// does. trailing: false is that choice (vertical tiers are not balanced by default either).
const balancing = { trailing: false };
// Each work opens on an odd page, the left-hand one in a book bound on the right, with a
// blank page before it when the last work ended on an odd page.
const work = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' } };
// 一 二 三 take three lines of their tier (3行取り), eight characters down.
const section = { level: 2, fontSize: pt(11), fontWeight: 600, lineSpan: 3, indent: em(8) };
// #endregion

// #region opener: the work's title down both tiers, seven lines across, the author below
// A page-span heading is laid out across the whole height of the page, both tiers and the
// gutter, in the flow frame: x runs down the page, y across it leftwards from the right
// edge of the type area. The design reserves seven lines (七行取り); the tiers start after it.
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: pt(down), y: pt(across) } });
const TITLE = 20; // pt
const opener = { enabled: true, minHeight: lines(7), slot: { elements: [
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 600,
    fontSize: pt(TITLE), lineHeight: 1, letterSpacing: pt(TITLE / 2), color: col('ink'),
    placement: at(5 * BODY, 2.5 * LEAD - TITLE / 2) },
  { kind: 'text', id: 'author', content: '{author}', fontFamily: MINCHO, fontSize: pt(10.5),
    letterSpacing: pt(3), color: col('ink'),
    placement: at(HEIGHT - 5 * 10.5 - 4 * 3 - 3 * BODY, 4.5 * LEAD - 10.5 / 2) },
] } };
// #endregion

// #region furniture: the hashira and the folio down the fore-edge
// anchor 'outer' is the fore-edge: the left margin of an odd page and the right one of an
// even page in a book bound on the right. The volume's name on the right-hand page, the
// work on the left; the folios count on from the volume's page 211.
const foreEdge = (id, content, parity, edge, y, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', 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) } },
  ...extra,
});
const header = { elements: [
  foreEdge('volume', '{title}', 'even', 'top', 2),
  foreEdge('work', '{chapterTitle}', 'odd', 'top', 2),
  // The folio in Arabic digits, set across the margin: down the fore-edge they would lie
  // on their side.
  foreEdge('folio', '{pageNumber}', 'all', 'bottom', -1, { pages: 'all', writingMode: undefined,
    letterSpacing: pt(0.5) }),
] };
const none = { elements: [] };
// The first work's opening page carries the colophon across the foot, in the edition's
// language (a style's footer serves every page of its section: gotcha style-header-whole-section).
const first = { id: 'first', footer: { elements: [{ kind: 'text', id: 'colophon', pages: 'opener',
  content: '{attr.colophon}', fontFamily: GOTHIC, fontSize: pt(5.5), lineHeight: 1.45,
  color: col('muted'), overflow: 'wrap', align: 'center',
  placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-6) },
    size: { width: mm(118) } } }] } };
// #endregion

const config = () => ({
  locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag)
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(148), height: mm(210), dpi: 150, // A5
    backgroundColor: col('paper'),
    margins: { top: mm(24), bottom: mm(18), left: mm(14), right: mm(16), mirror: true },
    pageNumbering: { startAt: 211 },
  },
  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, fontWeight: 600, color: col('ink'), balancing,
    levels: [{ ...work, advancedDesign: opener }, section] },
  headingStyles: [first],
  header,
  footer: none,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Esempio Markdown · 93 righe · content.en.mdtitle: "芥川龍之介集" author: "芥川龍之介" --- # 蜘蛛の糸 {style="first" colophon="Akutagawa Ryūnosuke, 蜘蛛の糸 (The Spider’s Thread, 1918) and 尾生の信 (Bisei’s Faith, 1920). Text: Aozora Bunko 92 and 24, public domain. Set in Noto Serif JP and Noto Sans JP (SIL OFL)."} ## 一 ある日の事でございます。{御釈迦様|おしゃかさま}は極楽の{蓮池|はすいけ}のふちを、独りでぶらぶら御歩きになっていらっしゃいました。池の中に咲いている{蓮|はす}の花は、みんな玉のようにまっ白で、そのまん中にある{金色|きんいろ}の{蕊|ずい}からは、何とも云えない{好|よ}い{匂|におい}が、{絶間|たえま}なくあたりへ{溢|あふ}れて居ります。極楽は丁度朝なのでございましょう。 やがて御釈迦様はその池のふちに{御佇|おたたず}みになって、水の{面|おもて}を{蔽|おお}っている蓮の葉の間から、ふと下の{容子|ようす}を御覧になりました。この極楽の蓮池の下は、丁度{地獄|じごく}の底に当って居りますから、{水晶|すいしよう}のような水を透き徹して、{三途|さんず}の河や針の山の景色が、丁度{覗|のぞ}き{眼鏡|めがね}を見るように、はっきりと見えるのでございます。 するとその地獄の底に、{犍陀多|かんだた}と云う男が一人、ほかの罪人と一しょに{蠢|うごめ}いている姿が、御眼に止まりました。この犍陀多と云う男は、人を殺したり家に火をつけたり、いろいろ悪事を働いた大泥坊でございますが、それでもたった一つ、善い事を致した覚えがございます。と申しますのは、ある時この男が深い林の中を通りますと、小さな{蜘蛛|くも}が一匹、路ばたを{這|は}って行くのが見えました。そこで犍陀多は早速足を挙げて、踏み殺そうと致しましたが、「いや、いや、これも小さいながら、命のあるものに違いない。その命を{無暗|むやみ}にとると云う事は、いくら何でも可哀そうだ。」と、こう急に思い返して、とうとうその蜘蛛を殺さずに助けてやったからでございます。 御釈迦様は地獄の容子を御覧になりながら、この犍陀多には蜘蛛を助けた事があるのを御思い出しになりました。そうしてそれだけの善い事をした{報|むくい}には、出来るなら、この男を地獄から救い出してやろうと御考えになりました。幸い、側を見ますと、{翡翠|ひすい}のような色をした蓮の葉の上に、極楽の蜘蛛が一匹、美しい銀色の糸をかけて居ります。御釈迦様はその蜘蛛の糸をそっと御手に御取りになって、玉のような{白蓮|しらはす}の間から、遥か下にある地獄の底へ、まっすぐにそれを御{下|おろ}しなさいました。 ## 二 こちらは地獄の底の血の池で、ほかの罪人と一しょに、浮いたり沈んだりしていた{犍陀多|かんだた}でございます。何しろどちらを見ても、まっ暗で、たまにそのくら暗からぼんやり浮き上っているものがあると思いますと、それは恐しい針の山の針が光るのでございますから、その心細さと云ったらございません。その上あたりは墓の中のようにしんと静まり返って、たまに聞えるものと云っては、ただ罪人がつく{微|かすか}な{嘆息|たんそく}ばかりでございます。これはここへ落ちて来るほどの人間は、もうさまざまな地獄の{責苦|せめく}に疲れはてて、泣声を出す力さえなくなっているのでございましょう。ですからさすが大泥坊の犍陀多も、やはり血の池の血に{咽|むせ}びながら、まるで死にかかった{蛙|かわず}のように、ただもがいてばかり居りました。 ところがある時の事でございます。{何気|なにげ}なく犍陀多が頭を挙げて、血の池の空を眺めますと、そのひっそりとした暗の中を、遠い遠い天上から、銀色の{蜘蛛|くも}の糸が、まるで人目にかかるのを恐れるように、一すじ細く光りながら、するすると自分の上へ垂れて参るのではございませんか。犍陀多はこれを見ると、思わず手を{拍|う}って喜びました。この糸に{縋|すが}りついて、どこまでものぼって行けば、きっと地獄からぬけ出せるのに相違ございません。いや、うまく行くと、極楽へはいる事さえも出来ましょう。そうすれば、もう針の山へ追い上げられる事もなくなれば、血の池に沈められる事もある筈はございません。 こう思いましたから{犍陀多|かんだた}は、早速その蜘蛛の糸を両手でしっかりとつかみながら、一生懸命に上へ上へとたぐりのぼり始めました。元より大泥坊の事でございますから、こう云う事には昔から、慣れ切っているのでございます。 しかし地獄と極楽との間は、何万里となくございますから、いくら{焦|あせ}って見た所で、容易に上へは出られません。ややしばらくのぼる{中|うち}に、とうとう犍陀多もくたびれて、もう一たぐりも上の方へはのぼれなくなってしまいました。そこで仕方がございませんから、まず一休み休むつもりで、糸の中途にぶら下りながら、遥かに目の下を見下しました。 すると、一生懸命にのぼった甲斐があって、さっきまで自分がいた血の池は、今ではもう暗の底にいつの間にかかくれて居ります。それからあのぼんやり光っている恐しい針の山も、足の下になってしまいました。この分でのぼって行けば、地獄からぬけ出すのも、存外わけがないかも知れません。犍陀多は両手を蜘蛛の糸にからみながら、ここへ来てから何年にも出した事のない声で、「しめた。しめた。」と笑いました。ところがふと気がつきますと、蜘蛛の糸の下の方には、{数限|かずかぎり}もない罪人たちが、自分ののぼった後をつけて、まるで{蟻|あり}の行列のように、やはり上へ上へ一心によじのぼって来るではございませんか。犍陀多はこれを見ると、驚いたのと恐しいのとで、しばらくはただ、{莫迦|ばか}のように大きな口を{開|あ}いたまま、眼ばかり動かして居りました。自分一人でさえ{断|き}れそうな、この細い蜘蛛の糸が、どうしてあれだけの{人数|にんず}の重みに堪える事が出来ましょう。もし万一途中で{断|き}れたと致しましたら、折角ここへまでのぼって来たこの{肝腎|かんじん}な自分までも、元の地獄へ{逆落|さかおと}しに落ちてしまわなければなりません。そんな事があったら、大変でございます。が、そう云う中にも、罪人たちは何百となく何千となく、まっ暗な血の池の底から、うようよと{這|は}い上って、細く光っている蜘蛛の糸を、一列になりながら、せっせとのぼって参ります。今の中にどうかしなければ、糸はまん中から二つに断れて、落ちてしまうのに違いありません。 そこで犍陀多は大きな声を出して、「こら、罪人ども。この蜘蛛の糸は{己|おれ}のものだぞ。お前たちは一体誰に{尋|き}いて、のぼって来た。下りろ。下りろ。」と{喚|わめ}きました。 その途端でございます。今まで何ともなかった蜘蛛の糸が、急に犍陀多のぶら下っている所から、ぷつりと音を立てて{断|き}れました。ですから犍陀多もたまりません。あっと云う{間|ま}もなく風を切って、{独楽|こま}のようにくるくるまわりながら、見る見る中に暗の底へ、まっさかさまに落ちてしまいました。 後にはただ極楽の蜘蛛の糸が、きらきらと細く光りながら、月も星もない空の中途に、短く垂れているばかりでございます。 ## 三 {御釈迦様|おしゃかさま}は極楽の{蓮池|はすいけ}のふちに立って、この一部{始終|しじゅう}をじっと見ていらっしゃいましたが、やがて{犍陀多|かんだた}が血の池の底へ石のように沈んでしまいますと、悲しそうな御顔をなさりながら、またぶらぶら御歩きになり始めました。自分ばかり地獄からぬけ出そうとする、犍陀多の無慈悲な心が、そうしてその心相当な罰をうけて、元の地獄へ落ちてしまったのが、御釈迦様の御目から見ると、浅間しく思召されたのでございましょう。 しかし極楽の蓮池の蓮は、少しもそんな事には{頓着|とんじゃく}致しません。その玉のような白い花は、御釈迦様の{御足|おみあし}のまわりに、ゆらゆら{萼|うてな}を動かして、そのまん中にある金色の{蕊|ずい}からは、何とも云えない{好|よ}い匂が、{絶間|たえま}なくあたりへ{溢|あふ}れて居ります。極楽ももう{午|ひる}に近くなったのでございましょう。 :::paragraphs{align=end endIndent=1} (大正七年四月十六日) ::: # 尾生の信 {尾生|びせい}は橋の下に{佇|たたず}んで、さっきから女の来るのを待っている。 見上げると、高い石の{橋欄|きょうらん}には、{蔦蘿|つたかずら}が半ば{這|は}いかかって、時々その間を通りすぎる往来の人の{白衣|はくい}の裾が、鮮かな入日に照らされながら、悠々と風に吹かれて行く。が、女は未だに来ない。 尾生はそっと口笛を鳴しながら、気軽く橋の下の{洲|す}を見渡した。 橋の下の{黄泥|こうでい}の洲は、二坪ばかりの広さを{剰|あま}して、すぐに水と続いている。{水際|みずぎわ}の{蘆|あし}の間には、{大方|おおかた}{蟹|かに}の{棲家|すみか}であろう、いくつも{円|まる}い穴があって、そこへ波が当る度に、たぶりと云うかすかな音が聞えた。が、女は未だに来ない。 尾生はやや待遠しそうに水際まで{歩|ほ}を移して、舟{一艘|いっそう}通らない静な川筋を眺めまわした。 川筋には青い{蘆|あし}が、{隙間|すきま}もなくひしひしと生えている。のみならずその蘆の間には、{所々|ところどころ}に{川楊|かわやなぎ}が、こんもりと円く茂っている。だからその間を縫う水の{面|おもて}も、川幅の割には広く見えない。ただ、{帯|おび}ほどの澄んだ水が、{雲母|きらら}のような雲の影をたった一つ{鍍金|めっき}しながら、ひっそりと蘆の中にうねっている。が、女は未だに来ない。 尾生は水際から歩をめぐらせて、今度は広くもない{洲|す}の上を、あちらこちらと歩きながら、おもむろに暮色を加えて行く、あたりの静かさに耳を傾けた。 橋の上にはしばらくの間、{行人|こうじん}の跡を絶ったのであろう。{沓|くつ}の音も、{蹄|ひづめ}の音も、あるいはまた車の音も、そこからはもう聞えて来ない。風の音、蘆の音、水の音、――それからどこかでけたたましく、{蒼鷺|あおさぎ}の啼く声がした。と思って立止ると、いつか潮がさし出したと見えて、{黄泥|こうでい}を洗う水の色が、さっきよりは間近に光っている。が、女は未だに来ない。 尾生は険しく{眉|まゆ}をひそめながら、橋の下のうす暗い洲を、いよいよ足早に歩き始めた。その内に川の水は、一寸ずつ、一尺ずつ、次第に洲の上へ上って来る。同時にまた川から{立昇|たちのぼ}る{藻|も}の{匀|におい}や水の匀も、冷たく肌にまつわり出した。見上げると、もう橋の上には鮮かな入日の光が消えて、ただ、石の{橋欄|きょうらん}ばかりが、ほのかに青んだ{暮方|くれがた}の空を、黒々と正しく切り抜いている。が、女は未だに来ない。 尾生はとうとう立ちすくんだ。 川の水はもう沓を濡しながら、鋼鉄よりも冷やかな光を{湛|たた}えて、漫々と橋の下に広がっている。すると、{膝|ひざ}も、腹も、胸も、恐らくは{頃刻|けいこく}を出ない内に、この{酷薄|こくはく}な満潮の水に隠されてしまうのに相違あるまい。いや、そう云う内にも{水嵩|みずかさ}は{益|ますます}高くなって、今ではとうとう{両脛|りょうはぎ}さえも、川波の下に没してしまった。が、女は未だに来ない。 尾生は水の中に立ったまま、まだ{一縷|いちる}の望を便りに、何度も橋の空へ眼をやった。 腹を{浸|ひた}した水の上には、とうに{蒼茫|そうぼう}たる暮色が立ち{罩|こ}めて、{遠近|おちこち}に茂った蘆や柳も、寂しい葉ずれの音ばかりを、ぼんやりした{靄|もや}の中から送って来る。と、尾生の鼻を{掠|かす}めて、{鱸|すずき}らしい魚が一匹、ひらりと白い腹を{飜|ひるがえ}した。その魚の躍った空にも、{疎|まばら}ながらもう星の光が見えて、{蔦蘿|つたかずら}のからんだ{橋欄|きょうらん}の形さえ、いち早い宵暗の中に{紛|まぎ}れている。が、女は未だに来ない。…… :::space{lines=1} :::paragraphs{indent=6} ―――――――――――― ::: :::space{lines=1} 夜半、月の光が{一川|いっせん}の蘆と柳とに{溢|あふ}れた時、川の水と微風とは静に{囁|ささや}き交しながら、橋の下の尾生の死骸を、やさしく海の方へ運んで行った。が、尾生の魂は、寂しい天心の月の光に、思い{憧|こが}れたせいかも知れない。ひそかに死骸を抜け出すと、ほのかに明るんだ空の向うへ、まるで水の{匀|におい}や{藻|も}の匀が音もなく川から立ち昇るように、うらうらと高く昇ってしまった。…… それから幾千年かを隔てた{後|のち}、この魂は無数の{流転|るてん}を{閲|けみ}して、また生を{人間|じんかん}に託さなければならなくなった。それがこう云う私に宿っている魂なのである。だから私は現代に生れはしたが、何一つ意味のある仕事が出来ない。昼も夜も漫然と夢みがちな生活を送りながら、ただ、何か{来|きた}るべき不可思議なものばかりを待っている。ちょうどあの尾生が{薄暮|はくぼ}の橋の下で、永久に来ない恋人をいつまでも待ち暮したように。 :::paragraphs{align=end endIndent=1} (大正八年十二月) :::
`; // content.<lang>.md: the same Japanese text in both // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Noto Serif JP': ['400', '600'], // the text; the titles and 一 二 三 '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 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 ū await loadCjkFonts({ [MINCHO]: ['400'] }, markdown, { vertical: true }); await loadCjkFonts({ [MINCHO]: ['600'] }, `${heads}`, { vertical: true }); const colophon = markdown.match(/colophon="([^"]*)"/)[1]; await loadCjkFonts({ [GOTHIC]: ['400'] }, `${title}${heads}${colophon}`, { vertical: true }); // #endregion const doc = await withLoadedFonts(() => buildDocument({ markdown }, config()), { ...kitFonts(FONTS), text: markdown }); showBook(doc, { title: t({ en: 'A Japanese story in two tiers, its title across both', es: 'Un cuento japonés en dos pisos, con el título sobre ambos' }) }); 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

#Pareggia l’ultima pagina

Attiva il bilanciamento e l’ultima pagina di ogni opera si taglia pari: la pagina 214 finisce con 10 righe nella fascia superiore e 10 in quella inferiore, la chiusura che usa un libro cinese o europeo a colonne. Sulla griglia dei caratteri il bilanciatore aggiunge solo righe intere, quindi ogni carattere mantiene la sua cella.

-const balancing = { trailing: false };
+const balancing = { enabled: true }; // the closing band is cut level

#Apri ogni opera sulla pagina successiva

parity: 'any' fa cominciare un’opera sulla pagina che viene dopo, qualunque sia, come fanno le riviste e le antologie di testi brevissimi.

-const work = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' } };
+const work = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } };

#Componi il racconto in una sola fascia

I romanzi e i bunko compongono il testo in una sola fascia, senza intercolonna; Nº 117 compone così un bunko.

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

Un titolo a tutta larghezza apre sempre una nuova pagina

Un titolo il cui livello o stile imposta span: 'page' viene impaginato come apertura in testa a una pagina, che abbia o no un breakBefore. Messo dopo altro testo, o dopo un’altra apertura nella stessa pagina, passa alla pagina successiva e lascia vuoto il resto di quella corrente. Un titolone su entrambe le colonne (o su entrambi i registri di una pagina verticale) sotto una testata va nella grafica dell’apertura stessa, accanto alla testata, oppure in un riquadro :::callout{span="page"} come titolo senza span, come fa newspaper-front-page con i suoi titoloni. Aperture progettate →

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 →

Crediti

Testo
  • 芥川龍之介「蜘蛛の糸」 (The Spider’s Thread, 1918); Aozora Bunko card 92, from 「芥川龍之介全集2」 (Chikuma Bunko, 1986), input 平山誠 and 野口英司, proofreading もりみつじゅんじ, converted with aozora.py · Akutagawa Ryūnosuke; Aozora Bunko volunteers · pubblico dominio
  • 芥川龍之介「尾生の信」 (Bisei’s Faith, 1920); Aozora Bunko card 24, from 「芥川龍之介全集3」 (Chikuma Bunko, 1986), input j.utiyama, proofreading かとうかおり, converted with aozora.py; the rule before the coda shortened to fit a tier · Akutagawa Ryūnosuke; Aozora Bunko volunteers · pubblico dominio
Font
Noto Serif JP (SIL OFL 1.1) · Noto Sans JP (SIL OFL 1.1)
SandboxPDF