Saltar al contenido principal
Receta número 122

Recetario · Capítulo 2 · Texto y tipografía

Kanbun con sus marcas de lectura y la lectura japonesa

Tres dichos de las Analectas y una cuarteta Tang como en un libro escolar japonés: marcas de retorno y okurigana en bermellón, y la lectura en kana debajo.

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

p. 一 · 1 de 3

  • Formato 148 × 210 mm
  • 1 columna
  • Klee One 10,5/21
  • Noto Sans JP
  • Zen Old Mincho
  • 3 páginas
  • Nivel
  • Postext 1.16.1
  • Compuesto en 45 ms
  • 132 líneas de código

En pocas palabras

El chino clásico como lo aprenden los lectores japoneses: unas marcas rojas pequeñas junto a los caracteres dicen en qué orden leerlos y qué terminaciones japonesas añadir, y la lectura japonesa completa sigue a cada pasaje.

Lo que vas a componer

Tres páginas de un libro escolar japonés de 漢文 (kanbun), el chino clásico leído como japonés, en una página A5 encuadernada por la derecha: tres dichos de las Analectas y la cuarteta Amanecer de primavera de Meng Haoran. Cada pasaje va en Zen Old Mincho de 15 pt, en el orden chino, con sus 訓点 (kunten) junto a los caracteres, en bermellón y a la mitad de su tamaño: las marcas de retorno (返り点) abajo a la izquierda de un carácter dicen a quien lee que siga y vuelva a él, y el okurigana (送り仮名) a su derecha añade las terminaciones japonesas. Bajo cada pasaje, su 書き下し文 (yomikudashi), el mismo texto escrito como japonés, va en Klee One, una letra escolar, con furigana donde la lectura es difícil. Los clásicos chinos con sus propias ayudas de lectura son la Nº 080.

Esta receta responde a

  • ¿Cómo imprimo kanbun con sus marcas de retorno y su okurigana?
  • ¿Cómo pongo furigana sobre los kanji, una lectura por carácter o una para toda la palabra?

La respuesta corta

script.js · líneas 32–52en el código completo
// :kunten[習]{kaeri="レ" okuri="フ"} sets the return mark small at the character's lower
// left and the okurigana at its lower right, down the line; the text keeps its Chinese
// order, which the reader follows by the marks. cjk.kunten sizes and colours them: half
// the character, in vermilion. The Japanese reading under each passage is plain kana and
// kanji with furigana: {君子|くん|し} gives each character its reading (jukugo ruby).
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  kunten: { fontSize: em(0.5), color: col('vermilion'), placement: 'inline' },
};
const paragraphStyles = [
  // The text: 15 pt on a pitch of two grid lines, room for the okurigana beside it. A
  // quatrain takes two lines, a couplet to the line with a space between its verses.
  { id: 'kanbun', fontFamily: MINCHO, fontSize: pt(KANBUN), lineHeight: pt(2 * LEAD),
    firstLineIndent: em(0), textAlign: 'justify' },
  { id: 'shi', fontFamily: MINCHO, fontSize: pt(KANBUN), lineHeight: pt(2 * LEAD),
    indent: em(2), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) },
  // The reading (書き下し文), two characters down in a school-book hand.
  { id: 'kudashi', indent: em(2), firstLineIndent: em(0), marginTop: pt(LEAD) },
  { id: 'kudashi-shi', indent: em(4), firstLineIndent: em(0), marginTop: pt(0),
    marginBottom: pt(0) },
];

Ingredientes

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

Elaboración

#1 · Marca cada carácter con :kunten

El código es la respuesta corta de arriba. :kunten[習]{kaeri="レ" okuri="フ"} da a 習 la marca de retorno レ y el okurigana フ; el texto conserva el carácter, así que la búsqueda, la copia y el PDF leen el pasaje tal como está escrito, y las marcas no ocupan ningún carácter suyo (Marcas, ruby y warichu). Lee 学ビテ而時ニ習フレ之ヲ: la レ de 習 manda leer antes 之 y volver, 之を習ふ; 而 no lleva marca y no se lee. En vertical la marca de retorno va abajo a la izquierda de su carácter y ocupa medio carácter de la línea, y el okurigana va a la derecha, empezando a media altura del carácter, como los compone JIS X 4051. cjk.kunten fija su tamaño, la mitad del texto, y su color, el bermellón con que las escribe un lector; un pasaje va en 15 pt sobre dos líneas de la retícula, lo que deja 27 pt junto a cada línea para el okurigana.

#2 · Salta con 一 二 y con 一レ

Los números llevan a quien lee más atrás. En 不二亦タ説バシカラ一乎 se salta 不, que lleva 二, se lee 亦た説ばしから y en el 一 se vuelve a 不: 亦た説ばしからずや. En 為政 la marca combinada 一レ de 為 hace las dos cosas: 可二以テ為一レ師ト矣 se lee 以て, 師と (レ), 為る y de vuelta a 可 (de 一 a 二), 以て師と為るべし. Los puntos de código de kanbun ㆑ ㆒ ㆓ se leen como los caracteres que representan, así que un texto copiado de otra parte conserva sus marcas.

#3 · Deja que un okurigana largo empuje al carácter siguiente

Un okurigana más largo que su carácter no corre junto al siguiente: en 花落ツルコト知ル多少 los cuatro kana de ツルコト ocupan dos caracteres de largo, y 知 baja para empezar tras ellos, como lo compone JIS X 4051 (§5.6.4). La línea crece, y su corte se mueve con ella.

#4 · Da a cada pasaje una etiqueta de dos líneas

script.js · líneas 56–71en el código completo
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: pt(down), y: pt(across) } });
const TITLE = 26; // pt
// gotcha: headings-drop-h1-break
const part = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: pt(5 * LEAD), slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 700,
      fontSize: pt(TITLE), lineHeight: 1, letterSpacing: pt(TITLE / 2), color: col('ink'),
      placement: at(2 * BODY, 2 * LEAD - TITLE / 2) },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: GOTHIC, fontSize: pt(8.5),
      letterSpacing: pt(2), color: col('vermilion'), placement: at(2 * BODY + 3 * TITLE + 18,
        2 * LEAD - 8.5 / 2) },
  ] } } };
// 2行取り, the passage's book and number in gothic, two characters down.
const passage = { level: 2, fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(9.5),
  color: col('muted'), lineSpan: 2, indent: em(2) };

Cada libro de la antología abre página con su título, 論語 o 唐詩, el primero con una nota bermellón pequeña sobre lo que contiene; cada pasaje lleva una etiqueta en gótica de dos líneas del texto (2行取り), 学而第一 o 春暁 孟浩然, para que los pasajes que siguen queden en la retícula. La lectura es un contenedor :::paragraphs{style="kudashi"} dos caracteres más abajo, en la letra y el cuerpo del texto, con furigana jukugo: {君子|くん|し} da a cada carácter su propia lectura, de modo que una línea podría cortarse entre ellos.

#5 · Carga cada voz con sus caracteres

script.js · líneas 175–188en el código completo
const FENCE = /:::paragraphs\{style="([\w-]+)"\}\n([\s\S]*?)\n:::/g;
const fence = (style) => [...markdown.matchAll(FENCE)].filter((m) => m[1] === style)
  .map((m) => m[2]).join('');
const kanbun = `${fence('kanbun')}${fence('shi')}`; // the text and its marks' kana
const reading = `${fence('kudashi')}${fence('kudashi-shi')}`;
const heads = markdown.match(/^#+ [^{\n]*/gm).join('');
const colophon = markdown.match(/colophon="([^"]*)"/)[1];
await loadFonts(FONTS, markdown); // the Latin files: the colophon
await loadCjkFonts({ [MINCHO]: ['400'] }, kanbun, { vertical: true });
await loadCjkFonts({ [MINCHO]: ['700'] }, heads);
await loadCjkFonts({ [KYOKASHO]: ['400'] }, reading, { vertical: true });
await loadCjkFonts({ [GOTHIC]: ['400', '700'] },
  `${heads}${colophon}${markdown.match(/kicker="([^"]*)"/)[1]}漢文訓読一二三四五六七八九十`,
  { vertical: true });

El texto clásico y la lectura van en letras distintas, así que cada una carga los archivos de sus propios caracteres: Zen Old Mincho para los pasajes y sus marcas, cuyos kana van dentro de los atributos de :kunten; Klee One para las lecturas y su furigana; Noto Sans JP para las etiquetas. La cuarteta va con dos versos por línea y un espacio ideográfico entre ellos, una forma habitual de imprimirla, para que el poema y su lectura quepan en la página bajo el título.

La receta completa

Sandbox
// ═══ Postext Cookbook · Nº 122 · Kanbun with its reading marks, and the Japanese reading ═══
// https://postext.dev/en/cookbook/kanbun-kundoku
// Code: MIT · Text: 論語, 孟浩然「春暁」 (public domain); kunten and readings CC BY 4.0
// Fonts: Zen Old Mincho, Klee One, 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 page is Japanese in both
const RECIPE = 'kanbun-kundoku';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink, the vermilion of hand-written kunten, a school-book paper
const palette = {
  ink: '#1e1c1a', // the text
  vermilion: '#c0392b', // 朱: the reading marks, as a reader adds them by hand
  muted: '#6a635c', // the headings' labels, the folios
  paper: '#fcfbf7',
};
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, KYOKASHO, GOTHIC] = ['Zen Old Mincho', 'Klee One', 'Noto Sans JP'];
const [BODY, LEAD, CHARS, LINES] = [10.5, 21, 36, 16]; // pt, pt: 36字 × 16行, a 2 em pitch
const KANBUN = 15; // pt: the classical text, set larger on two lines of the grid

// #region answer: kanbun with 返り点 and 送り仮名, its yomikudashi beside it in furigana
// :kunten[習]{kaeri="レ" okuri="フ"} sets the return mark small at the character's lower
// left and the okurigana at its lower right, down the line; the text keeps its Chinese
// order, which the reader follows by the marks. cjk.kunten sizes and colours them: half
// the character, in vermilion. The Japanese reading under each passage is plain kana and
// kanji with furigana: {君子|くん|し} gives each character its reading (jukugo ruby).
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  kunten: { fontSize: em(0.5), color: col('vermilion'), placement: 'inline' },
};
const paragraphStyles = [
  // The text: 15 pt on a pitch of two grid lines, room for the okurigana beside it. A
  // quatrain takes two lines, a couplet to the line with a space between its verses.
  { id: 'kanbun', fontFamily: MINCHO, fontSize: pt(KANBUN), lineHeight: pt(2 * LEAD),
    firstLineIndent: em(0), textAlign: 'justify' },
  { id: 'shi', fontFamily: MINCHO, fontSize: pt(KANBUN), lineHeight: pt(2 * LEAD),
    indent: em(2), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) },
  // The reading (書き下し文), two characters down in a school-book hand.
  { id: 'kudashi', indent: em(2), firstLineIndent: em(0), marginTop: pt(LEAD) },
  { id: 'kudashi-shi', indent: em(4), firstLineIndent: em(0), marginTop: pt(0),
    marginBottom: pt(0) },
];
// #endregion

// #region headings: each part opens a page; each passage under a two-line label
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: pt(down), y: pt(across) } });
const TITLE = 26; // pt
// gotcha: headings-drop-h1-break
const part = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: pt(5 * LEAD), slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 700,
      fontSize: pt(TITLE), lineHeight: 1, letterSpacing: pt(TITLE / 2), color: col('ink'),
      placement: at(2 * BODY, 2 * LEAD - TITLE / 2) },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: GOTHIC, fontSize: pt(8.5),
      letterSpacing: pt(2), color: col('vermilion'), placement: at(2 * BODY + 3 * TITLE + 18,
        2 * LEAD - 8.5 / 2) },
  ] } } };
// 2行取り, the passage's book and number in gothic, two characters down.
const passage = { level: 2, fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(9.5),
  color: col('muted'), lineSpan: 2, indent: em(2) };
// #endregion

const foreEdge = (id, content, edge, y, pages) => ({ kind: 'text', id, content, pages,
  writingMode: 'vertical-rl', fontFamily: GOTHIC, fontSize: pt(7.5), letterSpacing: pt(1),
  color: col('muted'), placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } } });
const header = { elements: [
  foreEdge('hashira', '{title} {chapterTitle}', 'top', 3, 'body'),
  foreEdge('folio', '{pageNumber}', 'bottom', -3, 'all'),
] };
// The first part's opening page carries the colophon across the foot.
const first = { id: 'first', footer: { elements: [{ kind: 'text', id: 'colophon',
  content: '{attr.colophon}', pages: 'opener', 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(-7) },
    size: { width: mm(118) } } }] } };

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(40), bottom: mm(32), left: mm(13), right: mm(16), mirror: true },
    pageNumbering: { format: 'japanese-informal' },
  },
  layout: { layoutType: 'single', writingMode: 'vertical-rl' },
  cjk,
  bodyText: {
    fontFamily: KYOKASHO, 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: [part, passage],
    balancing: { enabled: false } }, // gotcha: cjk-grid-balancing
  headingStyles: [first],
  paragraphStyles,
  header,
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Muestra en Markdown · 50 líneas · content.es.mdtitle: "漢文訓読" --- # 論語 {style="first" kicker="学而・為政より" colophon="Confucio, 論語 (las Analectas), tres dichos de los libros 學而 y 爲政; Meng Haoran, 春曉 (Amanecer de primavera). Kunten y lectura según la lectura escolar habitual, compuestos para esta receta. Compuesto en Zen Old Mincho, Klee One y Noto Sans JP (SIL OFL)."} ## 学而第一 :::paragraphs{style="kanbun"} 子:kunten[曰]{okuri="ハク"}、:kunten[学]{okuri="ビテ"}而:kunten[時]{okuri="ニ"}:kunten[習]{kaeri="レ" okuri="フ"}:kunten[之]{okuri="ヲ"}、:kunten[不]{kaeri="二"}:kunten[亦]{okuri="タ"}:kunten[説]{kaeri="一" okuri="バシカラ"}乎。:kunten[有]{kaeri="レ" okuri="リ"}朋:kunten[自]{kaeri="二" okuri="リ"}遠:kunten[方]{kaeri="一"}:kunten[来]{okuri="タル"}、:kunten[不]{kaeri="二"}:kunten[亦]{okuri="タ"}:kunten[楽]{kaeri="一" okuri="シカラ"}乎。人:kunten[不]{kaeri="レ" okuri="シテ"}:kunten[知]{okuri="ラ"}而:kunten[不]{kaeri="レ"}:kunten[慍]{okuri="ミ"}、:kunten[不]{kaeri="二"}:kunten[亦]{okuri="タ"}君:kunten[子]{kaeri="一" okuri="ナラ"}乎。 ::: :::paragraphs{style="kudashi"} 子{曰|い}はく、{学|まな}びて時に{之|これ}を習ふ、{亦|ま}た{説|よろこ}ばしからずや。{朋|とも}有り{遠方|えん|ぽう}より来たる、{亦|ま}た楽しからずや。人知らずして{慍|うら}みず、{亦|ま}た{君子|くん|し}ならずや。 ::: ## 学而第三 :::paragraphs{style="kanbun"} 子:kunten[曰]{okuri="ハク"}、巧言令色、:kunten[鮮]{okuri="ナシ"}矣仁。 ::: :::paragraphs{style="kudashi"} 子{曰|い}はく、{巧言令色|こう|げん|れい|しょく}、{鮮|すく}なし{仁|じん}。 ::: ## 為政第十一 :::paragraphs{style="kanbun"} 子:kunten[曰]{okuri="ハク"}、:kunten[温]{kaeri="レ" okuri="メテ"}:kunten[故]{okuri="キヲ"}而:kunten[知]{kaeri="レ" okuri="レバ"}:kunten[新]{okuri="シキヲ"}、:kunten[可]{kaeri="二" okuri="シ"}:kunten[以]{okuri="テ"}:kunten[為]{kaeri="一レ" okuri="ル"}:kunten[師]{okuri="ト"}矣。 ::: :::paragraphs{style="kudashi"} 子{曰|い}はく、{故|ふる}きを{温|あたた}めて新しきを知れば、{以|もっ}て師と{為|な}るべし。 ::: # 唐詩 ## 春暁 孟浩然 :::paragraphs{style="shi"} 春眠:kunten[不]{kaeri="レ"}:kunten[覚]{kaeri="レ" okuri="エ"}:kunten[暁]{okuri="ヲ"} 処処:kunten[聞]{kaeri="二" okuri="ク"}啼:kunten[鳥]{kaeri="一" okuri="ヲ"} 夜来風:kunten[雨]{okuri="ノ"}声 花:kunten[落]{okuri="ツルコト"}:kunten[知]{okuri="ル"}多少 ::: :::paragraphs{style="kudashi-shi"} {春眠|しゅん|みん}{暁|あかつき}を{覚|おぼ}えず {処処|しょ|しょ}{啼鳥|てい|ちょう}を聞く {夜来|や|らい}{風雨|ふう|う}の声 花落つること知る{多少|た|しょう} :::
`; // content.<lang>.md: the same Japanese text in both // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Zen Old Mincho': ['400', '700'], // the classical text and its marks; the part titles 'Klee One': ['400'], // the reading, in a school-book hand 'Noto Sans JP': ['400', '700'], // labels, the hashira and folios, the colophon }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region voices: each face loads the files that hold the characters it sets const FENCE = /:::paragraphs\{style="([\w-]+)"\}\n([\s\S]*?)\n:::/g; const fence = (style) => [...markdown.matchAll(FENCE)].filter((m) => m[1] === style) .map((m) => m[2]).join(''); const kanbun = `${fence('kanbun')}${fence('shi')}`; // the text and its marks' kana const reading = `${fence('kudashi')}${fence('kudashi-shi')}`; const heads = markdown.match(/^#+ [^{\n]*/gm).join(''); const colophon = markdown.match(/colophon="([^"]*)"/)[1]; await loadFonts(FONTS, markdown); // the Latin files: the colophon await loadCjkFonts({ [MINCHO]: ['400'] }, kanbun, { vertical: true }); await loadCjkFonts({ [MINCHO]: ['700'] }, heads); await loadCjkFonts({ [KYOKASHO]: ['400'] }, reading, { vertical: true }); await loadCjkFonts({ [GOTHIC]: ['400', '700'] }, `${heads}${colophon}${markdown.match(/kicker="([^"]*)"/)[1]}漢文訓読一二三四五六七八九十`, { vertical: true }); // #endregion const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); showBook(doc, { title: t({ en: 'Kanbun with its reading marks, and the Japanese reading', es: 'Kanbun con sus marcas de lectura y la lectura japonesa' }) }); 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

#Imprime las marcas en negro

Un libro impreso compone los kunten con la tinta del texto; quita el color.

-  kunten: { fontSize: em(0.5), color: col('vermilion'), placement: 'inline' },
+  kunten: { fontSize: em(0.5), placement: 'inline' },

#Pon las marcas entre las líneas

placement: 'interlinear' saca las marcas de retorno de la línea al hueco junto a la mitad inferior del carácter, de modo que el pasaje conserva el espaciado del texto desnudo (白文). JIS las pone en la línea, el valor por defecto.

-  kunten: { fontSize: em(0.5), color: col('vermilion'), placement: 'inline' },
+  kunten: { fontSize: em(0.5), color: col('vermilion'), placement: 'interlinear' },

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

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 }. Retícula de caracteres →

Créditos

Texto
  • 論語 (las Analectas de Confucio), 學而 1 y 3, 爲政 11, y 孟浩然「春曉」 (Amanecer de primavera), en shinjitai; los kunten (返り点, 送り仮名) y el yomikudashi siguen la lectura escolar japonesa habitual y se compusieron para esta receta (CC BY 4.0) · Confucius and his disciples; Meng Haoran; Ignacio Ferro (kunten and reading) · dominio público
Fuentes
Zen Old Mincho (SIL OFL 1.1) · Klee One (SIL OFL 1.1) · Noto Sans JP (SIL OFL 1.1)
SandboxPDF