# Un cuento japonés en dos pisos, con el título sobre ambos

> Dos cuentos de Akutagawa en unas obras completas: dos pisos de 25 caracteres en un A5, cada título sobre ambos en página impar y finales tal como caen.

- Versión HTML: https://postext.dev/es/cookbook/two-tier-tankobon
- Receta N.º 118 · Página y retícula · Nivel 2 (Intermedio) · Salidas: Canvas, PDF
- Géneros: Narrativa, teatro y prosa literaria
- Requiere postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · probada con 1.16.1, postext-pdf 1.16.1 el 2026-10-05
- Páginas: [211](https://postext.dev/cookbook/two-tier-tankobon/es/p01.webp?v=35b0d746), [212](https://postext.dev/cookbook/two-tier-tankobon/es/p02.webp?v=35b0d746), [213](https://postext.dev/cookbook/two-tier-tankobon/es/p03.webp?v=35b0d746), [214](https://postext.dev/cookbook/two-tier-tankobon/es/p04.webp?v=35b0d746), [215](https://postext.dev/cookbook/two-tier-tankobon/es/p05.webp?v=35b0d746), [216](https://postext.dev/cookbook/two-tier-tankobon/es/p06.webp?v=35b0d746), [217](https://postext.dev/cookbook/two-tier-tankobon/es/p07.webp?v=35b0d746)
- PDF: https://postext.dev/cookbook/two-tier-tankobon/es/two-tier-tankobon.pdf?v=35b0d746
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=two-tier-tankobon&lang=es (.postext: https://postext.dev/cookbook/two-tier-tankobon/es/two-tier-tankobon.postext)
- Última actualización: 2026-10-05
- Otros idiomas: [en](https://postext.dev/en/cookbook/two-tier-tankobon.md), [ca](https://postext.dev/ca/cookbook/two-tier-tankobon.md), [zh](https://postext.dev/zh/cookbook/two-tier-tankobon.md), [ja](https://postext.dev/ja/cookbook/two-tier-tankobon.md), [ar](https://postext.dev/ar/cookbook/two-tier-tankobon.md)

## En pocas palabras

Dos cuentos tal como los imprimen unas obras completas japonesas: el texto baja por la página en dos franjas apiladas, cada cuento abre en una página izquierda con el título a toda la altura, y su última página no se iguala.

## Lo que vas a componer

Siete páginas de un tomo de obras completas (全集) en una página A5 encuadernada por la derecha: *El hilo de la araña* y *La fe de Bisei*, de Akutagawa Ryūnosuke, compuestos en dos pisos (二段組) de 25 caracteres de alto y 21 líneas a lo ancho, en Noto Serif JP de 9 pt con un filete fino entre los pisos. Cada cuento abre en una página impar, la izquierda, con su título de 20 pt bajando por los dos pisos y el nombre del autor cerca del pie; los folios siguen desde la página 211. Las secciones 一 二 三 ocupan tres líneas de su piso. Cuando un cuento acaba, su última página queda tal como cae el texto, el 成り行き (nariyuki) japonés: en la página 214 el piso de arriba ocupa toda la página y el de abajo tiene tres líneas y la fecha. El periódico chino en pisos verticales es la [Nº 084](https://postext.dev/es/cookbook/vertical-newspaper-tiers.md), y un título de capítulo sobre dos columnas horizontales, la [Nº 017](https://postext.dev/es/cookbook/five-chapter-openers.md).

**Esta receta responde a:**

- ¿Cómo compongo un cuento japonés en dos pisos, con el título sobre ambos, abriendo en página izquierda?
- ¿Cómo fuerzo un salto de página o de columna, y hago que cada capítulo empiece en página impar?
- ¿Cómo defino la mancha en caracteres, tantos por línea y tantas líneas por página?

## La respuesta corta

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

## Ingredientes

**Enseña**

- [Una o dos columnas](https://postext.dev/es/docs/configuration.md#tipos-de-disposición): Una sola columna o dos columnas iguales con su medianil; dos columnas es el valor por defecto, así que en una novela hay que pedir una sola.
- [Texto vertical](https://postext.dev/es/docs/configuration.md#escritura-vertical): Chino y japonés compuestos de arriba abajo en líneas que se leen desde la derecha (layout.writingMode 'vertical-rl'), con las columnas como pisos, figuras y tablas en pie y la puntuación en sus formas verticales.
- [Equilibrado de columnas](https://postext.dev/es/docs/configuration.md#equilibrado-de-columnas): Hace que cada columna acabe a ras del pie de la página ajustando, por este orden, el espacio junto a títulos, listas y flotantes, la holgura de los párrafos y el espaciado entre letras; además nivela la última página de cada capítulo.

**También usa**

- [Libros encuadernados por la derecha](https://postext.dev/es/docs/configuration.md#encuadernación)
- [Capítulos que abren en página impar](https://postext.dev/es/docs/configuration.md#saltar-antes)
- [Títulos que ocupan líneas del texto (gyōdori)](https://postext.dev/es/docs/japanese-layout.md#títulos-y-bloques)
- [Retícula de caracteres](https://postext.dev/es/docs/configuration.md#retícula-de-caracteres)
- [Filete de columna](https://postext.dev/es/docs/configuration.md#filete-de-columna)
- [Corte de líneas japonés (kinsoku)](https://postext.dev/es/docs/japanese-layout.md#corte-de-líneas-kinsoku)
- [Espaciado de la puntuación japonesa (yakumono)](https://postext.dev/es/docs/japanese-layout.md#yakumono-el-espaciado-de-la-puntuación)
- [Furigana: lecturas en kana sobre los kanji](https://postext.dev/es/docs/japanese-layout.md#furigana)
- [Importar textos de Aozora Bunko](https://postext.dev/es/docs/japanese-layout.md#textos-de-aozora-bunko)
- [Fuentes chinas, japonesas y coreanas](https://postext.dev/es/docs/configuration.md#fuentes-chinas-japonesas-y-coreanas)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Exportación a PDF](https://postext.dev/es/docs/configuration.md#generación-de-pdf)
- [Corte de líneas en chino](https://postext.dev/es/docs/configuration.md#tipografía-de-asia-oriental)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Color del papel](https://postext.dev/es/docs/configuration.md#página)
- [Fuentes incrustadas en el PDF](https://postext.dev/es/docs/configuration.md#por-qué-un-proveedor-de-fuentes)
- [Preliminares en romanos](https://postext.dev/es/docs/document-format.md#numbering)
- [Ruby: lecturas en pinyin y zhuyin](https://postext.dev/es/docs/document-format.md#marcas-chinas-ruby-y-warichu)
- [Cabeceras por sección](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Espacio vertical explícito](https://postext.dev/es/docs/document-format.md#space)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`cjk`](https://postext.dev/es/docs/configuration.md#tipografía-de-asia-oriental), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headingStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`locale`](https://postext.dev/es/docs/configuration.md#separación-silábica), [`page`](https://postext.dev/es/docs/configuration.md#página)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`decompressWoff2`](https://postext.dev/es/docs/configuration.md#proveedor-de-fuentes-en-el-navegador-fontsource--woff2), `loadVerticalAlternates`, [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap), [`renderToPdf`](https://postext.dev/es/docs/configuration.md#generación-de-pdf)

**Tipografías**

- Noto Serif JP (OFL-1.1), Noto Sans JP (OFL-1.1)

## Elaboración

### 1 · Dos columnas giradas un cuarto de vuelta son dos pisos

El código es [la respuesta corta](#la-respuesta-corta) de arriba. En una página con `writingMode: 'vertical-rl'`, `layoutType: 'double'` apila las dos columnas: la primera llena el piso de arriba desde la derecha, luego el de abajo, y el medianil es la franja entre ellos ([Escritura vertical](/es/docs/configuration#escritura-vertical)). La [retícula de caracteres](/es/docs/configuration#retícula-de-caracteres) cuenta los caracteres de un piso, así que cada piso mide 25 caracteres de 9 pt y el medianil se redondea a dos caracteres justos; las 21 líneas de 15,75 pt ocupan 116,7 mm de los 148 de la página. El filete entre los pisos cruza la página, de 0,4 pt y en un gris cálido.

El equilibrado de Postext acabaría los dos pisos de la última página de un cuento a la misma altura, que es como cierran un capítulo los periódicos chinos y la mayoría de los libros europeos a dos columnas. Un libro japonés en pisos deja caer el texto: el piso de arriba recorre toda la página antes de que empiece el de abajo. `balancing.trailing: false` es esa decisión, y `enabled: false`, que la incluye, impide además que el equilibrado añada líneas sobre los títulos en una retícula de caracteres ([Equilibrado de columnas](/es/docs/configuration#equilibrado-de-columnas)).

### 2 · Baja el título de la obra por los dos pisos

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

Un nivel de título con `span: 'page'` se compone a toda la altura de la página, los dos pisos y el medianil, al principio del flujo, a la derecha de una página vertical; los pisos empiezan tras las siete líneas que reserva su diseño. Un título así siempre abre página, y `breakBefore.parity: 'odd'` hace que sea la izquierda: *El hilo de la araña* acaba en la página 214, que es par, de modo que *La fe de Bisei* abre en la 215 sin página en blanco entre ambos ([Span y diseño avanzado](/es/docs/configuration#span-y-diseño-avanzado)). En el diseño, `x` baja por la página e `y` la cruza desde el borde derecho de la caja; el nombre del autor se coloca desde el pie del piso de abajo, tres caracteres más arriba.

### 3 · Numera las secciones en tres líneas de su piso

El segundo nivel, `lineSpan: 3` con `indent: em(8)`, compone 一 二 三 ocho caracteres más abajo en una franja de tres líneas, como las marca el texto de Aozora (八字下げ, 中見出し), así que el texto que sigue a un número de sección se mantiene en las líneas del piso de al lado ([Configuración por nivel](/es/docs/configuration#configuración-por-nivel)). Las fechas que cierran los cuentos, （大正七年四月十六日）, se levantan un carácter del pie de su piso (地から一字上げ) con `:::paragraphs{align=end endIndent=1}`; el conversor de Aozora las escribe como un estilo `aozora-end1` que las deja pegadas al pie, y los atributos del contenedor añaden la subida de un carácter.

### 4 · Pon el tomo y la obra en el corte delantero

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

`anchor.to: 'outer'` coloca el hashira en el margen del corte delantero, el nombre del tomo, 芥川龍之介集, en las páginas derechas y el título de la obra en las izquierdas, y `pages: 'body'` los aparta de las páginas donde abre una obra ([Elementos de texto verticales](/es/docs/configuration#elementos-de-texto-verticales)). El folio va de través en el margen, en cifras arábigas, como lo imprimen muchas obras completas: bajando por el corte, tres cifras quedarían tumbadas. `pageNumbering.startAt: 211` numera las páginas como el tomo. El estilo del título de la primera obra lleva el colofón con `pages: 'opener'`, porque el pie de un estilo sirve a todas las páginas de su sección.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: https://github.com/drnachio/postext/tree/main/cookbook/two-tier-tankobon

### script.js

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

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

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

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

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

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag)
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(148), height: mm(210), dpi: 150, // A5
    backgroundColor: col('paper'),
    margins: { top: mm(24), bottom: mm(18), left: mm(14), right: mm(16), mirror: true },
    pageNumbering: { startAt: 211 },
  },
  layout,
  cjk,
  bodyText: {
    fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true,
  },
  headings: { fontFamily: MINCHO, fontWeight: 600, color: col('ink'), balancing,
    levels: [{ ...work, advancedDesign: opener }, section] }, // gotcha: headings-drop-h1-break
  headingStyles: [first],
  header,
  footer: none,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "芥川龍之介集"
author: "芥川龍之介"
---

# 蜘蛛の糸 {style="first" colophon="Akutagawa Ryūnosuke, 蜘蛛の糸 (El hilo de la araña, 1918) y 尾生の信 (La fe de Bisei, 1920). Texto: Aozora Bunko 92 y 24, dominio público. Compuesto en Noto Serif JP y Noto Sans JP (SIL OFL)."}

## 一

ある日の事でございます。{御釈迦様|おしゃかさま}は極楽の{蓮池|はすいけ}のふちを、独りでぶらぶら御歩きになっていらっしゃいました。池の中に咲いている{蓮|はす}の花は、みんな玉のようにまっ白で、そのまん中にある{金色|きんいろ}の{蕊|ずい}からは、何とも云えない{好|よ}い{匂|におい}が、{絶間|たえま}なくあたりへ{溢|あふ}れて居ります。極楽は丁度朝なのでございましょう。

やがて御釈迦様はその池のふちに{御佇|おたたず}みになって、水の{面|おもて}を{蔽|おお}っている蓮の葉の間から、ふと下の{容子|ようす}を御覧になりました。この極楽の蓮池の下は、丁度{地獄|じごく}の底に当って居りますから、{水晶|すいしよう}のような水を透き徹して、{三途|さんず}の河や針の山の景色が、丁度{覗|のぞ}き{眼鏡|めがね}を見るように、はっきりと見えるのでございます。

するとその地獄の底に、{犍陀多|かんだた}と云う男が一人、ほかの罪人と一しょに{蠢|うごめ}いている姿が、御眼に止まりました。この犍陀多と云う男は、人を殺したり家に火をつけたり、いろいろ悪事を働いた大泥坊でございますが、それでもたった一つ、善い事を致した覚えがございます。と申しますのは、ある時この男が深い林の中を通りますと、小さな{蜘蛛|くも}が一匹、路ばたを{這|は}って行くのが見えました。そこで犍陀多は早速足を挙げて、踏み殺そうと致しましたが、「いや、いや、これも小さいながら、命のあるものに違いない。その命を{無暗|むやみ}にとると云う事は、いくら何でも可哀そうだ。」と、こう急に思い返して、とうとうその蜘蛛を殺さずに助けてやったからでございます。

御釈迦様は地獄の容子を御覧になりながら、この犍陀多には蜘蛛を助けた事があるのを御思い出しになりました。そうしてそれだけの善い事をした{報|むくい}には、出来るなら、この男を地獄から救い出してやろうと御考えになりました。幸い、側を見ますと、{翡翠|ひすい}のような色をした蓮の葉の上に、極楽の蜘蛛が一匹、美しい銀色の糸をかけて居ります。御釈迦様はその蜘蛛の糸をそっと御手に御取りになって、玉のような{白蓮|しらはす}の間から、遥か下にある地獄の底へ、まっすぐにそれを御{下|おろ}しなさいました。

## 二

こちらは地獄の底の血の池で、ほかの罪人と一しょに、浮いたり沈んだりしていた{犍陀多|かんだた}でございます。何しろどちらを見ても、まっ暗で、たまにそのくら暗からぼんやり浮き上っているものがあると思いますと、それは恐しい針の山の針が光るのでございますから、その心細さと云ったらございません。その上あたりは墓の中のようにしんと静まり返って、たまに聞えるものと云っては、ただ罪人がつく{微|かすか}な{嘆息|たんそく}ばかりでございます。これはここへ落ちて来るほどの人間は、もうさまざまな地獄の{責苦|せめく}に疲れはてて、泣声を出す力さえなくなっているのでございましょう。ですからさすが大泥坊の犍陀多も、やはり血の池の血に{咽|むせ}びながら、まるで死にかかった{蛙|かわず}のように、ただもがいてばかり居りました。

ところがある時の事でございます。{何気|なにげ}なく犍陀多が頭を挙げて、血の池の空を眺めますと、そのひっそりとした暗の中を、遠い遠い天上から、銀色の{蜘蛛|くも}の糸が、まるで人目にかかるのを恐れるように、一すじ細く光りながら、するすると自分の上へ垂れて参るのではございませんか。犍陀多はこれを見ると、思わず手を{拍|う}って喜びました。この糸に{縋|すが}りついて、どこまでものぼって行けば、きっと地獄からぬけ出せるのに相違ございません。いや、うまく行くと、極楽へはいる事さえも出来ましょう。そうすれば、もう針の山へ追い上げられる事もなくなれば、血の池に沈められる事もある筈はございません。

こう思いましたから{犍陀多|かんだた}は、早速その蜘蛛の糸を両手でしっかりとつかみながら、一生懸命に上へ上へとたぐりのぼり始めました。元より大泥坊の事でございますから、こう云う事には昔から、慣れ切っているのでございます。

しかし地獄と極楽との間は、何万里となくございますから、いくら{焦|あせ}って見た所で、容易に上へは出られません。ややしばらくのぼる{中|うち}に、とうとう犍陀多もくたびれて、もう一たぐりも上の方へはのぼれなくなってしまいました。そこで仕方がございませんから、まず一休み休むつもりで、糸の中途にぶら下りながら、遥かに目の下を見下しました。

すると、一生懸命にのぼった甲斐があって、さっきまで自分がいた血の池は、今ではもう暗の底にいつの間にかかくれて居ります。それからあのぼんやり光っている恐しい針の山も、足の下になってしまいました。この分でのぼって行けば、地獄からぬけ出すのも、存外わけがないかも知れません。犍陀多は両手を蜘蛛の糸にからみながら、ここへ来てから何年にも出した事のない声で、「しめた。しめた。」と笑いました。ところがふと気がつきますと、蜘蛛の糸の下の方には、{数限|かずかぎり}もない罪人たちが、自分ののぼった後をつけて、まるで{蟻|あり}の行列のように、やはり上へ上へ一心によじのぼって来るではございませんか。犍陀多はこれを見ると、驚いたのと恐しいのとで、しばらくはただ、{莫迦|ばか}のように大きな口を{開|あ}いたまま、眼ばかり動かして居りました。自分一人でさえ{断|き}れそうな、この細い蜘蛛の糸が、どうしてあれだけの{人数|にんず}の重みに堪える事が出来ましょう。もし万一途中で{断|き}れたと致しましたら、折角ここへまでのぼって来たこの{肝腎|かんじん}な自分までも、元の地獄へ{逆落|さかおと}しに落ちてしまわなければなりません。そんな事があったら、大変でございます。が、そう云う中にも、罪人たちは何百となく何千となく、まっ暗な血の池の底から、うようよと{這|は}い上って、細く光っている蜘蛛の糸を、一列になりながら、せっせとのぼって参ります。今の中にどうかしなければ、糸はまん中から二つに断れて、落ちてしまうのに違いありません。

そこで犍陀多は大きな声を出して、「こら、罪人ども。この蜘蛛の糸は{己|おれ}のものだぞ。お前たちは一体誰に{尋|き}いて、のぼって来た。下りろ。下りろ。」と{喚|わめ}きました。

その途端でございます。今まで何ともなかった蜘蛛の糸が、急に犍陀多のぶら下っている所から、ぷつりと音を立てて{断|き}れました。ですから犍陀多もたまりません。あっと云う{間|ま}もなく風を切って、{独楽|こま}のようにくるくるまわりながら、見る見る中に暗の底へ、まっさかさまに落ちてしまいました。

後にはただ極楽の蜘蛛の糸が、きらきらと細く光りながら、月も星もない空の中途に、短く垂れているばかりでございます。

## 三

{御釈迦様|おしゃかさま}は極楽の{蓮池|はすいけ}のふちに立って、この一部{始終|しじゅう}をじっと見ていらっしゃいましたが、やがて{犍陀多|かんだた}が血の池の底へ石のように沈んでしまいますと、悲しそうな御顔をなさりながら、またぶらぶら御歩きになり始めました。自分ばかり地獄からぬけ出そうとする、犍陀多の無慈悲な心が、そうしてその心相当な罰をうけて、元の地獄へ落ちてしまったのが、御釈迦様の御目から見ると、浅間しく思召されたのでございましょう。

しかし極楽の蓮池の蓮は、少しもそんな事には{頓着|とんじゃく}致しません。その玉のような白い花は、御釈迦様の{御足|おみあし}のまわりに、ゆらゆら{萼|うてな}を動かして、そのまん中にある金色の{蕊|ずい}からは、何とも云えない{好|よ}い匂が、{絶間|たえま}なくあたりへ{溢|あふ}れて居ります。極楽ももう{午|ひる}に近くなったのでございましょう。

:::paragraphs{align=end endIndent=1}

（大正七年四月十六日）

:::

# 尾生の信

{尾生|びせい}は橋の下に{佇|たたず}んで、さっきから女の来るのを待っている。

見上げると、高い石の{橋欄|きょうらん}には、{蔦蘿|つたかずら}が半ば{這|は}いかかって、時々その間を通りすぎる往来の人の{白衣|はくい}の裾が、鮮かな入日に照らされながら、悠々と風に吹かれて行く。が、女は未だに来ない。

尾生はそっと口笛を鳴しながら、気軽く橋の下の{洲|す}を見渡した。

橋の下の{黄泥|こうでい}の洲は、二坪ばかりの広さを{剰|あま}して、すぐに水と続いている。{水際|みずぎわ}の{蘆|あし}の間には、{大方|おおかた}{蟹|かに}の{棲家|すみか}であろう、いくつも{円|まる}い穴があって、そこへ波が当る度に、たぶりと云うかすかな音が聞えた。が、女は未だに来ない。

尾生はやや待遠しそうに水際まで{歩|ほ}を移して、舟{一艘|いっそう}通らない静な川筋を眺めまわした。

川筋には青い{蘆|あし}が、{隙間|すきま}もなくひしひしと生えている。のみならずその蘆の間には、{所々|ところどころ}に{川楊|かわやなぎ}が、こんもりと円く茂っている。だからその間を縫う水の{面|おもて}も、川幅の割には広く見えない。ただ、{帯|おび}ほどの澄んだ水が、{雲母|きらら}のような雲の影をたった一つ{鍍金|めっき}しながら、ひっそりと蘆の中にうねっている。が、女は未だに来ない。

尾生は水際から歩をめぐらせて、今度は広くもない{洲|す}の上を、あちらこちらと歩きながら、おもむろに暮色を加えて行く、あたりの静かさに耳を傾けた。

橋の上にはしばらくの間、{行人|こうじん}の跡を絶ったのであろう。{沓|くつ}の音も、{蹄|ひづめ}の音も、あるいはまた車の音も、そこからはもう聞えて来ない。風の音、蘆の音、水の音、――それからどこかでけたたましく、{蒼鷺|あおさぎ}の啼く声がした。と思って立止ると、いつか潮がさし出したと見えて、{黄泥|こうでい}を洗う水の色が、さっきよりは間近に光っている。が、女は未だに来ない。

尾生は険しく{眉|まゆ}をひそめながら、橋の下のうす暗い洲を、いよいよ足早に歩き始めた。その内に川の水は、一寸ずつ、一尺ずつ、次第に洲の上へ上って来る。同時にまた川から{立昇|たちのぼ}る{藻|も}の{匀|におい}や水の匀も、冷たく肌にまつわり出した。見上げると、もう橋の上には鮮かな入日の光が消えて、ただ、石の{橋欄|きょうらん}ばかりが、ほのかに青んだ{暮方|くれがた}の空を、黒々と正しく切り抜いている。が、女は未だに来ない。

尾生はとうとう立ちすくんだ。

川の水はもう沓を濡しながら、鋼鉄よりも冷やかな光を{湛|たた}えて、漫々と橋の下に広がっている。すると、{膝|ひざ}も、腹も、胸も、恐らくは{頃刻|けいこく}を出ない内に、この{酷薄|こくはく}な満潮の水に隠されてしまうのに相違あるまい。いや、そう云う内にも{水嵩|みずかさ}は{益|ますます}高くなって、今ではとうとう{両脛|りょうはぎ}さえも、川波の下に没してしまった。が、女は未だに来ない。

尾生は水の中に立ったまま、まだ{一縷|いちる}の望を便りに、何度も橋の空へ眼をやった。

腹を{浸|ひた}した水の上には、とうに{蒼茫|そうぼう}たる暮色が立ち{罩|こ}めて、{遠近|おちこち}に茂った蘆や柳も、寂しい葉ずれの音ばかりを、ぼんやりした{靄|もや}の中から送って来る。と、尾生の鼻を{掠|かす}めて、{鱸|すずき}らしい魚が一匹、ひらりと白い腹を{飜|ひるがえ}した。その魚の躍った空にも、{疎|まばら}ながらもう星の光が見えて、{蔦蘿|つたかずら}のからんだ{橋欄|きょうらん}の形さえ、いち早い宵暗の中に{紛|まぎ}れている。が、女は未だに来ない。……

:::space{lines=1}

:::paragraphs{indent=6}

――――――――――――

:::

:::space{lines=1}

夜半、月の光が{一川|いっせん}の蘆と柳とに{溢|あふ}れた時、川の水と微風とは静に{囁|ささや}き交しながら、橋の下の尾生の死骸を、やさしく海の方へ運んで行った。が、尾生の魂は、寂しい天心の月の光に、思い{憧|こが}れたせいかも知れない。ひそかに死骸を抜け出すと、ほのかに明るんだ空の向うへ、まるで水の{匀|におい}や{藻|も}の匀が音もなく川から立ち昇るように、うらうらと高く昇ってしまった。……

それから幾千年かを隔てた{後|のち}、この魂は無数の{流転|るてん}を{閲|けみ}して、また生を{人間|じんかん}に託さなければならなくなった。それがこう云う私に宿っている魂なのである。だから私は現代に生れはしたが、何一つ意味のある仕事が出来ない。昼も夜も漫然と夢みがちな生活を送りながら、ただ、何か{来|きた}るべき不可思議なものばかりを待っている。ちょうどあの尾生が{薄暮|はくぼ}の橋の下で、永久に来ない恋人をいつまでも待ち暮したように。

:::paragraphs{align=end endIndent=1}

（大正八年十二月）

:::
`; // content.<lang>.md: the same Japanese text in both

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Noto Serif JP': ['400', '600'], // the text; the titles and 一 二 三
  'Noto Sans JP': ['400'], // the hashira, the folios and the colophon
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region voices: each face loads the files that hold the characters it sets
const title = markdown.match(/^title: "(.*)"$/m)[1];
const author = markdown.match(/^author: "(.*)"$/m)[1];
const heads = markdown.match(/^#+ [^{\n]*/gm).join('');
await loadFonts(FONTS, markdown); // the Latin files: the colophon, with ū
await loadCjkFonts({ [MINCHO]: ['400'] }, markdown, { vertical: true });
await loadCjkFonts({ [MINCHO]: ['600'] }, `${heads}`, { vertical: true });
const colophon = markdown.match(/colophon="([^"]*)"/)[1];
await loadCjkFonts({ [GOTHIC]: ['400'] }, `${title}${heads}${colophon}`, { vertical: true });
// #endregion
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showBook(doc, { title: t({ en: 'A Japanese story in two tiers, its title across both',
  es: 'Un cuento japonés en dos pisos, con el título sobre ambos' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);

// ─── Kit ── 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Iguala la última página

Si quitas `balancing`, la última página de cada obra se corta a la misma altura: la página 214 acaba con 10 líneas en el piso de arriba y 11 en el de abajo, el cierre de un libro chino o europeo en columnas.

```diff
-const balancing = { enabled: false, trailing: false };
+const balancing = {}; // the defaults: the closing band is cut level
```

### Abre cada obra en la página siguiente

`parity: 'any'` empieza una obra en la página que venga, como hacen las revistas y las antologías de piezas muy breves.

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

### Compón el cuento en un solo piso

Las novelas y los bunko componen el texto en un solo piso, sin medianil; la [Nº 117](https://postext.dev/es/cookbook/bunko-novel-furigana.md) compone así un bunko.

## Errores frecuentes

- **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.
- **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.
- **En una retícula de caracteres, desactiva el equilibrado de columnas.** El equilibrado de columnas llena una página que acaba corta (un título que se queda con su texto deja líneas en blanco al pie) añadiendo líneas de la retícula encima de los títulos y componiendo un párrafo una línea más holgado. Una línea china más holgada separa sus caracteres (0,13 em en una página GB/T 9704, muy por encima de balancing.maxTracking), así que los caracteres se salen de las columnas de la retícula y los títulos de sus líneas. Una página contada en casillas acaba corta: pon headings.balancing: { enabled: false }.
- **Un título a todo el ancho siempre abre una página nueva.** Un título cuyo nivel o estilo fija span: 'page' se compone como apertura en la cabeza de una página, tenga o no breakBefore. Colocado después de otro texto, o de otra apertura en la misma página, pasa a la página siguiente y deja vacío el resto de la actual. Un titular que cruza las dos columnas (o los dos pisos de una página vertical) bajo la cabecera del periódico va en el diseño de la propia apertura, junto a la cabecera, o dentro de un recuadro :::callout{span="page"} como título sin span, que es como newspaper-front-page compone sus titulares.
- **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.
- **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].

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: 芥川龍之介「蜘蛛の糸」 (El hilo de la araña, 1918); ficha 92 de Aozora Bunko, según 「芥川龍之介全集2」 (Chikuma Bunko, 1986), transcripción de 平山誠 y 野口英司, corrección de もりみつじゅんじ, convertida con aozora.py: Akutagawa Ryūnosuke; Aozora Bunko volunteers ([fuente](https://www.aozora.gr.jp/cards/000879/card92.html)), dominio público
- Texto: 芥川龍之介「尾生の信」 (La fe de Bisei, 1920); ficha 24 de Aozora Bunko, según 「芥川龍之介全集3」 (Chikuma Bunko, 1986), transcripción de j.utiyama, corrección de かとうかおり, convertida con aozora.py; la raya antes del final, acortada para que quepa en un piso: Akutagawa Ryūnosuke; Aozora Bunko volunteers ([fuente](https://www.aozora.gr.jp/cards/000879/card24.html)), dominio público
- Tipografías: Noto Serif JP (OFL-1.1), Noto Sans JP (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 084 · Una página de periódico vertical, en pisos](https://postext.dev/es/cookbook/vertical-newspaper-tiers.md): Dos hojas de un periódico chino de 1912 en vertical: el texto en dos pisos con un filete entre ellos, los telegramas en tres y el titular a lo alto de ambos. · Nivel 2 (Intermedio) · Periódicos y boletines
- [N.º 017 · Cinco aperturas de capítulo en un solo libro](https://postext.dev/es/cookbook/five-chapter-openers.md): Una apertura en el nivel 1 y cuatro estilos de título nombrados en el Markdown; cada uno cambia el color de acento, y algunos, los márgenes, columnas o folios. · Nivel 3 (Avanzado) · Cualquier género
- [N.º 075 · Una novela china vertical, encuadernada por la derecha](https://postext.dev/es/cookbook/vertical-novel-right-bound.md): El capítulo 1 de 三國演義 en una página 25開 de Taiwán: 40 caracteres por 16 columnas, un 回目 entre filetes, cabeceras en el corte y folios chinos. · Nivel 3 (Avanzado) · Narrativa, teatro y prosa literaria
