Saltar al contenido principal
Receta número 117

Recetario · Capítulo 1 · Página y retícula

Una novela en formato bunko, en vertical y con furigana

Diez noches de sueños de Sōseki en un bunko A6: 38 caracteres por 15 líneas leídas desde la derecha, lecturas junto a los kanji raros y títulos de tres líneas.

En esta página
Salida
Canvas · PDF
Postext
Probada con Postext 1.16.1
Requiere ≥ 1.16.1 · postext-pdf ≥ 1.16.1
Licencia
Actualizada el 5 oct 2026
Código MIT · Texto CC BY 4.0

pp. 二–三 · 2–3 de 9

  • Formato 105 × 148 mm
  • 1 columna
  • Shippori Mincho B1 9/15,8
  • Noto Sans JP
  • 9 páginas
  • Nivel
  • Postext 1.16.1
  • Compuesto en 16 ms
  • 144 líneas de código

En pocas palabras

El comienzo de un clásico japonés como libro de bolsillo: el texto corre en columnas de arriba abajo, se lee de derecha a izquierda y lleva kana pequeños junto a los caracteres más difíciles.

Lo que vas a componer

Nueve páginas de un libro de bolsillo japonés (文庫, bunko) con las dos primeras noches de Diez noches de sueños de Natsume Sōseki, en una página A6 de 105 × 148 mm encuadernada por la derecha. La portada queda sola a la izquierda del lomo; desde la página 2 el texto corre de arriba abajo en líneas que se leen desde la derecha, 38 caracteres por línea y 15 líneas por página, en Shippori Mincho B1 de 9 pt con un paso de 1,75 em. Las lecturas que Aozora Bunko da para los kanji difíciles van a su lado en kana de medio cuerpo, el título de cada noche ocupa tres líneas de la retícula y las páginas izquierdas llevan el título del libro por el corte delantero, sobre un folio en numerales kanji. El libro de bolsillo europeo con la misma estructura es la Nº 104, y la novela china en vertical, la Nº 075.

Esta receta responde a

  • ¿Cómo compongo una novela japonesa en vertical, encuadernada por la derecha, como un bunko de bolsillo?
  • ¿Cómo pongo furigana sobre los kanji, una lectura por carácter o una para toda la palabra?
  • ¿Cómo convierto un texto de Aozora Bunko en un libro de Postext?
  • ¿Cómo hago que un título japonés ocupe tres líneas del texto (3行取り) para que el texto siga en la retícula?

La respuesta corta

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

Ingredientes

Tipografía
Shippori Mincho B1, Noto Sans JP (SIL OFL 1.1)
Recursos
Ninguno: todas las imágenes se dibujan en código

Elaboración

#1 · Gira la página, cuéntala en caracteres y etiquétala ja

El código es la respuesta corta de arriba. layout.writingMode: 'vertical-rl' compone cada página como una página horizontal girada un cuarto de vuelta en el sentido de las agujas del reloj, y page.binding, que se queda en 'auto', encuaderna a la derecha un libro vertical: la página 1 sigue siendo impar, pero cae a la izquierda del lomo, y mirror: true pone su margen interior a la derecha (Escritura vertical, Encuadernación). La retícula de caracteres hace que la caja mida 38 caracteres de 9 pt hacia abajo (120,7 mm) y 15 líneas de 15,75 pt a lo ancho (83,3 mm); los márgenes de page son mínimos, y la retícula agranda cada pareja en la misma medida, así que la cabeza sigue 4 mm más honda que el pie. Si pides más líneas de las que caben entre los márgenes, la retícula quita una sin avisar en la página (cjkGridClamped en doc.configWarnings): el lomo de 9 mm y el corte de 12 mm son los que dejan sitio a las 15.

locale: 'ja' deja el resto como lo componen los libros japoneses, de modo que las cuatro líneas de cjk escritas aquí no cambian nada: muestran lo que elige la etiqueta. ja-very-strict es la regla de inicio de línea de JIS X 4051 (行頭禁則): ninguna línea empieza por 、。」, ー ni por un kana pequeño como っ. hangingPunctuation: 'allow' deja que un 、 o un 。 que abriría la línea siguiente cuelgue bajo el último carácter (ぶら下げ), que es lo que hacen los bunko en lugar de apretar la línea. paragraphStartBracket: 'half' es el patrón ③ de JLReq: un párrafo que empieza con 「 pone el corchete en la segunda mitad de su sangría de un carácter, y el primer carácter del diálogo queda donde empieza cualquier otro párrafo (Tipografía de Asia oriental). Los libros chinos lo hacen distinto en ambas cosas: no cuelgan ningún signo por defecto y sangran el párrafo dos caracteres, empiece o no con corchete.

#2 · Da a cada título tres líneas de la retícula

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

Un título japonés ocupa un número entero de líneas del texto, 行取り (gyōdori), para que las líneas que lo siguen queden en la misma retícula que las anteriores. lineSpan: 3 compone 第一夜 en una franja de tres líneas de 15,75 pt con sus caracteres centrados en ella; el cuerpo de 12,5 pt del título no mueve la retícula, y en la página 6 第二夜 cae en mitad de la página con el texto que lo sigue en las mismas líneas que el anterior. indent: em(5) cuenta caracteres del texto (五字下げ), que es como marca el texto de Aozora un 中見出し y como cuenta JLReq las sangrías de los títulos (Configuración por nivel).

#3 · Trae el texto y sus lecturas de Aozora Bunko

Las dos noches vienen de la ficha 799 de Aozora Bunko, convertida con aozora.py, del skill postext-port. El conversor lee el archivo como CP932, convierte cada lectura escrita 仰向《あおむき》 en una lectura de grupo {仰向|あおむき}, una sola tira de kana sobre toda la palabra, y cada [#5字下げ]…[#「第一夜」は中見出し] en un título. En un documento ja las lecturas siguen JIS X 4051: una lectura más corta que su base se reparte a lo largo de ella en proporción 1:2:1, y una más larga puede invadir un kana vecino, nunca un kanji (Marcas, ruby y warichu). El interlineado de 1,75 em deja 6,75 pt entre líneas, sitio para lecturas de 4,5 pt.

#4 · Pon el hashira en el corte delantero y los folios en kanji

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

En un bunko vertical la cabecera (柱, hashira) es pequeña y va en el margen exterior. anchor.to: 'outer' coloca allí un elemento, el margen izquierdo de una página impar y el derecho de una par en un libro encuadernado a la derecha, y writingMode: 'vertical-rl' lo compone de arriba abajo (Elementos de texto verticales). El título 夢十夜 va solo en las páginas izquierdas (parity: 'odd'), y pages: 'body' lo apartaría de cualquier página que abra un capítulo. pageNumbering.format: 'japanese-informal' imprime los folios 二, 三 … 九 (Numeración).

#5 · Centra la portada en el formato

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

La portada es un título de primer nivel con estilo propio, # 夢十夜 {style="title" …}, cuyo diseño ocupa la página. Sus elementos se anclan a la página y no a la caja, porque la caja de una página con márgenes simétricos queda 1,5 mm descentrada: x baja por la página e y la cruza desde el borde derecho, así que AXIS es la mitad del ancho de la página, esté el lomo del lado que esté. El colofón del pie va en Noto Sans JP: Shippori Mincho B1 no tiene ō ni ū, y el PDF dibujaría cajas vacías en «Sōseki».

#6 · Carga cada peso con los caracteres que compone

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

Fontsource sirve cada peso de una fuente japonesa en unos 120 archivos, cada uno con un tramo de caracteres, y loadCjkFonts descarga los que toca el texto que recibe. El peso del texto recibe toda la muestra con vertical: true, que además carga las formas verticales de la fuente para el lienzo: 、。「」 y ー, y los kana pequeños, que en vertical van en la esquina superior derecha de su casilla. renderToPdf recibe cjkPdfProvider, que incrusta esos mismos archivos como subconjuntos.

La receta 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`---
Muestra en Markdown · 77 líneas · 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 en todas las recetas · 453 líneas// ─── 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 ───────────────────────────────────────────────────────────────────────

El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗ (se abre en una nueva pestaña)

Variantes

#Marca las palabras que el original destaca

Las dos primeras noches de Sōseki no tienen 傍点, pero muchos textos de Aozora sí, y el conversor los escribe :dots[…]{style=sesame}. En un documento ja, *…* da las mismas marcas de sésamo junto a la línea, nunca cursiva.

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

#Pon todos los corchetes un carácter más abajo

El patrón ① conserva la sangría de un carácter y pone el corchete detrás, como en muchas novelas para lectores jóvenes.

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

#Aprieta en lugar de colgar

Con hangingPunctuation: 'none', un 、 o un 。 que abriría la línea entra en la anterior apretando sus corchetes y comas (追い込み), o el último carácter de esa línea baja con él (追い出し), y ningún signo queda bajo la línea.

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

Errores frecuentes

Error frecuente

Etiqueta un texto japonés 'ja', nunca zh-Hans ni LANG

Las ediciones de una receta son en y es, pero una muestra japonesa es japonesa en las dos: `locale: LANG` la etiquetaría como inglés o español, y una etiqueta china la compondría con las reglas chinas (puntuación Kaiming, kana pequeño libre de empezar línea, 图 en lugar de 図, formas chinas de los caracteres en el PDF). Escribe 'ja': elige la región de Japón (corte y puntuación de la JLReq, puntos de sésamo, reparto de los furigana, etiquetas 図 y 表) y desactiva la separación silábica. El lint falla un texto con kana bajo una etiqueta zh o ko. Corte de líneas japonés (kinsoku) →

Error frecuente

Cualquier objeto headings desactiva el salto de página del H1

Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →

Error frecuente

Las fuentes chinas se cargan por fragmentos, con el bloque cjk

Fontsource sirve una familia china, japonesa o coreana en un centenar de archivos por peso, cada uno con un intervalo de caracteres. loadFonts solo descarga el archivo latin, así que en pantalla los caracteres chinos salen de una fuente del sistema y se miden mal, y fontsourceProvider entrega al PDF ese archivo latin, que los imprime como cajas vacías. Añade el bloque cjk del kit, llama a loadCjkFonts(FONTS, markdown) después de loadFonts (una vez por voz, con el texto que compone, si el libro usa varias fuentes chinas) y pasa a renderToPdf fontProvider: cjkPdfProvider: ambos toman los archivos que contienen los caracteres del texto. Fuentes chinas, japonesas y coreanas →

Error frecuente

Un libro encuadernado por la derecha muestra sus pliegos con showBook

En un libro encuadernado por la derecha (texto árabe, hebreo o persa, chino vertical, o page.binding 'right') la página 1 sigue siendo impar, pero queda a la izquierda del lomo, y los pliegos se leen [3 | 2]. showPages compone todos los libros como si se encuadernaran por la izquierda; showBook, del bloque book (el bloque cjk trae la misma función), lee doc.binding y refleja los pliegos. capture.hero sigue nombrando un pliego en orden de lectura, [par, impar]: [2, 3]. Libros encuadernados por la derecha →

Error frecuente

Lee los archivos de Aozora como CP932, no como Shift_JIS

Los archivos de texto de Aozora Bunko están en Windows-31J (CP932). Leídos como Shift_JIS a secas, la raya ―― sale como rayas U+2014, ~ como el guion ondulado 〜, y algunos caracteres de NEC e IBM fallan. Léelos como cp932 y convierte después el marcado: el ruby entre 《》, las notas [#…] y los gaiji ※[#…] no son Markdown y se imprimen tal cual si se dejan. aozora.py, de la skill, hace las dos cosas. Importar textos de Aozora Bunko →

Error frecuente

Entrecomilla cada valor del frontmatter

YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984". Metadatos del documento →

Error frecuente

Un intervalo de Fontsource puede nombrar caracteres que su archivo no tiene

Fontsource da a cada archivo un unicode-range, y loadCjkFonts carga los archivos cuyos intervalos cubren la muestra, pero un intervalo puede prometer más de lo que su archivo tiene. El fragmento de Zen Old Mincho para U+3028–303F no tiene 〻 ni las marcas de repetición largas 〳〴〵, y la fuente tampoco tiene las vocales con macrón ō ū ā; el archivo latin-ext de Shippori Mincho B1 trae unos pocos caracteres y ninguno es ō, ū ni ā. Nada falla mientras corre la receta: en pantalla esos caracteres salen de una fuente del sistema y se miden con ella, y el PDF imprime la caja .notdef de la fuente, que solo la comprobación C25 señala después. Busca en la fuente una marca rara o una palabra en rōmaji antes de elegirla (fontTools sobre el archivo de Fontsource) y compón ese texto con una que la tenga: Shippori Mincho B1 y Noto Serif JP tienen 〻 y 〳〴〵; Noto Serif JP y Noto Sans JP, las vocales con macrón. Fuentes chinas, japonesas y coreanas →

Error frecuente

Una página vertical tiene dos marcos: el del texto girado y el de la hoja

Un documento vertical-rl compone el texto como una página horizontal girada un cuarto de vuelta en el sentido de las agujas del reloj, y el diseño de un título (los elementos de advancedDesign.slot) se coloca en ese marco girado: x baja por la página, y la cruza hacia la izquierda desde el borde derecho, y el ancho de un elemento se mide hacia abajo. Los elementos de la cabecera y el pie siguen en la hoja: x avanza hacia la derecha desde el borde izquierdo e y baja desde arriba. Un marco dibujado en la cabecera y un título colocado en el diseño toman, por tanto, sus desplazamientos en ejes distintos. En una portada vertical, ancla los elementos a `page` y mide desde sus bordes: anclados a `container`, los elementos del pie quedan unos milímetros (unos 5 mm) fuera del centro de la caja de texto. Texto vertical →

Créditos

Texto
  • 夏目漱石『坊っちゃん』 (Botchan, 1906), comienzo del capítulo primero; ficha 752 de Aozora Bunko, según 「ちくま日本文学全集 夏目漱石」 (Chikuma Shobō, 1992), transcripción de 真先芳秋, corrección de 柳沢成雄, convertida con aozora.py · Natsume Sōseki; Aozora Bunko volunteers · dominio público
Fuentes
Shippori Mincho B1 (SIL OFL 1.1) · Noto Sans JP (SIL OFL 1.1)
SandboxPDF