Lo que vas a componer
El comienzo de «El pueblo natal» (故乡, 1921), de Lu Xun, como lo compondría una edición continental en rústica de su primer libro: una página 大32开 de 140 × 203 mm cuya mancha se cuenta en caracteres, 28 por línea y 28 líneas por página, en Noto Serif SC de cuerpo 五号 (10,5 pt). Cada párrafo entra dos caracteres; las comas y las comillas ocupan medio carácter, como en la mayoría de los libros continentales; ninguna línea empieza con 。 o , ni acaba con “. El relato se abre en una página impar, bajo su título en el peso negro de la Song. Enfrente, una lámina dibujada en código muestra el campo de sandías bajo la luna que recuerda el narrador, con la frase que lo describe en la letra de pincel Ma Shan Zheng. Español justificado en una novela de bolsillo hace el mismo trabajo con una escritura latina.
Esta receta responde a
- ¿Cómo defino la mancha en caracteres, tantos por línea y tantas líneas por página?
- ¿Cómo evito que 。 y , empiecen una línea?
- ¿Cómo compongo la puntuación china en Kaiming, como los libros de la China continental, o a medio ancho?
- ¿Cómo dejo espacio entre palabras chinas y latinas sin teclearlo?
La respuesta corta
const cjk = {
// The type area in characters: 28 ems wide, 28 lines of LEAD tall. page.margins become
// minimums, and the grid centres the type area in the room they leave.
grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
// What 'zh-Hans' gives by itself, written out so a reader can compare regions and styles.
region: 'mainland',
lineBreak: 'gb', // no 。,、”》 opens a line, no “《( ends one, and no / at either end
punctuationWidth: 'kaiming', // ,、:; quotes, brackets ½ em; 。?! one em, ½ at a line end
compressAdjacent: true, // idle under Kaiming (no pair tops 1.5 em); 'fullwidth' needs it
latinSpacing: em(0.25), // 1921年, 用Noto Serif SC五号: a quarter em, none of it typed
};
const bodyText = {
fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
textAlign: 'justify', // spread between the characters, never between words
firstLineIndent: em(2), indentAfterHeading: true, // two characters, every paragraph
};
Ingredientes
- Funciones
- Retícula de caracteresCorte de líneas en chinoAnchura de la puntuación chinaJustificación entre caracteresEspacio entre chino y latínFuentes chinas, japonesas y coreanasSangrías, alineación y separación de párrafosTipografía del textoCabeceras y foliosAperturas diseñadasEstilos de títuloAtributos de títuloTextos, filetes y cajas en los diseños de páginaCabeceras según el tipo de páginaMárgenes simétricosEstilos de párrafoMarcadores del PDF
- También usa
- Color del papelExportación a PDFFuentes incrustadas en el PDFFiguras y tablas como recursosCabeceras por sección
- Tipografía
- Noto Serif SC, Noto Sans SC, Ma Shan Zheng (SIL OFL 1.1)
- Recursos
- El campo de sandías bajo la luna del relato, dibujado en código con la paleta de la página (Ignacio Ferro, MIT)
Elaboración
#1 · Una página contada en caracteres
El código está en la respuesta corta, más arriba. cjk.grid da a la medida 28 em de bodyText.fontSize (294 pt, 103,7 mm) y a la mancha 28 líneas de lineHeight (462 pt, 163 mm), y mueve los márgenes para que quepan, así que una línea llena de caracteres chinos tiene exactamente 28 y todas las páginas llenas acaban en la misma línea (Retícula de caracteres). Los cinco ajustes que siguen a la retícula son los que locale: 'zh-Hans' ya da por sí solo; van escritos para que puedas cambiarlos de uno en uno, aunque compressAdjacent no cambia nada mientras punctuationWidth no sea 'fullwidth': con Kaiming, dos signos seguidos nunca ocupan más de 1,5 em. En la página 71, el 。 que cierra un párrafo no cabe, y las reglas de principio de línea (todos los niveles de lineBreak salvo 'none'; 'gb' añade la barra) le prohíben abrir línea. La línea lo recoge componiendo a medio carácter el 。 que sigue a 事, y así 事。宏 se aprieta y el párrafo no gana una línea (Anchos de la puntuación). El blanco de las líneas cortas se reparte entre los caracteres, nunca entre palabras, y en estas páginas nunca pasa de 0,05 em (Chino, japonés y coreano).

#2 · Un 大32开 con más cabeza que pie
const page = {
width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
// 22 + 18 mm leave 163 mm, exactly 28 lines of 16.5 pt; 18 mm a side leave the 103.7 mm
// measure 0.3 mm to share. A grid that no longer fits is cut down (cjkGridClamped).
margins: { top: mm(22), bottom: mm(18), left: mm(18), right: mm(18), mirror: true },
};
Con la retícula activada, los márgenes son mínimos: la retícula coloca la mancha en el hueco que dejan y reparte lo que sobra. Arriba 22 mm y abajo 18 dejan justo 163 mm, así que el margen de cabeza sigue siendo 4 mm más alto que el de pie, como en los libros chinos; a los lados, 18 mm por lado dejan 0,3 mm que repartir. Si pides más caracteres o líneas de los que caben, la retícula se reduce a lo que cabe y avisa con cjkGridClamped; el cuerpo y el interlineado no cambian.
#3 · Un título hundido un número entero de líneas
const SINK = 8; // lines of LEAD: a whole number, so the text under it keeps to the grid
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
enabled: true,
minHeight: pt(SINK * LEAD),
slot: { elements: [
// The spacing after the last character is advance, not ink: the title stays centred.
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: SONG, fontWeight: 900,
fontSize: pt(46), letterSpacing: pt(23), lineHeight: 1.1, color: col('ink'),
align: 'center', placement: centred(6) },
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.2),
color: col('night'), placement: { ...centred(30), size: { width: mm(8) } } },
{ kind: 'text', id: 'author', content: '{author}', fontFamily: HEI, fontSize: pt(10.5),
letterSpacing: pt(5), color: col('ink'), align: 'center', placement: centred(34) },
] },
};
// Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener };
minHeight mide ocho líneas de 16,5 pt, de modo que el texto empieza en la novena línea de la retícula, sobre las mismas líneas de base que en cualquier otra página. Los 23 pt de espaciado separan 故 y 乡 medio carácter, lo habitual en un título de dos caracteres; Postext no cuenta el espaciado que queda tras el último carácter al centrar la línea, así que el título queda en el eje de la página (Estilos de encabezado).
#4 · El libro en la página par, el relato en la impar
const HEAD_Y = 11; // mm from the top edge to the heads; the hairline 5 mm lower
const label = { fontFamily: HEI, fontSize: pt(8), color: col('muted'), pages: 'body' };
const at = (edge, x, y = HEAD_Y) => ({ anchor: { to: 'page', edge },
offset: { x: mm(x), y: mm(y) } });
const header = { elements: [
{ kind: 'text', id: 'verso-folio', content: '{pageNumber}', parity: 'even', ...label,
placement: at('top-left', SIDE) },
{ kind: 'text', id: 'verso-book', content: '{title}', parity: 'even', ...label,
letterSpacing: pt(4), placement: at('top', 0) },
{ kind: 'text', id: 'recto-story', content: '{chapterTitle}', parity: 'odd', ...label,
letterSpacing: pt(4), placement: at('top', 0) },
{ kind: 'text', id: 'recto-folio', content: '{pageNumber}', parity: 'odd', ...label,
placement: at('top-right', -SIDE) },
{ kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
pages: 'body', placement: { ...at('top-left', SIDE, HEAD_Y + 5), size: { width: mm(MEASURE) } },
},
] };
// The opener has no head: its folio drops to the foot, centred under the text.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
...label, pages: 'opener', align: 'center', placement: at('bottom', 0, -10) }] };
Las novelas continentales llevan el título del libro sobre las páginas pares y el del relato sobre las impares, centrados, con el folio en el borde exterior y un filete fino debajo. pages: 'body' deja las cabeceras fuera de la apertura y de la lámina, y la apertura lleva el folio al pie. Las cabeceras van en Noto Sans SC, la Hei, a 8 pt: pequeñas, pero con una voz distinta de la Song del texto.
#5 · Una lámina a toda página
const resources = [{
id: 'moon', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
svg: { fileId: 'moon.svg', width: TRIM.width, height: TRIM.height }, // the trim's ratio
altText: t({
en: 'A golden full moon in a deep blue sky over a strip of sea. On the sand below, among '
+ 'rows of striped watermelons, a boy with a silver collar stabs a steel fork at the '
+ 'sand ahead of him, and the small animal he aimed at runs off between his legs.',
es: 'Una luna llena dorada en un cielo azul oscuro sobre una franja de mar. En la arena, '
+ 'entre hileras de sandías rayadas, un muchacho con un aro de plata al cuello clava una '
+ 'horquilla de acero en la arena, delante de él, y el animalillo al que apuntaba huye '
+ 'entre sus piernas.',
}),
}];
const quote = { fontFamily: KAI, fontSize: pt(14), lineHeight: 1.5, color: col('paper') };
// # 月下的瓜地 {style="plate" line1="…" line2="…" line3="…"}: a page with no head or folio.
// 'page' spans the design over the sheet: a heading's design in the column is cut at its foot.
const plate = {
id: 'plate', span: 'page', runningChapter: false, toc: false, // out of the PDF outline
breakBefore: { enabled: true, parity: 'any' }, // (gotcha: style-inherits-break)
header: { elements: [] }, footer: { elements: [] },
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'picture', resourceId: 'moon', placement: { // the whole trim
anchor: { to: 'bleed', edge: 'top-left' },
size: { width: mm(TRIM.width), height: mm(TRIM.height) } } },
{ kind: 'text', id: 'line1', content: '{attr.line1}', ...quote, align: 'left',
placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(2) } } },
{ kind: 'text', id: 'line2', content: '{attr.line2}', ...quote, align: 'left',
placement: { anchor: { to: '#line1', edge: 'below' } } },
{ kind: 'text', id: 'line3', content: '{attr.line3}', ...quote, align: 'left',
placement: { anchor: { to: '#line2', edge: 'below' } } },
] } },
};
La ilustración pertenece a un título propio, # 月下的瓜地 {style="plate" …}, cuyo estilo dibuja la página y le quita la cabecera y el folio. La lámina necesita span: 'page', porque el diseño de un título que se queda en la columna se corta al pie de la columna, 18 mm antes del borde. runningChapter: false con toc: false la deja fuera de los marcadores del PDF, que empiezan por 故乡. Las tres líneas de la cita son tres atributos y tres elementos de texto, porque el texto de diseño tiene su propio corte de líneas y no aplica los anchos de la puntuación china; al partirlas a mano, las líneas pierden además sus comas, como suele hacerse con las líneas de exhibición en chino.
#6 · Letras latinas y cifras en el texto chino
const paragraphStyles = [
// 小五 (9 pt) nudged to 9.1875 pt: 32 of its characters fill the 28-em measure exactly.
{ id: 'note', fontSize: pt((CHARS * BODY) / 32), lineHeight: pt(LEAD), color: col('ink'),
firstLineIndent: em(2), marginTop: pt(LEAD) }, // on the grid, a line below the text
{ id: 'colophon', fontFamily: HEI, fontSize: pt(7), lineHeight: pt(11), color: col('muted'),
textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];
La nota y el colofón de la página 76 son el sitio donde las cifras y los nombres latinos tocan los caracteres chinos. latinSpacing pone un cuarto de cuadratín allí donde se tocan, como en el 1921年 de la nota y en el 用Noto Serif SC五号 del colofón, escritos sin ningún espacio (Espacio entre chino y latín); en una línea justificada, ese espacio es lo primero que crece, hasta medio cuadratín. La nota va a 9,1875 pt y no a los 9 pt del 小五 para que 32 de sus caracteres llenen la medida: a 9 pt a cada línea le sobraban dos tercios de em, y se los quedaban los espacios entre chino y latín. En el Markdown, los nombres de las fuentes llevan espacios de no separación (U+00A0), que el espacio entre chino y latín respeta: con espacios normales, las líneas en bandera del colofón partían Noto Sans / SC.
La receta completa
// ═══ Postext Cookbook · Nº 074 · A Chinese novel page on a 28 × 28 grid ════════════ // https://postext.dev/en/cookbook/chinese-novel-horizontal // Code: MIT · Text: Lu Xun, 故乡 (1921), public domain, zh.wikisource · Plate: drawn in code // Fonts: Noto Serif SC, Noto Sans SC, Ma Shan Zheng (SIL OFL 1.1) · Needs postext ≥ 1.9.0 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'chinese-novel-horizontal'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // The night of the story's memory, and ink on a cream paper. const palette = { ink: '#1f1c19', // text: a warm near-black night: '#1d3150', // the accent: the deep blue sky of the plate, the opener's rule moon: '#dcaa45', // the golden moon, on the plate only melon: '#3c6a3b', // the green melons sand: '#e4d5b0', // the sand by the sea rule: '#c9c1b2', // the hairline under the running heads muted: '#6a645b', // running heads, folios, the colophon paper: '#fbf8f1', // a cream book paper }; 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: 'night (defaults)', value: { hex: palette.night, model: 'hex' } }, ]; const SONG = 'Noto Serif SC'; // 宋: the text, and the title in its black weight const HEI = 'Noto Sans SC'; // 黑: running heads, folios, the author, the colophon const KAI = 'Ma Shan Zheng'; // 楷: a brush regular script for the plate's quotation const [BODY, LEAD] = [10.5, 16.5]; // pt: 五号 on a 6 pt line gap, the grid's pitch const [CHARS, LINES] = [28, 28]; const TRIM = { width: 140, height: 203 }; // mm: 大32开 const MEASURE = (CHARS * BODY * 25.4) / 72; // 103.7 mm: 28 ems of 五号 const SIDE = (TRIM.width - MEASURE) / 2; // 18.1 mm: the grid centres the measure // #region answer: 28 characters × 28 lines of 五号, mainland line breaks and Kaiming marks const cjk = { // The type area in characters: 28 ems wide, 28 lines of LEAD tall. page.margins become // minimums, and the grid centres the type area in the room they leave. grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES }, // What 'zh-Hans' gives by itself, written out so a reader can compare regions and styles. region: 'mainland', lineBreak: 'gb', // no 。,、”》 opens a line, no “《( ends one, and no / at either end punctuationWidth: 'kaiming', // ,、:; quotes, brackets ½ em; 。?! one em, ½ at a line end compressAdjacent: true, // idle under Kaiming (no pair tops 1.5 em); 'fullwidth' needs it latinSpacing: em(0.25), // 1921年, 用Noto Serif SC五号: a quarter em, none of it typed }; const bodyText = { fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', // spread between the characters, never between words firstLineIndent: em(2), indentAfterHeading: true, // two characters, every paragraph }; // #endregion // #region page: 大32开 with a head margin larger than the foot const page = { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), // 22 + 18 mm leave 163 mm, exactly 28 lines of 16.5 pt; 18 mm a side leave the 103.7 mm // measure 0.3 mm to share. A grid that no longer fits is cut down (cjkGridClamped). margins: { top: mm(22), bottom: mm(18), left: mm(18), right: mm(18), mirror: true }, }; // #endregion // #region opener: the title in the Song face's black weight, sunk 8 lines const SINK = 8; // lines of LEAD: a whole number, so the text under it keeps to the grid const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } }); const opener = { enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [ // The spacing after the last character is advance, not ink: the title stays centred. { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SONG, fontWeight: 900, fontSize: pt(46), letterSpacing: pt(23), lineHeight: 1.1, color: col('ink'), align: 'center', placement: centred(6) }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.2), color: col('night'), placement: { ...centred(30), size: { width: mm(8) } } }, { kind: 'text', id: 'author', content: '{author}', fontFamily: HEI, fontSize: pt(10.5), letterSpacing: pt(5), color: col('ink'), align: 'center', placement: centred(34) }, ] }, }; // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener }; // #endregion // #region heads: the book on the verso, the story on the recto, a hairline under both const HEAD_Y = 11; // mm from the top edge to the heads; the hairline 5 mm lower const label = { fontFamily: HEI, fontSize: pt(8), color: col('muted'), pages: 'body' }; const at = (edge, x, y = HEAD_Y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } }); const header = { elements: [ { kind: 'text', id: 'verso-folio', content: '{pageNumber}', parity: 'even', ...label, placement: at('top-left', SIDE) }, { kind: 'text', id: 'verso-book', content: '{title}', parity: 'even', ...label, letterSpacing: pt(4), placement: at('top', 0) }, { kind: 'text', id: 'recto-story', content: '{chapterTitle}', parity: 'odd', ...label, letterSpacing: pt(4), placement: at('top', 0) }, { kind: 'text', id: 'recto-folio', content: '{pageNumber}', parity: 'odd', ...label, placement: at('top-right', -SIDE) }, { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.5), color: col('rule'), pages: 'body', placement: { ...at('top-left', SIDE, HEAD_Y + 5), size: { width: mm(MEASURE) } }, }, ] }; // The opener has no head: its folio drops to the foot, centred under the text. const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', ...label, pages: 'opener', align: 'center', placement: at('bottom', 0, -10) }] }; // #endregion // #region plate: the moonlit melon field faces the opener, the quotation set in Kai const resources = [{ id: 'moon', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'moon.svg', width: TRIM.width, height: TRIM.height }, // the trim's ratio altText: t({ en: 'A golden full moon in a deep blue sky over a strip of sea. On the sand below, among ' + 'rows of striped watermelons, a boy with a silver collar stabs a steel fork at the ' + 'sand ahead of him, and the small animal he aimed at runs off between his legs.', es: 'Una luna llena dorada en un cielo azul oscuro sobre una franja de mar. En la arena, ' + 'entre hileras de sandías rayadas, un muchacho con un aro de plata al cuello clava una ' + 'horquilla de acero en la arena, delante de él, y el animalillo al que apuntaba huye ' + 'entre sus piernas.', }), }]; const quote = { fontFamily: KAI, fontSize: pt(14), lineHeight: 1.5, color: col('paper') }; // # 月下的瓜地 {style="plate" line1="…" line2="…" line3="…"}: a page with no head or folio. // 'page' spans the design over the sheet: a heading's design in the column is cut at its foot. const plate = { id: 'plate', span: 'page', runningChapter: false, toc: false, // out of the PDF outline breakBefore: { enabled: true, parity: 'any' }, // (gotcha: style-inherits-break) header: { elements: [] }, footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'picture', resourceId: 'moon', placement: { // the whole trim anchor: { to: 'bleed', edge: 'top-left' }, size: { width: mm(TRIM.width), height: mm(TRIM.height) } } }, { kind: 'text', id: 'line1', content: '{attr.line1}', ...quote, align: 'left', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(2) } } }, { kind: 'text', id: 'line2', content: '{attr.line2}', ...quote, align: 'left', placement: { anchor: { to: '#line1', edge: 'below' } } }, { kind: 'text', id: 'line3', content: '{attr.line3}', ...quote, align: 'left', placement: { anchor: { to: '#line2', edge: 'below' } } }, ] } }, }; // #endregion // #region styles: the editor's note in 小五 Song, the colophon in Hei const paragraphStyles = [ // 小五 (9 pt) nudged to 9.1875 pt: 32 of its characters fill the 28-em measure exactly. { id: 'note', fontSize: pt((CHARS * BODY) / 32), lineHeight: pt(LEAD), color: col('ink'), firstLineIndent: em(2), marginTop: pt(LEAD) }, // on the grid, a line below the text { id: 'colophon', fontFamily: HEI, fontSize: pt(7), lineHeight: pt(11), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD / 2) }, ]; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: 'zh-Hans', // written out, never LANG (gotcha: cjk-locale-tag) colorPalette, page, layout: { layoutType: 'single' }, cjk, bodyText, // The designs paint the titles; weight 400 keeps the heading blocks in the loaded face. headings: { fontFamily: SONG, fontWeight: 400, levels: [chapter] }, headingStyles: [plate], paragraphStyles, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Muestra en Markdown · 141 líneas · content.es.md
title: "呐喊" author: "鲁迅" --- # 月下的瓜地 {style="plate" line1="深蓝的天空中挂着一轮金黄的圆月" line2="下面是海边的沙地" line3="都种着一望无际的碧绿的西瓜"} # 故乡 我冒了严寒,回到相隔二千余里,别了二十余年的故乡去。 时候既然是深冬;渐近故乡时,天气又阴晦了,冷风吹进船舱中,呜呜的响,从篷隙向外一望,苍黄的天底下,远近横着几个萧索的荒村,没有一些活气。我的心禁不住悲凉起来了。 阿!这不是我二十年来时时记得的故乡? 我所记得的故乡全不如此。我的故乡好得多了。但要我记起他的美丽,说出他的佳处来,却又没有影像,没有言辞了。仿佛也就如此。于是我自己解释说:故乡本也如此,——虽然没有进步,也未必有如我所感的悲凉,这只是我自己心情的改变罢了,因为我这次回乡,本没有什么好心绪。 我这次是专为了别他而来的。我们多年聚族而居的老屋,已经公同卖给别姓了,交屋的期限,只在本年,所以必须赶在正月初一以前,永别了熟识的老屋,而且远离了熟识的故乡,搬家到我在谋食的异地去。 第二日清早晨我到了我家的门口了。瓦楞上许多枯草的断茎当风抖着,正在说明这老屋难免易主的原因。几房的本家大约已经搬走了,所以很寂静。我到了自家的房外,我的母亲早已迎着出来了,接着便飞出了八岁的侄儿宏儿。 我的母亲很高兴,但也藏着许多凄凉的神情,教我坐下,歇息,喝茶,且不谈搬家的事。宏儿没有见过我,远远的对面站着只是看。 但我们终于谈到搬家的事。我说外间的寓所已经租定了,又买了几件家具,此外须将家里所有的木器卖去,再去增添。母亲也说好,而且行李也略已齐集,木器不便搬运的,也小半卖去了,只是收不起钱来。 “你休息一两天,去拜望亲戚本家一回,我们便可以走了。”母亲说。 “是的。” “还有闰土,他每到我家来时,总问起你,很想见你一回面。我已经将你到家的大约日期通知他,他也许就要来了。” 这时候,我的脑里忽然闪出一幅神异的图画来:深蓝的天空中挂着一轮金黄的圆月,下面是海边的沙地,都种着一望无际的碧绿的西瓜,其间有一个十一二岁的少年,项带银圈,手捏一柄钢叉,向一匹猹尽力的刺去,那猹却将身一扭,反从他的胯下逃走了。 这少年便是闰土。我认识他时,也不过十多岁,离现在将有三十年了;那时我的父亲还在世,家景也好,我正是一个少爷。那一年,我家是一件大祭祀的值年。这祭祀,说是三十多年才能轮到一回,所以很郑重;正月里供祖像,供品很多,祭器很讲究,拜的人也很多,祭器也很要防偷去。我家只有一个忙月(我们这里给人做工的分三种:整年给一定人家做工的叫长年;按日给人做工的叫短工;自己也种地,只在过年过节以及收租时候来给一定的人家做工的称忙月),忙不过来,他便对父亲说,可以叫他的儿子闰土来管祭器的。 我的父亲允许了;我也很高兴,因为我早听到闰土这名字,而且知道他和我仿佛年纪,闰月生的,五行缺土,所以他的父亲叫他闰土。他是能装弶捉小鸟雀的。 我于是日日盼望新年,新年到,闰土也就到了。好容易到了年末,有一日,母亲告诉我,闰土来了,我便飞跑的去看。他正在厨房里,紫色的圆脸,头戴一顶小毡帽,颈上套一个明晃晃的银项圈,这可见他的父亲十分爱他,怕他死去,所以在神佛面前许下愿心,用圈子将他套住了。他见人很怕羞,只是不怕我,没有旁人的时候,便和我说话,于是不到半日,我们便熟识了。 我们那时候不知道谈些什么,只记得闰土很高兴,说是上城之后,见了许多没有见过的东西。 第二日,我便要他捕鸟。他说: “这不能。须大雪下了才好。我们沙地上,下了雪,我扫出一块空地来,用短棒支起一个大竹匾,撒下秕谷,看鸟雀来吃时,我远远地将缚在棒上的绳子只一拉,那鸟雀就罩在竹匾下了。什么都有:稻鸡、角鸡、鹁鸪、蓝背……” 我于是又很盼望下雪。 闰土又对我说: “现在太冷,你夏天到我们这里来。我们日里到海边检贝壳去,红的绿的都有,鬼见怕也有,观音手也有,晚上我和爹管西瓜去,你也去。” “管贼么?” “不是。走路的人口渴了摘一个瓜吃,我们这里是不算偷的。要管的是獾猪、刺猬、猹。月亮地下,你听,啦啦的响了,猹在咬瓜了。你便捏了胡叉,轻轻地走去……” 我那时并不知道这所谓猹的是怎么一件东西——便是现在也没有知道——只是无端的觉得状如小狗而很凶猛。 “他不咬人么?” “有胡叉呢。走到了,看见猹了,你便刺。这畜生很伶俐,倒向你奔来,反从胯下窜了。他的皮毛是油一般的滑。……” 我素不知道天下有这许多新鲜事:海边有如许五色的贝壳;西瓜有这样危险的经历,我先前单知道他在水果店里出卖罢了。 “我们沙地里,潮汛要来的时候,就有许多跳鱼儿只是跳,都有青蛙似的两个脚。……” 阿!闰土的心里有无穷无尽的希奇的事,都是我往常的朋友所不知道的。他们不知道一些事,闰土在海边时,他们都和我一样只看见院子里高墙上的四角的天空。 可惜正月过去了,闰土须回家里去,我急得大哭,他也躲到厨房里,哭着不肯出门,但终于被他父亲带走了。他后来还托他的父亲带给我一包贝壳和几枝很好看的鸟毛,我也曾送他一两次东西,但从此没有再见面。 现在我的母亲提起了他,我这儿时的记忆,忽而全都闪电似的苏生过来,似乎看到了我的美丽的故乡了。我应声说: “这好极!他,——怎样?……” “他?……他景况也很不如意……”母亲说着,便向房外看,“这些人又来了。说是买木器,顺手也就随便拿走的,我得去看看。” 母亲站起身,出去了。门外有几个女人的声音,我便招宏儿走近面前,和他闲话:问他可会写字,可愿意出门。 “我们坐火车去么?” “我们坐火车去。” “船呢?” “先坐船,……” “哈!这模样了!胡子这么长了!”一种尖利的怪声突然大叫起来。 我吃了一吓,赶忙抬起头,却见一个凸颧骨,薄嘴唇,五十岁上下的女人站在我面前,两手搭在髀间,没有系裙,张着两脚,正像一个画图仪器里细脚伶仃的圆规。 我愕然了。 “不认识了么?我还抱你咧!” 我愈加愕然了。幸而我的母亲也就进来,从旁说: “他多年出门,统忘却了。你该记得罢,”便向着我说,“这是斜对门的杨二嫂,……开豆腐店的。” 哦,我记得了。我孩子时候,在斜对门的豆腐店里确乎终日坐着一个杨二嫂,人都叫伊“豆腐西施”。但是擦着白粉,颧骨没有这么高,嘴唇也没有这么薄。而且终日坐着,我也从没有见过这圆规式的姿势。那时人说:因为伊,这豆腐店的买卖非常好。但这大约因为年龄的关系,我却并未蒙着一毫感化,所以竟完全忘却了。然而圆规很不平,显出鄙夷的神色,仿佛嗤笑法国人不知道拿破仑,美国人不知道华盛顿似的,冷笑说: “忘了?这真是贵人眼高。……” “那有这事……我……”我惶恐着,站起来说。 “那么,我对你说。迅哥儿,你阔了,搬动又笨重,你还要什么这些破烂木器,让我拿去罢。我们小户人家,用得着。” “我并没有阔哩。我须卖了这些,再去……” “阿呀呀,你放了道台了,还说不阔?你现在有三房姨太太;出门便是八抬的大轿,还说不阔?吓,什么都瞒不过我。” 我知道无话可说了,便闭了口,默默的站着。 “阿呀阿呀,真是愈有钱,便愈是一毫不肯放松,愈是一毫不肯放松,便愈有钱……”圆规一面愤愤的回转身,一面絮絮的说,慢慢向外走,顺便将我母亲的一副手套塞在裤腰里,出去了。 此后又有近处的本家和亲戚来访问我。我一面应酬,偷空便收拾些行李,这样的过了三四天。 一日是天气很冷的午后,我吃过午饭,坐着喝茶,觉得外面有人进来了,便回头去看。我看时,不由的非常出惊,慌忙站起身,迎着走去。 这来的便是闰土。虽然我一见便知道是闰土,但又不是我这记忆上的闰土了。他身材增加了一倍;先前的紫色的圆脸,已经变作灰黄,而且加上了很深的皱纹;眼睛也像他父亲一样,周围都肿得通红,这我知道,在海边种地的人,终日吹着海风,大抵是这样的。他头上是一顶破毡帽,身上只一件极薄的棉衣,浑身瑟索着;手里提着一个纸包和一枝长烟管,那手也不是我所记得的红活圆实的手,却又粗又笨而且开裂,像是松树皮了。 我这时很兴奋,但不知道怎么说才好,只是说: “阿!闰土哥,——你来了?……” 我接着便有许多话,想要连珠一般涌出:角鸡、跳鱼儿、贝壳、猹,……但又总觉得被什么挡着似的。单在脑里面回旋,吐不出口外去。 他站住了,脸上现出欢喜和凄凉的神情;动着嘴唇,却没有作声。他的态度终于恭敬起来了,分明的叫道: “老爷!……” 我似乎打了一个寒噤;我就知道,我们之间已经隔了一层可悲的厚障壁了。我也说不出话。 :::paragraphs{style="note"} (本篇作于1921年1月,同年5月1日发表于《新青年》第9卷第1号,1923年8月收入小说集《呐喊》。这里节选前半篇,至闰土来访为止。文字据1948年版《鲁迅全集》第一卷,改排为简体横排:引号『』改作“”,“馀”“偸”改作“余”“偷”。) ::: :::paragraphs{style="colophon"} 本书正文用Noto Serif SC五号字排,每行28字,每面28行;书眉、页码和版本说明用Noto Sans SC,篇首题句用Ma Shan Zheng,均为SIL OFL字体。篇首插图由程序绘制。 ::: :::paragraphs{style="colophon"} Lu Xun, «El pueblo natal» (故乡), 1921: la primera mitad del relato · Texto: dominio público, de zh.wikisource · Lámina: dibujada en código. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the boy, the moon and the melon field, drawn in millimetres at the trim size function mulberry32(seed) { // a seeded PRNG: the same field on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round( parseInt(hex.slice(i, i + 2), 16) * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k) .toString(16).padStart(2, '0')).join('')}`; const n2 = (v) => +v.toFixed(2); function drawPlate(W, H) { const rnd = mulberry32(1921); const P = palette; const out = []; const rect = (y0, y1, fill) => out.push(`<rect x="0" y="${n2(y0)}" width="${W}" ` + `height="${n2(y1 - y0)}" fill="${fill}"/>`); const circle = (cx, cy, r, fill, extra = '') => out.push(`<circle cx="${n2(cx)}" ` + `cy="${n2(cy)}" r="${n2(r)}" fill="${fill}"${extra}/>`); const line = (pts, color, w) => out.push(`<path d="M${pts.map(([x, y]) => `${n2(x)} ${n2(y)}`) .join('L')}" fill="none" stroke="${color}" stroke-width="${n2(w)}" ` + 'stroke-linecap="round" stroke-linejoin="round"/>'); const HORIZON = 112; // mm: the line of the sea const SHORE = 120; // mm: where the sand begins // The sky in four flat steps, darkest at the top; the moon in three rings of light. [[0, 0], [56, 0.06], [84, 0.12], [100, 0.19]].forEach(([y, k]) => rect(y, HORIZON, mix(P.night, '#6f8fb8', k))); const [MX, MY] = [98, 66]; [[30, 0.1], [23, 0.15], [17.5, 0.22]].forEach(([r, k]) => circle(MX, MY, r, mix(P.night, '#9fb6d4', k))); circle(MX, MY, 13, P.moon); circle(MX - 3.8, MY - 2.6, 2.4, mix(P.moon, '#ffffff', 0.22)); circle(MX + 4.4, MY + 3.6, 1.6, mix(P.moon, P.night, 0.1)); // The sea: a dark strip, the moon's path broken on the swell. rect(HORIZON, SHORE, mix(P.night, '#0f2a33', 0.55)); for (let i = 0; i < 9; i++) { const w = 1.5 + rnd() * (4 + i * 0.8); out.push(`<rect x="${n2(MX - w / 2 + (rnd() - 0.5) * 5)}" y="${n2(HORIZON + 0.8 + i * 0.8)}" ` + `width="${n2(w)}" height="0.35" fill="${mix(P.moon, P.night, 0.1 + i * 0.06)}"/>`); } // The sand, in two tones. rect(SHORE, H, P.sand); out.push(`<path d="M0 ${SHORE + 12}C35 ${SHORE + 6} 80 ${SHORE + 16} ${W} ${SHORE + 8}` + `L${W} ${H}L0 ${H}Z" fill="${mix(P.sand, P.melon, 0.07)}"/>`); // Rows of melons to the sea: each nearer row lower, larger and sparser. const [leaf, stripe] = [mix(P.melon, P.ink, 0.25), mix(P.melon, P.ink, 0.5)]; const melon = (x, y, s) => { out.push(`<ellipse cx="${n2(x)}" cy="${n2(y)}" rx="${n2(s)}" ry="${n2(s * 0.66)}" ` + `fill="${P.melon}"/>`); for (const k of [-0.62, -0.2, 0.2, 0.62]) { // stripes follow the melon's curve out.push(`<path d="M${n2(x - s * 0.96)} ${n2(y)}Q${n2(x)} ${n2(y + k * s * 1.3)} ` + `${n2(x + s * 0.96)} ${n2(y)}" fill="none" stroke="${stripe}" ` + `stroke-width="${n2(s * 0.11)}" stroke-linecap="round"/>`); } out.push(`<ellipse cx="${n2(x - s * 0.38)}" cy="${n2(y - s * 0.34)}" rx="${n2(s * 0.3)}" ` + `ry="${n2(s * 0.1)}" fill="${mix(P.melon, '#ffffff', 0.35)}"/>`); }; const leafAt = (x, y, s, a) => out.push(`<path d="M0 0C${n2(s * 0.3)} ${n2(-s * 0.9)} ` + `${n2(s * 1.4)} ${n2(-s * 0.8)} ${n2(s * 1.6)} 0C${n2(s * 1.4)} ${n2(s * 0.7)} ` + `${n2(s * 0.3)} ${n2(s * 0.8)} 0 0Z" fill="${leaf}" ` + `transform="translate(${n2(x)} ${n2(y)}) rotate(${n2(a)})"/>`); const rows = 8; const rowY = (row) => SHORE + 3 + (H - SHORE + 8) * ((row + 1) / rows) ** 1.8; const field = []; for (let row = 0; row < rows; row++) { const depth = (row + 1) / rows; // 0 far, 1 near const y = rowY(row); const s = 0.8 + 6.4 * depth ** 1.7; // a melon's half-length, mm const wave = (x) => y + Math.sin(x / 11 + row * 1.7) * (0.3 + depth * 1.2); const vine = []; for (let x = -3; x <= W + 3; x += 3) vine.push([x, wave(x)]); line(vine, leaf, 0.15 + depth * 0.45); for (let x = rnd() * s * 4; x < W + s; x += s * (3.2 + rnd() * 3.4)) { field.push({ row, x, y: wave(x) - s * 0.5, s, depth }); } } // The boy with the silver collar stabs at the badger-like zha, which slips between his legs. const boy = { x: 47, y: rowY(4) + 2, h: 31 }; // feet on the fifth row for (const m of field) { if (m.row === 4 && m.x > boy.x - 14 && m.x < boy.x + 32) continue; // his patch, the fork's leafAt(m.x - m.s * 0.6, m.y + m.s * 0.4, m.s * 0.9, 190 + rnd() * 40); leafAt(m.x + m.s * 0.5, m.y + m.s * 0.45, m.s * 0.8, -20 + rnd() * 40); melon(m.x, m.y, m.s); } const figure = mix(P.night, P.ink, 0.35); const u = boy.h / 30; // the figure is drawn on a 30-unit height const at = (x, y) => [boy.x + x * u, boy.y - y * u]; const xy = (x, y) => at(x, y).map(n2).join(' '); const steel = mix(P.sand, '#ffffff', 0.4); line([at(-1, 12), at(-5.5, 0.4)], figure, 2 * u); // the back leg line([at(1.2, 12), at(6, 5.5), at(7.5, 0.4)], figure, 2 * u); // the front knee bent out.push(`<path d="M${xy(-3.2, 22.6)}L${xy(3.4, 22.6)}L${xy(4.2, 11)}L${xy(-3.8, 11)}Z" ` + `fill="${figure}"/>`); // the tunic // The silver collar round the neck: its back arc behind the neck, its front arc over it. const collar = (sweep) => out.push(`<path d="M${xy(-1.2, 23.1)}A${n2(1.6 * u)} ` + `${n2(0.55 * u)} 0 0 ${sweep} ${xy(2, 23.1)}" fill="none" stroke="${steel}" ` + `stroke-width="${n2(0.55 * u)}"/>`); collar(1); line([at(0.4, 22.2), at(0.4, 24.4)], figure, 2 * u); // the neck circle(...at(0.4, 26.4), 2.9 * u, figure); // the head collar(0); line([at(2.6, 21.5), at(8.2, 11.8)], figure, 1.5 * u); // both hands on the shaft line([at(-2.4, 21), at(4.4, 14.3)], figure, 1.5 * u); // The steel fork stabs down at the sand: a shaft, a crossbar and three parallel tines. const [butt, head] = [[-3, 19.4], [19, 4.4]]; const len = Math.hypot(head[0] - butt[0], head[1] - butt[1]); const [dx, dy] = [(head[0] - butt[0]) / len, (head[1] - butt[1]) / len]; const across = (k) => [head[0] - k * 1.3 * dy, head[1] + k * 1.3 * dx]; line([at(...butt), at(...head)], steel, 0.55 * u); line([at(...across(-1)), at(...across(1))], steel, 0.45 * u); for (const k of [-1, 0, 1]) { const [x0, y0] = across(k); line([at(x0, y0), at(x0 + 3.8 * dx, y0 + 3.8 * dy)], steel, 0.45 * u); } // The zha he aimed at has slipped between his legs and runs off to the left, tail up. const fur = mix(P.sand, P.ink, 0.62); const [zx, zy] = at(0.6, 1.6); out.push(`<path d="M${n2(zx - 4.6 * u)} ${n2(zy - 0.2 * u)}L${n2(zx - 2.6 * u)} ` + `${n2(zy - 1.6 * u)}C${n2(zx)} ${n2(zy - 2.4 * u)} ${n2(zx + 3 * u)} ${n2(zy - 1.8 * u)} ` + `${n2(zx + 3.4 * u)} ${n2(zy - 0.2 * u)}C${n2(zx + 2 * u)} ${n2(zy + 1 * u)} ` + `${n2(zx - 2 * u)} ${n2(zy + 1 * u)} ${n2(zx - 4.6 * u)} ${n2(zy - 0.2 * u)}Z" ` + `fill="${fur}"/>`); // a pointed snout, a round back line([[zx + 3 * u, zy - 1 * u], [zx + 4.8 * u, zy - 2.8 * u]], fur, 0.7 * u); // tail [[-2, 1.8], [-0.8, 2.2], [1.4, 2], [2.6, 1.6]].forEach(([ddx, ddy], i) => line( [[zx + ddx * u, zy + 0.4 * u], [zx + (ddx + (i % 2 ? 1 : -1)) * u, zy + ddy * u]], fur, 0.45 * u)); // legs mid-stride return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}mm" height="${H}mm" ` + `viewBox="0 0 ${W} ${H}">${out.join('')}</svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Noto Serif SC': ['400', '900'], // SONG: the text; the title 'Noto Sans SC': ['400'], // HEI: running heads, folios, the author, the colophon 'Ma Shan Zheng': ['400'], // KAI: the plate's quotation }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // Each voice loads the files of the characters it sets (gotcha: cjk-fonts-slices). const part = (re) => markdown.match(re)?.[0] ?? ''; const colophon = part(/:::paragraphs\{style="colophon"\}[\s\S]*$/); await loadFonts(FONTS, markdown); await loadCjkFonts({ [SONG]: ['400'] }, markdown); await loadCjkFonts({ [SONG]: ['900'] }, '故乡'); await loadCjkFonts({ [HEI]: ['400'] }, `呐喊故乡鲁迅0123456789${colophon}`); await loadCjkFonts({ [KAI]: ['400'] }, part(/^# .*style="plate".*$/m)); await loadSvg('moon.svg', drawPlate(TRIM.width, TRIM.height)); // The plate is page 70 of the book, a verso: folios and parity follow the book. const continuation = { pageIndexOffset: 69, pageNumbering: { startAt: 70 } }; const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'A Chinese novel page on a 28 × 28 grid', es: 'Una página de novela china en una retícula de 28 × 28' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);Kit · core, fonts, viewer, pdf, images, cjk: igual en todas las recetas · 457 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 · 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
#Compón toda la puntuación a ancho completo (全角式)
Las comas, las comas de enumeración y las comillas ocupan un carácter entero, 。” ocupa dos y en cada línea cabe menos texto.
- punctuationWidth: 'kaiming', // ,、:; quotes, brackets ½ em; 。?! one em, ½ at a line end
- compressAdjacent: true, // idle under Kaiming (no pair tops 1.5 em); 'fullwidth' needs it
+ punctuationWidth: 'fullwidth',
+ compressAdjacent: false,#Muestra la retícula mientras compones
El lienzo dibuja un cuadrado gris claro por cada posición de carácter de cada línea (稿纸); el PDF no lo incluye salvo que renderToPdf reciba characterGrid: true.
- grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
+ grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES, show: true },Errores frecuentes
Error frecuente
Las fuentes chinas se cargan por fragmentos, con el bloque cjk
Fontsource sirve una familia china, japonesa o coreana en un centenar de archivos por peso, cada uno con un intervalo de caracteres. loadFonts solo descarga el archivo latin, así que en pantalla los caracteres chinos salen de una fuente del sistema y se miden mal, y fontsourceProvider entrega al PDF ese archivo latin, que los imprime como cajas vacías. Añade el bloque cjk del kit, llama a loadCjkFonts(FONTS, markdown) después de loadFonts (una vez por voz, con el texto que compone, si el libro usa varias fuentes chinas) y pasa a renderToPdf fontProvider: cjkPdfProvider: ambos toman los archivos que contienen los caracteres del texto. Fuentes chinas, japonesas y coreanas →
Error frecuente
Etiqueta el documento zh-Hans o zh-Hant, no con LANG
Las ediciones de una receta son en y es, pero una muestra china es china en las dos: `locale: LANG` la etiquetaría como inglés o español, separaría sus palabras latinas, llamaría Figure o Figura a sus figuras y daría al PDF un idioma equivocado. Escribe tú la etiqueta: 'zh-Hans' (convenciones de la China continental: corte GB, puntuación Kaiming) o 'zh-Hant' (Taiwán: puntuación de ancho completo centrada); 'zh-HK' para Hong Kong. Un 'zh' a secas se lee como chino simplificado continental. Corte de líneas en chino →
Error frecuente
Compón la puntuación de cada región con una fuente de esa región
Los anchos de la puntuación mueven el blanco de cada signo al lado donde lo pone la región del documento, no al lado donde lo dibuja la fuente: con zh-Hans, una coma Kaiming conserva la mitad izquierda de su caja, donde una fuente simplificada dibuja el signo, y cede la derecha. Una fuente tradicional centra ,。 en la caja, así que la mitad que se va se lleva parte del signo y la coma se pega al carácter siguiente. LXGW WenKai TC, la única Kai de texto de Fontsource, lo hace en texto simplificado. Compón el texto, las notas y las citas continentales en Noto Serif SC o Noto Sans SC, deja las fuentes TC para zh-Hant y usa una letra de pincel simplificada (Ma Shan Zheng) solo en líneas de exhibición, donde el texto de diseño no aplica anchos de puntuación. Además dibuja formas heredadas que no imprime ni el estándar continental ni el de Taiwán, 為 con el 爫 de 爲 y 令 (en 冷, 領) con el pie de 卩: revisa los caracteres que compone en cada página. Anchura de la puntuación china →
Error frecuente
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 →
Error frecuente
Un estilo de título hereda el salto de página de su nivel
Una entrada de headingStyles toma de su nivel de título todo lo que no fija, también breakBefore. Un índice o un colofón con estilo sobre un H1 tras un :::pagebreak hereda la paridad 'odd' y cae detrás de una página en blanco. Dale a ese estilo breakBefore: { enabled: false }. Estilos de título →
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 · cjkGridClamped
Retícula de caracteres demasiado grande
Por qué. `cjk.grid` pide más caracteres por línea o más líneas por página de los que caben entre los márgenes con ese cuerpo e interlineado, así que la retícula se compone con los que caben.
Solución. Baja `charsPerLine` o `linesPerPage`, estrecha los márgenes (son mínimos) o reduce `bodyText.fontSize` o `lineHeight`. Documentación →
- La lámina es un título, y un título tiene marcador en el PDF: sin
runningChapter: falseytoc: falseen el estilo de la lámina, los marcadores empezarían por 月下的瓜地, en el primer nivel, antes de 故乡. Con uno solo de los dos ajustes, el marcador sigue ahí. El PDF etiquetado conserva el título de la lámina como H1. - La fuente de los títulos tiene que ser una de las que carga
FONTS, aunque un diseño pinte todos los títulos: con el peso 700 por defecto, el kit encontró Noto Serif SC 700 en la maqueta y cargó su archivo latino con un aviso.headings.fontWeight: 400deja los bloques de título en la fuente del texto.
Créditos
- Receta
- Ignacio Ferro
- Texto
- «El pueblo natal» (故乡, 1921), primera mitad, de las Obras completas de 1948, en caracteres simplificados · Lu Xun (鲁迅) · dominio público
- La nota del editor y el colofón · Postext Cookbook · original
- Imágenes
- El campo de sandías bajo la luna del relato, dibujado en código con la paleta de la página · Ignacio Ferro · MIT
- Fuentes
- Noto Serif SC (SIL OFL 1.1) · Noto Sans SC (SIL OFL 1.1) · Ma Shan Zheng (SIL OFL 1.1)
- Código
- MIT, como Postext


