Saltar al contenido principal
Receta número 79

Recetario · Capítulo 5 · Estructura del libro

Un índice de nombres chinos, por pinyin y por trazos

Las mismas marcas :index en dos ediciones de una antología de 紅樓夢: index.groupBy agrupa los nombres por la inicial en pinyin en zh-Hans y por trazos en zh-Hant.

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

pp. 6–7 de 16

  • Formato 140 × 203 mm
  • 1 columna
  • Noto Serif SC 10,5/18
  • LXGW WenKai TC
  • Ma Shan Zheng
  • Noto Sans SC
  • Noto Sans TC
  • Noto Serif TC
  • 16 páginas
  • Nivel
  • Postext 1.9.0
  • Compuesto en 78 ms
  • 180 líneas de código

Lo que vas a componer

Una antología breve de 紅樓夢 (Sueño en el pabellón rojo) que recoge, de los capítulos 1 a 3, los pasajes en que aparece cada personaje y termina con un índice de sus 47 nombres, más cuatro remisiones desde otros nombres. Sale en dos ediciones con las mismas marcas. La de China continental, en caracteres simplificados, ordena los nombres por su inicial en pinyin, F, H, J…; la de Taiwán, en caracteres tradicionales, por el número de trazos del primer carácter, 三畫, 四畫… Las dos son libros en rústica de 大32开, 140 × 203 mm, con 26 caracteres por línea en Noto Serif, los títulos de capítulo en una Kai y los antetítulos, los folios y las letras del índice en Noto Sans. La tarjeta amplía los primeros grupos del índice simplificado, de la F a la L. Un índice onomástico y un índice de materias hace el mismo tipo de índice para una monografía en inglés o en español.

Esta receta responde a

  • ¿Cómo agrupo un índice de nombres chinos por pinyin o por número de trazos?
  • ¿Cómo llevo los caracteres chinos al PDF sin cajas vacías?

La respuesta corta

script.js · líneas 44–57en el código completo
// Marks in the Markdown, one per passage; main is the page where the character enters:
//   字:index[士隐]{term="甄士隐" main}      :index{term="贾化" see="贾雨村"}
// Printed at the end of each edition: # 人名索引 {style="index"}, then :::index
const index = (e) => ({
  // 'auto' reads the same from the locale: pinyin for zh-Hans, strokes for zh-Hant.
  groupBy: e.locale === 'zh-Hans' ? 'pinyin' : 'stroke', // A B C… or 三畫 四畫…
  fontFamily: e.serif, fontSize: pt(9), lineHeight: pt(14),
  separator: '\u3000', locatorSeparator: ',', // 贾宝玉 4,6: an ideographic space, then ,
  see: { italic: false }, // 见 / 見 upright: Chinese has no italic (gotcha: cjk-no-italic)
  groups: { fontFamily: e.sans, fontSize: pt(9.5), fontWeight: 700, color: col('cinnabar'),
    marginTop: pt(7) },
});
const indexHeading = { id: 'index', numbered: false, breakBefore: { enabled: true, parity: 'any' },
  layout: { layoutType: 'double', gutterWidth: pt(21) } }; // two columns, 2 characters apart

Ingredientes

Tipografía
Noto Serif SC, Noto Sans SC, Ma Shan Zheng, Noto Serif TC, Noto Sans TC, LXGW WenKai TC (SIL OFL 1.1)
Recursos
Ninguno: todas las imágenes se dibujan en código

Elaboración

#1 · Marca cada nombre donde el lector se lo encuentra

La configuración es la respuesta corta de arriba; lo demás está en el Markdown. La marca archiva el nombre completo, lo imprima o no el texto: 字:index[士隐]{term="甄士隐" main} imprime 士隐 y anota la página bajo 甄士隐. main pone la página en negrita, y la nota bajo el título del índice explica qué significa aquí: la página en que el personaje sale a escena. Jia Baoyu nace en el capítulo 2 sin que lo nombren (又生了一位公子), así que la marca va sin texto que imprimir, :index{term="贾宝玉"}, y su entrada dice 4, 6. Los apodos y los nombres de antes tienen su propia remisión, :index[凤辣子]{term="凤辣子" see="王熙凤"}, que Postext imprime a la manera china, tras un punto y sin espacio: 凤辣子。见王熙凤 (Marcas de índice).

#2 · Deja que el idioma decida el orden

script.js · líneas 30–37en el código completo
// The mainland edition in Simplified characters and the Taiwan edition in Traditional ones.
// Each takes the Song (serif), Hei (sans) and Kai faces cut for its script. Fontsource has no
// Kai text face with mainland forms: LXGW WenKai TC draws 冷 the Taiwan way, so the mainland
// titles take Ma Shan Zheng, a brush Kai.
const EDITIONS = {
  hans: { locale: 'zh-Hans', serif: 'Noto Serif SC', sans: 'Noto Sans SC', kai: 'Ma Shan Zheng' },
  hant: { locale: 'zh-Hant', serif: 'Noto Serif TC', sans: 'Noto Sans TC', kai: 'LXGW WenKai TC' },
};

Las dos ediciones comparten la configuración, y al índice le basta con cambiar locale: groupBy: 'auto' agrupa un índice zh-Hans por pinyin y uno zh-Hant por trazos; la respuesta corta escribe los dos valores para que los veas. Las entradas se ordenan según el mismo criterio que las letras de grupo, así que los mismos nombres cambian de sitio: 甄费 va antes que 甄士隐 en la Z (fèi antes que shì) y detrás en 十四畫 (士 tiene tres trazos; 費, doce); 宁国公 y 荣国公 se separan en la N y la R, mientras que 寧國公 y 榮國公 siguen juntos entre los nombres de catorce trazos (Índice analítico). Las lecturas y los trazos salen de la ordenación china del navegador; un apellido que lee mal (曾, 单, 解) lleva una clave sort escrita con caracteres que tengan la lectura correcta.

#3 · Carga cada fuente con los caracteres que compone

script.js · líneas 271–280en el código completo
const DIGITS = '0123456789';
const HEADS = '第回一二三四五六七八九十画畫ABCDEFGHIJKLMNOPQRSTUVWXYZ'; // 第一回, the index heads
const all = (md, re) => [...md.matchAll(re)].map((m) => m[1]).join('\n');
for (const [key, md] of [['hans', markdown], ['hant', traditional]]) {
  e = EDITIONS[key];
  await loadCjkFonts({ [e.serif]: ['400'] }, md); // the text, the index, the running heads
  await loadCjkFonts({ [e.serif]: ['700'] }, DIGITS); // the bold page numbers
  await loadCjkFonts({ [e.sans]: ['700'] }, all(md, /kicker="([^"]*)"/g) + HEADS + DIGITS);
  await loadCjkFonts({ [e.kai]: ['400'] }, all(md, /^# ([^{\n]*)/gm)); // the titles alone
}

Fontsource parte cada peso de una fuente china en unos cien archivos, y loadCjkFonts descarga los que toca su texto. La Song recibe el libro entero, así que las cabeceras no le cuestan nada; su negrita, las cifras de las páginas principales; la Hei, el texto de los atributos kicker, las letras del índice y las cifras de los folios, y la Kai, los tres pareados y 人名索引. El PDF sigue el mismo camino: cjkPdfProvider incrusta esos archivos, cada uno reducido a los caracteres que usan sus páginas, y ningún carácter sale como una caja vacía. La Kai cambia con la edición. LXGW WenKai TC, la única Kai de texto de Fontsource, dibuja formas heredadas, 冷 con el antiguo pie 卩 de 令, y centra comas y puntos, así que los títulos de la edición simplificada van en Ma Shan Zheng, una Kai de pincel con las formas del continente (Fuentes chinas, japonesas y coreanas).

#4 · Pon la nota en la apertura del índice

script.js · líneas 61–81en el código completo
const opener = (e, sink, kicker, note = []) => ({
  enabled: true,
  minHeight: pt(LEAD * sink), // the text starts on the same line after every opener
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: kicker, fontFamily: e.sans, fontSize: pt(9),
      fontWeight: 700, letterSpacing: pt(2.7), color: col('cinnabar'), align: 'center',
      placement: { ...at('container', 'top', 0, 8), size: { width: 'fill', height: 'auto' } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: e.kai, fontSize: pt(16),
      lineHeight: 1.45, color: col('ink'), align: 'center',
      overflow: 'wrap', // design text ends in an ellipsis by default
      placement: { ...at('#kicker', 'below', 0, 3), size: { width: 'fill', height: 'auto' } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
      placement: { ...at('#title', 'below', (MEASURE - 12) / 2, 4), size: { width: mm(12) } } },
    ...note,
  ] },
});
// The note (note="…" on the heading) sits above the columns: no paragraph for balancing to loosen.
const indexNote = (e) => [{ kind: 'text', id: 'note', content: '{attr.note}',
  fontFamily: e.serif, fontSize: pt(9), lineHeight: 1.6, color: col('muted'), align: 'center',
  overflow: 'wrap', placement: { ...at('#rule', 'below', -(MEASURE - 12) / 2, 4),
    size: { width: 'fill', height: 'auto' } } }];

La nota es un atributo del título, # 人名索引 {style="index" kicker="按汉语拼音排列" note="…"}, y ocupa toda la caja encima de las dos columnas. Como párrafo dentro de las columnas servía de palanca al equilibrado: para igualar la última página de la edición tradicional, la maquetación lo aflojaba a doce caracteres por línea en una columna de catorce. El texto de diseño no aplica las reglas chinas de corte de línea y compone todos los signos a ancho completo, mientras que el texto simplificado pone ,、; a medio ancho, así que la nota son tres frases cortas, una por línea (\n en el atributo), y cada una lleva un punto como único signo. Las aperturas de capítulo usan el mismo diseño, con 第{numberHan}回 como antetítulo, que imprime 第一回 en la escritura del documento.

#5 · Muestra las dos páginas de índice juntas

script.js · líneas 304–320en el código completo
const indexPage = (doc) => doc.pages[doc.blocks.find((b) => b.headingStyleId === 'index')
  ?.pageIndex ?? doc.pages.length - 1];
const labels = { hans: t({ en: 'Simplified · by pinyin', es: 'Simplificado · por pinyin' }),
  hant: t({ en: 'Traditional · by strokes', es: 'Tradicional · por trazos' }) };
document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="indexes" style="
  display:flex;flex-wrap:wrap;justify-content:center;gap:24px;padding:28px 16px;background:#e9e4da">
  ${Object.keys(docs).map((key) => `<figure style="margin:0;width:min(340px,44vw)">
  <canvas id="index-${key}" role="img" style="display:block;width:100%;background:#fff;
  box-shadow:0 18px 36px -18px rgb(0 0 0 / .45)"></canvas><figcaption style="margin-top:10px;
  font:600 11px/1 system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;color:#5b534a">
  ${labels[key]}</figcaption></figure>`).join('')}</section>`);
for (const [key, doc] of Object.entries(docs)) {
  const canvas = document.getElementById(`index-${key}`);
  const page = indexPage(doc);
  canvas.setAttribute('aria-label', `${labels[key]}, ${page.pageLabel}`);
  renderPageToCanvas(page, doc, canvas, { scale: (2 * canvas.clientWidth) / page.width });
}

El código compone las dos ediciones y deja la simplificada en la mesa. Los botones 简体版 y 繁體版 cambian de edición, y con cada cambio se ofrece el PDF de la que está a la vista, chinese-name-index-hans.pdf o chinese-name-index-hant.pdf. La captura registra las dos (capture.doc: ['first', 'last']): las ocho primeras páginas de arriba son de la edición simplificada, y las cuatro siguientes, de la tradicional, su primera página y su índice por trazos. La franja que hay encima de la mesa muestra la primera página de índice de cada edición. El PDF que acompaña a la receta es el de la edición simplificada.

La receta completa

Sandbox
// ═══ Postext Cookbook · Nº 079 · An index of Chinese names, by pinyin and by strokes ═══
// https://postext.dev/en/cookbook/chinese-name-index
// Code: MIT · Text: 紅樓夢 (1792), zh.wikisource revision 9685985 (CC BY-SA 4.0) · Pictures: none
// Fonts: Noto Serif/Sans SC/TC, Ma Shan Zheng, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.9.0
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'chinese-name-index';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink on a warm paper, one cinnabar for the heads and the index letters
const palette = {
  ink: '#1f1b18', // text
  cinnabar: '#a3301f', // the one accent: 回 numbers, index heads
  muted: '#6b635a', // running heads, folios, colophon
  rule: '#d6cdbf', // the short rule under each title
  paper: '#fbf8f1',
};
// A design element paints the hex written beside its paletteId (gotcha: palette-skips-designs).
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' } })),
  // The engine's defaults link to main-color: point it at the accent, never the default blue.
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.cinnabar, model: 'hex' } },
];
// #endregion

// #region editions: one book, two scripts; the locale decides the index order
// The mainland edition in Simplified characters and the Taiwan edition in Traditional ones.
// Each takes the Song (serif), Hei (sans) and Kai faces cut for its script. Fontsource has no
// Kai text face with mainland forms: LXGW WenKai TC draws 冷 the Taiwan way, so the mainland
// titles take Ma Shan Zheng, a brush Kai.
const EDITIONS = {
  hans: { locale: 'zh-Hans', serif: 'Noto Serif SC', sans: 'Noto Sans SC', kai: 'Ma Shan Zheng' },
  hant: { locale: 'zh-Hant', serif: 'Noto Serif TC', sans: 'Noto Sans TC', kai: 'LXGW WenKai TC' },
};
// #endregion
const LEAD = 18; // body leading in pt: 1.7 × the 10.5 pt text (五号)
const MEASURE = (26 * 10.5 * 25.4) / 72; // the grid's 26 characters of 10.5 pt, in mm
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region answer: the same marks, grouped by pinyin in zh-Hans and by strokes in zh-Hant
// Marks in the Markdown, one per passage; main is the page where the character enters:
//   字:index[士隐]{term="甄士隐" main}      :index{term="贾化" see="贾雨村"}
// Printed at the end of each edition: # 人名索引 {style="index"}, then :::index
const index = (e) => ({
  // 'auto' reads the same from the locale: pinyin for zh-Hans, strokes for zh-Hant.
  groupBy: e.locale === 'zh-Hans' ? 'pinyin' : 'stroke', // A B C… or 三畫 四畫…
  fontFamily: e.serif, fontSize: pt(9), lineHeight: pt(14),
  separator: '\u3000', locatorSeparator: ',', // 贾宝玉 4,6: an ideographic space, then ,
  see: { italic: false }, // 见 / 見 upright: Chinese has no italic (gotcha: cjk-no-italic)
  groups: { fontFamily: e.sans, fontSize: pt(9.5), fontWeight: 700, color: col('cinnabar'),
    marginTop: pt(7) },
});
const indexHeading = { id: 'index', numbered: false, breakBefore: { enabled: true, parity: 'any' },
  layout: { layoutType: 'double', gutterWidth: pt(21) } }; // two columns, 2 characters apart
// #endregion

// #region opener: 第一回 in Hei over the couplet in Kai; the index adds its note
const opener = (e, sink, kicker, note = []) => ({
  enabled: true,
  minHeight: pt(LEAD * sink), // the text starts on the same line after every opener
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: kicker, fontFamily: e.sans, fontSize: pt(9),
      fontWeight: 700, letterSpacing: pt(2.7), color: col('cinnabar'), align: 'center',
      placement: { ...at('container', 'top', 0, 8), size: { width: 'fill', height: 'auto' } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: e.kai, fontSize: pt(16),
      lineHeight: 1.45, color: col('ink'), align: 'center',
      overflow: 'wrap', // design text ends in an ellipsis by default
      placement: { ...at('#kicker', 'below', 0, 3), size: { width: 'fill', height: 'auto' } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
      placement: { ...at('#title', 'below', (MEASURE - 12) / 2, 4), size: { width: mm(12) } } },
    ...note,
  ] },
});
// The note (note="…" on the heading) sits above the columns: no paragraph for balancing to loosen.
const indexNote = (e) => [{ kind: 'text', id: 'note', content: '{attr.note}',
  fontFamily: e.serif, fontSize: pt(9), lineHeight: 1.6, color: col('muted'), align: 'center',
  overflow: 'wrap', placement: { ...at('#rule', 'below', -(MEASURE - 12) / 2, 4),
    size: { width: 'fill', height: 'auto' } } }];
// #endregion

// #region running-heads: book title on the verso, the chapter on the recto, folios outside
// The heads in the Song face, which has every character of the book loaded; Hei for the folios.
const head = (e, id, content, parity, edge, y, extra = {}) => ({
  kind: 'text', id, content, parity, fontFamily: e.sans, fontSize: pt(7.5), fontWeight: 700,
  letterSpacing: pt(0.8), color: col('muted'), align: edge.endsWith('left') ? 'left' : 'right',
  placement: at('container', edge, 0, y), ...extra,
});
const song = (e) => ({ pages: 'body', fontFamily: e.serif, fontWeight: 400, fontSize: pt(8) });
const header = (e) => ({ elements: [
  head(e, 'verso-title', '{title}', 'even', 'top-left', 14, song(e)),
  head(e, 'recto-title', '{chapterTitle}', 'odd', 'top-right', 14, song(e)),
] });
const footer = (e) => ({ elements: [ // folios at the outer foot, openers included
  head(e, 'verso-folio', '{pageNumber}', 'even', 'bottom-left', -12),
  head(e, 'recto-folio', '{pageNumber}', 'odd', 'bottom-right', -12),
] });
// #endregion

let e = EDITIONS.hans; // the edition config() lays out: the build loop below sets it
const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: e.locale, // zh-Hans or zh-Hant, never LANG (gotcha: cjk-locale-tag)
  colorPalette,
  page: { sizePreset: 'custom', width: mm(140), height: mm(203), dpi: 150, // 大32开
    backgroundColor: col('paper'),
    // Minimums: the character grid centres its type area in what they leave, 天头 over 地脚.
    margins: { top: mm(24), bottom: mm(20), left: mm(16), right: mm(20), mirror: true } },
  layout: { layoutType: 'single' },
  cjk: { grid: { enabled: true, charsPerLine: 26, linesPerPage: 24 } },
  bodyText: { fontFamily: e.serif, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true, // 2 characters
    avoidWidows: true, avoidOrphans: true },
  headings: { fontFamily: e.kai, fontWeight: 400, color: col('ink'), levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, fontSize: pt(16), marginBottom: pt(0),
      advancedDesign: opener(e, 6, '第{numberHan}回'), // 第一回, 第二回… in the edition's script
      breakBefore: { enabled: true, parity: 'any' } }, // each 回 on a new page
  ] },
  headingStyles: [{ ...indexHeading, span: 'page', marginBottom: pt(0),
    advancedDesign: opener(e, 7, '{attr.kicker}', indexNote(e)) }],
  index: index(e),
  paragraphStyles: [{ id: 'colophon', fontFamily: e.serif, fontSize: pt(7), lineHeight: pt(10.5),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  header: header(e),
  footer: footer(e),
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Muestra en Markdown · 63 líneas · content.es.mdtitle: "红楼梦人物初见" subtitle: "程乙本前三回选读" --- # 甄士隐梦幻识通灵 \\ 贾雨村风尘怀闺秀 却说那:index[女娲氏]{term="女娲"}炼石补天之时,于大荒山无稽崖炼成高十二丈、见方二十四丈大的顽石三万六千五百零一块。那娲皇只用了三万六千五百块,单单剩下一块未用,弃在青埂峰下。谁知此石自经锻炼之后,灵性已通,自去自来,可大可小。因见众石俱得补天,独自己无才,不得入选,遂自怨自愧,日夜悲哀。 又不知过了几世几劫,因有个:index[空空道人]{term="空空道人" main}访道求仙,从这大荒山无稽崖青埂峰下经过,忽见一块大石,上面字迹分明,编述历历。空空道人乃从头一看,原来是无才补天,幻形入世,被那:index[茫茫大士]{term="茫茫大士" main}:index[渺渺真人]{term="渺渺真人" main}携入红尘,引登彼岸的一块顽石。 按那石头上书云:当日地陷东南,这东南有个姑苏城,城中阊门,最是红尘中一二等富贵风流之地。这阊门外有个十里街,街内有个仁清巷,巷内有个古庙,因地方狭窄,人皆呼作“葫芦庙”。庙旁住着一家乡宦,姓甄,名费:index{term="甄费" see="甄士隐"},字:index[士隐]{term="甄士隐" main},嫡妻:index[封氏]{term="封氏" main}。性情贤淑,深明礼义。家中虽不甚富贵,然本地也推他为望族了。因这甄士隐禀性恬淡,不以功名为念,每日只以观花种竹、酌酒吟诗为乐,倒是神仙一流人物。只是一件不足:年过半百,膝下无儿,只有一女,乳名:index[英莲]{term="英莲" main},年方三岁。 那僧道:“此事说来好笑。只因当年这个石头,娲皇未用,自己却也落得逍遥自在,各处去游玩。……一日,来到:index[警幻仙子]{term="警幻仙子" main}处,那仙子知他有些来历,因留他在赤霞宫中,名他为赤霞宫:index[神瑛侍者]{term="神瑛侍者" main}。他却常在西方灵河岸上行走,看见那灵河岸上三生石畔有棵:index[绛珠仙草]{term="绛珠仙草" main},十分娇娜可爱,遂日以甘露灌溉,‘这绛珠草’始得久延岁月。……” 这士隐正在痴想,忽见隔壁葫芦庙内寄居的一个穷儒——姓贾名化:index{term="贾化" see="贾雨村"},表字时飞,别号:index[雨村]{term="贾雨村" main}的——走来。这贾雨村原系湖州人氏,也是诗书仕宦之族。因他生于末世,父母祖宗根基已尽,人口衰丧,只剩得他一身一口,在家乡无益,因进京求取功名,再整基业。自前岁来此,又淹蹇住了,暂寄庙中安身,每日卖文作字为生,故士隐常与他交接。 真是闲处光阴易过,倏忽又是元宵佳节。士隐令家人:index[霍启]{term="霍启" main}抱了英莲去看社火花灯。半夜中,霍启因要小解,便将英莲放在一家门坎上坐着。待他小解完了来抱时,那有英莲的踪影?急的霍启直寻了半夜,至天明不见,那霍启也不敢回来见主人,便逃往他乡去了。 他岳丈名唤:index[封肃]{term="封肃" main},本贯大如州人氏,虽是务农,家中却还殷实。 # 贾夫人仙逝扬州城 \\ 冷子兴演说荣国府 却说:index[娇杏]{term="娇杏"}那丫头便是当年回顾:index[雨村]{term="贾雨村"}的。因偶然一看,便弄出这段奇缘,也是意想不到之事。谁知他命运两济:不承望自到雨村身边,只一年,便生一子;又半载,雨村嫡配忽染疾下世,雨村便将他扶作正室夫人。正是:“偶因一回顾,便为人上人。” 那日偶又游至维扬地方,闻得今年盐政点的是:index[林如海]{term="林如海" main}。 这林如海,姓林,名海,表字如海,乃是前科的探花,今已升兰台寺大夫。本贯姑苏人氏,今钦点为巡盐御史,到任未久。……只嫡妻:index[贾氏]{term="贾敏"}生得一女,乳名:index[黛玉]{term="林黛玉"},年方五岁,夫妻爱之如掌上明珠。 刚入肆门,只见座上吃酒之客,有一人起身大笑,接了出来,口内说:“奇遇,奇遇!”雨村忙看时,此人是都中古董行中贸易,姓:index[冷号子兴]{term="冷子兴" main}的,旧日在都相识。 子兴叹道:“正说的是这两门呢!待我告诉你:当日:index[宁国公]{term="宁国公"}与:index[荣国公]{term="荣国公"}是一母同胞弟兄两个。宁公居长,生了两个儿子。宁公死后,长子:index[贾代化]{term="贾代化"}袭了官,也养了两个儿子。长子名贾敷,八九岁上死了。只剩了一个次子:index[贾敬]{term="贾敬"},袭了官,如今一味好道,只爱烧丹炼汞,别事一概不管。幸而早年留下一个儿子,名唤:index[贾珍]{term="贾珍"},因他父亲一心想作神仙,把官倒让他袭了。他父亲又不肯住在家里,只在都中城外和那些道士们胡羼。这位珍爷也生了一个儿子,今年才十六岁,名叫:index[贾蓉]{term="贾蓉"}。如今敬老爷不管事了。这珍爷那里干正事?只一味高乐不了,把那宁国府竟翻过来了,也没有敢来管他的人。……自荣公死后,长子:index[贾代善]{term="贾代善"}袭了官,娶的是金陵世勋史侯家的小姐:index{term="贾母"}为妻,生了两个儿子:长名:index[贾赦]{term="贾赦"},次名:index[贾政]{term="贾政"}。如今代善早已去世,太夫人尚在。……这政老爷的夫人:index[王氏]{term="王夫人"},头胎生的公子名叫:index[贾珠]{term="贾珠"},十四岁进学,后来娶了妻,生了子,不到二十岁,一病就死了。第二胎生了一位小姐,生在大年初一,就奇了。不想隔了十几年又生了一位公子:index{term="贾宝玉"},说来更奇:一落胞胎,嘴里便衔下一块五彩晶莹的玉来,还有许多字迹。你道是新闻不是?” 子兴道:“便是贾府中现在三个也不错。政老爷的长女名:index[元春]{term="贾元春"},因贤孝才德选入宫作女史去了。二小姐乃是赦老爷姨娘所出,名:index[迎春]{term="贾迎春"};三小姐,政老爷庶出,名:index[探春]{term="贾探春"};四小姐乃宁府珍爷的胞妹,名:index[惜春]{term="贾惜春"}。因史老夫人极爱孙女,都跟在祖母这边一处读书,听得个个不错。” 若问那赦老爷,也有一子,名叫:index[贾琏]{term="贾琏"},今已二十多岁了,亲上做亲,娶的是政老爷夫人王氏内侄女:index{term="王熙凤"},今已娶了四五年。 # 托内兄如海荐西宾 \\ 接外孙贾母惜孤女 却说雨村忙回头看时,不是别人,乃是当日同僚一案参革的:index[张如圭]{term="张如圭" main}。 :index[黛玉]{term="林黛玉" main}方进房,只见两个人扶着一位鬓发如银的老母迎上来。黛玉知是:index[外祖母]{term="贾母" main},正欲下拜,早被外祖母抱住,搂入怀中,“心肝儿肉”叫着大哭起来。当下侍立之人无不落泪,黛玉也哭个不休。待众人慢慢劝解住了,那黛玉方拜见了外祖母,贾母方一一指与黛玉道:“这是你:index[大舅母]{term="邢夫人" main}。这是:index[二舅母]{term="王夫人" main}。这是你先前珠大哥的媳妇:index[珠大嫂子]{term="李纨" main}。”黛玉一一拜见了。 一语未完,只听后院中有笑语声,说:“我来迟了,没得迎接远客!”黛玉思忖道:“这些人个个皆敛声屏气如此,这来者是谁,这样放诞无礼?……”心下想时,只见一群媳妇丫鬟拥着一个丽人:index{term="王熙凤" main}从后房门进来。……一双丹凤三角眼,两弯柳叶吊梢眉。身量苗条,体格风骚。粉面含春威不露,丹唇未启笑先闻。 黛玉连忙起身接见。贾母笑道:“你不认得他。他是我们这里有名的一个‘泼辣货’,南京所谓‘辣子’你只叫他:index[凤辣子]{term="凤辣子" see="王熙凤"}就是了。”黛玉正不知以何称呼,众姊妹都忙告诉黛玉道:“这是琏二嫂子。”黛玉虽不曾识面,听见他母亲说过:“大舅贾赦之子贾琏娶的就是二舅母王氏的内侄女,自幼假充男儿教养,学名叫做:index[王熙凤]{term="王熙凤"}。”黛玉忙陪笑见礼,以“嫂”呼之。 一语未了,只听外面一阵脚步响,丫鬟进来报道宝玉来了:index{term="贾宝玉" main}。黛玉心想:“这个宝玉不知是怎样个惫懒人呢。……”及至进来一看,却是位青年公子。……面若中秋之月,色如春晓之花,鬓若刀裁,眉如墨画,鼻如悬胆,睛若秋波。虽怒时而似笑,即瞋视而有情。项上金螭缨络,又有一根五色丝绦,系着一块美玉。 黛玉只带了两个人来:一个是自己的奶娘:index[王嬷嬷]{term="王嬷嬷" main},一个是十岁的小丫头,名唤:index[雪雁]{term="雪雁" main}。贾母见雪雁甚小,一团孩气,王嬷嬷又极老,料黛玉皆不遂心,将自己身边一个二等小丫头,名唤:index[鹦哥]{term="鹦哥" main}的,与了黛玉。 当下王嬷嬷与鹦哥陪侍黛玉在碧纱橱内;宝玉乳母:index[李嬷嬷]{term="李嬷嬷" main}并大丫头名唤:index[袭人]{term="袭人" main}的陪侍在外面大床上。 原来这袭人亦是贾母之婢,本名蕊珠:index{term="蕊珠" see="袭人"}。贾母因溺爱宝玉,恐宝玉之婢不中使,素喜蕊珠心地纯良,遂与宝玉。宝玉因知他本姓花,又曾见旧人诗句有“花气袭人”之句,遂回明贾母,即把蕊珠更名袭人。 黛玉虽不知原委,探春等却晓得是议论金陵城中居住的薛家姨母之子——表兄:index[薛蟠]{term="薛蟠"},倚财仗势打死人命,现在应天府案下审理。如今舅舅:index[王子腾]{term="王子腾"}得了信,遣人来告诉这边,意欲唤取进京之意。 # 人名索引 {style="index" kicker="按汉语拼音排列" note="本索引收录前三回选文中的人物。\n粗体页码为人物出场之页。\n别名与原名另立参见条目。"} :::index :::paragraphs{style="colophon"} Sueño en el pabellón rojo, capítulos 1 a 3, edición en caracteres simplificados: los pasajes en que aparecen los personajes, según el texto de Cheng y Gao de 1792 (zh.wikisource, revisión 9685985, CC BY-SA 4.0), convertido a caracteres simplificados con OpenCC, con —— donde el original pone --. Los cortes dentro de un párrafo se marcan con ……. Compuesto en Noto Serif SC, Noto Sans SC y Ma Shan Zheng (SIL OFL). :::
`; // the Simplified edition, content.<lang>.md const traditional = String.raw`---
Muestra en Markdown · 63 líneas · content.hant.es.mdtitle: "紅樓夢人物初見" subtitle: "程乙本前三回選讀" --- # 甄士隱夢幻識通靈 \\ 賈雨村風塵懷閨秀 卻說那:index[女媧氏]{term="女媧"}煉石補天之時,於大荒山無稽崖煉成高十二丈、見方二十四丈大的頑石三萬六千五百零一塊。那媧皇只用了三萬六千五百塊,單單剩下一塊未用,棄在青埂峰下。誰知此石自經鍛鍊之後,靈性已通,自去自來,可大可小。因見眾石俱得補天,獨自己無才,不得入選,遂自怨自愧,日夜悲哀。 又不知過了幾世幾劫,因有個:index[空空道人]{term="空空道人" main}訪道求仙,從這大荒山無稽崖青埂峰下經過,忽見一塊大石,上面字跡分明,編述歷歷。空空道人乃從頭一看,原來是無才補天,幻形入世,被那:index[茫茫大士]{term="茫茫大士" main}:index[渺渺真人]{term="渺渺真人" main}攜入紅塵,引登彼岸的一塊頑石。 按那石頭上書云:當日地陷東南,這東南有個姑蘇城,城中閶門,最是紅塵中一二等富貴風流之地。這閶門外有個十里街,街內有個仁清巷,巷內有個古廟,因地方狹窄,人皆呼作「葫蘆廟」。廟旁住著一家鄉宦,姓甄,名費:index{term="甄費" see="甄士隱"},字:index[士隱]{term="甄士隱" main},嫡妻:index[封氏]{term="封氏" main}。性情賢淑,深明禮義。家中雖不甚富貴,然本地也推他為望族了。因這甄士隱稟性恬淡,不以功名為念,每日只以觀花種竹、酌酒吟詩為樂,倒是神仙一流人物。只是一件不足:年過半百,膝下無兒,只有一女,乳名:index[英蓮]{term="英蓮" main},年方三歲。 那僧道:「此事說來好笑。只因當年這個石頭,媧皇未用,自己卻也落得逍遙自在,各處去遊玩。……一日,來到:index[警幻仙子]{term="警幻仙子" main}處,那仙子知他有些來歷,因留他在赤霞宮中,名他為赤霞宮:index[神瑛侍者]{term="神瑛侍者" main}。他卻常在西方靈河岸上行走,看見那靈河岸上三生石畔有棵:index[絳珠仙草]{term="絳珠仙草" main},十分嬌娜可愛,遂日以甘露灌溉,『這絳珠草』始得久延歲月。……」 這士隱正在痴想,忽見隔壁葫蘆廟內寄居的一個窮儒——姓賈名化:index{term="賈化" see="賈雨村"},表字時飛,別號:index[雨村]{term="賈雨村" main}的——走來。這賈雨村原係湖州人氏,也是詩書仕宦之族。因他生於末世,父母祖宗根基已盡,人口衰喪,只剩得他一身一口,在家鄉無益,因進京求取功名,再整基業。自前歲來此,又淹蹇住了,暫寄廟中安身,每日賣文作字為生,故士隱常與他交接。 真是閒處光陰易過,倏忽又是元宵佳節。士隱令家人:index[霍啟]{term="霍啟" main}抱了英蓮去看社火花燈。半夜中,霍啟因要小解,便將英蓮放在一家門坎上坐著。待他小解完了來抱時,那有英蓮的蹤影?急的霍啟直尋了半夜,至天明不見,那霍啟也不敢回來見主人,便逃往他鄉去了。 他岳丈名喚:index[封肅]{term="封肅" main},本貫大如州人氏,雖是務農,家中卻還殷實。 # 賈夫人仙逝揚州城 \\ 冷子興演說榮國府 卻說:index[嬌杏]{term="嬌杏"}那丫頭便是當年回顧:index[雨村]{term="賈雨村"}的。因偶然一看,便弄出這段奇緣,也是意想不到之事。誰知他命運兩濟:不承望自到雨村身邊,只一年,便生一子;又半載,雨村嫡配忽染疾下世,雨村便將他扶作正室夫人。正是:「偶因一回顧,便為人上人。」 那日偶又遊至維揚地方,聞得今年鹽政點的是:index[林如海]{term="林如海" main}。 這林如海,姓林,名海,表字如海,乃是前科的探花,今已升蘭臺寺大夫。本貫姑蘇人氏,今欽點為巡鹽御史,到任未久。……只嫡妻:index[賈氏]{term="賈敏"}生得一女,乳名:index[黛玉]{term="林黛玉"},年方五歲,夫妻愛之如掌上明珠。 剛入肆門,只見座上吃酒之客,有一人起身大笑,接了出來,口內說:「奇遇,奇遇!」雨村忙看時,此人是都中古董行中貿易,姓:index[冷號子興]{term="冷子興" main}的,舊日在都相識。 子興嘆道:「正說的是這兩門呢!待我告訴你:當日:index[寧國公]{term="寧國公"}與:index[榮國公]{term="榮國公"}是一母同胞弟兄兩個。寧公居長,生了兩個兒子。寧公死後,長子:index[賈代化]{term="賈代化"}襲了官,也養了兩個兒子。長子名賈敷,八九歲上死了。只剩了一個次子:index[賈敬]{term="賈敬"},襲了官,如今一味好道,只愛燒丹鍊汞,別事一概不管。幸而早年留下一個兒子,名喚:index[賈珍]{term="賈珍"},因他父親一心想作神仙,把官倒讓他襲了。他父親又不肯住在家裡,只在都中城外和那些道士們胡羼。這位珍爺也生了一個兒子,今年才十六歲,名叫:index[賈蓉]{term="賈蓉"}。如今敬老爺不管事了。這珍爺那裡幹正事?只一味高樂不了,把那寧國府竟翻過來了,也沒有敢來管他的人。……自榮公死後,長子:index[賈代善]{term="賈代善"}襲了官,娶的是金陵世勳史侯家的小姐:index{term="賈母"}為妻,生了兩個兒子:長名:index[賈赦]{term="賈赦"},次名:index[賈政]{term="賈政"}。如今代善早已去世,太夫人尚在。……這政老爺的夫人:index[王氏]{term="王夫人"},頭胎生的公子名叫:index[賈珠]{term="賈珠"},十四歲進學,後來娶了妻,生了子,不到二十歲,一病就死了。第二胎生了一位小姐,生在大年初一,就奇了。不想隔了十幾年又生了一位公子:index{term="賈寶玉"},說來更奇:一落胞胎,嘴裡便銜下一塊五彩晶瑩的玉來,還有許多字跡。你道是新聞不是?」 子興道:「便是賈府中現在三個也不錯。政老爺的長女名:index[元春]{term="賈元春"},因賢孝才德選入宮作女史去了。二小姐乃是赦老爺姨娘所出,名:index[迎春]{term="賈迎春"};三小姐,政老爺庶出,名:index[探春]{term="賈探春"};四小姐乃寧府珍爺的胞妹,名:index[惜春]{term="賈惜春"}。因史老夫人極愛孫女,都跟在祖母這邊一處讀書,聽得個個不錯。」 若問那赦老爺,也有一子,名叫:index[賈璉]{term="賈璉"},今已二十多歲了,親上做親,娶的是政老爺夫人王氏內侄女:index{term="王熙鳳"},今已娶了四五年。 # 託內兄如海薦西賓 \\ 接外孫賈母惜孤女 卻說雨村忙回頭看時,不是別人,乃是當日同僚一案參革的:index[張如圭]{term="張如圭" main}。 :index[黛玉]{term="林黛玉" main}方進房,只見兩個人扶著一位鬢髮如銀的老母迎上來。黛玉知是:index[外祖母]{term="賈母" main},正欲下拜,早被外祖母抱住,摟入懷中,「心肝兒肉」叫著大哭起來。當下侍立之人無不落淚,黛玉也哭個不休。待眾人慢慢勸解住了,那黛玉方拜見了外祖母,賈母方一一指與黛玉道:「這是你:index[大舅母]{term="邢夫人" main}。這是:index[二舅母]{term="王夫人" main}。這是你先前珠大哥的媳婦:index[珠大嫂子]{term="李紈" main}。」黛玉一一拜見了。 一語未完,只聽後院中有笑語聲,說:「我來遲了,沒得迎接遠客!」黛玉思忖道:「這些人個個皆斂聲屏氣如此,這來者是誰,這樣放誕無禮?……」心下想時,只見一群媳婦丫鬟擁著一個麗人:index{term="王熙鳳" main}從後房門進來。……一雙丹鳳三角眼,兩彎柳葉吊梢眉。身量苗條,體格風騷。粉面含春威不露,丹脣未啟笑先聞。 黛玉連忙起身接見。賈母笑道:「你不認得他。他是我們這裡有名的一個『潑辣貨』,南京所謂『辣子』你只叫他:index[鳳辣子]{term="鳳辣子" see="王熙鳳"}就是了。」黛玉正不知以何稱呼,眾姊妹都忙告訴黛玉道:「這是璉二嫂子。」黛玉雖不曾識面,聽見他母親說過:「大舅賈赦之子賈璉娶的就是二舅母王氏的內侄女,自幼假充男兒教養,學名叫做:index[王熙鳳]{term="王熙鳳"}。」黛玉忙陪笑見禮,以「嫂」呼之。 一語未了,只聽外面一陣腳步響,丫鬟進來報道寶玉來了:index{term="賈寶玉" main}。黛玉心想:「這個寶玉不知是怎樣個憊懶人呢。……」及至進來一看,卻是位青年公子。……面若中秋之月,色如春曉之花,鬢若刀裁,眉如墨畫,鼻如懸膽,睛若秋波。雖怒時而似笑,即瞋視而有情。項上金螭纓絡,又有一根五色絲絛,繫著一塊美玉。 黛玉只帶了兩個人來:一個是自己的奶孃:index[王嬤嬤]{term="王嬤嬤" main},一個是十歲的小丫頭,名喚:index[雪雁]{term="雪雁" main}。賈母見雪雁甚小,一團孩氣,王嬤嬤又極老,料黛玉皆不遂心,將自己身邊一個二等小丫頭,名喚:index[鸚哥]{term="鸚哥" main}的,與了黛玉。 當下王嬤嬤與鸚哥陪侍黛玉在碧紗櫥內;寶玉乳母:index[李嬤嬤]{term="李嬤嬤" main}並大丫頭名喚:index[襲人]{term="襲人" main}的陪侍在外面大床上。 原來這襲人亦是賈母之婢,本名蕊珠:index{term="蕊珠" see="襲人"}。賈母因溺愛寶玉,恐寶玉之婢不中使,素喜蕊珠心地純良,遂與寶玉。寶玉因知他本姓花,又曾見舊人詩句有「花氣襲人」之句,遂回明賈母,即把蕊珠更名襲人。 黛玉雖不知原委,探春等卻曉得是議論金陵城中居住的薛家姨母之子——表兄:index[薛蟠]{term="薛蟠"},倚財仗勢打死人命,現在應天府案下審理。如今舅舅:index[王子騰]{term="王子騰"}得了信,遣人來告訴這邊,意欲喚取進京之意。 # 人名索引 {style="index" kicker="依筆畫排列" note="本索引收錄前三回選文中的人物。\n粗體頁碼為人物出場之頁。\n別名與原名另立參見條目。"} :::index :::paragraphs{style="colophon"} Sueño en el pabellón rojo, capítulos 1 a 3, edición en caracteres tradicionales: los pasajes en que aparecen los personajes, según el texto de Cheng y Gao de 1792 (zh.wikisource, revisión 9685985, CC BY-SA 4.0), con 原⁠係 donde el original pone 原⁠系 y —— donde pone --. Los cortes dentro de un párrafo se marcan con ……. Compuesto en Noto Serif TC, Noto Sans TC y LXGW WenKai TC (SIL OFL). :::
`; // the Traditional edition, content.hant.<lang>.md // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses (gotcha: fonts-first). The CJK families load by slices. const FONTS = { 'Noto Serif SC': ['400', '700'], 'Noto Sans SC': ['700'], 'Ma Shan Zheng': ['400'], 'Noto Serif TC': ['400', '700'], 'Noto Sans TC': ['700'], 'LXGW WenKai TC': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown + traditional); // #region voices: each face loads the files of the characters it sets (gotcha: cjk-fonts-slices) const DIGITS = '0123456789'; const HEADS = '第回一二三四五六七八九十画畫ABCDEFGHIJKLMNOPQRSTUVWXYZ'; // 第一回, the index heads const all = (md, re) => [...md.matchAll(re)].map((m) => m[1]).join('\n'); for (const [key, md] of [['hans', markdown], ['hant', traditional]]) { e = EDITIONS[key]; await loadCjkFonts({ [e.serif]: ['400'] }, md); // the text, the index, the running heads await loadCjkFonts({ [e.serif]: ['700'] }, DIGITS); // the bold page numbers await loadCjkFonts({ [e.sans]: ['700'] }, all(md, /kicker="([^"]*)"/g) + HEADS + DIGITS); await loadCjkFonts({ [e.kai]: ['400'] }, all(md, /^# ([^{\n]*)/gm)); // the titles alone } // #endregion const docs = {}; for (const [key, md] of [['hans', markdown], ['hant', traditional]]) { e = EDITIONS[key]; docs[key] = await buildWithFonts(() => buildDocument({ markdown: md }, config()), md); } const title = t({ en: 'A Chinese name index', es: 'Un índice de nombres chinos' }); // The desk shows either edition, and the PDF is built from the one on it. const switches = ['hans', 'hant'].map((key) => Object.assign(document.createElement('button'), { type: 'button', value: key, textContent: key === 'hans' ? '简体版' : '繁體版' })); const show = (key) => { showPages(docs[key], { title }); for (const b of switches) b.ariaPressed = String(b.value === key); // the edition on the desk document.querySelectorAll('#pt-actions a, [data-postext-pdf]').forEach((old) => old.remove()); offerPdf(() => renderToPdf(docs[key], { fontProvider: cjkPdfProvider }), `${RECIPE}-${key}.pdf`); }; for (const b of switches) b.addEventListener('click', () => show(b.value)); show('hans'); document.getElementById('pt-actions').prepend(...switches); document.head.insertAdjacentHTML('beforeend', '<style>#pt-actions [aria-pressed=true] { text-decoration: underline }</style>'); // #region side-by-side: the first index page of each edition, above the desk const indexPage = (doc) => doc.pages[doc.blocks.find((b) => b.headingStyleId === 'index') ?.pageIndex ?? doc.pages.length - 1]; const labels = { hans: t({ en: 'Simplified · by pinyin', es: 'Simplificado · por pinyin' }), hant: t({ en: 'Traditional · by strokes', es: 'Tradicional · por trazos' }) }; document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="indexes" style=" display:flex;flex-wrap:wrap;justify-content:center;gap:24px;padding:28px 16px;background:#e9e4da"> ${Object.keys(docs).map((key) => `<figure style="margin:0;width:min(340px,44vw)"> <canvas id="index-${key}" role="img" style="display:block;width:100%;background:#fff; box-shadow:0 18px 36px -18px rgb(0 0 0 / .45)"></canvas><figcaption style="margin-top:10px; font:600 11px/1 system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;color:#5b534a"> ${labels[key]}</figcaption></figure>`).join('')}</section>`); for (const [key, doc] of Object.entries(docs)) { const canvas = document.getElementById(`index-${key}`); const page = indexPage(doc); canvas.setAttribute('aria-label', `${labels[key]}, ${page.pageLabel}`); renderPageToCanvas(page, doc, canvas, { scale: (2 * canvas.clientWidth) / page.width }); } // #endregion
Kit · core, fonts, viewer, pdf, cjk: igual en todas las recetas · 422 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 goes to fontsourceProvider (the "pdf" block). */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return fontsourceProvider(family, weight, style); 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())); })); } /** 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 ↗

Variantes

#Ordena por pinyin un índice en caracteres tradicionales

Una edición tradicional para lectores que buscan los nombres por su lectura puede ordenarlos por pinyin. Pon groupBy: 'pinyin' en las dos ediciones: las letras de grupo son A–Z y las entradas conservan los caracteres tradicionales. El antetítulo dice cómo se ordena el índice, así que cámbialo también en el Markdown tradicional, a kicker="依漢語拼音排列".

-  groupBy: e.locale === 'zh-Hans' ? 'pinyin' : 'stroke', // A B C… or 三畫 四畫…
+  groupBy: 'pinyin', // A B C… in both scripts

#Imprime los nombres sin letras de grupo

Una lista corta de nombres se lee bien con una línea en blanco entre grupos y ninguna letra encima. Conserva los grupos y quita las letras. groupBy: 'none' no sirve para esto: solo separa símbolos, números y palabras, y aquí todos los nombres empiezan por un carácter chino, así que los 47 irían en un solo bloque.

-  groups: { fontFamily: e.sans, fontSize: pt(9.5), fontWeight: 700, color: col('cinnabar'),
-    marginTop: pt(7) },
+  groups: { enabled: false, marginTop: pt(14) }, // no heads; a blank line between groups

Errores frecuentes

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

El chino no tiene cursiva

Las familias CJK no traen cursiva, y la tipografía china marca el énfasis con puntos junto a los caracteres, no inclinándolos. En un documento etiquetado como chino, *…* pone puntos de énfasis en los caracteres chinos que abarca y deja la cursiva para las palabras latinas (cjk.emphasis: 'dots', lo predeterminado en chino); :dots[…] los pone en cualquier sitio. Con cjk.emphasis: 'italic', o en un documento etiquetado en o es, *…* hace que el canvas incline los glifos redondos, una oblicua sintética que la tipografía china nunca usa (el PDF recurre a la redonda): conserva la etiqueta china, o marca el énfasis en negrita o en una fuente Kai (LXGW WenKai) con un estilo de párrafo o de chip. Fuentes chinas, japonesas y coreanas →

Error frecuente

Etiqueta el documento zh-Hans o zh-Hant, no con LANG

Las ediciones de una receta son en y es, pero una muestra china es china en las dos: `locale: LANG` la etiquetaría como inglés o español, separaría sus palabras latinas, llamaría Figure o Figura a sus figuras y daría al PDF un idioma equivocado. Escribe tú la etiqueta: 'zh-Hans' (convenciones de la China continental: corte GB, puntuación Kaiming) o 'zh-Hant' (Taiwán: puntuación de ancho completo centrada); 'zh-HK' para Hong Kong. Un 'zh' a secas se lee como chino simplificado continental. Corte de líneas en chino →

Error frecuente

Compón la puntuación de cada región con una fuente de esa región

Los anchos de la puntuación mueven el blanco de cada signo al lado donde lo pone la región del documento, no al lado donde lo dibuja la fuente: con zh-Hans, una coma Kaiming conserva la mitad izquierda de su caja, donde una fuente simplificada dibuja el signo, y cede la derecha. Una fuente tradicional centra ,。 en la caja, así que la mitad que se va se lleva parte del signo y la coma se pega al carácter siguiente. LXGW WenKai TC, la única Kai de texto de Fontsource, lo hace en texto simplificado. Compón el texto, las notas y las citas continentales en Noto Serif SC o Noto Sans SC, deja las fuentes TC para zh-Hant y usa una letra de pincel simplificada (Ma Shan Zheng) solo en líneas de exhibición, donde el texto de diseño no aplica anchos de puntuación. Además dibuja formas heredadas que no imprime ni el estándar continental ni el de Taiwán, 為 con el 爫 de 爲 y 令 (en 冷, 領) con el pie de 卩: revisa los caracteres que compone en cada página. Anchura de la puntuación china →

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

Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones

postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer. Paleta de color semántica →

Error frecuente

Una configuración se cachea por identidad: crea un objeto nuevo

El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config(). Páginas en un canvas →

  • La configuración index es del documento, así que un documento imprime un solo orden. Un libro que quiera un índice por pinyin y otro por trazos se compone dos veces, como hace este código.

Créditos

Texto
  • Sueño en el pabellón rojo (紅樓夢), capítulos 1 a 3, pasajes en que aparecen los personajes, en la edición de Cheng y Gao de 1792 (程乙本); transcripción de Wikisource, revisión 9685985. La edición tradicional lee 原係 donde el original dice 原系; las dos componen su -- como ——; la edición simplificada, convertida con OpenCC · Cao Xueqin, Cheng Weiyuan and Gao E; transcription by Wikisource editors · CC BY-SA 4.0
Fuentes
Noto Serif SC (SIL OFL 1.1) · Noto Sans SC (SIL OFL 1.1) · Ma Shan Zheng (SIL OFL 1.1) · Noto Serif TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1)
SandboxPDF