Saltar al contenido principal
Receta número 77

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

Marcas de nombre y de título, en vertical y en horizontal

Los Anales básicos de Xiang Yu con marcas :name y :book, en vertical y en horizontal: las líneas van a la izquierda de los nombres y luego debajo de ellos.

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. 二–三 · 2–3 de 3

  • Formato 140 × 203 mm
  • 1 columna
  • Noto Serif TC 10,5/18
  • LXGW WenKai TC
  • Noto Sans TC
  • 3 páginas
  • Nivel
  • Postext 1.9.0
  • Compuesto en 11 ms
  • 167 líneas de código

Lo que vas a componer

Tres páginas de muestra para una antología de las Memorias históricas, en el formato de 140 × 203 mm de un libro chino 大32開. El pasaje es el comienzo de los Anales básicos de Xiang Yu con la puntuación completa de una edición de clásicos: una línea recta junto a cada persona, lugar, estado y dinastía, y una línea ondulada junto a cada título de libro o de capítulo, las dos en bermellón. La página 1 lleva la presentación y las normas de la edición en letra Kai. Las páginas 2 y 3 componen dos veces el mismo Markdown, en vertical y en horizontal, y el pliego enseña adónde va cada marca: a la izquierda del nombre en las líneas verticales, debajo en las horizontales. El libro se encuaderna por la derecha y una banda añil lleva cada título. La receta Nº 044, Lycidas en el texto de 1645, distingue los nombres a la manera europea, en cursiva.

Esta receta responde a

  • ¿Cómo marco los nombres propios y los títulos de un texto chino con una línea recta y una ondulada?
  • ¿Por qué el énfasis de mi texto chino sale inclinado y qué debo usar en su lugar?
  • ¿Cómo compongo un libro chino en vertical, encuadernado por la derecha?

La respuesta corta

script.js · líneas 45–62en el código completo
// :name[項梁] draws the proper-name line, :book[史記] the book-title mark, :dots[…] dots.
const cjk = {
  bookTitleMark: 'wavy', // zh-Hant's default; mainland editions of the classics use it too
  annotationColor: col('mark'), // unset, the marks print in the colour of the text
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
};
// The book runs down the page, bound on the right: the lines stand left of the names.
const layout = { layoutType: 'single', writingMode: 'vertical-rl' };
// # 項羽本紀 {style="across"} sets the same Markdown across the page, 28 characters to
// the line: the lines run under the names. Its margins set its grid; the book's grid
// counts down the page.
const MEASURE = 28 * SIZE * PT; // mm
const across = {
  id: 'across',
  layout: { layoutType: 'single', writingMode: 'horizontal-tb' },
  margins: { top: mm(SIDE), bottom: mm(H - SIDE - 25 * LEAD * PT), // 25 lines
    left: mm((W - MEASURE) / 2), right: mm((W - MEASURE) / 2) },
};

Ingredientes

Tipografía
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 · El marcado pone las marcas; la dirección de escritura, su lado

El código es la respuesta corta de arriba. :name[項梁] y :book[史記] conservan sus caracteres en el texto, así que la búsqueda, el texto copiado y el PDF leen 項梁 y 史記, y les añaden las marcas al lado (Marcas chinas, ruby y warichu). El lado lo decide solo la dirección de escritura: layout.writingMode: 'vertical-rl' pone las líneas a la izquierda de los caracteres, y el estilo de título across, cuya disposición es 'horizontal-tb', debajo. bookTitleMark: 'wavy' es lo que un documento zh-Hant recibe por defecto; va escrito porque, al pasar a zh-Hans, los títulos saldrían entre 《》. Cuando dos marcas del mismo tipo se tocan, como en 東漢班固 o en 史記項羽本紀, cada una cede un octavo de cuadratín en el punto de encuentro, y las líneas se leen como dos nombres.

#2 · Un interlineado con sitio para las marcas

script.js · líneas 32–41en el código completo
const [SONG, HEI, KAI] = ['Noto Serif TC', 'Noto Sans TC', 'LXGW WenKai TC']; // 宋, 黑, 楷
const PT = 25.4 / 72; // mm in a point
const [W, H] = [140, 203]; // mm: 大32開
const SIZE = 10.5; // pt: 五號
// A gap of 7.5 pt (0.71 em) between lines: a line of marks needs half an em of it, dots on
// one side and lines on the other five eighths. At 15 pt the build reports every marked
// paragraph (cjkMarksExceedLeading: gap 0.43 em).
const LEAD = 18; // pt
const [CHARS, LINES] = [42, 17]; // the vertical grid: characters down a line, lines across
const SIDE = (W - LINES * LEAD * PT) / 2; // mm: the side margins of a vertical page

Las marcas viven en el hueco entre líneas y nunca cambian el paso de línea (Marcas, ruby y warichu). Con 10,5 pt sobre 18 pt el hueco es de 0,71 cuadratines, y caben en él las líneas de nombre de una columna y los puntos de la siguiente, como en la quinta norma de la página 1. La retícula cuenta la página en caracteres, 42 a lo largo de cada línea y 17 líneas a lo ancho, y mueve los márgenes para que la caja mida justo eso: 25,7 mm de margen en la cabeza y 21,7 mm al pie (Retícula de caracteres).

#3 · Una apertura que gira con la página

script.js · líneas 66–89en el código completo
// The grid centres the text between the minimum margins; the band stops at its foot.
const FOOT = 20 + (H - 24 - 20 - CHARS * SIZE * PT) / 2; // mm, with minimums of 24 and 20
const BAND = SIDE + 3.5 * LEAD * PT; // mm from the trim, which is the right edge down the page
const onBand = { align: 'left', overflow: 'wrap' }; // design text centres and cuts by default
const next = (id, y) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  minHeight: pt(4 * LEAD), // the text starts on the fifth line, half a line clear of the band
  slot: { elements: [
    // Across the page the band's length runs past the trim; down it, it ends at the text's
    // foot, clear of the folio.
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: mm(H - FOOT), height: mm(BAND) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: HEI,
      fontWeight: 700, fontSize: pt(8), letterSpacing: pt(1.6), color: col('tint'),
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(6 - SIDE) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: SONG,
      fontWeight: 700, fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3),
      color: col('paper'), placement: next('kicker', 2) },
    { kind: 'text', id: 'byline', content: '{attr.byline}', ...onBand, fontFamily: KAI,
      fontSize: pt(10), color: col('tint'), placement: next('title', 2) },
  ] },
};

El diseño de un título se compone en el flujo de su página, así que un mismo juego de elementos sirve en las dos direcciones (Escritura vertical). Anclada a la esquina superior izquierda de la sangre, la banda cruza la cabeza de la página horizontal y baja por el borde derecho de las verticales, que es donde empieza la lectura. El antetítulo, el título y el autor se apilan también en el flujo: uno debajo de otro en horizontal, uno a la izquierda de otro en vertical. La longitud de la banda se mide a lo largo del flujo: 181,3 mm la detienen al pie del texto vertical, lejos del folio, y en la página horizontal pasan del corte.

#4 · Folios en un libro encuadernado por la derecha

script.js · líneas 93–102en el código completo
const foot = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  fontFamily: HEI, fontSize: pt(7), color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-11) } } });
const folio = { fontFamily: SONG, fontSize: pt(9) };
// Bound on the right, a recto (odd) lies left of the spine: its outer edge is its left one.
const footer = { elements: [
  foot('folio-odd', '{pageNumber}', 'odd', 'bottom-left', SIDE, folio),
  foot('folio-even', '{pageNumber}', 'even', 'bottom-right', -SIDE, folio),
  foot('slug', '{attr.slug}', 'all', 'bottom', 0, { letterSpacing: pt(0.4) }),
] };

El texto vertical encuaderna el libro por la derecha (Encuadernación): la página 1 queda a la izquierda del lomo y el borde exterior de una página impar es el izquierdo. El folio impar va abajo a la izquierda y el par abajo a la derecha, al revés que en un libro occidental, y pageNumbering.format: 'trad-chinese-informal' los imprime 一, 二, 三. El rótulo entre los dos sale de un atributo de cada título, slug="…"; con el colofón, es lo único que cambia entre la edición inglesa y la española.

#5 · Una segunda tinta

script.js · líneas 17–29en el código completo
const palette = {
  ink: '#221e1b', // the text
  band: '#23394b', // the opener band and the small heads
  mark: '#b5412c', // the name, title and emphasis marks: the second ink
  tint: '#c7d2da', // kickers and bylines on the band
  muted: '#6f6a64', // folios, slug lines, the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'band (defaults)', value: { hex: palette.band, model: 'hex' } },
];

Una edición de clásicos imprime las marcas con la tinta del texto. Una antología escolar puede imprimirlas en un segundo color, para separarlas de los trazos de los caracteres: annotationColor las enlaza con la entrada mark de la paleta, el bermellón de los comentarios en tinta roja, y un solo cambio en esa entrada cambia el tono de todas las líneas y todos los puntos del libro.

La receta completa

Sandbox
// ═══ Postext Cookbook · Nº 077 · Proper-name and book-title marks, across and down ═════
// https://postext.dev/en/cookbook/proper-name-marks
// Code: MIT · Text: Sima Qian, Shiji 7 (PD) · Punctuation, headnote, conventions: CC BY 4.0
// Fonts: Noto Serif TC, Noto Sans TC, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.9.0
// Specimen pages for a classical reader: the opening of the Basic Annals of Xiang Yu with
// its proper-name lines and wavy title lines, set down the page and then across it.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the slug lines and the colophon ('en' | 'es')
const RECIPE = 'proper-name-marks';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the indigo of a thread-bound cover, vermilion for the marks
const palette = {
  ink: '#221e1b', // the text
  band: '#23394b', // the opener band and the small heads
  mark: '#b5412c', // the name, title and emphasis marks: the second ink
  tint: '#c7d2da', // kickers and bylines on the band
  muted: '#6f6a64', // folios, slug lines, the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'band (defaults)', value: { hex: palette.band, model: 'hex' } },
];
// #endregion
// #region type: 五號 on 18 pt, 42 characters by 17 lines down the page
const [SONG, HEI, KAI] = ['Noto Serif TC', 'Noto Sans TC', 'LXGW WenKai TC']; // 宋, 黑, 楷
const PT = 25.4 / 72; // mm in a point
const [W, H] = [140, 203]; // mm: 大32開
const SIZE = 10.5; // pt: 五號
// A gap of 7.5 pt (0.71 em) between lines: a line of marks needs half an em of it, dots on
// one side and lines on the other five eighths. At 15 pt the build reports every marked
// paragraph (cjkMarksExceedLeading: gap 0.43 em).
const LEAD = 18; // pt
const [CHARS, LINES] = [42, 17]; // the vertical grid: characters down a line, lines across
const SIDE = (W - LINES * LEAD * PT) / 2; // mm: the side margins of a vertical page
// #endregion

// #region answer: marks by the markup, their side by the writing mode
// :name[項梁] draws the proper-name line, :book[史記] the book-title mark, :dots[…] dots.
const cjk = {
  bookTitleMark: 'wavy', // zh-Hant's default; mainland editions of the classics use it too
  annotationColor: col('mark'), // unset, the marks print in the colour of the text
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
};
// The book runs down the page, bound on the right: the lines stand left of the names.
const layout = { layoutType: 'single', writingMode: 'vertical-rl' };
// # 項羽本紀 {style="across"} sets the same Markdown across the page, 28 characters to
// the line: the lines run under the names. Its margins set its grid; the book's grid
// counts down the page.
const MEASURE = 28 * SIZE * PT; // mm
const across = {
  id: 'across',
  layout: { layoutType: 'single', writingMode: 'horizontal-tb' },
  margins: { top: mm(SIDE), bottom: mm(H - SIDE - 25 * LEAD * PT), // 25 lines
    left: mm((W - MEASURE) / 2), right: mm((W - MEASURE) / 2) },
};
// #endregion

// #region opener: one design both ways: a band over the text across, right of it down
// The grid centres the text between the minimum margins; the band stops at its foot.
const FOOT = 20 + (H - 24 - 20 - CHARS * SIZE * PT) / 2; // mm, with minimums of 24 and 20
const BAND = SIDE + 3.5 * LEAD * PT; // mm from the trim, which is the right edge down the page
const onBand = { align: 'left', overflow: 'wrap' }; // design text centres and cuts by default
const next = (id, y) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  minHeight: pt(4 * LEAD), // the text starts on the fifth line, half a line clear of the band
  slot: { elements: [
    // Across the page the band's length runs past the trim; down it, it ends at the text's
    // foot, clear of the folio.
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: mm(H - FOOT), height: mm(BAND) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: HEI,
      fontWeight: 700, fontSize: pt(8), letterSpacing: pt(1.6), color: col('tint'),
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(6 - SIDE) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: SONG,
      fontWeight: 700, fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3),
      color: col('paper'), placement: next('kicker', 2) },
    { kind: 'text', id: 'byline', content: '{attr.byline}', ...onBand, fontFamily: KAI,
      fontSize: pt(10), color: col('tint'), placement: next('title', 2) },
  ] },
};
// #endregion

// #region folios: Chinese numerals at the outer foot, the slug line in the middle
const foot = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  fontFamily: HEI, fontSize: pt(7), color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-11) } } });
const folio = { fontFamily: SONG, fontSize: pt(9) };
// Bound on the right, a recto (odd) lies left of the spine: its outer edge is its left one.
const footer = { elements: [
  foot('folio-odd', '{pageNumber}', 'odd', 'bottom-left', SIDE, folio),
  foot('folio-even', '{pageNumber}', 'even', 'bottom-right', -SIDE, folio),
  foot('slug', '{attr.slug}', 'all', 'bottom', 0, { letterSpacing: pt(0.4) }),
] };
// #endregion

// The front page sets the headnote and the conventions in the Kai face. A section's
// bodyStyle sets its list numbers bold unless its orderedLists say otherwise.
const front = { id: 'front', bodyStyle: { fontFamily: KAI, orderedLists: { fontWeight: 400 } } };

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'zh-Hant', // Taiwan: full-width centred punctuation (gotcha: cjk-locale-tag)
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(W), height: mm(H), dpi: 150,
    // Minimums: the grid centres its 42 × 17 characters, 天頭 25.7 mm over 地腳 21.7 mm.
    margins: { top: mm(24), bottom: mm(20), left: mm(SIDE), right: mm(SIDE), mirror: true },
    pageNumbering: { format: 'trad-chinese-informal' }, // 一, 二, 三
  },
  layout,
  cjk,
  bodyText: {
    fontFamily: SONG, fontSize: pt(SIZE), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
  },
  headings: {
    fontFamily: HEI, fontWeight: 700, color: col('band'),
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        advancedDesign: opener, marginBottom: pt(0) },
      // No space above: 題解 sits on the fifth line, where the other pages' text starts;
      // a :::space line sets 凡例 apart from the headnote.
      { level: 2, fontSize: pt(SIZE), lineHeight: pt(LEAD), letterSpacing: pt(2),
        marginTop: pt(0), marginBottom: pt(0) },
    ],
  },
  headingStyles: [front, across],
  // No gap after 一、: the text starts two ems in, on the grid, like a paragraph's.
  orderedLists: { numberFormat: 'trad-chinese-informal', separator: '、', fontFamily: KAI,
    fontWeight: 400, color: col('ink'), gap: em(0), marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [
    { id: 'colophon', fontFamily: HEI, fontSize: pt(7), lineHeight: pt(10), color: col('muted'),
      textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  header: { elements: [] }, // every page opens a section: the band is its head
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// The Chinese is the same in both editions; the slug lines and the colophon change.
const markdown = String.raw`---
Muestra en Markdown · 43 líneas · content.es.mdtitle: "史記選讀" author: "司馬遷" --- # 史記選讀 {style="front" kicker="版式樣張" byline="題解 凡例" slug="Hoja de muestra: la presentación y las normas de la edición"} ## 題解 :book[項羽本紀]是:book[史記]第七卷,記述:name[秦]末:name[項羽]起兵、分封諸侯,以至兵敗:name[垓下]、自刎:name[烏江]的始末。:name[司馬遷]將這位未曾稱帝的人物列入「本紀」,與帝王並列。本樣張節錄篇首,寫:name[項羽]的出身與少年志氣。:name[東漢]:name[班固]:book[漢書]:book[陳勝項籍傳]敘述同一段事,文字大多沿用:book[史記]。 :::space ## 凡例 1. 正文依維基文庫所收:book[史記]卷七錄文;標點、分段與題解為本樣張所加。 2. 人名、地名、國名、朝代名旁加專名號,直排標於字左,橫排標於字下。 3. 書名、篇名旁加浪線書名號,位置與專名號相同,如:book[史記]:book[項羽本紀]。 4. 兩個專名緊相連接時各自畫線,相接處各縮八分之一字,如:name[楚]:name[漢]、:name[東漢]:name[班固]。 5. 需要強調的字旁加著重號,直排在字右,橫排在字下,如:dots[萬人敵]。 # 項羽本紀 {kicker="樣張一 直排" byline="〔漢〕司馬遷" slug="Muestra 1, en vertical: las líneas a la izquierda de los nombres"} :name[項籍]者,:name[下相]人也,字:name[羽]。初起時,年二十四。其季父:name[項梁]。:name[梁]父即:name[楚]將:name[項燕],為:name[秦]將:name[王翦]所戮者也。:name[項氏]世世為:name[楚]將,封於:name[項],故姓:name[項氏]。 :name[項籍]少時,學書不成,去學劍,又不成。:name[項梁]怒之。:name[籍]曰:「書,足以記名姓而已;劍,一人敵,不足學。學萬人敵。」於是:name[項梁]乃教:name[籍]兵法。:name[籍]大喜,略知其意,又不肯竟學。 :name[項梁]嘗有:name[櫟陽]逮,乃請:name[蘄]獄掾:name[曹咎]書抵:name[櫟陽]獄掾:name[司馬欣],以故事得已。:name[項梁]殺人,與:name[籍]避仇於:name[吳中],:name[吳中]賢士大夫皆出:name[項梁]下。每:name[吳中]有大繇役及喪,:name[項梁]常為主辦,陰以兵法部勒賓客及子弟,以是知其能。 :name[秦始皇帝]游:name[會稽],渡:name[浙江],:name[梁]與:name[籍]俱觀。:name[籍]曰:「彼可取而代也!」:name[梁]掩其口曰:「毋妄言,族矣。」:name[梁]以此奇:name[籍]。:name[籍]長八尺餘,力能扛鼎,才氣過人,雖:name[吳中]子弟,皆已憚:name[籍]矣。 # 項羽本紀 {style="across" kicker="樣張二 橫排" byline="〔漢〕司馬遷" slug="Muestra 2, en horizontal: las líneas bajo los nombres"} :name[項籍]者,:name[下相]人也,字:name[羽]。初起時,年二十四。其季父:name[項梁]。:name[梁]父即:name[楚]將:name[項燕],為:name[秦]將:name[王翦]所戮者也。:name[項氏]世世為:name[楚]將,封於:name[項],故姓:name[項氏]。 :name[項籍]少時,學書不成,去學劍,又不成。:name[項梁]怒之。:name[籍]曰:「書,足以記名姓而已;劍,一人敵,不足學。學萬人敵。」於是:name[項梁]乃教:name[籍]兵法。:name[籍]大喜,略知其意,又不肯竟學。 :name[項梁]嘗有:name[櫟陽]逮,乃請:name[蘄]獄掾:name[曹咎]書抵:name[櫟陽]獄掾:name[司馬欣],以故事得已。:name[項梁]殺人,與:name[籍]避仇於:name[吳中],:name[吳中]賢士大夫皆出:name[項梁]下。每:name[吳中]有大繇役及喪,:name[項梁]常為主辦,陰以兵法部勒賓客及子弟,以是知其能。 :name[秦始皇帝]游:name[會稽],渡:name[浙江],:name[梁]與:name[籍]俱觀。:name[籍]曰:「彼可取而代也!」:name[梁]掩其口曰:「毋妄言,族矣。」:name[梁]以此奇:name[籍]。:name[籍]長八尺餘,力能扛鼎,才氣過人,雖:name[吳中]子弟,皆已憚:name[籍]矣。 :::paragraphs{style="colophon"} Compuesto en Noto Serif TC, Noto Sans TC y LXGW WenKai TC (SIL Open Font License). Texto: Sima Qian, Shiji, capítulo 7, según zh.wikisource (dominio público). Puntuación, párrafos, presentación y normas de la edición escritos para esta muestra (CC BY 4.0). :::
`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // A Chinese face comes in slices: each voice loads the files of the text it sets // (gotcha: cjk-fonts-slices). Kai sets the front page and the bylines, Hei the small heads. const FONTS = { 'Noto Serif TC': ['400', '700'], 'Noto Sans TC': ['400', '700'], 'LXGW WenKai TC': ['400'], }; const heads = markdown.match(/^#.*$/gm).join('\n'); // titles, kickers, bylines, slug lines const preface = markdown.slice(0, markdown.indexOf('\n# ', markdown.indexOf('# ') + 2)); const colophon = markdown.slice(markdown.lastIndexOf(':::paragraphs')); const NUMERALS = '一二三四五六七八九十、'; // folios and list numbers, which the text may lack // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadCjkFonts({ [SONG]: ['400'] }, markdown + NUMERALS, { vertical: true }); // The bold sets the two titles, which have no punctuation, so it loads no vertical forms. // A browser that ignores the forms' feature settings would otherwise take a twin of the // bold files for vertical forms and set the text's brackets upright in the regular. await loadCjkFonts({ [SONG]: ['700'] }, heads); await loadCjkFonts({ [HEI]: ['400', '700'] }, heads + colophon, { vertical: true }); await loadCjkFonts({ [KAI]: ['400'] }, preface + heads + NUMERALS, { vertical: true }); const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); showBook(doc, { title: t({ en: 'Name and title marks, down and across', es: 'Marcas de nombre y de título, en vertical y en horizontal' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);
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

#Imprime los títulos entre 《》

Los libros de China continental que no son de clásicos ponen los títulos entre corchetes angulares: el Markdown no cambia y los corchetes se imprimen como puntuación del texto.

-  bookTitleMark: 'wavy', // zh-Hant's default; mainland editions of the classics use it too
+  bookTitleMark: 'brackets', // 《史記》《項羽本紀》

#Imprime las marcas con la tinta del texto

Sin annotationColor, cada marca toma el color del bloque que marca.

-  annotationColor: col('mark'), // unset, the marks print in the colour of the text

Errores frecuentes

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

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

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

Error frecuente

Un libro encuadernado por la derecha muestra sus pliegos con showBook

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

Error frecuente

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

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 →

Aviso de maquetación · cjkMarksExceedLeading

Marcas pegadas a la línea vecina

Por qué. Un párrafo con puntos de énfasis o líneas de nombre propio y de título tiene un hueco entre líneas de menos de medio em (cinco octavos con marcas por los dos lados): las marcas viven en el interlineado, que no crece por ellas, así que tocan la línea vecina.

Solución. Dale al párrafo un estilo de párrafo con más interlineado, o sube `bodyText.lineHeight`. Documentación →

  • Un título que dibuja un diseño imprime su texto a través del diseño, y el texto de diseño pierde las marcas: # :book[項羽本紀] no mostraría ninguna línea ondulada en la banda. Las cabeceras, los pies de figura y las celdas de tabla también imprimen su texto sin marcas.
  • Postext no se detiene por cjkMarksExceedLeading: la composición lo anota en doc.contentWarnings y el panel Revisión del Sandbox lo muestra, pero las páginas se imprimen igual, con las marcas pegadas a la línea vecina. Si cambias el interlineado, revisa esa lista; con 15 pt recoge todos los párrafos marcados de estas páginas.

Créditos

Texto
  • El comienzo de los Anales básicos de Xiang Yu (項羽本紀), Memorias históricas (史記), capítulo 7; los caracteres, según la transcripción de zh.wikisource · Sima Qian (司馬遷) · dominio público
  • La puntuación, los párrafos y las marcas del pasaje, la presentación (題解), las normas de la edición (凡例), los rótulos al pie y el colofón · Ignacio Ferro · CC BY 4.0
Fuentes
Noto Serif TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1)
SandboxPDF