# A Chinese official document to GB/T 9704

> A four-page notice on A4 to the national standard: 28 × 22 characters of 三号, a red letterhead, heads 一、（一）1.（1）, an annex and “— 1 —” folios.

- HTML version: https://postext.dev/en/cookbook/chinese-official-document
- Recipe Nº 082 · Page & grid · Level 2 (Intermediate) · Outputs: Canvas, PDF
- Genres: Reports
- Requires postext ≥ 1.9.0, postext-pdf ≥ 1.9.0 · tested with 1.9.0, postext-pdf 1.9.0 on 2026-09-30
- Pages: [1](https://postext.dev/cookbook/chinese-official-document/en/p01.webp?v=094d6094), [2](https://postext.dev/cookbook/chinese-official-document/en/p02.webp?v=094d6094), [3](https://postext.dev/cookbook/chinese-official-document/en/p03.webp?v=094d6094), [4](https://postext.dev/cookbook/chinese-official-document/en/p04.webp?v=094d6094)
- PDF: https://postext.dev/cookbook/chinese-official-document/en/chinese-official-document.pdf?v=094d6094
- Open in Sandbox: https://postext.dev/en/sandbox#recipe=chinese-official-document&lang=en (.postext: https://postext.dev/cookbook/chinese-official-document/en/chinese-official-document.postext)
- Last updated: 2026-09-29
- Other languages: [es](https://postext.dev/es/cookbook/chinese-official-document.md)

## What you'll build

A notice from an invented publishers’ association in an invented city, 示例市 (“Example City”), set as a mainland office sets its papers under GB/T 9704—2012, 《党政机关公文格式》. It runs to four A4 pages of 28 characters to the line and 22 lines to the page. Page 1 opens with the issuer’s name in red, the document number over a red rule, and a two-line title in 二号. The heads are numbered 一、（一）1.（1）, each level in its own face. The signature and date sit to the right, and an annex on a page of its own carries the 版记 between rules at its foot. The folios read “— 1 —”, at the right on odd pages and at the left on even ones. Every sheet says in its head margin that it is a specimen. [Business letter to DIN 5008](https://postext.dev/en/cookbook/din-business-letter.md) does the same job to a European standard.

**This recipe answers:**

- How do I lay out a Chinese official document to GB/T 9704, with its red letterhead, grid and folios?
- How do I set the type area in characters, so many to the line and so many lines to the page?
- How do I number chapters 第一回, 第二回 in Chinese numerals?

## The short answer

A4, 28 characters × 22 lines of 三号, full-width marks on the grid.

```js
// script.js, lines 28–48
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

**Teaches**

- [Character grid](https://postext.dev/en/docs/configuration.md#character-grid): The type area specified the Chinese way, as characters per line by lines per page (cjk.grid): the measure is a whole number of ems, the margins grow to centre it, and the grid can be drawn on screen.
- [Chinese numerals](https://postext.dev/en/docs/configuration.md#numbering): Page numbers, lists, headings and figure counters in Chinese numerals (simp-chinese-informal, trad-chinese-informal, cjk-decimal…), and heading templates such as 第{1:一}回 for 第一回, 第二回.
- [Numbered headings](https://postext.dev/en/docs/configuration.md#per-level-overrides): Numbering templates per level (1, 1.1, IV, A, 01), which the openers, running heads and contents also print.

**Also uses**

- [Chinese punctuation widths](https://postext.dev/en/docs/configuration.md#punctuation-widths)
- [Chinese line breaking](https://postext.dev/en/docs/configuration.md#east-asian-typography)
- [Justification between characters](https://postext.dev/en/docs/justification.md#chinese-japanese-and-korean)
- [Space between Chinese and Latin](https://postext.dev/en/docs/configuration.md#space-between-han-and-latin)
- [Chinese, Japanese and Korean fonts](https://postext.dev/en/docs/configuration.md#chinese-japanese-and-korean-fonts)
- [Trim size](https://postext.dev/en/docs/configuration.md#page-size-presets)
- [Mirrored margins](https://postext.dev/en/docs/configuration.md#mirrored-margins)
- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Text, rules and boxes in page designs](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Anchoring design elements](https://postext.dev/en/docs/configuration.md#element-placement)
- [Running heads and folios](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Heading styles](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Line breaks in titles](https://postext.dev/en/docs/document-format.md#line-breaks-in-titles)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Table style](https://postext.dev/en/docs/configuration.md#table-style)
- [PDF export](https://postext.dev/en/docs/configuration.md#generating-pdfs)
- [Column balancing](https://postext.dev/en/docs/configuration.md#column-balancing)
- [Figures exactly here](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)
- [Full-width chapter band](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Paper colour](https://postext.dev/en/docs/configuration.md#page)
- [Fonts embedded in the PDF](https://postext.dev/en/docs/configuration.md#why-a-font-provider)
- [Custom resource types](https://postext.dev/en/docs/configuration.md#resource-types)
- [Tables from data](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)
- [Unnumbered chapters](https://postext.dev/en/docs/configuration.md#heading-styles)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`cjk`](https://postext.dev/en/docs/configuration.md#east-asian-typography), [`colorPalette`](https://postext.dev/en/docs/configuration.md#color-palette), [`footer`](https://postext.dev/en/docs/configuration.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration.md#headers--footers), [`headingStyles`](https://postext.dev/en/docs/configuration.md#heading-styles), [`headings`](https://postext.dev/en/docs/configuration.md#headings), [`layout`](https://postext.dev/en/docs/configuration.md#layout), [`locale`](https://postext.dev/en/docs/configuration.md#hyphenation), [`page`](https://postext.dev/en/docs/configuration.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration.md#paragraph-styles), [`resourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`tableStyle`](https://postext.dev/en/docs/configuration.md#table-style)

**APIs**

- [`buildDocument`](https://postext.dev/en/docs/configuration.md#building-a-document), [`clearMeasurementCache`](https://postext.dev/en/docs/configuration.md#measurement-cache), [`decompressWoff2`](https://postext.dev/en/docs/configuration.md#browser-font-provider-fontsource--woff2), [`mergeCells`](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement), [`parseTSV`](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap), [`renderToPdf`](https://postext.dev/en/docs/configuration.md#generating-pdfs)

**Typefaces**

- Noto Serif SC (OFL-1.1), Noto Sans SC (OFL-1.1), LXGW WenKai TC (OFL-1.1)

## Method

### 1 · A type area counted in cells

The code is [the short answer](#the-short-answer) above. GB/T 9704 fixes the head margin at 37 mm and the binding margin at 28 mm, and fills a 156 × 225 mm type area with 22 lines of 28 characters in 三号. `cjk.grid` takes the numbers as they are: 28 ems of 15.75 pt make 155.6 mm, and 22 lines of 29 pt make 225.1 mm ([Character grid](/en/docs/configuration#character-grid)). Word’s 三号 is 16 pt, which would make the line 158 mm, wider than the standard’s type area; 15.75 pt is the value in typesetters’ tables. With the grid on, the margins are minimums. These leave 0.02 mm to share, so the type area stays where the standard puts it. The standard names 仿宋 for the text; Fontsource has no Fangsong face, so the text is set in the Song of Noto Serif SC (see Pitfalls).

Every mark takes a whole cell and gives none of it up, so in a line of Chinese the characters stand in the columns of the grid the standard draws. Arabic numerals are narrower than a cell, so in a line such as 版心156毫米×225毫米 the characters after the digits fall between the columns. When a comma falls on the 29th cell, `hangingPunctuation: 'allow'` hangs it in the margin instead of pushing a character onto the next line and spreading the rest ([Hanging punctuation](/en/docs/configuration#hanging-punctuation)).

![Page 2: 二、培训地点. Page 2: heads on four levels, 二、培训地点, （一）排版规范, 1.公文格式 and （1）版心与字体, each on a line of its own and indented two characters, over paragraphs of 28 characters to the line. At the end of the second-to-last line a comma hangs past the right edge of the text. The folio — 2 — stands at the left.](https://postext.dev/cookbook/chinese-official-document/en/p02.webp?v=094d6094)

*Page 2: the comma after 各一份 fills the 28th cell; the one after 可自愿报名 hangs in a 29th, past the edge of the type area, and the next line starts at the margin.*

### 2 · Four levels of heads, one line each

```js
// script.js, lines 52–62
// 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}）'),
];
```

The standard sets 一、 in 黑体, （一） in 楷体, and 1. and （1） in the text face, all at the text size. `{2:一}` prints the informal Chinese numeral in the script of `locale` ([Numbering](/en/docs/configuration#numbering)). With the body’s size and leading and no margins, a head takes one line of the grid, and the text under it stays on the grid too. Heads have no indent of their own, so the two ideographic spaces at the start of each template are the two cells the standard leaves ([Per-level overrides](/en/docs/configuration#per-level-overrides)).

### 3 · The letterhead, line by line

```js
// script.js, lines 66–92
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
```

The letterhead is the design of the H1, whose attributes carry the copy number, the issuer and the document number: the config holds the stationery, the Markdown the document. `lineTop(n)` gives the top of grid line *n*, so each element stands where the standard counts it: the number two blank lines under the issuer’s name, the title two blank lines under the red rule. At 46 pt the name’s ink runs from 35 mm under the top of the type area to the foot of line 5, which leaves lines 6 and 7 blank and puts the number on line 8; at 48 pt it would reach into line 6 and push the rest of the page a line down. The `\\` in the heading breaks the title after 关于举办, a short line over a long one: a trapezoid, one of the two shapes the standard allows (the other is a diamond). `minHeight` reserves 13 lines and `marginBottom` one more, so the addressee starts on line 15 ([Span and advanced design](/en/docs/configuration#span-and-advanced-design)).

### 4 · Folios under the type area

```js
// script.js, lines 96–105
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) } } }] };
```

In the footer, the container’s top edge is the foot of the type area, so `offset.y` measures down from the text ([Headers & footers](/en/docs/configuration#headers--footers)). The dashes stand 7 mm under it, and one 四号 cell in from the outer edge: `parity` puts the odd folios at the right and the even ones at the left. The head margin prints `{subtitle}` from the frontmatter. That line and the colophon under the 版记 are all that changes between the English and the Spanish edition.

### 5 · The annex and the 版记

```js
// script.js, lines 109–127
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),
  ] } },
};
```

The annex heading takes a style of its own: a new page, 附件 on line 1, the title on line 3, and no margin under the three lines it reserves. The blank line 4 is the float gap every inline resource keeps above it (`inlineResourceGap`, under [Layout](/en/docs/configuration#layout)), so the table’s top rule sits on line 5. The 版记 is part of the same design, drawn on the two grid lines that end on the type area’s foot. Its elements have `reserve: false`, so they add nothing to the room the heading takes and the table is not pushed down past them. The standard puts the 版记 on the last page, after the annexes; with a one-page annex that is the annex’s page. A longer annex would need the 版记 drawn by a heading on its last page.

### 6 · The signature block

```js
// script.js, lines 131–137
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) },
];
```

A notice without a seal sets the issuer’s name two cells in from the right and the date under it, starting two cells further right. A date longer than the name ends two cells short of the right edge instead, and the name moves left to keep the two-cell step. Ours is 6.9 ems, a shade shorter than the name’s 7, so by the letter it would start two cells right of the name and end 0.14 em from the margin. We read the rule by what it is for, a date that never runs to the edge, and take the second case: the date ends just over two cells short. `indent` places both lines on whole cells, 17 and 19 ems from the margin ([Paragraph styles](/en/docs/configuration#paragraph-styles)).

## The whole recipe

One file, composed from the recipe's folder with the sample text and the Cookbook's shared kit inlined; it builds its own page. To run it, put it in a `<script type="module">` on an empty page, or paste it into the JS panel of a new CodePen (as a module). It imports postext from esm.sh, so there is nothing to install or build.

- Source folder: https://github.com/drnachio/postext/tree/main/cookbook/chinese-official-document

### script.js

```js
// ═══ 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`---
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 ── 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 ───────────────────────────────────────────────────────────────────────
```

## Variations

### Compress marks that meet

Pairs like ），and 》（ take a cell and a half instead of two, as in most books; the characters after them leave the columns of the grid.

```diff
-  punctuationWidth: 'fullwidth', compressAdjacent: false, trimLineStart: false,
+  punctuationWidth: 'fullwidth', compressAdjacent: true, trimLineStart: true,
```

### Show the grid while you set the page

The canvas draws a grey square for every cell of the 28 × 22 grid, and the PDF leaves it out unless `renderToPdf` gets `characterGrid: true`.

```diff
-  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
+  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES, show: true },
```

## Pitfalls

- **Fontsource has no Fangsong (仿宋) face.** GB/T 9704 and most mainland forms set their text in 仿宋, a Song face drawn with the thin, slightly rising strokes of the brush. None of the Chinese families on Fontsource is a Fangsong, so a recipe sets the text in the Song face it has, Noto Serif SC, and says so. A Fangsong you hold a licence for goes in as your own font: register its file with FontFace, name the family in bodyText.fontFamily and give renderToPdf a fontProvider that returns its bytes.
- **On a character grid, turn column balancing off.** Column balancing fills a page that ends short, as a head kept with its text leaves blank lines at the foot, by adding grid lines above heads and by setting a paragraph one line looser. A looser Chinese line spreads its characters (0.13 em on a GB/T 9704 page, far past balancing.maxTracking), so the characters leave the columns of the grid and the heads leave their lines. A page counted in cells ends short instead: set headings.balancing: { enabled: false }.
- **Chinese faces load by slices, through the cjk block.** Fontsource serves a Chinese, Japanese or Korean family as about a hundred files per weight, each covering a range of characters. loadFonts fetches only the latin file, so on screen the Han characters come from a system face and measure wrong, and fontsourceProvider hands the PDF that latin file, which prints them as empty boxes. List the cjk kit block, call loadCjkFonts(FONTS, markdown) after loadFonts (once per voice, with the text it sets, when the book uses several CJK faces) and give renderToPdf fontProvider: cjkPdfProvider: both take the files that hold the text's characters.
- **Tag the document zh-Hans or zh-Hant, not with LANG.** A recipe's editions are en and es, but a Chinese sample is Chinese in both: `locale: LANG` would tag it English or Spanish, hyphenate its Latin words, label its figures Figure or Figura and give the PDF the wrong language. Write the tag yourself: 'zh-Hans' (mainland conventions: GB line breaking, Kaiming punctuation) or 'zh-Hant' (Taiwan: full-width centred punctuation); 'zh-HK' for Hong Kong. A bare 'zh' reads as Simplified, mainland.
- **Any headings object switches off the H1 page break.** By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config.
- **Merged cells need hiddenBy placeholders: use mergeCells.** Cells are laid out by their position in the row array, so a merged cell needs placeholder cells marked hiddenBy where it spreads; leaving them out, as HTML does, shifts every later column. Build merges with mergeCells.
- **Design text overflow defaults to 'ellipsis-end'.** A design text element that does not fit its width ends in an ellipsis by default. Set overflow: 'wrap' for titles that should break onto more lines.
- **Load every face before layout.** Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late.
- **Layout warning: Character grid too large** (`cjkGridClamped`). `cjk.grid` asks for more characters per line or lines per page than the page margins leave room for at the body size and line height, so the grid is set with the most that fit. Fix: Lower `charsPerLine` or `linesPerPage`, narrow the margins (they are minimums), or set a smaller `bodyText.fontSize` or `lineHeight`. ([Documentation](https://postext.dev/en/docs/configuration.md#character-grid))

- At 150 dpi a 29 pt line is 60.41666… px. When a head and a two-line paragraph fell on the last three lines of a page, the check that keeps a head with its text summed the grid lines 10⁻¹³ px short and sent the head to the next page, which left three blank lines. At 144 dpi a point is two pixels and the sums are exact, so the page keeps its 22 lines.
- A justified line shares its slack between the Chinese characters and any space it holds, and the space takes more than its share: on page 2 the one in GB/T 15834 opened to nearly twice the width of the one in GB/T 9704 above it. The content writes both numbers with a no-break space (U+00A0), which keeps each number in one piece, so the slack goes between the characters.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 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
- Type: Noto Serif SC (OFL-1.1), Noto Sans SC (OFL-1.1), LXGW WenKai TC (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 058 · Business letter to DIN 5008](https://postext.dev/en/cookbook/din-business-letter.md): A two-page German quotation on A4. The subject line is the H1, and its design pins the letterhead, the address and the information block at form B’s positions. · Level 2 (Intermediate) · Single sheets & ephemera
- [Nº 074 · A Chinese novel page on a 28 × 28 grid](https://postext.dev/en/cookbook/chinese-novel-horizontal.md): Lu Xun’s “My Old Home” on a 大32开 page: 28 characters to the line, 28 lines to the page, GB line breaks, Kaiming punctuation and Han–Latin spacing. · Level 2 (Intermediate) · Fiction, drama & literary prose
- [Nº 005 · Running heads by parity in a book of essays](https://postext.dev/en/cookbook/running-heads-by-parity.md): Header elements filtered by parity and page role: book title on versos, essay title cut short on rectos, folio tabs in the margin, a drop folio on openers. · Level 2 (Intermediate) · Fiction, drama & literary prose
