# Un article xinès citat segons la GB/T 7714

> Un article de revista xinesa a dues columnes: les citacions [@clau] surten com [1] i [2–4] volats, i les referències porten [M], [J], [D] i [EB/OL].

- Versió HTML: https://postext.dev/ca/cookbook/gbt7714-chinese-paper
- Recepta Núm. 090 · Estructura del llibre · Nivell 2 (Intermedi) · Sortides: Canvas, PDF
- Gèneres: Articles i treballs acadèmics
- Requereix postext ≥ 1.12.0, postext-pdf ≥ 1.12.0 · provada amb 1.12.0, postext-pdf 1.12.0 el 2026-10-01
- Pàgines: [41](https://postext.dev/cookbook/gbt7714-chinese-paper/es/p01.webp?v=e8a7c6ce), [42](https://postext.dev/cookbook/gbt7714-chinese-paper/es/p02.webp?v=e8a7c6ce)
- PDF: https://postext.dev/cookbook/gbt7714-chinese-paper/es/gbt7714-chinese-paper.pdf?v=e8a7c6ce
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=gbt7714-chinese-paper&lang=es (.postext: https://postext.dev/cookbook/gbt7714-chinese-paper/es/gbt7714-chinese-paper.postext)
- Última actualització: 2026-10-01
- Altres idiomes: [en](https://postext.dev/en/cookbook/gbt7714-chinese-paper.md), [es](https://postext.dev/es/cookbook/gbt7714-chinese-paper.md), [zh](https://postext.dev/zh/cookbook/gbt7714-chinese-paper.md), [ar](https://postext.dev/ar/cookbook/gbt7714-chinese-paper.md)

## En poques paraules

Un article breu d'una revista científica xinesa. L'autor escriu un codi per a cada font; Postext numera les fonts per ordre de citació i compon la llista de referències com la demanen les revistes xineses.

## Què compondràs

Dues pàgines d'un article de recerca en una revista inventada d'estudis editorials, 示例出版研究 («Recerca Editorial d'Exemple»), compost com compon els articles una revista universitària xinesa. La pàgina és un 16开 de 184 × 260 mm, amb dues columnes de 23 caràcters de 小五 sobre una retícula de 15 pt. Un bloc de títol travessa la pàgina: el nom de la revista sobre una banda anyil, el títol en Hei negreta, els autors, les seves filiacions i un resum amb les paraules clau. Les citacions segueixen la GB/T 7714—2015, la norma nacional xinesa de referències, en el sistema numèric (顺序编码制): cada obra rep un número per l'ordre en què se cita per primera vegada, el número surt volat entre claudàtors i tres o més números seguits s'uneixen en un interval. La llista de referències dona a cada entrada el seu codi de tipus de document i escriu 等 després dels tres primers autors d'una obra xinesa i «et al.» després dels d'una occidental. [Un capítol de tesi citat en APA 7](https://postext.dev/ca/cookbook/apa-thesis-with-bibtex.md) fa servir el mateix marcatge de citacions en un estil autor-any.

**Aquesta recepta respon a:**

- Com cito segons la GB/T 7714 en un article xinès, amb números volats i codis de tipus?
- Com cito obres i componc la bibliografia en APA, IEEE o un altre estil de cita?

## La resposta curta

```js
// script.js, línies 31–45
// Register the engine once, before the first build. The GB/T 7714 numeric style writes
// superscript [n] in the order works are first cited, joins three or more in a row into a
// range and sets the list with its type codes: [M] book, [J] article, [D] thesis, [C]
// conference paper, [EB/OL] web page. Its CSL file holds a second layout for works whose
// language is English, with "et al." for 等, commented out: uncommented, a Western entry
// takes "et al." and a Chinese one keeps 等, each chosen by the entry's `language`.
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const GBT = STYLES['china-national-standard-gb-t-7714-2015-numeric'];
const citations = {
  style: 'custom', // the bundled style with its English layout switched on
  customStyle: GBT.replace(/<!-- (<layout[^>]*locale="en">[\s\S]*?<\/layout>)\s*-->/, '$1'),
  locale: 'zh-CN', // 等, 卷, 版 and the other terms of the list
  bibliography: { fontSize: em(7.5 / BODY), lineHeight: pt(12), entrySpacing: pt(1.5),
    labelWidth: em(1.7) }, // turnovers under the text, not past it: [1] and a space
};
```

## Ingredients

**Ensenya**

- [Cites en un estil de cita](https://postext.dev/ca/docs/document-format.md#citacions-i-bibliografia): Obres citades com a [@clau, pàg. 33] i formatades en un estil CSL (APA, Chicago, MLA, IEEE, Vancouver, ISO 690, GB/T 7714…) triat a la configuració, enllaçades amb les seves entrades.
- [Bibliografia a partir de les referències](https://postext.dev/ca/docs/document-format.md#citacions-i-bibliografia): La llista d'obres citades, construïda a partir de les referències del document (front matter o un bloc BibTeX) on hi ha :::bibliography o després de l'últim capítol.

**També fa servir**

- [Retícula de caràcters](https://postext.dev/ca/docs/configuration.md#retícula-de-caràcters)
- [Fonts xineses, japoneses i coreanes](https://postext.dev/ca/docs/configuration.md#fonts-xineses-japoneses-i-coreanes)
- [Amplada de la puntuació xinesa](https://postext.dev/ca/docs/configuration.md#amplades-de-la-puntuació)
- [Una o dues columnes](https://postext.dev/ca/docs/configuration.md#tipus-de-disposició)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Ancoratge d'elements de disseny](https://postext.dev/ca/docs/configuration.md#posicionament-delements)
- [Títols numerats](https://postext.dev/ca/docs/configuration.md#configuració-per-nivell)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Capítols sense número](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Requadres](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Chips en línia](https://postext.dev/ca/docs/configuration.md#estils-de-xip)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)
- [Tall de línies en xinès](https://postext.dev/ca/docs/configuration.md#tipografia-de-làsia-oriental)
- [Equilibri de columnes](https://postext.dev/ca/docs/configuration.md#equilibratge-de-columnes)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration.md#per-què-un-proveïdor-de-fonts)
- [Preliminars en romans](https://postext.dev/ca/docs/document-format.md#numbering)
- [Salts de línia als títols](https://postext.dev/ca/docs/document-format.md#salts-de-línia-als-títols)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration.md#estils-davís), [`chipStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-xip), [`citations`](https://postext.dev/ca/docs/configuration.md#cites), [`cjk`](https://postext.dev/ca/docs/configuration.md#tipografia-de-làsia-oriental), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration.md#partició-de-mots), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)

**API**

- [`LOCALES`](https://postext.dev/ca/docs/document-format.md#citacions-i-bibliografia), [`STYLES`](https://postext.dev/ca/docs/document-format.md#citacions-i-bibliografia), [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`createCiteprocEngine`](https://postext.dev/ca/docs/document-format.md#citacions-i-bibliografia), [`decompressWoff2`](https://postext.dev/ca/docs/configuration.md#proveïdor-de-fonts-al-navegador-fontsource--woff2), [`registerCitationEngine`](https://postext.dev/ca/docs/document-format.md#citacions-i-bibliografia), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap), [`renderToPdf`](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)

**Tipus de lletra**

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

## Elaboració

### 1 · L'estil, i la seva plantilla per a les obres occidentals

El codi és [la resposta curta](#la-resposta-curta) de més amunt. El motor de citacions es registra una vegada, abans de la primera composició, i `postext-citeproc` porta els tres estils de la GB/T 7714: numèric, autor-any i de notes ([Citacions](/ca/docs/configuration#cites)). Al text, una citació és una clau entre claudàtors. `[@tinker1963]` imprimeix un [1] volat, i `[@rayner2016; @dyson2001; @lin2023]` imprimeix [2–4]. Dos números seguits queden com a llista, [5,6]. Una obra citada de nou conserva el primer número: [4] al tercer paràgraf, i [1] i [2] més endavant.

La norma demana «et al.» després dels tres primers autors d'una obra occidental i 等 després dels d'una xinesa. El fitxer CSL inclòs porta dues plantilles per a això, una per a les obres en anglès, però l'anglesa està comentada, de manera que totes les entrades reben 等. La resposta pren l'XML de l'estil de `STYLES`, descomenta aquesta plantilla i passa el resultat com a estil propi. Cada entrada pren llavors la plantilla del seu propi camp `language`: Rayner i quatre coautors queden en «RAYNER K, SCHOTTER E R, MASSON M E J, et al.», i 林岚 i tres coautors en «林岚, 周明远, 陈思齐, 等». `locale: 'zh-CN'` escriu en xinès la resta de paraules de la llista.

### 2 · Les referències en un bloc CSL-YAML

Les obres van en un bloc `:::references{format=csl-yaml}` al final del Markdown, en el format que Zotero exporta com a CSL YAML ([Citar](/ca/docs/document-format#citar)). El tipus CSL decideix el codi: `book` imprimeix [M], `article-journal` [J], `thesis` [D], `paper-conference` [C] després de la seva `//` i les actes, i `webpage` [EB/OL] amb la data de consulta entre claudàtors. Un article amb DOI passa a [J/OL] i conserva el DOI, com demana la norma. Els noms xinesos van a `literal`, perquè l'estil no els parteixi en cognom i nom. `:::bibliography{title=""}` col·loca la llista sota el títol sense número 参考文献, i `labelWidth` alinea les línies següents de cada entrada amb el seu text, no més endins.

![Página 42: 2 结果.](https://postext.dev/cookbook/gbt7714-chinese-paper/es/p02.webp?v=e8a7c6ce)

*Pàgina 2: entrades occidentals en majúscules amb et al., entrades xineses amb 等, cadascuna amb el seu codi de tipus.*

### 3 · El bloc de títol sobre una banda

```js
// script.js, línies 49–69
const BAND = 30; // mm from the trim's top
const at = (id, edge, y) => ({ anchor: { to: id ? `#${id}` : 'container', edge },
  offset: { y: mm(y) }, size: { width: mm(AREA) } });
const line = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), align: 'center',
  overflow: 'wrap', placement, ...extra });
const onBand = (x, y) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y) }, size: { width: mm(AREA) } });
const masthead = { enabled: true, minHeight: pt(9 * LEAD), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('indigo') },
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: 'fill', height: mm(BAND) } } },
  line('journal', '示例出版研究', HEI, 15, 'paper', onBand(15.8, 13),
    { fontWeight: 700, align: 'left', letterSpacing: pt(3) }),
  line('issue', '第44卷　第3期　2026年9月', HEI, 8, 'mist', onBand(15.8, 16),
    { align: 'right' }),
  line('title', '{titleText}', HEI, 17, 'ink', at('', 'top-left', 14),
    { fontWeight: 700, lineHeight: 1.45 }),
  line('authors', '{attr.authors}', KAI, 12, 'ink', at('title', 'below', 4)),
  line('affiliations', '{attr.affiliations}', SONG, 7.5, 'muted', at('authors', 'below', 2)),
] } };
```

El bloc de títol és el disseny de l'H1, amb un estil de títol que ocupa les dues columnes. La banda és una caixa ancorada al tall, i el nom de la revista hi va a sobre en blanc. El títol, els autors i les filiacions surten del títol i els seus atributs, cadascun col·locat sota l'anterior (`#title`, `below`), de manera que un títol de tres línies empeny la resta cap avall ([Span i disseny avançat](/ca/docs/configuration#span-i-disseny-avançat)).

### 4 · El resum i les seves etiquetes

```js
// script.js, línies 73–80
// In 宋, not 楷: the one Kai on Fontsource is a Taiwan face (gotcha: cjk-face-region).
const calloutStyles = [{ id: 'abstract', span: 'page', backgroundEnabled: false,
  padding: { top: pt(0), bottom: pt(0), left: em(2), right: em(2) },
  marginTop: pt(0), marginBottom: pt(LEAD),
  body: { fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), firstLineIndent: pt(0),
    textAlign: 'justify' } }];
const chipStyles = [{ id: 'label', fontFamily: HEI, bold: true, color: col('indigo'),
  backgroundEnabled: false, borderWidth: pt(0), paddingX: em(0), gap: em(0.5) }];
```

El resum és un requadre a tota l'amplada, sagnat dos caràcters per cada costat. Les etiquetes són chips: `:chip[摘　要：]{style="label"}` compon la paraula en Hei negreta, en l'anyil de la revista, sense caixa ([Estils de chip](/ca/docs/configuration#estils-de-xip)). Les revistes xineses solen compondre el resum en 楷体; aquesta el deixa en Song, pel motiu que s'explica a Errors freqüents.

```js
// script.js, línies 16–22
const palette = {
  ink: '#1a1a1a', indigo: '#24427a', mist: '#b8c6e0', rule: '#9aa3b5', muted: '#5f6470',
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.indigo })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
```

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/gbt7714-chinese-paper

### script.js

```js
// ═══ Postext Cookbook · Nº 090 · A Chinese paper cited to GB/T 7714 ═════════════════
// https://postext.dev/en/cookbook/gbt7714-chinese-paper
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Noto Serif SC, Noto Sans SC, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.12.0
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerCitationEngine,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
import { createCiteprocEngine, STYLES, LOCALES } from 'https://esm.sh/postext-citeproc';

const LANG = 'es'; // @lang: the language of the frame; the paper is Chinese in both editions
const RECIPE = 'gbt7714-chinese-paper';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: black text, one indigo for the journal's name, its rules and its labels
const palette = {
  ink: '#1a1a1a', indigo: '#24427a', mist: '#b8c6e0', rule: '#9aa3b5', muted: '#5f6470',
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.indigo })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const [SONG, HEI, KAI] = ['Noto Serif SC', 'Noto Sans SC', 'LXGW WenKai TC']; // 宋, 黑, 楷
const [BODY, LEAD] = [9, 15]; // pt: 小五 on a 15 pt line, the grid both columns share
const [CHARS, LINES] = [23, 40]; // characters to a column's line, lines to a column
const PT = 25.4 / 72; // mm in a point
const AREA = (2 * CHARS + 2) * BODY * PT; // mm: two columns and a 2-em gutter, 152.4

// #region answer: GB/T 7714 numbered: [1] in citation order, [2–4], 等 or et al. by language
// Register the engine once, before the first build. The GB/T 7714 numeric style writes
// superscript [n] in the order works are first cited, joins three or more in a row into a
// range and sets the list with its type codes: [M] book, [J] article, [D] thesis, [C]
// conference paper, [EB/OL] web page. Its CSL file holds a second layout for works whose
// language is English, with "et al." for 等, commented out: uncommented, a Western entry
// takes "et al." and a Chinese one keeps 等, each chosen by the entry's `language`.
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const GBT = STYLES['china-national-standard-gb-t-7714-2015-numeric'];
const citations = {
  style: 'custom', // the bundled style with its English layout switched on
  customStyle: GBT.replace(/<!-- (<layout[^>]*locale="en">[\s\S]*?<\/layout>)\s*-->/, '$1'),
  locale: 'zh-CN', // 等, 卷, 版 and the other terms of the list
  bibliography: { fontSize: em(7.5 / BODY), lineHeight: pt(12), entrySpacing: pt(1.5),
    labelWidth: em(1.7) }, // turnovers under the text, not past it: [1] and a space
};
// #endregion

// #region masthead: the journal's name on an indigo band, then title, authors, affiliations
const BAND = 30; // mm from the trim's top
const at = (id, edge, y) => ({ anchor: { to: id ? `#${id}` : 'container', edge },
  offset: { y: mm(y) }, size: { width: mm(AREA) } });
const line = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), align: 'center',
  overflow: 'wrap', placement, ...extra });
const onBand = (x, y) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y) }, size: { width: mm(AREA) } });
const masthead = { enabled: true, minHeight: pt(9 * LEAD), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('indigo') },
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: 'fill', height: mm(BAND) } } },
  line('journal', '示例出版研究', HEI, 15, 'paper', onBand(15.8, 13),
    { fontWeight: 700, align: 'left', letterSpacing: pt(3) }),
  line('issue', '第44卷　第3期　2026年9月', HEI, 8, 'mist', onBand(15.8, 16),
    { align: 'right' }),
  line('title', '{titleText}', HEI, 17, 'ink', at('', 'top-left', 14),
    { fontWeight: 700, lineHeight: 1.45 }),
  line('authors', '{attr.authors}', KAI, 12, 'ink', at('title', 'below', 4)),
  line('affiliations', '{attr.affiliations}', SONG, 7.5, 'muted', at('authors', 'below', 2)),
] } };
// #endregion

// #region abstract: across both columns, its labels in 黑 the colour of the journal
// In 宋, not 楷: the one Kai on Fontsource is a Taiwan face (gotcha: cjk-face-region).
const calloutStyles = [{ id: 'abstract', span: 'page', backgroundEnabled: false,
  padding: { top: pt(0), bottom: pt(0), left: em(2), right: em(2) },
  marginTop: pt(0), marginBottom: pt(LEAD),
  body: { fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), firstLineIndent: pt(0),
    textAlign: 'justify' } }];
const chipStyles = [{ id: 'label', fontFamily: HEI, bold: true, color: col('indigo'),
  backgroundEnabled: false, borderWidth: pt(0), paddingX: em(0), gap: em(0.5) }];
// #endregion

// Running heads on the body pages, the folio at the outer corner, over a hairline.
const head = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: HEI, fontSize: pt(7.5), color: col('muted'), overflow: 'clip',
  align: edge, placement: { anchor: { to: 'page', edge: `top-${edge}` },
    offset: { x: mm(x), y: mm(12) } }, ...extra });
const folio = { fontWeight: 700, color: col('indigo') };
const header = { elements: [
  head('v-folio', '{pageNumber}', 'even', 'left', 15.8, folio),
  head('v-head', '示例出版研究　2026年第3期', 'even', 'left', 24),
  head('r-head', '赵一鸣，等：横排中文正文的行长与行距', 'odd', 'right', -24),
  head('r-folio', '{pageNumber}', 'odd', 'right', -15.8, folio),
  { kind: 'rule', id: 'head-rule', pages: 'body', thickness: pt(0.5), color: col('rule'),
    placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(15.8), y: mm(16.5) },
      size: { width: mm(AREA) } } },
] };

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'zh-Hans', // written out, never LANG (gotcha: cjk-locale-tag)
  colorPalette,
  citations,
  page: { width: mm(184), height: mm(260), dpi: 150, pageNumbering: { startAt: 41 },
    // 16开; with the grid on the margins are minimums, grown to centre the 23 × 40 area
    margins: { top: mm(22), bottom: mm(20), left: mm(15), right: mm(15), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: pt(2 * BODY) },
  cjk: { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } },
  bodyText: {
    fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
  },
  headings: { fontFamily: HEI, fontWeight: 700, color: col('ink'),
    balancing: { enabled: false }, // heads stay on the grid
    levels: [
      { level: 1, breakBefore: { enabled: true, parity: 'any' } }, // headings-drop-h1-break
      { level: 2, numberingTemplate: '{2}', numberSeparator: '　', fontSize: pt(10.5),
        lineHeight: pt(2 * LEAD), marginTop: pt(0), marginBottom: pt(0) }, // 1　实验方法
      { level: 3, numberingTemplate: '{2}.{3}', numberSeparator: '　', fontSize: pt(BODY),
        lineHeight: pt(LEAD), marginTop: pt(0), marginBottom: pt(0) }, // 1.1　被试
    ] },
  headingStyles: [
    { id: 'article', numbered: false, span: 'page', advancedDesign: masthead },
    { id: 'intro', numbered: false }, // 引言 goes before section 1, unnumbered
    { id: 'references', numbered: false },
  ],
  calloutStyles,
  chipStyles,
  paragraphStyles: [{ id: 'colophon', fontFamily: SONG, fontSize: pt(6.5), lineHeight: pt(9),
    color: col('muted'), firstLineIndent: pt(0), textAlign: 'left', marginTop: pt(LEAD) }],
  header,
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "横排中文正文的行长与行距"
---

# 横排中文正文的行长与行距：\\一项纸本与屏幕的对照阅读实验 {style="article" authors="赵一鸣¹　孙雨桐²" affiliations="1. 示例大学新闻与传播学院，示例市　100000；2. 示例出版社排版中心，示例市　100000"}

:::callout{type="abstract"}
:chip[摘　要：]{style="label"}行长和行距是横排中文正文最基本的两个版式参数，现行出版物大多凭经验确定。本文以48名大学生为被试，比较每行20、28、36字三种行长与1.5倍、1.8倍两种行距在纸本和屏幕上的阅读速度与理解成绩。结果表明：行长为28字时阅读速度最快；行长增至36字，纸本上的理解成绩无显著变化，屏幕上则下降约6%；1.8倍行距的优势在长行条件下比在短行条件下明显。据此建议单栏横排书刊正文以每行26～30字为宜，行长超过32字时应相应加大行距。

:chip[关键词：]{style="label"}行长；行距；中文排版；阅读速度；屏幕阅读

:chip[文献标志码：]{style="label"}A　　:chip[文章编号：]{style="label"}1000-0000(2026)03-0041-04
:::

## 引言 {style="intro"}

行长与行距对阅读的影响，西文排版界讨论已久。Tinker在20世纪中叶以印刷品为材料做了大量易读性实验，认为最适宜的行长取决于字号和行距，不能脱离二者单独规定[@tinker1963]。近二十年来，眼动记录和屏幕阅读进入这一领域，研究者发现阅读速度与理解成绩并不总是同步变化，只用速度评价版式容易得出片面的结论[@rayner2016; @dyson2001; @lin2023]。

中文的情况与西文不同。汉字等宽，行长可以直接用字数计量，出版社也习惯以“每行字数×每页行数”规定版心，32开图书的正文多为每行26～28字[@zhou2019; @chen2021]。W3C的《中文排版需求》整理了中文版面的基本要素和术语[@clreq]，但行长、行距取什么值，仍由各出版社自行决定。

这些数值大多来自排版经验，以汉字为材料、同时比较纸本与屏幕的实证研究还很少。林岚等[@lin2023]测量了屏幕上三种行长的阅读速度，没有考察行距；王晓和李文[@wang2022]讨论了屏幕阅读中的行距，只用了一种行长。本研究把行长和行距放在同一个实验中，在纸本和屏幕上分别测量阅读速度和理解成绩，为书刊正文的版式设计提供依据。

## 实验方法

### 被试

示例大学本科生48名，其中男生21名、女生27名，年龄18～23岁，母语均为汉语，视力或矫正视力正常，此前均未参加过阅读实验。

### 材料与版式

从近年出版的科普读物中选取说明文24篇，每篇约600字，难度经预实验平衡。版式按3（行长：每行20、28、36字）×2（行距：1.5倍、1.8倍）设计，字号统一为五号（10.5 pt），字体为宋体，两端对齐。纸本材料用A4纸单面印刷；屏幕材料在27英寸显示器上呈现，调整显示比例，使字的视角大小与纸本一致。

### 程序与测量

每名被试在纸本和屏幕上各读12篇，媒介的先后顺序在被试间平衡，版式条件在篇目间轮换。每读完一篇，回答5道理解题。阅读速度以每分钟字数计，理解成绩以答对题数的百分比计。屏幕条件下同时记录眼动，采样率为1000 Hz。

## 结果

### 阅读速度

行长为28字时，纸本和屏幕上的阅读速度均为最快，平均每分钟分别读512字和486字。行长20字时，换行次数增多，速度下降4%左右；行长36字时，速度下降7%左右，眼动记录显示换行后的回视次数明显增加。行太短和太长都会降低速度，这与Tinker对西文的观察方向一致[@tinker1963]。

### 理解成绩

纸本上，三种行长的理解成绩差异不显著。屏幕上，行长36字时的理解成绩比28字时低约6%。行距的作用主要出现在长行条件下：行长36字时，1.8倍行距的理解成绩比1.5倍行距高约4%；行长20字时，两种行距几乎没有差别。

## 讨论

长行降低了屏幕阅读的理解成绩，原因可能在换行时的视线定位。行越长，眼睛从行尾回到下一行行首的距离越大，落点越容易偏离；加大行距，相邻两行分得更开，这类错误随之减少。这一解释与眼动研究对换行和回视的描述相符[@rayner2016]，也说明行长和行距需要一并考虑。

本研究的材料只有说明文，被试也限于大学生；文学作品和其他年龄段的读者是否表现出同样的规律，还需要进一步检验。屏幕条件只用了一种显示器，没有考察手机等小屏幕设备，那里的行长通常不到20字。

## 结论

单栏横排的中文书刊正文，每行26～30字是较稳妥的选择。行长超过32字时，行距宜加大到1.8倍左右。面向屏幕的版式比纸本更需要控制行长，不宜把纸本的版心原样搬到屏幕上。

## 参考文献 {style="references"}

:::bibliography{title=""}

:::paragraphs{style="colophon"}
Artículo de muestra escrito para el Recetario de Postext. La revista, sus autores, su experimento y las obras chinas [4]–[6] y [8] son ficticios; las obras [1]–[3] y [7] son reales.
:::

:::references{format=csl-yaml}
- id: tinker1963
  type: book
  language: en
  author: [{family: Tinker, given: Miles A.}]
  title: Legibility of print
  publisher: Iowa State University Press
  publisher-place: Ames
  issued: 1963
- id: rayner2016
  type: article-journal
  language: en
  author: [{family: Rayner, given: Keith}, {family: Schotter, given: Elizabeth R.}, {family: Masson, given: Michael E. J.}, {family: Potter, given: Mary C.}, {family: Treiman, given: Rebecca}]
  title: "So much to read, so little time: how do we read, and can speed reading help?"
  container-title: Psychological Science in the Public Interest
  volume: 17
  issue: 1
  page: 4-34
  issued: 2016
  DOI: 10.1177/1529100615623267
- id: dyson2001
  type: article-journal
  language: en
  author: [{family: Dyson, given: Mary C.}, {family: Haselgrove, given: Mark}]
  title: The influence of reading speed and line length on the effectiveness of reading from screen
  container-title: International Journal of Human-Computer Studies
  volume: 54
  issue: 4
  page: 585-612
  issued: 2001
  DOI: 10.1006/ijhc.2001.0458
- id: lin2023
  type: article-journal
  language: zh-CN
  author: [{literal: 林岚}, {literal: 周明远}, {literal: 陈思齐}, {literal: 王晓}]
  title: 屏幕上横排中文的行长与阅读速度
  container-title: 示例出版研究
  volume: 41
  issue: 2
  page: 15-24
  issued: 2023
- id: zhou2019
  type: book
  language: zh-CN
  author: [{literal: 周明远}]
  title: 汉字排版概论
  publisher: 示例出版社
  publisher-place: 示例市
  issued: 2019
- id: chen2021
  type: thesis
  genre: 硕士学位论文
  language: zh-CN
  author: [{literal: 陈思齐}]
  title: 中文书刊版心设计研究
  publisher: 示例大学
  publisher-place: 示例市
  issued: 2021
- id: wang2022
  type: paper-conference
  language: zh-CN
  author: [{literal: 王晓}, {literal: 李文}]
  title: 屏幕阅读中的行距选择
  container-title: 第五届数字出版学术研讨会论文集
  editor: [{literal: 示例出版学会}]
  publisher: 示例出版社
  publisher-place: 示例市
  page: 88-95
  issued: 2022
- id: clreq
  type: webpage
  language: zh-CN
  author: [{literal: W3C}]
  title: 中文排版需求
  URL: https://www.w3.org/TR/clreq/
  accessed: 2026-09-30
:::
`; // content.<lang>.md: the same Chinese text in both

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first)
  'Noto Serif SC': ['400'], // SONG: the text, the references, the affiliations
  'Noto Sans SC': ['400', '700'], // HEI: running heads; the journal, title, heads, labels
  'LXGW WenKai TC': ['400'], // KAI: the authors' names
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// Each voice loads the files of what it sets, and the Song the words the style adds.
const all = (re) => (markdown.match(re) ?? []).join('');
const labels = all(/:chip\[[^\]]*\]/g);
const heads = `${all(/^#+ .*$/gm)}示例出版研究第卷期年月赵一鸣等横排中文正文的行长与行距`;
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [SONG]: ['400'] }, `${markdown}等版卷期页`);
await loadCjkFonts({ [HEI]: ['400', '700'] }, `${heads}${labels}0123456789`);
await loadCjkFonts({ [KAI]: ['400'] }, all(/authors="[^"]*"/g));
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showPages(doc, { title: t({ en: 'A Chinese paper cited to GB/T 7714',
  es: 'Un artículo chino citado según la GB/T 7714' }) });
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 ───────────────────────────────────────────────────────────────────────
```

## Variants

### Números a la línia en lloc de volats

Algunes revistes imprimeixen els números de citació a la línia, al cos del text. `marker` canvia com es marca el número i deixa l'estil intacte: `'brackets'` dona [1] a la línia; `'corner'`, 〔1〕.

```diff
   locale: 'zh-CN', // 等, 卷, 版 and the other terms of the list
+  marker: 'brackets',
```

### Les obres xineses primer

Les bibliografies mixtes s'ordenen a vegades amb les obres xineses davant de les occidentals. En un estil numèric els números deixarien de córrer en ordre per la llista, així que convé més l'estil autor-any.

```diff
-  customStyle: GBT.replace(/<!-- (<layout[^>]*locale="en">[\s\S]*?<\/layout>)\s*-->/, '$1'),
+  customStyle: STYLES['china-national-standard-gb-t-7714-2015-author-date']
+    .replace(/<!-- (<layout[^>]*locale="en">[\s\S]*?<\/layout>)\s*-->/g, '$1'),
   locale: 'zh-CN', // 等, 卷, 版 and the other terms of the list
-  bibliography: { fontSize: em(7.5 / BODY), lineHeight: pt(12), entrySpacing: pt(1.5),
+  bibliography: { groupByLanguage: true, fontSize: em(7.5 / BODY), lineHeight: pt(12),
```

## Errors freqüents

- **Etiqueta el document zh-Hans o zh-Hant, no amb LANG.** Les edicions d'una recepta són en i es, però una mostra xinesa és xinesa en totes dues: `locale: LANG` l'etiquetaria com a anglès o castellà, separaria les seves paraules llatines, anomenaria Figure o Figura les seves figures i donaria al PDF una llengua equivocada. Escriu tu l'etiqueta: 'zh-Hans' (convencions de la Xina continental: tall GB, puntuació Kaiming) o 'zh-Hant' (Taiwan: puntuació d'amplada completa centrada); 'zh-HK' per a Hong Kong. Un 'zh' tot sol es llegeix com a xinès simplificat continental.
- **Les fonts xineses es carreguen per fragments, amb el bloc cjk.** Fontsource serveix una família xinesa, japonesa o coreana en un centenar de fitxers per pes, cadascun amb un interval de caràcters. loadFonts només baixa el fitxer latin, així que a la pantalla els caràcters xinesos surten d'una font del sistema i es mesuren malament, i fontsourceProvider lliura al PDF aquest fitxer latin, que els imprimeix com a caixes buides. Afegeix el bloc cjk del kit, crida loadCjkFonts(FONTS, markdown) després de loadFonts (una vegada per veu, amb el text que compon, si el llibre fa servir diverses fonts xineses) i passa a renderToPdf fontProvider: cjkPdfProvider: tots dos agafen els fitxers que contenen els caràcters del text.
- **En una retícula de caràcters, desactiva l'equilibri de columnes.** L'equilibri de columnes omple una pàgina que acaba curta (un títol que es queda amb el seu text deixa línies en blanc al peu) afegint línies de la retícula a sobre dels títols i component un paràgraf una línia més folgat. Una línia xinesa més folgada separa els seus caràcters (0,13 em en una pàgina GB/T 9704, molt per sobre de balancing.maxTracking), de manera que els caràcters surten de les columnes de la retícula i els títols de les seves línies. Una pàgina comptada en caselles acaba curta: posa headings.balancing: { enabled: false }.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **Posa entre cometes cada valor del frontmatter.** YAML llegeix title: 1984 com un nombre i una data com un objecte Date, i els valors que no són cadenes s'imprimeixen buits als marcadors i deixen el PDF sense títol. Posa entre cometes cada valor: title: "1984".
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.

- Una citació narrativa, `@zhou2019` sense claudàtors, anomena els autors i afegeix el número, però la clau continua pels caràcters xinesos que vinguin darrere, així que `@zhou2019认为` es llegeix com una clau que no existeix. Escriu el nom al text i la citació darrere, `周明远[@zhou2019]认为`, com fan de tota manera els articles xinesos.
- L'estil numèric inclòs no imprimeix localitzadors: `[@zhou2019, 页 45]` dona [5] sense la pàgina. La GB/T 7714 posa la pàgina després del claudàtor, dins del volat, [5]45; mentre l'estil no ho faci, escriu la pàgina a la frase.
- LXGW WenKai TC, l'única Kai de Fontsource, és una font de Taiwan: dibuixa ，i 。 al centre de la casella, i amb `zh-Hans` el motor retalla la meitat dreta de la casella, cosa que talla el signe. El resum va en Noto Serif SC, i la Kai es queda per als noms dels autors, que no porten puntuació.
- En una retícula de caràcters, l'equilibrat de columnes afegiria línies sobre els títols i espaiaria els caràcters d'un paràgraf per omplir una columna curta. `headings.balancing.enabled: false` manté cada línia a la retícula, i la segona columna de la pàgina 1 acaba tres línies abans: el títol 2 结果 necessita les seves dues línies i dues de text a sota, així que obre la pàgina 2.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipus de lletra: Noto Serif SC (OFL-1.1), Noto Sans SC (OFL-1.1), LXGW WenKai TC (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 087 · Un capítol de tesi citat en APA 7](https://postext.dev/ca/cookbook/apa-thesis-with-bibtex.md): Un capítol de tesi doctoral amb citacions [@clau, p. 33] que surten en APA 7 gràcies a citeproc-js, amb la llista de referències presa d'un bloc BibTeX. · Nivell 2 (Intermedi) · Articles i treballs acadèmics, Informes i memòries
- [Núm. 082 · Un document oficial xinès segons la GB/T 9704](https://postext.dev/ca/cookbook/chinese-official-document.md): Un avís de quatre pàgines en A4 segons la norma nacional: 28 × 22 caràcters de 三号, membret vermell, títols 一、（一）1.（1）, annex i folis «— 1 —». · Nivell 2 (Intermedi) · Informes i memòries
- [Núm. 002 · Article a dues columnes amb equacions numerades](https://postext.dev/ca/cookbook/journal-article-with-maths.md): Article de física a dues columnes amb set equacions numerades, compostes amb el MathJax de la versió ?bundle. Al PDF continuen sent vectorials. · Nivell 3 (Avançat) · Articles i treballs acadèmics
