Saltar al contingut principal
Recepta número 117

Receptari · Capítol 1 · Pàgina i retícula

Una novel·la en format bunko, en vertical i amb furigana

Deu nits de somnis de Sōseki en un bunko A6: 38 caràcters per 15 línies llegides des de la dreta, lectures al costat dels kanji rars i títols de tres línies.

En aquesta pàgina
Sortida
Canvas · PDF
Nivell
Intermedi
Postext
Provada amb Postext 1.16.1
Requereix ≥ 1.16.1 · postext-pdf ≥ 1.16.1
Llicència
Actualitzada el 5 d’oct. del 2026
Codi MIT · Text CC BY 4.0

pp. 二–三 · 2–3 de 9

  • Mostra en castellà: encara no hi ha edició en català
  • Format 105 × 148 mm
  • 1 columna
  • Shippori Mincho B1 9/15,8
  • Noto Sans JP
  • 9 pàgines
  • Nivell
  • Postext 1.16.1
  • Compost en 16 ms
  • 144 línies de codi

En poques paraules

El començament d'un clàssic japonès com a llibre de butxaca: el text va en columnes de dalt a baix, es llegeix de dreta a esquerra i porta kana petits al costat dels caràcters més difícils.

Què compondràs

Nou pàgines d'un llibre de butxaca japonès (文庫, bunko) amb les dues primeres nits de Deu nits de somnis de Natsume Sōseki, en una pàgina A6 de 105 × 148 mm enquadernada per la dreta. La portada queda sola a l'esquerra del llom; des de la pàgina 2 el text baixa de dalt a baix en línies que es llegeixen des de la dreta, 38 caràcters per línia i 15 línies per pàgina, en Shippori Mincho B1 de 9 pt amb un pas d'1,75 em. Les lectures que Aozora Bunko dona per als kanji difícils van al seu costat en kana de mig cos, el títol de cada nit ocupa tres línies de la retícula i les pàgines de l'esquerra porten el títol del llibre pel tall, sobre un foli en numerals kanji. El llibre de butxaca europeu amb la mateixa estructura és la Núm. 104, i la novel·la xinesa en vertical, la Núm. 075.

Aquesta recepta respon a

  • Com componc una novel·la japonesa en vertical, enquadernada per la dreta, com un bunko de butxaca?
  • Com poso furigana damunt dels kanji, una lectura per caràcter o una per a tota la paraula?
  • Com converteixo un text d'Aozora Bunko en un llibre de Postext?
  • Com faig que un títol japonès ocupi tres línies del text (3行取り) perquè el text continuï a la retícula?

La resposta curta

script.js · línies 33–52al codi complet
// '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
};

Ingredients

Tipografia
Shippori Mincho B1, Noto Sans JP (SIL OFL 1.1)
Recursos
Cap: totes les imatges es dibuixen en codi

Elaboració

#1 · Gira la pàgina, compta-la en caràcters i etiqueta-la ja

El codi és la resposta curta de més amunt. layout.writingMode: 'vertical-rl' compon cada pàgina com una pàgina horitzontal girada un quart de volta en el sentit de les agulles del rellotge, i page.binding, que es queda en 'auto', enquaderna per la dreta un llibre vertical: la pàgina 1 continua sent la senar, però cau a l'esquerra del llom, i mirror: true posa el seu marge interior a la dreta (Escriptura vertical, Enquadernació). La retícula de caràcters fa que la caixa de text mesuri 38 caràcters de 9 pt cap avall (120,7 mm) i 15 línies de 15,75 pt d'amplada (83,3 mm). Els marges de page són mínims i la retícula engrandeix cada parell en la mateixa mesura, de manera que el cap continua 4 mm més fondo que el peu. Si demanes més línies de les que caben entre els marges, la retícula en treu una sense avisar a la pàgina (cjkGridClamped a doc.configWarnings): el llom de 9 mm i el tall de 12 mm són els que deixen lloc a les 15.

locale: 'ja' deixa la resta tal com la componen els llibres japonesos, així que les quatre línies de cjk escrites aquí no canvien res: mostren el que tria l'etiqueta. ja-very-strict és la regla d'inici de línia de JIS X 4051 (行頭禁則): cap línia no comença amb 、。」, ー ni amb un kana petit com っ. hangingPunctuation: 'allow' deixa que un 、 o un 。 que obriria la línia següent pengi sota l'últim caràcter (ぶら下げ), que és el que fan els bunko en lloc d'estrènyer la línia. paragraphStartBracket: 'half' és el patró ③ de JLReq: un paràgraf que comença amb 「 posa el claudàtor a la segona meitat del seu sagnat d'un caràcter, i el primer caràcter del diàleg queda on comença qualsevol altre paràgraf (Tipografia de l'Àsia oriental). Els llibres xinesos ho fan diferent en tots dos casos: per defecte no pengen cap signe i sagnen el paràgraf dos caràcters, tant si comença amb claudàtor com si no.

#2 · Dona a cada títol tres línies de la retícula

script.js · línies 56–63al codi complet
// 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 (gotcha: headings-drop-h1-break)
};

Un títol japonès ocupa un nombre enter de línies del text, 行取り (gyōdori), perquè les línies que el segueixen quedin en la mateixa retícula que les anteriors. lineSpan: 3 compon 第一夜 en una franja de tres línies de 15,75 pt amb els caràcters centrats; el cos de 12,5 pt del títol no mou la retícula, i a la pàgina 6 第二夜 cau a mitja pàgina amb el text que el segueix a les mateixes línies que l'anterior. indent: em(5) compta caràcters del text (五字下げ), que és com el text d'Aozora marca un 中見出し i com JLReq compta els sagnats dels títols (Configuració per nivell).

#3 · Porta el text i les lectures d'Aozora Bunko

Les dues nits surten de la fitxa 799 d'Aozora Bunko, convertida amb aozora.py, de l'skill postext-port. El convertidor llegeix el fitxer com a CP932, converteix cada lectura escrita 仰向《あおむき》 en una lectura de grup {仰向|あおむき}, una sola tira de kana sobre tota la paraula, i cada [#5字下げ]…[#「第一夜」は中見出し] en un títol. En un document ja les lectures segueixen JIS X 4051: una lectura més curta que la seva base es reparteix al llarg d'ella en proporció 1:2:1, i una de més llarga pot envair un kana veí, però mai un kanji (Marques, ruby i warichu). L'interlineat d'1,75 em deixa 6,75 pt entre línies, prou per a lectures de 4,5 pt.

#4 · Posa el hashira al tall i els folis en kanji

script.js · línies 67–78al codi complet
// 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: [] };

En un bunko vertical la capçalera (柱, hashira) és petita i va al marge exterior. anchor.to: 'outer' hi col·loca un element, al marge esquerre d'una pàgina senar i al dret d'una de parella en un llibre enquadernat per la dreta, i writingMode: 'vertical-rl' el compon de dalt a baix (Elements de text verticals). El títol 夢十夜 va només a les pàgines de l'esquerra (parity: 'odd'), i pages: 'body' l'apartaria de qualsevol pàgina que obrís un capítol. pageNumbering.format: 'japanese-informal' imprimeix els folis 二, 三 … 九 (Numeració).

#5 · Centra la portada en el format

script.js · línies 82–110al codi complet
// 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) },
  ] } },
};

La portada és un títol de primer nivell amb estil propi, # 夢十夜 {style="title" …}, el disseny del qual ocupa la pàgina. Els seus elements s'ancoren a la pàgina i no a la caixa de text, perquè la caixa d'una pàgina amb marges simètrics queda descentrada 1,5 mm: x baixa per la pàgina i y la travessa des de la vora dreta, així que AXIS és la meitat de l'amplada de la pàgina, sigui quin sigui el costat del llom. El colofó del peu va en Noto Sans JP: Shippori Mincho B1 no té ō ni ū, i el PDF dibuixaria caixes buides a «Sōseki».

#6 · Carrega cada pes amb els caràcters que compon

script.js · línies 219–228al codi complet
// 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 serveix cada pes d'una font japonesa en uns 120 fitxers, cadascun amb un tram de caràcters, i loadCjkFonts baixa els que toca el text que rep. El pes del text rep tota la mostra amb vertical: true, que a més carrega les formes verticals de la font per al llenç: 、。「」 i ー, i els kana petits, que en vertical van a la cantonada superior dreta de la seva casella. renderToPdf rep cjkPdfProvider, que incrusta aquests mateixos fitxers com a subconjunts.

La recepta 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.16.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @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 (gotcha: headings-drop-h1-break)
};
// #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 = () => ({ // a factory: the engine caches resolved configs per object
  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`---
Mostra en Markdown · 77 línies · content.es.mdtitle: "夢十夜" author: "夏目漱石" --- # 夢十夜 {style="title" colophon="Natsume Sōseki, 夢十夜 (Diez noches de sueños, 1908), noches primera y segunda.\nTexto: Aozora Bunko 799, dominio público.\nCompuesto en Shippori Mincho B1 y 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 buildWithFonts(() => buildDocument({ markdown }, config()), 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: igual a totes les receptes · 453 línies// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { 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', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true 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; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { 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, { 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 v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { 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 res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show 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 v1 ── 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 = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { cjkSlices.cache.set(css, fetch(css) .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], css).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` that holds code point `cp`, if any. */ function cjkSliceFor(slices, cp) { return slices.find((slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi)); } /** 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 buildWithFonts 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 that * hold the characters its pages set (`request.codePoints`); 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.filter((slice) => picked.has(slice)).map(async (slice) => { const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); } /** 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. Latin comes first: postext-pdf draws a character from the first * file that has it, as the browser takes a character both files hold from * latin. 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 = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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)]); return ext ? [latin, ext] : 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 ───────────────────────────────────────────────────────────────────────

L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)

Variants

#Marca les paraules que l'original destaca

Les dues primeres nits de Sōseki no tenen 傍点, però molts textos d'Aozora sí, i el convertidor els escriu :dots[…]{style=sesame}. En un document ja, *…* dona les mateixes marques de sèsam al costat de la línia, mai cursiva.

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

#Posa tots els claudàtors un caràcter més avall

El patró ① conserva el sagnat d'un caràcter i posa el claudàtor al darrere, com fan moltes novel·les per a lectors joves.

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

#Estreny en lloc de penjar

Amb hangingPunctuation: 'none', un 、 o un 。 que obriria la línia entra a l'anterior estrenyent-ne els claudàtors i les comes (追い込み), o l'últim caràcter d'aquella línia baixa amb ell (追い出し), i cap signe no queda sota la línia.

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

Errors freqüents

Error freqüent

Etiqueta un text japonès 'ja', mai zh-Hans ni LANG

Les edicions d'una recepta són en i es, però una mostra japonesa és japonesa en totes dues: `locale: LANG` l'etiquetaria com a anglès o castellà, i una etiqueta xinesa la compondria amb les regles xineses (puntuació Kaiming, kana petit lliure de començar línia, 图 en lloc de 図, formes xineses dels caràcters al PDF). Escriu 'ja': tria la regió del Japó (tall i puntuació de la JLReq, punts de sèsam, repartiment dels furigana, etiquetes 図 i 表) i desactiva la partició de mots. El lint falla un text amb kana sota una etiqueta zh o ko. Tall de línies japonès (kinsoku) →

Error freqüent

Qualsevol objecte headings desactiva el salt de pàgina de l'H1

Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →

Error freqüent

Les fonts xineses es carreguen per fragments, amb el bloc cjk

Fontsource serveix una família xinesa, japonesa o coreana en un centenar de fitxers per pes, cadascun amb un interval de caràcters. loadFonts només baixa el fitxer latin, així que a la pantalla els caràcters xinesos surten d'una font del sistema i es mesuren malament, i fontsourceProvider lliura al PDF aquest fitxer latin, que els imprimeix com a caixes buides. Afegeix el bloc cjk del kit, crida loadCjkFonts(FONTS, markdown) després de loadFonts (una vegada per veu, amb el text que compon, si el llibre fa servir diverses fonts xineses) i passa a renderToPdf fontProvider: cjkPdfProvider: tots dos agafen els fitxers que contenen els caràcters del text. Fonts xineses, japoneses i coreanes →

Error freqüent

Un llibre enquadernat per la dreta mostra els seus plecs amb showBook

En un llibre enquadernat per la dreta (text àrab, hebreu o persa, xinès vertical, o page.binding 'right') la pàgina 1 continua sent senar, però queda a l'esquerra del llom, i els plecs es llegeixen [3 | 2]. showPages compon tots els llibres com si s'enquadernessin per l'esquerra; showBook, del bloc book (el bloc cjk porta la mateixa funció), llegeix doc.binding i reflecteix els plecs. capture.hero continua anomenant un plec en ordre de lectura, [parella, senar]: [2, 3]. Llibres enquadernats per la dreta →

Error freqüent

Llegeix els fitxers d'Aozora com a CP932, no com a Shift_JIS

Els fitxers de text d'Aozora Bunko són en Windows-31J (CP932). Llegits com a Shift_JIS a seques, el guió ―― surt com a guions U+2014, ~ com el guió ondulat 〜, i alguns caràcters de NEC i IBM fallen. Llegeix-los com a cp932 i després converteix el marcatge: el ruby entre 《》, les notes [#…] i els gaiji ※[#…] no són Markdown i s'imprimeixen tal qual si s'hi deixen. aozora.py, de la skill, fa totes dues coses. Importar textos d'Aozora Bunko →

Error freqüent

Posa entre cometes cada valor del frontmatter

YAML llegeix title: 1984 com un nombre i una data com un objecte Date, i els valors que no són cadenes s'imprimeixen buits als marcadors i deixen el PDF sense títol. Posa entre cometes cada valor: title: "1984". Metadades del document →

Error freqüent

Un interval de Fontsource pot anomenar caràcters que el seu fitxer no té

Fontsource dona a cada fitxer un unicode-range, i loadCjkFonts carrega els fitxers els intervals dels quals cobreixen la mostra, però un interval pot prometre més del que el seu fitxer té. El fragment de Zen Old Mincho per a U+3028–303F no té 〻 ni les marques de repetició llargues 〳〴〵, i la font tampoc no té les vocals amb macró ō ū ā; el fitxer latin-ext de Shippori Mincho B1 porta uns quants caràcters i cap no és ō, ū ni ā. No falla res mentre corre la recepta: a la pantalla aquests caràcters surten d'una font del sistema i es mesuren amb ella, i el PDF imprimeix la caixa .notdef de la font, que només la comprovació C25 assenyala després. Busca a la font una marca rara o una paraula en rōmaji abans de triar-la (fontTools sobre el fitxer de Fontsource) i compon aquest text amb una que la tingui: Shippori Mincho B1 i Noto Serif JP tenen 〻 i 〳〴〵; Noto Serif JP i Noto Sans JP, les vocals amb macró. Fonts xineses, japoneses i coreanes →

Error freqüent

Una pàgina vertical té dos marcs: el del text girat i el del full

Un document vertical-rl compon el text com una pàgina horitzontal girada un quart de volta en el sentit de les agulles del rellotge, i el disseny d'un títol (els elements d'advancedDesign.slot) es col·loca en aquest marc girat: x baixa per la pàgina, y la travessa cap a l'esquerra des de la vora dreta, i l'amplada d'un element es mesura cap avall. Els elements de la capçalera i el peu continuen al full: x avança cap a la dreta des de la vora esquerra i y baixa des de dalt. Un marc dibuixat a la capçalera i un títol col·locat al disseny prenen, per tant, els desplaçaments en eixos diferents. En una portada vertical, ancora els elements a `page` i mesura des de les seves vores: ancorats a `container`, els elements del peu queden uns quants mil·límetres (uns 5 mm) fora del centre de la caixa de text. Text vertical →

Crèdits

Text
  • 夏目漱石『坊っちゃん』 (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 · domini públic
Fonts
Shippori Mincho B1 (SIL OFL 1.1) · Noto Sans JP (SIL OFL 1.1)
SandboxPDF