Saltar al contenido principal
Receta número 76

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

Una cartilla con pinyin: la lectura sobre cada carácter

Ruby carácter a carácter en una cartilla de Hong Kong: {人之初|rén zhī chū} centra una sílaba de pinyin sobre cada carácter, en Andika, en 28 pt de interlínea.

En esta página

pp. 36–37 · 1–2 de 4

  • Formato 184 × 260 mm
  • 1 columna
  • LXGW WenKai TC 26/54
  • Andika
  • Noto Sans TC
  • 4 páginas
  • Nivel
  • Postext 1.9.0
  • Compuesto en 16 ms
  • 180 líneas de código

Lo que vas a componer

Dos pliegos de 《蒙學誦讀》, una cartilla inventada de Hong Kong, donde los niños leen en voz alta el Clásico de los tres caracteres en mandarín (putonghua). Cada página es una lección de cuatro pareados en kai de 一号 (26 pt), y cada carácter va bajo su sílaba de pinyin. Las sílabas van en Andika porque dibuja la a y la g de un solo piso que imprimen los libros escolares chinos. Una banda verde pálido lleva la etiqueta roja de la lección, un dibujo y el título en 初号 (42 pt) con su propia lectura. Debajo del texto, seis cuadrículas de escritura (田字格) guardan los caracteres para copiar, y una nota cuenta a la familia qué dicen los versos. Su pareja en español es la cartilla de sílabas en chips, donde cada sílaba es un chip de color en lugar de una lectura sobre un carácter.

Una cartilla se aparta a propósito de las normas del libro chino. Un niño lee pocos caracteres y grandes, así que la línea lleva doce de 26 pt donde un libro pone entre 25 y 40 de 10,5 pt, y la interlínea pasa un poco del doble del cuerpo para que quepan las lecturas. Cada pareado va centrado en su propia línea, sin justificar ni sangrar. El texto va en kai, la letra que sigue al pincel, porque enseña los trazos que el niño aprende a escribir; un libro lo compondría en song.

Esta receta responde a

  • ¿Cómo pongo pinyin sobre los caracteres chinos?

La respuesta corta

script.js · líneas 36–56en el código completo
// {人之初|rén zhī chū} gives each character its own syllable (mono ruby): three readings for
// three characters, split on the spaces. The reading sits in the line gap, centred on its
// character; a syllable wider than the character widens that character's box by what the
// reading needs, less the quarter of the reading's size it may lend a neighbour.
const cjk = {
  // The type area in characters: 12 per line, 11 lines of 54 pt. The margins grow to centre it.
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11 },
  ruby: {
    fontFamily: PINYIN, // one-storey a and g, as a Chinese primer prints them
    // 9.9 pt over the text, 16 pt over the title: the widest syllables (xiāng, zhuān) still fit
    // over one character, so every couplet is 8 em long and keeps to the grid.
    fontSize: em(0.38),
    color: col('pinyin'),
  },
};
// The line pitch never changes for a reading: the gap between lines (54 − 26 = 28 pt) must
// hold it, or the build warns rubyExceedsLeading.
const text = {
  fontFamily: KAI, fontSize: pt(TEXT), lineHeight: pt(LINE),
  textAlign: 'center', firstLineIndent: pt(0), // one couplet to a line, centred
};

Ingredientes

Tipografía
LXGW WenKai TC, Noto Sans TC, Andika (SIL OFL 1.1)
Recursos
  • Las cuadrículas de escritura y los cuatro dibujos de las lecciones, dibujados en código con la paleta de la página (Postext Cookbook, CC BY 4.0)

Elaboración

#1 · Una sílaba sobre cada carácter

El código es la respuesta corta de arriba. {人之初|rén zhī chū} da tres lecturas a tres caracteres, separadas por los espacios: cada sílaba se centra sobre su carácter, y la línea puede cortarse entre ellos. La lectura no ocupa sitio propio. Va en el hueco entre líneas, aquí 54 − 26 = 28 pt, y un hueco más estrecho que la lectura se avisa como rubyExceedsLeading. Una sílaba más ancha que su carácter sí ocupa sitio: ensancha la caja de ese carácter, y en una línea centrada los caracteres que la siguen se salen de la retícula. Las lecturas llevan el cuerpo con el que las sílabas más anchas de la muestra, xiāng y zhuān, aún caben sobre un carácter, 0,38 em (9,9 pt): cada pareado mide ocho caracteres y cada carácter conserva su casilla. Con 0,45 em, 性相近,習相遠 se estiraría hasta ocho caracteres y medio y abriría huecos alrededor de 相.

#2 · El título conserva su lectura

script.js · líneas 60–77en el código completo
const BAND = 68; // mm from the top edge
// Heading designs ignore parity: a place for the drawing on each side, 14 mm from the outer
// edge, named {left="…"} on a verso and {right="…"} on a recto. A missing attribute draws nothing.
const picture = (side, x) => ({ kind: 'image', id: `picture-${side}`,
  resourceId: `{attr.${side}}`, decorative: true, reserve: false,
  placement: { anchor: { to: 'page', edge: `top-${side}` }, offset: { x: mm(x), y: mm(14) },
    size: { width: mm(42), height: mm(42) } } });
const opener = { enabled: true, slot: { elements: [
  { kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('tint') },
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: mm(184), height: mm(BAND) } } },
  picture('left', 14), picture('right', -14),
  { kind: 'text', id: 'lesson', content: '{titleText}', fontFamily: HEI, fontSize: pt(11),
    fontWeight: 700, letterSpacing: pt(2), color: col('paper'), align: 'center', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top' } },
    box: { backgroundColor: col('red'), borderRadius: mm(3.5),
      padding: { top: mm(1.2), right: mm(3.6), bottom: mm(1.2), left: mm(3.6) } } },
] } };

El texto de un diseño no imprime lecturas, así que el título no puede salir del diseño de la apertura. El título de primer nivel, 第一課, dibuja la banda, el dibujo y la etiqueta roja; el de la lección es el título de segundo nivel que va debajo, sin diseño propio, en 初号, y el compositor de texto lo compone con su pinyin. reserve: false deja la banda y el dibujo fuera de la altura del título, y span: 'page' los pinta debajo del texto.

Un diseño de título no atiende a parity, así que el dibujo tiene un elemento a cada lado de la página: {attr.left} a 14 mm del borde izquierdo y {attr.right} a 14 mm del derecho. Las lecciones de página par escriben # 第一課 {left="sprout"}, y las de impar, {right="shuttle"}, de modo que cada dibujo queda en el lado exterior del pliego; el elemento cuyo atributo falta no dibuja nada.

#3 · Seis cuadrículas con un solo atributo

script.js · líneas 81–101en el código completo
// Design text prints no readings, so the squares hold the characters alone. A Han character
// is one em wide, so tracking of (pitch − em) sets one in the middle of each square.
const [SQ, GAP, WRITE] = [15, 3.4, 30]; // mm, mm, pt
const ROW = 6 * SQ + 5 * GAP; // mm
const X0 = (MEASURE - ROW) / 2; // the row is centred on the measure
const EM = WRITE * 25.4 / 72; // mm: one character at 30 pt, 10.6 mm wide
const squares = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'label', content: '{titleText}', fontFamily: HEI, fontSize: pt(10),
    fontWeight: 700, letterSpacing: pt(1.5), color: col('red'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(X0) } } },
  ...Array.from({ length: 6 }, (_, k) => ({ kind: 'image', id: `square${k}`, resourceId: 'tian',
    decorative: true, placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(X0 + k * (SQ + GAP)), y: mm(7) },
      size: { width: mm(SQ), height: mm(SQ) } } })),
  { kind: 'text', id: 'chars', content: '{attr.write}', fontFamily: KAI, fontSize: pt(WRITE),
    lineHeight: 1, letterSpacing: mm(SQ + GAP - EM), color: col('ink'), align: 'left',
    verticalAlign: 'middle', overflow: 'clip',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(X0 + (SQ - EM) / 2), y: mm(7) },
      size: { width: mm(ROW + GAP), height: mm(SQ) } } },
] } };

### 我會寫 {write="人之本不相以"} pasa al diseño los seis caracteres en un atributo. En LXGW WenKai TC cada carácter chino mide un cuadratín, así que un espaciado igual al paso de las cuadrículas menos un cuadratín (18,4 − 10,6 mm) lleva cada carácter a su cuadrícula, y un solo elemento de texto llena la fila. La cuadrícula es un SVG dibujado en código: un marco rojo y una cruz de trazos.

#4 · Cada fuente carga sus propios caracteres

script.js · líneas 331–338en el código completo
// Fontsource cuts a Chinese face into about a hundred files by character range
// (gotcha: cjk-fonts-slices). The Kai sets the whole sample; the Hei only the headings'
// labels and the footer's series line, so it fetches a few files.
const labels = (markdown.match(/^#{1,3} [^{\n]*/gm) ?? []).join('') + '蒙學誦讀 第一冊';
await loadFonts(FONTS, markdown); // the latin files, and Andika's latin-ext for ǎ ǐ ǒ ǔ
await Promise.all([loadCjkFonts({ [KAI]: FONTS[KAI] }, markdown),
  loadCjkFonts({ [HEI]: FONTS[HEI] }, labels),
  ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))]);

Fontsource corta cada fuente china en un centenar de archivos por intervalos de caracteres. La kai compone toda la muestra y carga los archivos que contienen sus caracteres. La hei solo compone las etiquetas, los rótulos y el pie, así que recibe ese texto y descarga unos pocos archivos. Andika llega con loadFonts, que añade el archivo latin-ext cuando el texto tiene letras más allá de Latin-1, como ǎ y ǐ.

#5 · La etiqueta de idioma trae la puntuación de Hong Kong

script.js · líneas 121–123en el código completo
  // Punctuation at full width, where LXGW WenKai TC centres it as Hong Kong and Taiwan print
  // it, and the basic line-breaking rules. Written out, never LANG (gotcha: cjk-locale-tag).
  locale: 'zh-HK',

zh-HK fija las reglas de Hong Kong: puntuación de ancho completo y el juego básico de reglas de corte, que aquí ningún pareado necesita. LXGW WenKai TC dibuja la coma y el punto en el centro de la casilla, como los imprimen Hong Kong y Taiwán, y por eso esta cartilla es de Hong Kong. Una cartilla continental también va en kai, en caracteres simplificados y con la coma y el punto abajo a la izquierda de la casilla. Pero Fontsource no tiene una kai de texto para el chino simplificado, y la tradicional pegaría esos signos al carácter siguiente, así que en este Recetario una página continental toma Noto Serif SC, la song de la página de novela continental.

La receta completa

Sandbox
// ═══ Postext Cookbook · Nº 076 · A pinyin primer: readings over every character ═══
// https://postext.dev/en/cookbook/pinyin-primer
// Code: MIT · Text: 三字經 (PD); pinyin, notes, drawings: original (CC BY 4.0)
// Fonts: LXGW WenKai TC, Noto Sans TC, Andika (SIL OFL 1.1) · Needs postext ≥ 1.9.0
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a primer's colours, every one linked by id
const palette = {
  ink: '#29241f', // the characters: a warm near-black
  pinyin: '#355a4d', // the readings, a shade off the ink so the two layers part
  red: '#bf3a2b', // lesson badges and the writing squares
  jade: '#2f7a5e', // folios and drawings
  tint: '#edf4ea', // the band behind each lesson's title
  cream: '#faf3e4', // the note for families
  muted: '#6d665e', // series line, colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the red.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.red })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const [KAI, HEI, PINYIN] = ['LXGW WenKai TC', 'Noto Sans TC', 'Andika'];
const TEXT = 26; // pt: 一号, the size of a first reader's text
const LINE = 54; // pt: 2.1 × the size, so a reading fits between two lines
const CHARS = 12; // characters per line: the measure is 12 × 26 pt = 110 mm
const MEASURE = CHARS * TEXT * 25.4 / 72; // mm

// #region answer: one reading per character, in Andika, in a line gap wide enough to hold it
// {人之初|rén zhī chū} gives each character its own syllable (mono ruby): three readings for
// three characters, split on the spaces. The reading sits in the line gap, centred on its
// character; a syllable wider than the character widens that character's box by what the
// reading needs, less the quarter of the reading's size it may lend a neighbour.
const cjk = {
  // The type area in characters: 12 per line, 11 lines of 54 pt. The margins grow to centre it.
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11 },
  ruby: {
    fontFamily: PINYIN, // one-storey a and g, as a Chinese primer prints them
    // 9.9 pt over the text, 16 pt over the title: the widest syllables (xiāng, zhuān) still fit
    // over one character, so every couplet is 8 em long and keeps to the grid.
    fontSize: em(0.38),
    color: col('pinyin'),
  },
};
// The line pitch never changes for a reading: the gap between lines (54 − 26 = 28 pt) must
// hold it, or the build warns rubyExceedsLeading.
const text = {
  fontFamily: KAI, fontSize: pt(TEXT), lineHeight: pt(LINE),
  textAlign: 'center', firstLineIndent: pt(0), // one couplet to a line, centred
};
// #endregion

// #region opener: a tinted band with the lesson's badge and its drawing
const BAND = 68; // mm from the top edge
// Heading designs ignore parity: a place for the drawing on each side, 14 mm from the outer
// edge, named {left="…"} on a verso and {right="…"} on a recto. A missing attribute draws nothing.
const picture = (side, x) => ({ kind: 'image', id: `picture-${side}`,
  resourceId: `{attr.${side}}`, decorative: true, reserve: false,
  placement: { anchor: { to: 'page', edge: `top-${side}` }, offset: { x: mm(x), y: mm(14) },
    size: { width: mm(42), height: mm(42) } } });
const opener = { enabled: true, slot: { elements: [
  { kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('tint') },
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: mm(184), height: mm(BAND) } } },
  picture('left', 14), picture('right', -14),
  { kind: 'text', id: 'lesson', content: '{titleText}', fontFamily: HEI, fontSize: pt(11),
    fontWeight: 700, letterSpacing: pt(2), color: col('paper'), align: 'center', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top' } },
    box: { backgroundColor: col('red'), borderRadius: mm(3.5),
      padding: { top: mm(1.2), right: mm(3.6), bottom: mm(1.2), left: mm(3.6) } } },
] } };
// #endregion

// #region squares: six writing squares (田字格) with the lesson's characters to copy
// Design text prints no readings, so the squares hold the characters alone. A Han character
// is one em wide, so tracking of (pitch − em) sets one in the middle of each square.
const [SQ, GAP, WRITE] = [15, 3.4, 30]; // mm, mm, pt
const ROW = 6 * SQ + 5 * GAP; // mm
const X0 = (MEASURE - ROW) / 2; // the row is centred on the measure
const EM = WRITE * 25.4 / 72; // mm: one character at 30 pt, 10.6 mm wide
const squares = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'label', content: '{titleText}', fontFamily: HEI, fontSize: pt(10),
    fontWeight: 700, letterSpacing: pt(1.5), color: col('red'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(X0) } } },
  ...Array.from({ length: 6 }, (_, k) => ({ kind: 'image', id: `square${k}`, resourceId: 'tian',
    decorative: true, placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(X0 + k * (SQ + GAP)), y: mm(7) },
      size: { width: mm(SQ), height: mm(SQ) } } })),
  { kind: 'text', id: 'chars', content: '{attr.write}', fontFamily: KAI, fontSize: pt(WRITE),
    lineHeight: 1, letterSpacing: mm(SQ + GAP - EM), color: col('ink'), align: 'left',
    verticalAlign: 'middle', overflow: 'clip',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(X0 + (SQ - EM) / 2), y: mm(7) },
      size: { width: mm(ROW + GAP), height: mm(SQ) } } },
] } };
// #endregion

// The page number in a jade disc at the outer foot, the series beside it.
const DISC = 8; // mm
const at = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-10) } });
const folio = (parity, edge, x, sign) => [
  { kind: 'text', id: `n-${parity}`, parity, content: '{pageNumber}', fontFamily: PINYIN,
    fontSize: pt(10), fontWeight: 700, color: col('paper'), align: 'center',
    verticalAlign: 'middle', overflow: 'clip',
    placement: { ...at(edge, x), size: { width: mm(DISC), height: mm(DISC) } },
    box: { backgroundColor: col('jade'), borderRadius: mm(DISC / 2) } },
  { kind: 'text', id: `s-${parity}`, parity, content: '{title} 第一冊', fontFamily: HEI,
    fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1), color: col('muted'),
    align: sign > 0 ? 'left' : 'right', verticalAlign: 'middle', overflow: 'clip',
    placement: { ...at(edge, x + sign * (DISC + 3)), size: { width: mm(60), height: mm(DISC) } } },
];

const config = () => ({ // a factory: the engine caches resolved configs per object
  // #region locale: Hong Kong's rules, the ones the Kai face is drawn for
  // Punctuation at full width, where LXGW WenKai TC centres it as Hong Kong and Taiwan print
  // it, and the basic line-breaking rules. Written out, never LANG (gotcha: cjk-locale-tag).
  locale: 'zh-HK',
  // #endregion
  colorPalette,
  page: { sizePreset: 'custom', width: mm(184), height: mm(260), dpi: 150, // 16开
    // Minimums, the head deeper than the foot (天头 over 地脚): cjk.grid adds what the
    // 12 × 11 type area (110 × 210 mm) leaves, 3.2 mm to each, so 27.2 over 23.2 mm.
    margins: { top: mm(24), bottom: mm(20), left: mm(18), right: mm(18), mirror: true } },
  layout: { layoutType: 'single' },
  cjk,
  bodyText: { ...text, color: col('ink'), boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('ink') }, // the palette does not reach referenceColor
  headings: { fontFamily: KAI, color: col('ink'), fontWeight: 400, textAlign: 'center',
    snapToGrid: false,
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // Every lesson opens a page; span 'page' paints the band under the text.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginBottom: mm(4), advancedDesign: opener },
      // The lesson's title: a plain heading, so its readings print (初号, 42 pt).
      { level: 2, fontSize: pt(42), lineHeight: pt(76), marginTop: pt(0), marginBottom: mm(6) },
      { level: 3, marginTop: mm(7), marginBottom: mm(6), advancedDesign: squares },
    ] },
  paragraphStyles: [
    { id: 'colophon', fontFamily: PINYIN, fontSize: pt(7), lineHeight: pt(9),
      color: col('muted'), textAlign: 'center', marginTop: mm(3) },
  ],
  calloutStyles: [
    { id: 'family', background: col('cream'), borderRadius: mm(3), snapToGrid: false,
      marginTop: mm(0), marginBottom: mm(0),
      padding: { top: mm(3), right: mm(5), bottom: mm(3.5), left: mm(5) },
      titleStyle: { fontFamily: PINYIN, fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1.2),
        textTransform: 'uppercase', color: col('red') },
      body: { fontFamily: PINYIN, fontSize: pt(9.5), lineHeight: pt(13), color: col('ink'),
        boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left',
        firstLineIndent: pt(0), paragraphSpacing: true } },
  ],
  header: { elements: [] },
  footer: { elements: [...folio('even', 'bottom-left', 20, 1),
    ...folio('odd', 'bottom-right', -20, -1)] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Muestra en Markdown · 86 líneas · content.es.mdtitle: "蒙學誦讀" --- # 第一課 {left="sprout"} ## {人之初|rén zhī chū} {人之初|rén zhī chū},{性本善|xìng běn shàn}。 {性相近|xìng xiāng jìn},{習相遠|xí xiāng yuǎn}。 {苟不教|gǒu bú jiào},{性乃遷|xìng nǎi qiān}。 {教之道|jiào zhī dào},{貴以專|guì yǐ zhuān}。 ### 我會寫 {write="人之本不相以"} :::callout{type="family" title="Para las familias"} Al nacer, las personas son buenas. Por naturaleza se parecen; las costumbres las van separando. Sin enseñanza, la naturaleza se tuerce, y enseñar da fruto cuando se hace con constancia. Leed juntos cada verso en voz alta, una sílaba por carácter, y señalad el carácter mientras lo decís. Las marcas sobre las vocales son los cuatro tonos: ā llano, á ascendente, ǎ descendente y ascendente, à descendente. ::: # 第二課 {right="shuttle"} ## {昔孟母|xī mèng mǔ} {昔孟母|xī mèng mǔ},{擇鄰處|zé lín chǔ}。 {子不學|zǐ bù xué},{斷機杼|duàn jī zhù}。 {竇燕山|dòu yān shān},{有義方|yǒu yì fāng}。 {教五子|jiào wǔ zǐ},{名俱揚|míng jù yáng}。 ### 我會寫 {write="子母山五方名"} :::callout{type="family" title="Para las familias"} Antiguamente, la madre de Mencio cambió de casa para buscar buenos vecinos, y cuando su hijo faltó a las lecciones cortó la tela de su telar. Dou Yanshan tenía un buen método: educó a sus cinco hijos y los cinco se hicieron un nombre. A Mencio (Mèngzǐ, hacia 372-289 a. C.) se le llama el Segundo Sabio, después de Confucio. Dou Yanshan, funcionario del siglo X, vio a sus cinco hijos aprobar los exámenes imperiales. ::: # 第三課 {left="brush"} ## {養不教|yǎng bú jiào} {養不教|yǎng bú jiào},{父之過|fù zhī guò}。 {教不嚴|jiào bù yán},{師之惰|shī zhī duò}。 {子不學|zǐ bù xué},{非所宜|fēi suǒ yí}。 {幼不學|yòu bù xué},{老何為|lǎo hé wéi}? ### 我會寫 {write="父師學幼老何"} :::callout{type="family" title="Para las familias"} Criar sin educar es culpa del padre; enseñar sin exigencia, dejadez del maestro. No está bien que un niño no estudie: quien no aprende de pequeño, ¿qué hará de mayor? La palabra bù, «no», se dice bú delante de un cuarto tono, así que el primer verso se lee yǎng bú jiào. El libro imprime el tono que se pronuncia. ::: # 第四課 {right="jade"} ## {玉不琢|yù bù zhuó} {玉不琢|yù bù zhuó},{不成器|bù chéng qì}。 {人不學|rén bù xué},{不知義|bù zhī yì}。 {為人子|wéi rén zǐ},{方少時|fāng shào shí}。 {親師友|qīn shī yǒu},{習禮儀|xí lǐ yí}。 ### 我會寫 {write="玉成知方友禮"} :::callout{type="family" title="Para las familias"} El jade sin tallar no llega a ser una pieza; quien no aprende no sabe lo que es justo. De pequeño, uno se arrima a maestros y amigos y aprende buenos modales. Practicad los seis caracteres en las cuadrículas: primero en el aire con el dedo, después con lápiz, trazo a trazo. ::: :::paragraphs{style="colophon"} Compuesto en LXGW WenKai TC, Noto Sans TC y Andika (SIL OFL) · Texto: el Clásico de los tres caracteres (siglo XIII), zh.wikisource · Pinyin, notas y dibujos: Recetario de Postext, CC BY 4.0 :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the drawings, in the palette's colours // No words in them: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts). const P = palette; const mix = (a, b, t) => `#${[1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16) * (1 - t) + parseInt(b.slice(i, i + 2), 16) * t).toString(16).padStart(2, '0')).join('')}`; const [LEAF, SOIL, WOOD, GOLD] = [mix(P.jade, P.paper, 0.25), '#9a6b47', '#b07a4f', '#e2a83c']; const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const line = (d, color, w, extra = '') => `<path d="${d}" fill="none" stroke="${color}" ` + `stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`; const dot = (x, y, r, fill, extra = '') => `<circle cx="${x}" cy="${y}" r="${r}" ` + `fill="${fill}"${extra}/>`; const drawings = { // The writing square: a red frame and a dashed cross, the guide for placing strokes. tian: () => svg(15, 15, line('M7.5 .4V14.6M.4 7.5H14.6', mix(P.red, P.paper, 0.55), 0.18, ' stroke-dasharray=".7 .55"') + `<rect x=".2" y=".2" width="14.6" height="14.6" fill="none" ` + `stroke="${P.red}" stroke-width=".35"/>`), // 人之初: a seedling out of the earth. sprout: () => svg(42, 42, dot(21, 21, 17, mix(P.tint, P.paper, 0.6)) + path('M5 35C12 29 30 29 37 35Z', SOIL) + line('M21 31C21 25 20.5 21 22 16', P.jade, 1.3) + path('M21.4 22C15 23 9.5 19.5 9 13.5C15.5 13 20.5 16.5 21.4 22Z', LEAF) + path('M21.8 18C24 11 30 8 35.5 9.5C34.5 16 28.5 19.5 21.8 18Z', P.jade) + line('M21 21.6C17 19.5 13.5 17 11.5 15M22.4 17.4C26 14.5 29.5 12 33.5 10.4', mix(P.jade, P.paper, 0.5), 0.35)), // 昔孟母: the shuttle (杼) of a loom crossing the warp, over the cloth already woven. shuttle: () => svg(42, 42, dot(21, 21, 17, mix(P.tint, P.paper, 0.6)) + path('M9 29H33V36H9Z', P.cream) + [30.2, 31.6, 33, 34.4].map((y) => line(`M9 ${y}H33`, mix(P.red, P.paper, 0.45), 0.5)).join('') + Array.from({ length: 11 }, (_, k) => line(`M${10 + k * 2.2} 7V36`, mix(P.muted, P.paper, 0.5), 0.25)).join('') + path('M3 23C10 17 32 17 39 23C32 29 10 29 3 23Z', WOOD) + path('M3 23L7 21.4V24.6ZM39 23L35 21.4V24.6Z', mix(WOOD, P.ink, 0.45)) + path('M13 20.6H29Q30 20.6 30 21.6V24.4Q30 25.4 29 25.4H13Q12 25.4 12 24.4V21.6Q12 20.6 13' + ' 20.6Z', mix(WOOD, P.ink, 0.6)) + path('M14 21.4H28V24.6H14Z', P.red) + [16, 18.5, 21, 23.5, 26].map((x) => line(`M${x} 21.4V24.6`, mix(P.red, P.paper, 0.4), 0.3)) .join('') + line('M28 23C32 23 33 27.5 35 30S37.5 34 39.5 34.5', P.red, 0.45)), // 養不教: a brush setting its first stroke in a writing square. brush: () => svg(42, 42, dot(21, 21, 17, mix(P.tint, P.paper, 0.6)) + path('M8 12H30V36H8Z', P.paper, ` stroke="${mix(P.ink, P.paper, 0.75)}" stroke-width=".25"`) + line('M19 17V33M11 25H27', mix(P.red, P.paper, 0.55), 0.18, ' stroke-dasharray=".7 .55"') + path('M11 17H27V33H11Z', 'none', ` stroke="${P.red}" stroke-width=".3"`) + path('M13.2 25.2C15.5 23.9 20.5 23.5 24 23.8C25.2 23.9 25.5 25 24.4 25.4C21 26.1 16.5 26.3' + ' 13.6 26.1C12.9 26 12.8 25.4 13.2 25.2Z', P.ink) + line('M28.4 20.4L37.5 7.5', GOLD, 1.7) + line('M31 16.7L31.4 16.1M34.3 12L34.7 11.4', mix(GOLD, P.ink, 0.35), 1.8) + path('M27.3 19.4L29.7 21.2L28.9 22.2L26.6 20.5Z', P.ink) + path('M24.6 24.6C24.8 23.1 25.6 21.6 26.7 20.4L28.9 22.1C28.1 23.4 26.6 24.4 24.6 24.6Z', P.ink)), // 玉不琢: a jade disc (璧), carved with rows of grain, on a red cord. jade: () => svg(42, 42, dot(21, 21, 17, mix(P.tint, P.paper, 0.6)) + line('M21 3V11', P.red, 0.9) + path('M21 23m-12 0a12 12 0 1 0 24 0a12 12 0 1 0 -24 0Z' + 'M21 23m-4.2 0a4.2 4.2 0 1 1 8.4 0a4.2 4.2 0 1 1 -8.4 0Z', P.jade, ' fill-rule="evenodd"') + [7.2, 9.6].flatMap((r) => Array.from({ length: Math.round(r * 2.2) }, (_, k) => { const a = (k / Math.round(r * 2.2)) * 2 * Math.PI; return dot(+(21 + r * Math.cos(a)).toFixed(2), +(23 + r * Math.sin(a)).toFixed(2), 0.55, mix(P.jade, P.paper, 0.45)); })).join('') + line('M21 18.8V14', P.red, 0.9) + dot(21, 35.6, 1.3, P.red) + path('M19.6 36.4H22.4L23.6 41H18.4Z', P.red)), }; const artwork = Object.entries(drawings).map(([id, draw]) => { const [width, height] = draw().match(/width="(\d+)" height="(\d+)"/).slice(1).map(Number); return { id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width, height } }; }); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses. Layout measures with the browser's fonts, so the // kit loads them from Fontsource before the first build (gotcha: fonts-first). const FONTS = { 'LXGW WenKai TC': ['400'], // 楷: the text and the titles 'Noto Sans TC': ['700'], // 黑: badges, labels, the series line Andika: ['400', '700'], // the pinyin, the notes, the folios }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region voices: each Chinese face loads the files of the characters it sets // Fontsource cuts a Chinese face into about a hundred files by character range // (gotcha: cjk-fonts-slices). The Kai sets the whole sample; the Hei only the headings' // labels and the footer's series line, so it fetches a few files. const labels = (markdown.match(/^#{1,3} [^{\n]*/gm) ?? []).join('') + '蒙學誦讀 第一冊'; await loadFonts(FONTS, markdown); // the latin files, and Andika's latin-ext for ǎ ǐ ǒ ǔ await Promise.all([loadCjkFonts({ [KAI]: FONTS[KAI] }, markdown), loadCjkFonts({ [HEI]: FONTS[HEI] }, labels), ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))]); // #endregion // Page 1 is page 36 of the primer: a verso, so the four lessons lie as two spreads. const continuation = { pageIndexOffset: 35, pageNumbering: { startAt: 36 } }; const doc = await buildWithFonts(() => buildDocument({ markdown, resources: artwork, continuation }, config()), markdown); showBook(doc, { title: t({ en: 'A pinyin primer', es: 'Una cartilla con pinyin' }) });
Kit · core, fonts, viewer, images, cjk: igual en todas las recetas · 417 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 · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── 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

#Muestra la retícula mientras compones

show: true dibuja en el canvas las doce casillas de cada línea; el PDF las omite salvo que renderToPdf reciba characterGrid: true.

-  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11 },
+  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11, show: true },

#Pon el zhuyin al lado de los caracteres

Las lecturas en bopomofo se colocan en una columna a la derecha de cada carácter; la cartilla de zhuyin las compone en una página vertical.

Errores frecuentes

Error frecuente

Las lecturas se imprimen en el texto, no en diseños, pies ni celdas

Las lecturas ruby se dibujan en párrafos, títulos, elementos de lista, citas y recuadros. Un elemento de texto de un diseño (una apertura, una cabecera, una etiqueta), un pie, una nota al pie y una celda de tabla imprimen los caracteres base sin sus lecturas. Un título que necesita su pinyin es un título sin diseño propio: dibuja lo que lo rodea (una banda, el número de la lección) con el diseño del título anterior, cuyos elementos con reserve: false se pintan bajo el texto de una apertura span: 'page'. Ruby: lecturas en pinyin y zhuyin →

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

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

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

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

El texto dentro de un SVG <img> no puede usar fuentes web

Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie. Figuras y tablas como recursos →

Error frecuente

Carga todas las fuentes antes de componer

La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →

Aviso de maquetación · rubyExceedsLeading

Lecturas pegadas a la línea vecina

Por qué. Un párrafo con lecturas ruby encima o debajo del texto tiene un hueco entre líneas más estrecho que las lecturas: ocupan el interlineado y tocan la línea vecina.

Solución. Compón el párrafo con más interlineado (al menos el cuerpo más `cjk.ruby.fontSize`), o reduce las lecturas. Documentación →

  • Esta receta no ofrece PDF. El fontsourceProvider del kit incrusta solo el archivo latin de una fuente, y las letras con tono ā ǎ ǐ ǒ ǔ están en latin-ext, así que el PDF las perdería y postext-pdf avisaría de cada una con missingGlyph. Si lo necesitas, compón las lecturas con una fuente china, cuyos archivos elige cjkPdfProvider carácter a carácter.

  • Comprueba cada carácter de las cuadrículas con las formas normalizadas de la región. LXGW WenKai TC dibuja 為 con la parte de arriba de 爲 (爫), una forma antigua que pasa en el texto corrido, como en 老何為 y 為人子, pero no en una cuadrícula que copia un niño de Hong Kong. Por eso la lección tercera practica 幼.

Créditos

Texto
Imágenes
  • Las cuadrículas de escritura y los cuatro dibujos de las lecciones, dibujados en código con la paleta de la página · Postext Cookbook · CC BY 4.0
Fuentes
LXGW WenKai TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1) · Andika (SIL OFL 1.1)
Sandbox