Lo que vas a componer
Un aviso de una asociación de editores inventada, en una ciudad también inventada, 示例市 («Ciudad Ejemplo»), compuesto como compone sus papeles una oficina de la China continental según la GB/T 9704—2012, 《党政机关公文格式》. Son cuatro páginas A4 de 28 caracteres por línea y 22 líneas por página. La primera abre con el nombre del emisor en rojo, el número del documento sobre un filete rojo y un título de dos líneas en 二号. Los títulos se numeran 一、(一)1.(1), cada nivel con su propia letra. La firma y la fecha van a la derecha, y un anexo en página propia lleva al pie el 版记 entre filetes. Los folios dicen «— 1 —», a la derecha en las páginas impares y a la izquierda en las pares. Cada hoja avisa en el margen superior de que es una muestra. Carta comercial DIN 5008 hace el mismo trabajo con una norma europea.
Esta receta responde a
- ¿Cómo compongo un documento oficial chino según la GB/T 9704, con su membrete rojo, su retícula y sus folios?
- ¿Cómo defino la mancha en caracteres, tantos por línea y tantas líneas por página?
- ¿Cómo numero los capítulos 第一回, 第二回 con numerales chinos?
La respuesta corta
const cjk = {
grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES }, // 28 ems × 22 lines
// Every mark takes a whole cell, as on the standard's grid, and never gives any of it up.
punctuationWidth: 'fullwidth', compressAdjacent: false, trimLineStart: false,
hangingPunctuation: 'allow', // a , that may not open a line hangs past the 28th cell
latinSpacing: em(0), // 〔2026〕7号 and 2026年9月28日 set solid, as the standard prints them
};
const page = {
// 144 dpi is 2 px to the point: the 29 pt lines add up with no rounding (see Pitfalls).
width: mm(210), height: mm(297), dpi: 144, backgroundColor: col('paper'),
// With the grid on, margins are minimums. These leave 28 × 22 cells and 0.02 mm to share,
// so the type area sits 37 mm under the head and 28 mm from the binding edge.
margins: { top: mm(TOP), bottom: mm(297 - TOP - AREA.h - 0.02), left: mm(INNER),
right: mm(210 - INNER - AREA.w - 0.02), mirror: true },
};
const bodyText = {
fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
textAlign: 'justify', // a short line spreads between its characters, never between words
firstLineIndent: em(2), indentAfterHeading: true, // 左空二字,回行顶格
};
Ingredientes
- Funciones
- Retícula de caracteresNumeración chinaTítulos numeradosAnchura de la puntuación chinaCorte de líneas en chinoJustificación entre caracteresEspacio entre chino y latínFuentes chinas, japonesas y coreanasFormato de páginaMárgenes simétricosAperturas diseñadasTextos, filetes y cajas en los diseños de páginaAnclaje de elementos de diseñoCabeceras y foliosEstilos de títuloAtributos de títuloSaltos de línea en los títulosEstilos de párrafoEstilo de tablasExportación a PDF
- También usa
- Equilibrado de columnasFiguras justo aquíBanda de capítulo a todo el anchoColor del papelFuentes incrustadas en el PDFTipos de recurso propiosTablas a partir de datosCapítulos sin número
- Tipografía
- Noto Serif SC, Noto Sans SC, LXGW WenKai TC (SIL OFL 1.1)
- Recursos
- Ninguno: todas las imágenes se dibujan en código
Elaboración
#1 · Una mancha contada en casillas
El código está en la respuesta corta, más arriba. La GB/T 9704 fija el margen de cabeza en 37 mm y el de lomo en 28 mm, y llena una mancha de 156 × 225 mm con 22 líneas de 28 caracteres de cuerpo 三号. cjk.grid toma esas cifras tal cual: 28 em de 15,75 pt dan 155,6 mm, y 22 líneas de 29 pt, 225,1 mm (Retícula de caracteres). En Word, el 三号 mide 16 pt, y con él la línea tendría 158 mm, más ancha que la mancha de la norma; 15,75 pt es el valor de las tablas de los cajistas. Con la retícula activada, los márgenes son mínimos. Estos dejan 0,02 mm que repartir, así que la mancha se queda donde la pone la norma. La norma pide 仿宋 para el texto; Fontsource no tiene ninguna Fangsong, así que el texto va en la Song de Noto Serif SC (mira Errores frecuentes).
Cada signo ocupa una casilla entera y no cede nada de ella, de modo que, en una línea de chino, los caracteres se alinean en las columnas de la retícula que dibuja la norma. Las cifras arábigas son más estrechas que una casilla, así que en una línea como 版心156毫米×225毫米 los caracteres que siguen a las cifras caen entre las columnas. Cuando una coma cae en la casilla 29, hangingPunctuation: 'allow' la deja colgar en el margen en lugar de mandar un carácter a la línea siguiente y espaciar el resto (Puntuación colgada).

#2 · Cuatro niveles de títulos, una línea cada uno
// One line each at the body size, nothing above or below: every head stays on the grid.
// Headings have no indent of their own, so two ideographic spaces open each template.
const level = (n, fontFamily, fontWeight, numberingTemplate) => ({ level: n, fontFamily,
fontWeight, numberingTemplate, numberSeparator: '', fontSize: pt(BODY), lineHeight: pt(LEAD),
marginTop: pt(0), marginBottom: pt(0) });
const levels = [
level(2, HEI, 500, ' {2:一}、'), // 一 is the informal numeral in the document's script
level(3, KAI, 400, ' ({3:一})'),
level(4, SONG, 400, ' {4}.'),
level(5, SONG, 400, ' ({5})'),
];
La norma compone 一、 en 黑体, (一) en 楷体, y 1. y (1) en la letra del texto, todos al cuerpo del texto. {2:一} escribe el numeral chino corriente en la escritura de locale (Numeración). Con el cuerpo y la interlínea del texto y sin márgenes, cada título ocupa una línea de la retícula, y el texto que lo sigue tampoco se sale de ella. Los títulos no tienen sangría propia, así que los dos espacios ideográficos con que empieza cada plantilla son las dos casillas que deja la norma (Configuración por nivel).
#3 · El membrete, línea a línea
const LINE = LEAD * MM; // 10.23 mm
const lineTop = (n) => (n - 1) * LINE; // mm from the top of the type area to grid line n
const face = (fontFamily, size, fontWeight, colour, lineHeight = LEAD / size) => ({
fontFamily, fontSize: pt(size), fontWeight, color: col(colour), lineHeight });
// A text y mm down the type area, x mm in from its edge, as wide as the type area.
const text = (id, content, look, y, { edge = 'top-left', x = 0, width = AREA.w, ...more } = {},
) => ({ kind: 'text', id, content, ...look, align: 'center', overflow: 'wrap', ...more,
placement: { anchor: { to: 'container', edge }, offset: { x: mm(x), y: mm(y) },
size: { width: width === 'auto' ? 'auto' : mm(width) } } }); // wrap, not '…'
const rule = (id, y, thickness, colour = 'ink', reserve = false) => ({ kind: 'rule', id, reserve,
direction: 'horizontal', thickness, color: col(colour), placement: { anchor: { to: 'container',
edge: 'top-left' }, offset: { y: mm(y) }, size: { width: mm(AREA.w) } } });
const PAD = { left: pt(5), right: pt(5) }; // the specimen stamp's: not a GB/T 9704 element
const letterhead = { enabled: true, minHeight: pt(13 * LEAD), slot: { elements: [
text('copy', '{attr.copy}', face(SONG, BODY, 400, 'ink'), lineTop(1), { align: 'left' }),
text('stamp', '样 张', face(HEI, BODY, 500, 'red', 1.3), lineTop(1) + 1.5, { edge: 'top-right',
width: 'auto', box: { borderColor: col('red'), borderWidth: pt(1), padding: PAD } }),
// The name's ink starts 35 mm down, 0.07 em over its box; at 46 pt it ends in line 5.
text('issuer', '{attr.issuer}文件', face(SONG, 46, 900, 'red', 1), 35 + 0.07 * 46 * MM),
// The number two blank lines under the name; the red rule 4 mm under its characters.
text('number', '{attr.number}', face(SONG, BODY, 400, 'ink'), lineTop(8)),
rule('red-rule', lineTop(8) + ((LEAD + BODY) / 2) * MM + 4, mm(0.5), 'red', true),
text('title', '{titleText}', face(SONG, 22, 900, 'ink'), lineTop(12)), // 二号, 2 lines under
] } };
const notice = { level: 1, span: 'page', advancedDesign: letterhead,
marginTop: pt(0), marginBottom: pt(LEAD), // 空一行: a blank line, then the addressee
breakBefore: { enabled: true, parity: 'any' } }; // gotcha: headings-drop-h1-break
El membrete es el diseño del H1, cuyos atributos llevan el número de ejemplar, el emisor y el número del documento: la configuración guarda el papel timbrado y el Markdown, el documento. lineTop(n) da el borde superior de la línea n de la retícula, así que cada elemento queda donde lo cuenta la norma: el número, dos líneas en blanco por debajo del nombre del emisor; el título, dos líneas en blanco por debajo del filete rojo. A 46 pt, la tinta del nombre va desde 35 mm por debajo del borde superior de la mancha hasta el pie de la línea 5, de modo que las líneas 6 y 7 quedan en blanco y el número cae en la 8; a 48 pt entraría en la línea 6 y bajaría una línea todo lo demás. El \\ del título lo parte después de 关于举办, una línea corta sobre una larga: un trapecio, una de las dos formas que admite la norma (la otra es el rombo). minHeight reserva 13 líneas y marginBottom una más, de modo que el destinatario empieza en la línea 15 (Span y diseño avanzado).
#4 · Folios bajo la mancha
const FOLIO = 14; // pt: 四号
const folio = (id, parity, edge, x) => ({ kind: 'text', id, parity, content: '— {pageNumber} —',
...face(SONG, FOLIO, 400, 'ink', 1), align: edge.endsWith('left') ? 'left' : 'right',
overflow: 'clip', placement: { anchor: { to: 'container', edge }, // the type area's foot
offset: { x: pt(x), y: mm(7 - (FOLIO / 2) * MM) } } }); // the dashes 7 mm down
const footer = { elements: [folio('recto', 'odd', 'top-right', -FOLIO),
folio('verso', 'even', 'top-left', FOLIO)] };
const header = { elements: [{ kind: 'text', id: 'specimen', content: '{subtitle}', // 样张 …
...face(HEI, 7.5, 400, 'muted', 1.2), align: 'center', overflow: 'wrap',
placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(16) } } }] };
En el pie, el borde superior del contenedor es el pie de la mancha, así que offset.y se mide hacia abajo desde el texto (Encabezados y pies). Las rayas quedan 7 mm por debajo y a una casilla de 四号 del borde exterior: parity pone los folios impares a la derecha y los pares a la izquierda. El margen superior imprime {subtitle}, del frontmatter. Esa línea y el colofón que va bajo el 版记 son lo único que cambia entre la edición inglesa y la española.
#5 · El anexo y el 版记
const IMPRINT = AREA.h - 2 * LINE; // mm: two rows of the grid, ending on the type area's foot
const small = face(SONG, FOLIO, 400, 'ink', LEAD / FOLIO); // 四号
const inset = { x: FOLIO * MM, width: AREA.w - 2 * FOLIO * MM, reserve: false }; // 左右各空一字
const annex = {
id: 'annex', numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' },
span: 'page', marginTop: pt(0), marginBottom: pt(0), // line 4 is the table's float gap
advancedDesign: { enabled: true, minHeight: pt(3 * LEAD), slot: { elements: [
text('label', '{attr.label}', face(HEI, BODY, 500, 'ink'), lineTop(1), { align: 'left' }),
text('title', '{titleText}', face(SONG, 22, 900, 'ink'), lineTop(3)),
// reserve: false keeps the 版记 out of the room the heading takes: the table goes on.
rule('imprint-top', IMPRINT, mm(0.35)),
text('cc', '抄送:{attr.cc}', small, IMPRINT, { ...inset, align: 'left' }),
rule('imprint-mid', IMPRINT + LINE, mm(0.25)),
text('office', '{attr.office}', small, IMPRINT + LINE, { ...inset, align: 'left' }),
text('printed', '{attr.printed}', small, IMPRINT + LINE, { ...inset, align: 'right' }),
rule('imprint-foot', AREA.h - 0.35 / 2, mm(0.35)),
text('colophon', '{attr.colophon}', face(HEI, 7, 400, 'muted'), AREA.h + 16, inset),
] } },
};
El título del anexo tiene un estilo propio: página nueva, 附件 en la línea 1, el título en la 3 y ningún margen bajo las tres líneas que reserva. La línea 4 en blanco es el hueco que todo recurso en línea deja encima (inlineResourceGap, en Disposición), así que el filete superior de la tabla cae en la línea 5. El 版记 forma parte del mismo diseño y ocupa las dos líneas de la retícula que acaban en el pie de la mancha. Sus elementos llevan reserve: false, así que no suman nada al espacio que reserva el título y no empujan la tabla hacia abajo. La norma pone el 版记 en la última página, detrás de los anexos; con un anexo de una página, esa es la del anexo. Un anexo más largo pediría que dibujara el 版记 un título de su última página.
#6 · La firma y la fecha
const paragraphStyles = [
{ id: 'flush', firstLineIndent: pt(0) }, // 主送机关:居左顶格
{ id: 'annexes', marginTop: pt(LEAD) }, // 附件说明:正文下空一行,左空二字
// The date, 6.9 ems, starts two cells right of the name and ends two short: 右空二字.
{ id: 'signature', indent: em(17), firstLineIndent: pt(0), marginTop: pt(LEAD) },
{ id: 'date', indent: em(19), firstLineIndent: pt(0) },
];
Un aviso sin sello pone el nombre del emisor a dos casillas del margen derecho y la fecha debajo, dos casillas más a la derecha. Si la fecha es más larga que el nombre, acaba a dos casillas del margen derecho y el nombre se desplaza a la izquierda para conservar el escalón de dos casillas. La nuestra mide 6,9 em, algo menos que los 7 del nombre, así que, al pie de la letra, empezaría dos casillas a la derecha del nombre y acabaría a 0,14 em del margen. Leemos la regla por lo que busca, que la fecha no llegue nunca al borde, y aplicamos el segundo caso: la fecha acaba a poco más de dos casillas del margen. indent coloca las dos líneas en casillas enteras, a 17 y 19 em del margen (Estilos de párrafo).
La receta completa
// ═══ Postext Cookbook · Nº 082 · A Chinese official document to GB/T 9704 ═════════ // https://postext.dev/en/cookbook/chinese-official-document // Code: MIT · Text: a fictitious notice written for the recipe (CC BY 4.0) · Pictures: none // Fonts: Noto Serif SC, Noto Sans SC, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.9.0 import { buildDocument, renderPageToCanvas, clearMeasurementCache, parseTSV, mergeCells, } 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-official-document'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // GB/T 9704 prints everything black but the letterhead: the issuer's name and a rule in red. const palette = { ink: '#161616', red: '#d2161e', muted: '#8a8580', paper: '#ffffff' }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.red }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const SONG = 'Noto Serif SC'; // 宋: the text (in place of 仿宋, see the write-up) and 小标宋 const HEI = 'Noto Sans SC'; // 黑: the 一、 heads, the annex label, the specimen stamp const KAI = 'LXGW WenKai TC'; // 楷: the (一) heads const MM = 25.4 / 72; // mm per pt const [BODY, LEAD, CHARS, LINES] = [15.75, 29, 28, 22]; // 三号 on 29 pt lines, 28 × 22 of them const AREA = { w: CHARS * BODY * MM, h: LINES * LEAD * MM }; // 155.6 × 225.1 mm: the 版心 const [TOP, INNER] = [37, 28]; // mm: 天头 and 订口, the head and binding margins // #region answer: A4, 28 characters × 22 lines of 三号, full-width marks on the grid const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES }, // 28 ems × 22 lines // Every mark takes a whole cell, as on the standard's grid, and never gives any of it up. punctuationWidth: 'fullwidth', compressAdjacent: false, trimLineStart: false, hangingPunctuation: 'allow', // a , that may not open a line hangs past the 28th cell latinSpacing: em(0), // 〔2026〕7号 and 2026年9月28日 set solid, as the standard prints them }; const page = { // 144 dpi is 2 px to the point: the 29 pt lines add up with no rounding (see Pitfalls). width: mm(210), height: mm(297), dpi: 144, backgroundColor: col('paper'), // With the grid on, margins are minimums. These leave 28 × 22 cells and 0.02 mm to share, // so the type area sits 37 mm under the head and 28 mm from the binding edge. margins: { top: mm(TOP), bottom: mm(297 - TOP - AREA.h - 0.02), left: mm(INNER), right: mm(210 - INNER - AREA.w - 0.02), mirror: true }, }; const bodyText = { fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', // a short line spreads between its characters, never between words firstLineIndent: em(2), indentAfterHeading: true, // 左空二字,回行顶格 }; // #endregion // #region levels: 一、 in Hei, (一) in Kai, 1. and (1) in the text face, all 三号 on the grid // One line each at the body size, nothing above or below: every head stays on the grid. // Headings have no indent of their own, so two ideographic spaces open each template. const level = (n, fontFamily, fontWeight, numberingTemplate) => ({ level: n, fontFamily, fontWeight, numberingTemplate, numberSeparator: '', fontSize: pt(BODY), lineHeight: pt(LEAD), marginTop: pt(0), marginBottom: pt(0) }); const levels = [ level(2, HEI, 500, ' {2:一}、'), // 一 is the informal numeral in the document's script level(3, KAI, 400, ' ({3:一})'), level(4, SONG, 400, ' {4}.'), level(5, SONG, 400, ' ({5})'), ]; // #endregion // #region letterhead: the 版头 of page 1, placed line by line on the grid const LINE = LEAD * MM; // 10.23 mm const lineTop = (n) => (n - 1) * LINE; // mm from the top of the type area to grid line n const face = (fontFamily, size, fontWeight, colour, lineHeight = LEAD / size) => ({ fontFamily, fontSize: pt(size), fontWeight, color: col(colour), lineHeight }); // A text y mm down the type area, x mm in from its edge, as wide as the type area. const text = (id, content, look, y, { edge = 'top-left', x = 0, width = AREA.w, ...more } = {}, ) => ({ kind: 'text', id, content, ...look, align: 'center', overflow: 'wrap', ...more, placement: { anchor: { to: 'container', edge }, offset: { x: mm(x), y: mm(y) }, size: { width: width === 'auto' ? 'auto' : mm(width) } } }); // wrap, not '…' const rule = (id, y, thickness, colour = 'ink', reserve = false) => ({ kind: 'rule', id, reserve, direction: 'horizontal', thickness, color: col(colour), placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(y) }, size: { width: mm(AREA.w) } } }); const PAD = { left: pt(5), right: pt(5) }; // the specimen stamp's: not a GB/T 9704 element const letterhead = { enabled: true, minHeight: pt(13 * LEAD), slot: { elements: [ text('copy', '{attr.copy}', face(SONG, BODY, 400, 'ink'), lineTop(1), { align: 'left' }), text('stamp', '样 张', face(HEI, BODY, 500, 'red', 1.3), lineTop(1) + 1.5, { edge: 'top-right', width: 'auto', box: { borderColor: col('red'), borderWidth: pt(1), padding: PAD } }), // The name's ink starts 35 mm down, 0.07 em over its box; at 46 pt it ends in line 5. text('issuer', '{attr.issuer}文件', face(SONG, 46, 900, 'red', 1), 35 + 0.07 * 46 * MM), // The number two blank lines under the name; the red rule 4 mm under its characters. text('number', '{attr.number}', face(SONG, BODY, 400, 'ink'), lineTop(8)), rule('red-rule', lineTop(8) + ((LEAD + BODY) / 2) * MM + 4, mm(0.5), 'red', true), text('title', '{titleText}', face(SONG, 22, 900, 'ink'), lineTop(12)), // 二号, 2 lines under ] } }; const notice = { level: 1, span: 'page', advancedDesign: letterhead, marginTop: pt(0), marginBottom: pt(LEAD), // 空一行: a blank line, then the addressee breakBefore: { enabled: true, parity: 'any' } }; // gotcha: headings-drop-h1-break // #endregion // #region folios: “— 1 —” in 四号, 7 mm under the type area, a cell in from the outer edge const FOLIO = 14; // pt: 四号 const folio = (id, parity, edge, x) => ({ kind: 'text', id, parity, content: '— {pageNumber} —', ...face(SONG, FOLIO, 400, 'ink', 1), align: edge.endsWith('left') ? 'left' : 'right', overflow: 'clip', placement: { anchor: { to: 'container', edge }, // the type area's foot offset: { x: pt(x), y: mm(7 - (FOLIO / 2) * MM) } } }); // the dashes 7 mm down const footer = { elements: [folio('recto', 'odd', 'top-right', -FOLIO), folio('verso', 'even', 'top-left', FOLIO)] }; const header = { elements: [{ kind: 'text', id: 'specimen', content: '{subtitle}', // 样张 … ...face(HEI, 7.5, 400, 'muted', 1.2), align: 'center', overflow: 'wrap', placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(16) } } }] }; // #endregion // #region annex: the annex on a page of its own, the 版记 at the foot of that last page const IMPRINT = AREA.h - 2 * LINE; // mm: two rows of the grid, ending on the type area's foot const small = face(SONG, FOLIO, 400, 'ink', LEAD / FOLIO); // 四号 const inset = { x: FOLIO * MM, width: AREA.w - 2 * FOLIO * MM, reserve: false }; // 左右各空一字 const annex = { id: 'annex', numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' }, span: 'page', marginTop: pt(0), marginBottom: pt(0), // line 4 is the table's float gap advancedDesign: { enabled: true, minHeight: pt(3 * LEAD), slot: { elements: [ text('label', '{attr.label}', face(HEI, BODY, 500, 'ink'), lineTop(1), { align: 'left' }), text('title', '{titleText}', face(SONG, 22, 900, 'ink'), lineTop(3)), // reserve: false keeps the 版记 out of the room the heading takes: the table goes on. rule('imprint-top', IMPRINT, mm(0.35)), text('cc', '抄送:{attr.cc}', small, IMPRINT, { ...inset, align: 'left' }), rule('imprint-mid', IMPRINT + LINE, mm(0.25)), text('office', '{attr.office}', small, IMPRINT + LINE, { ...inset, align: 'left' }), text('printed', '{attr.printed}', small, IMPRINT + LINE, { ...inset, align: 'right' }), rule('imprint-foot', AREA.h - 0.35 / 2, mm(0.35)), text('colophon', '{attr.colophon}', face(HEI, 7, 400, 'muted'), AREA.h + 16, inset), ] } }, }; // #endregion // #region styles: the addressee flush left, the name and the date to the right const paragraphStyles = [ { id: 'flush', firstLineIndent: pt(0) }, // 主送机关:居左顶格 { id: 'annexes', marginTop: pt(LEAD) }, // 附件说明:正文下空一行,左空二字 // The date, 6.9 ems, starts two cells right of the name and ends two short: 右空二字. { id: 'signature', indent: em(17), firstLineIndent: pt(0), marginTop: pt(LEAD) }, { id: 'date', indent: em(19), firstLineIndent: pt(0) }, ]; // #endregion // The annex's table: the days, the sessions and who teaches them, every cell centred. const SCHEDULE = [['日期', '时间', '内容', '主讲'], ['10月20日', '上午9:00—11:30', '公文格式国家标准解读', '林 岚'], ['', '下午14:00—16:30', '版心、字体字号与行距', '周明远'], ['10月21日', '上午9:00—11:30', '标题层次、序数与页码', '陈思齐'], ['', '下午14:00—16:30', '书刊横排与竖排样张', '林 岚'], ['10月22日', '上午9:00—11:30', '公文样张排版实操', '周明远'], ['', '下午14:00—16:30', '上机考核与讲评', '全体教员']]; const cells = parseTSV(SCHEDULE.map((row) => row.join('\t')).join('\n')).rows .map((row) => row.map((c) => ({ ...c, align: 'center', verticalAlign: 'middle' }))); // Each date spans its two rows (gotcha: merged-cells-hiddenby). Widths in 四号 ems, 31.5 in all. const schedule = [1, 3, 5].reduce((model, row) => mergeCells(model, { start: { row, col: 0 }, end: { row: row + 1, col: 0 } }), { rows: cells, headerRowCount: 1, columnWidths: [5.6, 9.6, 11.3, 5] }); const resources = [{ id: 'schedule', typeId: 'schedule', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'here' }, table: { model: schedule } }]; const resourceTypes = [{ id: 'schedule', name: '日程', shortLabel: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // no label, no number 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, resourceTypes, headings: { fontFamily: SONG, fontWeight: 400, color: col('ink'), levels: [notice, ...levels], balancing: { enabled: false } }, // no lines added over heads, no paragraph set loose headingStyles: [annex], paragraphStyles, header, footer, tableStyle: { borderColor: col('ink'), borderWidth: pt(0.75), cellPadding: mm(2), headerBackgroundEnabled: false, headerBold: false, headerFontFamily: HEI, bodyFontFamily: SONG, headerFontSize: pt(FOLIO), bodyFontSize: pt(FOLIO), // 四号 headerColor: col('ink'), bodyColor: col('ink') }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Muestra en Markdown · 81 líneas · content.es.md
title: "示例市出版协会关于举办2026年中文排版实务培训班的通知" subtitle: "样张 · Muestra compuesta según la GB/T 9704—2012 · La asociación, su personal y este aviso son ficticios" --- # 示例市出版协会关于举办 \\ 2026年中文排版实务培训班的通知 {copy="000001" issuer="示例市出版协会" number="示出协〔2026〕7号"} :::paragraphs{style="flush"} 各会员单位: ::: 为提高会员单位排版人员的业务水平,协会定于2026年10月举办中文排版实务培训班。现将有关事项通知如下: ## 培训时间 2026年10月20日至22日,共3天。参训人员于10月19日14:00至18:00到示例市图书大厦一楼大厅报到,报到时请出示单位介绍信。 ## 培训地点 示例市图书大厦五楼报告厅(示例市文昌路88号)。 ## 培训内容 ### 排版规范 #### 公文格式 学习《党政机关公文格式》(GB/T 9704—2012),重点掌握以下两项内容: ##### 版心与字体 版心156毫米×225毫米,每页22行,每行28字,正文一般用3号仿宋体字。 ##### 页码与版记 页码用4号半角阿拉伯数字,单页居右,双页居左;版记置于公文最后一页。 #### 标点符号 学习《标点符号用法》(GB/T 15834—2011),掌握标点在行首行末的禁则和两个标点连用时的处理。 ### 实务操作 按字数和行数设定版心,完成书刊样张和公文样张各一份,由教员逐页讲评。 ## 参加对象和名额 各会员单位从事编辑、校对和排版工作的人员,可自愿报名,每个单位限报2人,全市共80个名额,报满为止。 ## 报名办法 报名时间为即日起至2026年10月10日,可任选以下一种方式报名。 ### 网上报名 登录示例市出版协会网站“培训报名”栏目,填写报名表并上传近一年排版的书刊或公文一份。 ### 书面报名 报名表加盖单位公章后,送至协会秘书处(示例市图书大厦十二楼)。 ## 其他事项 培训不收取费用,食宿费用回原单位报销。参训人员须自带笔记本电脑。联系人:协会秘书处周明远、陈思齐。 :::paragraphs{style="annexes"} 附件:培训日程安排 ::: :::paragraphs{style="signature"} 示例市出版协会 ::: :::paragraphs{style="date"} 2026年9月28日 ::: (此件公开发布) # 培训日程安排 {style="annex" label="附件" cc="示例市新闻出版局,示例市图书馆学会。" office="示例市出版协会秘书处" printed="2026年9月28日印发" colophon="Noto Serif SC, Noto Sans SC y LXGW WenKai TC (SIL OFL) · Un aviso ficticio escrito para la receta"} ::resource{id="schedule"}`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Noto Serif SC': ['400', '900'], // SONG: the text, number, folios, 版记; the name, the titles 'Noto Sans SC': ['400', '500'], // HEI: the head line, table heads; the 一、 heads, label, stamp 'LXGW WenKai TC': ['400'], // KAI: the (一) heads }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // Each voice loads the files of what it sets, template numerals too (gotcha: cjk-fonts-slices). const lines = (re) => (markdown.match(re) ?? []).join(''); const [table, heads] = [SCHEDULE.flat().join(''), SCHEDULE[0].join('')]; await loadFonts(FONTS, markdown); await loadCjkFonts({ [SONG]: ['400'] }, `${markdown}${table}0123456789—.()`); await loadCjkFonts({ [SONG]: ['900'] }, `${lines(/^# .*$/gm)}文件`); await loadCjkFonts({ [HEI]: ['400'] }, `${lines(/^subtitle: .*$|colophon="[^"]*"/gm)}${heads}`); await loadCjkFonts({ [HEI]: ['500'] }, `${lines(/^## .*$/gm)}一二三四五六七八九十、附件样 张`); await loadCjkFonts({ [KAI]: ['400'] }, `${lines(/^### .*$/gm)}一二三四五六七八九十()`); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'A Chinese official document to GB/T 9704', es: 'Un documento oficial chino según la GB/T 9704' }) }); 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
#Comprime los signos que se tocan
Parejas como ),y 》( ocupan casilla y media en lugar de dos, como en la mayoría de los libros; los caracteres que las siguen se salen de las columnas de la retícula.
- punctuationWidth: 'fullwidth', compressAdjacent: false, trimLineStart: false,
+ punctuationWidth: 'fullwidth', compressAdjacent: true, trimLineStart: true,#Muestra la retícula mientras compones
El lienzo dibuja un cuadrado gris por cada casilla de la retícula de 28 × 22, y 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
Fontsource no tiene ninguna fuente Fangsong (仿宋)
La GB/T 9704 y casi todos los impresos de la China continental componen el texto en 仿宋, una letra Song dibujada con los trazos finos y algo ascendentes del pincel. Ninguna de las familias chinas de Fontsource es una Fangsong, así que una receta compone el texto con la Song que hay, Noto Serif SC, y lo dice. Una Fangsong con licencia entra como fuente propia: registra su archivo con FontFace, nombra la familia en bodyText.fontFamily y pasa a renderToPdf un fontProvider que devuelva sus bytes. Fuentes chinas, japonesas y coreanas →
Error frecuente
En una retícula de caracteres, desactiva el equilibrado de columnas
El equilibrado de columnas llena una página que acaba corta (un título que se queda con su texto deja líneas en blanco al pie) añadiendo líneas de la retícula encima de los títulos y componiendo un párrafo una línea más holgado. Una línea china más holgada separa sus caracteres (0,13 em en una página GB/T 9704, muy por encima de balancing.maxTracking), así que los caracteres se salen de las columnas de la retícula y los títulos de sus líneas. Una página contada en casillas acaba corta: pon headings.balancing: { enabled: false }. Retícula de caracteres →
Error frecuente
Las fuentes chinas se cargan por fragmentos, con el bloque cjk
Fontsource sirve una familia china, japonesa o coreana en un centenar de archivos por peso, cada uno con un intervalo de caracteres. loadFonts solo descarga el archivo latin, así que en pantalla los caracteres chinos salen de una fuente del sistema y se miden mal, y fontsourceProvider entrega al PDF ese archivo latin, que los imprime como cajas vacías. Añade el bloque cjk del kit, llama a loadCjkFonts(FONTS, markdown) después de loadFonts (una vez por voz, con el texto que compone, si el libro usa varias fuentes chinas) y pasa a renderToPdf fontProvider: cjkPdfProvider: ambos toman los archivos que contienen los caracteres del texto. Fuentes chinas, japonesas y coreanas →
Error frecuente
Etiqueta el documento zh-Hans o zh-Hant, no con LANG
Las ediciones de una receta son en y es, pero una muestra china es china en las dos: `locale: LANG` la etiquetaría como inglés o español, separaría sus palabras latinas, llamaría Figure o Figura a sus figuras y daría al PDF un idioma equivocado. Escribe tú la etiqueta: 'zh-Hans' (convenciones de la China continental: corte GB, puntuación Kaiming) o 'zh-Hant' (Taiwán: puntuación de ancho completo centrada); 'zh-HK' para Hong Kong. Un 'zh' a secas se lee como chino simplificado continental. Corte de líneas en chino →
Error frecuente
Cualquier objeto headings desactiva el salto de página del H1
Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →
Error frecuente
El desbordamiento del texto de diseño es 'ellipsis-end' por defecto
Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas. Textos, filetes y cajas en los diseños de página →
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 →
- A 150 ppp, una línea de 29 pt mide 60,41666… px. Cuando un título y un párrafo de dos líneas caían en las tres últimas líneas de una página, la comprobación que mantiene el título junto a su texto sumaba las líneas de la retícula 10⁻¹³ px por debajo y mandaba el título a la página siguiente, con tres líneas en blanco detrás. A 144 ppp un punto son dos píxeles y las sumas salen exactas, así que la página conserva sus 22 líneas.
- Una línea justificada reparte lo que le sobra entre los caracteres chinos y los espacios que contenga, y el espacio se lleva más de lo que le toca: en la página 2, el de GB/T 15834 llegó casi al doble de ancho que el de GB/T 9704, más arriba. El contenido escribe los dos números con un espacio de no separación (U+00A0), que mantiene cada número de una pieza, y el sobrante va entre los caracteres.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Un aviso de una asociación de editores ficticia, escrito para la receta con la forma que fija la GB/T 9704—2012 · Postext Cookbook · CC BY 4.0
- Fuentes
- Noto Serif SC (SIL OFL 1.1) · Noto Sans SC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1)
- Código
- MIT, como Postext


