En poques paraules
Un avís oficial xinès de quatre pàgines. Segueix les normes nacionals per a aquests documents, amb un membret vermell, apartats numerats i números de pàgina amb un format fix.
Què compondràs
Un avís d'una associació d'editors inventada, en una ciutat també inventada, 示例市 («Ciutat Exemple»), compost com compon els seus papers una oficina de la Xina continental segons la GB/T 9704—2012, 《党政机关公文格式》. Són quatre pàgines A4 de 28 caràcters per línia i 22 línies per pàgina. La primera s'obre amb el nom de l'emissor en vermell, el número del document sobre un filet vermell i un títol de dues línies en 二号. Els títols es numeren 一、(一)1.(1), cada nivell amb la seva pròpia lletra. La signatura i la data van a la dreta, i un annex en pàgina pròpia porta al peu el 版记 entre filets. Els folis diuen «— 1 —», a la dreta a les pàgines senars i a l'esquerra a les parelles. Cada full avisa al marge superior que és una mostra. Carta comercial DIN 5008 fa la mateixa feina amb una norma europea.
Aquesta recepta respon a
- Com componc un document oficial xinès segons la GB/T 9704, amb la seva capçalera vermella, la seva retícula i els seus folis?
- Com defineixo la caixa en caràcters, tants per línia i tantes línies per pàgina?
- Com numero els capítols 第一回, 第二回 amb numerals xinesos?
La resposta curta
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, // 左空二字,回行顶格
};
Ingredients
- Funcions
- Retícula de caràctersNumeració xinesaTítols numeratsAmplada de la puntuació xinesaTall de línies en xinèsJustificació entre caràctersEspai entre xinès i llatíFonts xineses, japoneses i coreanesFormat de pàginaMarges simètricsObertures dissenyadesTextos, filets i caixes als dissenys de pàginaAncoratge d'elements de dissenyCapçaleres i folisEstils de títolAtributs de títolSalts de línia als títolsEstils de paràgrafEstil de taulesExportació a PDF
- També fa servir
- Equilibri de columnesFigures just aquíBanda de capítol d'amplada completaColor del paperFonts incrustades al PDFTipus de recurs propisTaules a partir de dadesCapítols sense número
- Tipografia
- Noto Serif SC, Noto Sans SC, LXGW WenKai TC (SIL OFL 1.1)
- Recursos
- Cap: totes les imatges es dibuixen en codi
Elaboració
#1 · Una caixa comptada en caselles
El codi és a la resposta curta, més amunt. La GB/T 9704 fixa el marge de cap en 37 mm i el de llom en 28 mm, i omple una caixa de 156 × 225 mm amb 22 línies de 28 caràcters de cos 三号. cjk.grid pren aquestes xifres tal qual: 28 em de 15,75 pt donen 155,6 mm, i 22 línies de 29 pt, 225,1 mm (Retícula de caràcters). A Word, el 三号 fa 16 pt, i amb aquest cos la línia tindria 158 mm, més ampla que la caixa de la norma; 15,75 pt és el valor de les taules dels caixistes. Amb la retícula activada, els marges són mínims. Aquests deixen 0,02 mm per repartir, així que la caixa es queda on la posa la norma. La norma demana 仿宋 per al text; Fontsource no té cap Fangsong, així que el text va en la Song de Noto Serif SC (mira Errors freqüents).
Cada signe ocupa una casella sencera i no en cedeix res, de manera que, en una línia de xinès, els caràcters s'alineen a les columnes de la retícula que dibuixa la norma. Les xifres aràbigues són més estretes que una casella, així que en una línia com 版心156毫米×225毫米 els caràcters que segueixen les xifres cauen entre les columnes. Quan una coma cau a la casella 29, hangingPunctuation: 'allow' la deixa penjar al marge en lloc d'enviar un caràcter a la línia següent i espaiar la resta (Puntuació penjada).

#2 · Quatre nivells de títols, una línia cadascun
// 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 compon 一、 en 黑体, (一) en 楷体, i 1. i (1) en la lletra del text, tots al cos del text. {2:一} escriu el numeral xinès corrent en l'escriptura de locale (Numeració). Amb el cos i l'interlineat del text i sense marges, cada títol ocupa una línia de la retícula, i el text que el segueix tampoc no en surt. Els títols no tenen sagnat propi, així que els dos espais ideogràfics amb què comença cada plantilla són les dues caselles que deixa la norma (Configuració per nivell).
#3 · El membret, línia a línia
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 membret és el disseny de l'H1, els atributs del qual porten el número d'exemplar, l'emissor i el número del document: la configuració guarda el paper timbrat i el Markdown, el document. lineTop(n) dona la vora superior de la línia n de la retícula, així que cada element queda on el compta la norma: el número, dues línies en blanc per sota del nom de l'emissor; el títol, dues línies en blanc per sota del filet vermell. A 46 pt, la tinta del nom va des de 35 mm per sota de la vora superior de la caixa fins al peu de la línia 5, de manera que les línies 6 i 7 queden en blanc i el número cau a la 8; a 48 pt entraria a la línia 6 i baixaria una línia tota la resta. El \\ del títol el parteix després de 关于举办, una línia curta sobre una de llarga: un trapezi, una de les dues formes que admet la norma (l'altra és el rombe). minHeight reserva 13 línies i marginBottom una més, de manera que el destinatari comença a la línia 15 (Span i disseny avançat).
#4 · Folis sota la caixa
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) } } }] };
Al peu, la vora superior del contenidor és el peu de la caixa, així que offset.y es mesura cap avall des del text (Capçaleres i peus). Les ratlles queden 7 mm per sota i a una casella de 四号 de la vora exterior: parity posa els folis senars a la dreta i els parells a l'esquerra. El marge superior imprimeix {subtitle}, del frontmatter. Aquesta línia i el colofó que va sota el 版记 són l'única cosa que canvia entre l'edició anglesa i la castellana.
#5 · L'annex i 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ítol de l'annex té un estil propi: pàgina nova, 附件 a la línia 1, el títol a la 3 i cap marge sota les tres línies que reserva. La línia 4 en blanc és l'espai que tot recurs en línia deixa a sobre (inlineResourceGap, a Disposició), així que el filet superior de la taula cau a la línia 5. El 版记 forma part del mateix disseny i ocupa les dues línies de la retícula que acaben al peu de la caixa. Els seus elements porten reserve: false, així que no sumen res a l'espai que reserva el títol i no empenyen la taula cap avall. La norma posa el 版记 a l'última pàgina, darrere dels annexos; amb un annex d'una pàgina, aquesta és la de l'annex. Un annex més llarg demanaria que dibuixés el 版记 un títol de la seva última pàgina.
#6 · La signatura i la data
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 avís sense segell posa el nom de l'emissor a dues caselles del marge dret i la data a sota, dues caselles més a la dreta. Si la data és més llarga que el nom, acaba a dues caselles del marge dret i el nom es desplaça a l'esquerra per conservar el graó de dues caselles. La nostra fa 6,9 em, una mica menys que els 7 del nom, així que, al peu de la lletra, començaria dues caselles a la dreta del nom i acabaria a 0,14 em del marge. Llegim la regla pel que busca, que la data no arribi mai a la vora, i apliquem el segon cas: la data acaba a poc més de dues caselles del marge. indent col·loca les dues línies en caselles senceres, a 17 i 19 em del marge (Estils de paràgraf).
La recepta 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`---Mostra en Markdown · 81 línies · 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 a totes les receptes · 422 línies
// ─── 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 ───────────────────────────────────────────────────────────────────────
L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)
Variants
#Comprimeix els signes que es toquen
Parelles com ),i 》( ocupen casella i mitja en lloc de dues, com a la majoria dels llibres; els caràcters que les segueixen surten de les columnes de la retícula.
- punctuationWidth: 'fullwidth', compressAdjacent: false, trimLineStart: false,
+ punctuationWidth: 'fullwidth', compressAdjacent: true, trimLineStart: true,#Mostra la retícula mentre compons
El llenç dibuixa un quadrat gris per cada casella de la retícula de 28 × 22, i el PDF no l'inclou llevat que renderToPdf rebi characterGrid: true.
- grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
+ grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES, show: true },Errors freqüents
Error freqüent
Fontsource no té cap font Fangsong (仿宋)
La GB/T 9704 i gairebé tots els impresos de la Xina continental componen el text en 仿宋, una lletra Song dibuixada amb els traços fins i una mica ascendents del pinzell. Cap de les famílies xineses de Fontsource no és una Fangsong, de manera que una recepta compon el text amb la Song que hi ha, Noto Serif SC, i ho diu. Una Fangsong amb llicència entra com a font pròpia: registra'n el fitxer amb FontFace, anomena la família a bodyText.fontFamily i passa a renderToPdf un fontProvider que en retorni els bytes. Fonts xineses, japoneses i coreanes →
Error freqüent
En una retícula de caràcters, desactiva l'equilibri de columnes
L'equilibri de columnes omple una pàgina que acaba curta (un títol que es queda amb el seu text deixa línies en blanc al peu) afegint línies de la retícula a sobre dels títols i component un paràgraf una línia més folgat. Una línia xinesa més folgada separa els seus caràcters (0,13 em en una pàgina GB/T 9704, molt per sobre de balancing.maxTracking), de manera que els caràcters surten de les columnes de la retícula i els títols de les seves línies. Una pàgina comptada en caselles acaba curta: posa headings.balancing: { enabled: false }. Retícula de caràcters →
Error freqüent
Les fonts xineses es carreguen per fragments, amb el bloc cjk
Fontsource serveix una família xinesa, japonesa o coreana en un centenar de fitxers per pes, cadascun amb un interval de caràcters. loadFonts només baixa el fitxer latin, així que a la pantalla els caràcters xinesos surten d'una font del sistema i es mesuren malament, i fontsourceProvider lliura al PDF aquest fitxer latin, que els imprimeix com a caixes buides. Afegeix el bloc cjk del kit, crida loadCjkFonts(FONTS, markdown) després de loadFonts (una vegada per veu, amb el text que compon, si el llibre fa servir diverses fonts xineses) i passa a renderToPdf fontProvider: cjkPdfProvider: tots dos agafen els fitxers que contenen els caràcters del text. Fonts xineses, japoneses i coreanes →
Error freqüent
Etiqueta el document zh-Hans o zh-Hant, no amb LANG
Les edicions d'una recepta són en i es, però una mostra xinesa és xinesa en totes dues: `locale: LANG` l'etiquetaria com a anglès o castellà, separaria les seves paraules llatines, anomenaria Figure o Figura les seves figures i donaria al PDF una llengua equivocada. Escriu tu l'etiqueta: 'zh-Hans' (convencions de la Xina continental: tall GB, puntuació Kaiming) o 'zh-Hant' (Taiwan: puntuació d'amplada completa centrada); 'zh-HK' per a Hong Kong. Un 'zh' tot sol es llegeix com a xinès simplificat continental. Tall de línies en xinès →
Error freqüent
Qualsevol objecte headings desactiva el salt de pàgina de l'H1
Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →
Error freqüent
El desbordament del text de disseny és 'ellipsis-end' per defecte
Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
Carrega totes les fonts abans de compondre
La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →
Avís de maquetació · cjkGridClamped
Retícula de caràcters massa gran
Per què. `cjk.grid` demana més caràcters per línia o més línies per pàgina dels que hi caben entre els marges amb aquest cos i aquest interlineat, de manera que la retícula es compon amb els que hi caben.
Solució. Abaixa `charsPerLine` o `linesPerPage`, estreny els marges (són mínims) o redueix `bodyText.fontSize` o `lineHeight`. Documentació →
- A 150 ppp, una línia de 29 pt fa 60,41666… px. Quan un títol i un paràgraf de dues línies queien a les tres últimes línies d'una pàgina, la comprovació que manté el títol amb el seu text sumava les línies de la retícula 10⁻¹³ px per sota i enviava el títol a la pàgina següent, amb tres línies en blanc darrere. A 144 ppp un punt són dos píxels i les sumes surten exactes, així que la pàgina conserva les seves 22 línies.
- Una línia justificada reparteix el que li sobra entre els caràcters xinesos i els espais que contingui, i l'espai s'emporta més del que li toca: a la pàgina 2, el de GB/T 15834 va arribar gairebé al doble d'amplada que el de GB/T 9704, més amunt. El contingut escriu els dos números amb un espai de no separació (U+00A0), que manté cada número d'una peça, i el sobrant va entre els caràcters.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- A notice from a fictitious publishers’ association, written for the recipe in the form GB/T 9704—2012 sets out · Postext Cookbook · CC BY 4.0
- Fonts
- Noto Serif SC (SIL OFL 1.1) · Noto Sans SC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


