Em poucas palavras
Um aviso oficial chinês de quatro páginas. Segue as regras nacionais para esse tipo de documento, com cabeçalho vermelho, seções numeradas e números de página num formato fixo.
O que você vai compor
Um aviso de uma associação de editores inventada, numa cidade também inventada, 示例市 (“Cidade Exemplo”), composto como uma repartição da China continental compõe os seus papéis pela GB/T 9704—2012, 《党政机关公文格式》. São quatro páginas A4 de 28 caracteres por linha e 22 linhas por página. A página 1 abre com o nome do emissor em vermelho, o número do documento sobre um fio vermelho e um título de duas linhas em 二号. Os títulos são numerados 一、(一)1.(1), cada nível na sua fonte. A assinatura e a data ficam à direita, e um anexo numa página própria traz no pé o 版记 entre fios. Os fólios dizem “— 1 —”, à direita nas páginas ímpares e à esquerda nas pares. Cada folha avisa na margem superior que é um modelo. Carta comercial pela DIN 5008 faz o mesmo trabalho com uma norma europeia.
Esta receita responde a
- Como diagramo um documento oficial chinês segundo a GB/T 9704, com o timbre vermelho, a grade e os fólios?
- Como defino a mancha em caracteres, tantos por linha e tantas linhas por página?
- Como numero os capítulos 第一回, 第二回 com numerais chineses?
A 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, // 左空二字,回行顶格
};
Ingredientes
- Funcionalidades
- Grade de caracteresNumeração chinesaTítulos numeradosLargura da pontuação chinesaQuebra de linha em chinêsJustificação entre caracteresEspaço entre chinês e latimFontes chinesas, japonesas e coreanasFormato da páginaMargens espelhadasAberturas desenhadasTextos, fios e caixas nos designs de páginaAncoragem de elementos de designCabeços e fóliosEstilos de títuloAtributos de títuloQuebras de linha nos títulosEstilos de parágrafoEstilo de tabelaExportação para PDF
- Também usa
- Equilíbrio de colunasFiguras exatamente aquiFaixa de capítulo em largura totalCor do papelFontes incorporadas ao PDFTipos de recurso personalizadosTabelas a partir de dadosCapítulos sem número
- Tipografia
- Noto Serif SC, Noto Sans SC, LXGW WenKai TC (SIL OFL 1.1)
- Materiais
- Nenhum: todas as imagens são desenhadas em código
Preparo
#1 · Uma mancha contada em casas
O código é a resposta curta lá em cima. A GB/T 9704 fixa a margem superior em 37 mm e a margem de encadernação em 28 mm, e enche uma mancha de 156 × 225 mm com 22 linhas de 28 caracteres de corpo 三号. cjk.grid toma os números como estão: 28 emes de 15,75 pt dão 155,6 mm, e 22 linhas de 29 pt dão 225,1 mm (grade de caracteres). No Word, o 三号 tem 16 pt, o que faria a linha ter 158 mm, mais larga que a mancha da norma; 15,75 pt é o valor das tabelas dos tipógrafos. Com a grade ligada, as margens são mínimas. Estas deixam 0,02 mm para distribuir, então a mancha fica onde a norma a põe. A norma pede 仿宋 para o texto; a Fontsource não tem nenhuma Fangsong, então o texto é composto na Song da Noto Serif SC (veja Erros comuns).
Todo sinal ocupa uma casa inteira e não cede nada dela, então numa linha de chinês os caracteres ficam nas colunas da grade que a norma desenha. Os algarismos arábicos são mais estreitos que uma casa, então numa linha como 版心156毫米×225毫米 os caracteres depois dos algarismos caem entre as colunas. Quando uma vírgula cai na 29ª casa, hangingPunctuation: 'allow' a deixa pendurada na margem em vez de mandar um caractere para a linha seguinte e espaçar o resto (pontuação pendente).

#2 · Quatro níveis de títulos, uma linha cada
// 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})'),
];
A norma compõe 一、 em 黑体, (一) em 楷体, e 1. e (1) na fonte do texto, todos no corpo do texto. {2:一} imprime o numeral chinês informal na escrita de locale (numeração). Com o corpo e a entrelinha do texto e sem margens, um título ocupa uma linha da grade, e o texto embaixo dele também fica na grade. Os títulos não têm recuo próprio, então os dois espaços ideográficos no começo de cada modelo são as duas casas que a norma deixa (configurações por nível).
#3 · O timbre, linha por linha
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
O timbre é o design do H1, cujos atributos trazem o número de exemplar, o emissor e o número do documento: a configuração guarda o papel timbrado, e o Markdown, o documento. lineTop(n) dá o topo da linha n da grade, então cada elemento fica onde a norma o conta: o número, duas linhas em branco abaixo do nome do emissor; o título, duas linhas em branco abaixo do fio vermelho. A 46 pt, a tinta do nome vai de 35 mm abaixo do topo da mancha até o pé da linha 5, o que deixa as linhas 6 e 7 em branco e põe o número na linha 8; a 48 pt ela entraria na linha 6 e empurraria o resto da página uma linha para baixo. A \\ no título o quebra depois de 关于举办, uma linha curta sobre uma longa: um trapézio, uma das duas formas que a norma admite (a outra é o losango). minHeight reserva 13 linhas e marginBottom, mais uma, então o destinatário começa na linha 15 (span e design avançado).
#4 · Fólios sob a 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) } } }] };
No rodapé, a borda superior do contêiner é o pé da mancha, então offset.y mede para baixo a partir do texto (cabeçalhos e rodapés). Os travessões ficam 7 mm abaixo dela e a uma casa de 四号 da borda externa: parity põe os fólios ímpares à direita e os pares à esquerda. A margem superior imprime {subtitle}, do frontmatter. Essa linha e o colofão sob o 版记 são tudo o que muda entre a edição em inglês e a em espanhol.
#5 · O anexo e o 版记
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),
] } },
};
O título do anexo tem um estilo próprio: página nova, 附件 na linha 1, o título na linha 3 e nenhuma margem sob as três linhas que ele reserva. A linha 4 em branco é o espaço que todo recurso em linha mantém acima de si (inlineResourceGap, em layout), então o fio superior da tabela fica na linha 5. O 版记 faz parte do mesmo design, desenhado nas duas linhas da grade que terminam no pé da mancha. Os seus elementos têm reserve: false, então não somam nada ao espaço que o título ocupa e a tabela não é empurrada para baixo deles. A norma põe o 版记 na última página, depois dos anexos; com um anexo de uma página, essa é a página do anexo. Um anexo mais longo precisaria que o 版记 fosse desenhado por um título na sua última página.
#6 · O bloco de assinatura
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) },
];
Um aviso sem carimbo põe o nome do emissor a duas casas da margem direita e a data embaixo, começando duas casas mais à direita. Uma data mais longa que o nome termina, em vez disso, a duas casas da margem direita, e o nome se desloca para a esquerda para manter o degrau de duas casas. A nossa mede 6,9 emes, um pouco menos que os 7 do nome, então, ao pé da letra, começaria duas casas à direita do nome e terminaria a 0,14 em da margem. Lemos a regra pelo que ela quer, uma data que nunca encosta na borda, e aplicamos o segundo caso: a data termina a pouco mais de duas casas da margem. indent põe as duas linhas em casas inteiras, a 17 e 19 emes da margem (estilos de parágrafo).
A receita 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 = 'en'; // @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`---Amostra em Markdown · 81 linhas · content.en.md
title: "示例市出版协会关于举办2026年中文排版实务培训班的通知" subtitle: "样张 · A specimen set to GB/T 9704—2012 · The association, its staff and this notice are fictitious" --- # 示例市出版协会关于举办 \\ 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 and LXGW WenKai TC (SIL OFL) · A fictitious notice written for the recipe"} ::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 em todas as receitas · 459 linhas
// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). 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', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { 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` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds 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. */ 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' }; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes 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; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The family's Fontsource metadata (weights, styles, subsets), 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 /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. */ 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 v2 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType: the nearest weight the * family ships, upright if it has no italic; latin, then what the face's * letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { 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 text = String.fromCodePoint(...(request?.codePoints ?? [])); const more = kitSubsetsFor(text, meta); const files = await Promise.all(['latin', ...more].map(async (subset) => { const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no 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 gets the latin file fontsourceProvider fetches (the "pdf" block) * and, when the face sets letters only latin-ext has, that file too. */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return cjkLatinPdfFiles(family, weight, style, request); 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())); })); } /** A Latin family set next to the CJK faces: its latin file, then its * latin-ext file when the face sets letters only latin-ext has (ō ū in * Hepburn rōmaji, ǎ in pinyin), the file loadFonts adds on screen for * them. Latin comes first: postext-pdf draws a character from the first * file that has it, as the browser takes a character both files hold from * latin. A face Fontsource ships without latin-ext, or whose file does * not come, gets latin alone, and the PDF names the letters it lacks. */ async function cjkLatinPdfFiles(family, weight, style, request) { const meta = await fontsourceMeta(family); const beyond = [...(request?.codePoints ?? [])].some(cjkLatinExtOnly); if (!beyond || !meta?.subsets?.includes('latin-ext')) return fontsourceProvider(family, weight, style); 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 id = fontsourceId(family); const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-ext-${w}-${s}.woff2`; const [latin, ext] = await Promise.all([fontsourceProvider(family, weight, style), fetch(url) .then(async (res) => (res.ok ? decompressWoff2(new Uint8Array(await res.arrayBuffer())) : null), () => null)]); return ext ? [latin, ext] : latin; } /** Whether code point `cp` is in Fontsource's latin-ext file and not in * its latin file: Latin Extended-A and -B, IPA, the spacing modifiers and * Latin Extended Additional (loadFonts's test for latin-ext), less the * few latin holds too (ı Œ œ ʻ ʼ ˆ ˚ ˜). */ function cjkLatinExtOnly(cp) { if (!((cp >= 0x100 && cp <= 0x2ff) || (cp >= 0x1e00 && cp <= 0x1eff))) return false; return ![0x131, 0x152, 0x153, 0x2bb, 0x2bc, 0x2c6, 0x2da, 0x2dc].includes(cp); } /** 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 ───────────────────────────────────────────────────────────────────────
O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)
Variações
#Comprimir os sinais que se encontram
Pares como ),e 》( ocupam uma casa e meia em vez de duas, como na maioria dos livros; os caracteres que vêm depois saem das colunas da grade.
- punctuationWidth: 'fullwidth', compressAdjacent: false, trimLineStart: false,
+ punctuationWidth: 'fullwidth', compressAdjacent: true, trimLineStart: true,#Mostrar a grade enquanto você compõe a página
O canvas desenha um quadrado cinza para cada casa da grade de 28 × 22, e o PDF não o inclui a não ser que renderToPdf receba characterGrid: true.
- grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
+ grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES, show: true },Erros comuns
Erro comum
O Fontsource não tem nenhuma fonte Fangsong (仿宋)
A GB/T 9704 e a maioria dos formulários da China continental compõem o texto em 仿宋, uma fonte Song desenhada com os traços finos e levemente ascendentes do pincel. Nenhuma das famílias chinesas do Fontsource é Fangsong, então uma receita compõe o texto com a Song disponível, a Noto Serif SC, e informa isso. Uma Fangsong para a qual você tenha licença entra como fonte própria: registre o arquivo com FontFace, indique a família em bodyText.fontFamily e passe a renderToPdf um fontProvider que devolva os bytes dela. Fontes chinesas, japonesas e coreanas →
Erro comum
Em uma grade de caracteres, desative o balanceamento de colunas
O balanceamento de colunas preenche uma página que termina curta (como quando um título mantido junto ao seu texto deixa linhas em branco no pé) acrescentando linhas da grade acima dos títulos e compondo um parágrafo com uma linha mais frouxa. Uma linha chinesa mais frouxa espaça os seus caracteres (0,13 eme em uma página GB/T 9704, muito acima de balancing.maxTracking), então os caracteres saem das colunas da grade e os títulos saem das suas linhas. Uma página contada em células deve terminar curta: defina headings.balancing: { enabled: false }. Grade de caracteres →
Erro comum
Fontes chinesas são carregadas em fatias, pelo bloco cjk
O Fontsource serve uma família chinesa, japonesa ou coreana em cerca de cem arquivos por peso, cada um com um intervalo de caracteres. loadFonts baixa só o arquivo latin, então na tela os caracteres chineses vêm de uma fonte do sistema e são medidos errado, e o fontsourceProvider entrega ao PDF esse arquivo latin, que os imprime como caixas vazias. Inclua o bloco cjk do kit, chame loadCjkFonts(FONTS, markdown) depois de loadFonts (uma vez por voz, com o texto que ela compõe, quando o livro usa várias fontes CJK) e passe a renderToPdf fontProvider: cjkPdfProvider: os dois pegam os arquivos que contêm os caracteres do texto. Fontes chinesas, japonesas e coreanas →
Erro comum
Marque o documento como zh-Hans ou zh-Hant, não com LANG
As edições de uma receita são en e es, mas uma amostra chinesa é chinesa nas duas: `locale: LANG` a marcaria como inglês ou espanhol, hifenizaria as suas palavras latinas, chamaria as suas figuras de Figure ou Figura e daria ao PDF o idioma errado. Escreva você mesmo a marcação: 'zh-Hans' (convenções da China continental: quebra de linha GB, pontuação Kaiming) ou 'zh-Hant' (Taiwan: pontuação de largura inteira centralizada); 'zh-HK' para Hong Kong. Um 'zh' sozinho é lido como chinês simplificado continental. Uma amostra japonesa leva 'ja' (armadilha ja-locale-tag). Quebra de linha em chinês →
Erro comum
Qualquer objeto headings desativa a quebra de página do H1
Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →
Erro comum
O excesso de texto de design é 'ellipsis-end' por padrão
Um elemento de texto de design que não cabe na sua largura termina em reticências por padrão. Use overflow: 'wrap' nos títulos que devem passar para mais linhas. Textos, fios e caixas nos designs de página →
Erro comum
Carregue todas as fontes antes do layout
O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada. Fontes antes da diagramação →
Aviso de diagramação · cjkGridClamped
Grade de caracteres grande demais
Por quê. `cjk.grid` pede mais caracteres por linha ou mais linhas por página do que cabem entre as margens com esse corpo e essa entrelinha, então a grade é composta com o máximo que cabe.
Correção. Reduza `charsPerLine` ou `linesPerPage`, estreite as margens (elas são mínimas) ou use um `bodyText.fontSize` ou um `lineHeight` menor. Docs →
- A 150 dpi, uma linha de 29 pt mede 60,41666… px. Quando um título e um parágrafo de duas linhas caíam nas três últimas linhas de uma página, a verificação que mantém o título junto do seu texto somava as linhas da grade 10⁻¹³ px a menos e mandava o título para a página seguinte, deixando três linhas em branco. A 144 dpi, um ponto são dois pixels e as somas dão exatas, então a página mantém as suas 22 linhas.
- Uma linha justificada distribui a sua folga entre os caracteres chineses e os espaços que tiver, e o espaço leva mais do que a sua parte: na página 2, o de GB/T 15834 abriu quase o dobro da largura do de GB/T 9704, logo acima. O conteúdo escreve os dois números com um espaço não separável (U+00A0), que mantém cada número inteiro, e a folga vai para entre os caracteres.
Créditos
- Receita
- Ignacio Ferro
- Texto
- 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
- Fontes
- 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 o Postext
Editar este texto ↗ (abre em uma nova aba)Pasta da receita no GitHub ↗ (abre em uma nova aba)


