Kurz gesagt
Eine vierseitige chinesische Mitteilung im Stil einer Behörde. Sie folgt den landesweiten Regeln für solche Schreiben, mit rotem Kopf, nummerierten Abschnitten und Seitenzahlen in fester Form.
Was Sie setzen
Eine Mitteilung eines erfundenen Verlegerverbands in einer erfundenen Stadt, 示例市 („Beispielstadt“), gesetzt, wie eine Behörde in Festlandchina ihre Schreiben nach GB/T 9704—2012 setzt, 《党政机关公文格式》. Sie umfasst vier A4-Seiten mit 28 Zeichen pro Zeile und 22 Zeilen pro Seite. Seite 1 beginnt mit dem Namen des Absenders in Rot, dem Aktenzeichen über einer roten Linie und einem zweizeiligen Titel in 二号. Die Überschriften sind 一、(一)1.(1) nummeriert, jede Ebene in ihrer eigenen Schrift. Unterschrift und Datum stehen rechts, und eine Anlage auf einer eigenen Seite trägt an ihrem Fuß den 版记 zwischen Linien. Die Pagina lautet „— 1 —“, rechts auf ungeraden und links auf geraden Seiten. Jedes Blatt weist im Kopfsteg aus, dass es ein Muster ist. Geschäftsbrief nach DIN 5008 erledigt dieselbe Aufgabe nach einer europäischen Norm.
Dieses Rezept beantwortet
- Wie gestalte ich ein chinesisches Amtsschreiben nach GB/T 9704, mit rotem Briefkopf, Raster und Paginas?
- Wie lege ich den Satzspiegel in Zeichen fest, mit so vielen Zeichen pro Zeile und so vielen Zeilen pro Seite?
- Wie nummeriere ich Kapitel 第一回, 第二回 mit chinesischen Zahlzeichen?
Die kurze Antwort
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 = {
width: mm(210), height: mm(297), dpi: 150, 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, // 左空二字,回行顶格
};
Zutaten
- Funktionen
- ZeichenrasterChinesische ZahlzeichenNummerierte ÜberschriftenBreite chinesischer SatzzeichenChinesischer ZeilenumbruchBlocksatz zwischen ZeichenAbstand zwischen Chinesisch und LateinChinesische, japanische und koreanische SchriftenEndformatGespiegelte RänderGestaltete AuftakteText, Linien und Kästen in SeitengestaltungenGestaltungselemente verankernKolumnentitel und SeitenzahlenÜberschriftenformateÜberschriftenattributeZeilenumbrüche in TitelnAbsatzformateTabellenformatPDF-Export
- Nutzt außerdem
- Abbildungen genau hierKapitelband über die ganze BreitePapierfarbeIm PDF eingebettete SchriftenEigene RessourcentypenTabellen aus DatenKapitel ohne Nummer
- Schrift
- Noto Serif SC, Noto Sans SC, LXGW WenKai TC (SIL OFL 1.1)
- Dateien
- Keine: Jedes Bild wird im Code gezeichnet
Zubereitung
#1 · Ein in Zellen gezählter Satzspiegel
Den Code finden Sie oben unter Die kurze Antwort. GB/T 9704 legt den Kopfsteg auf 37 mm und den Bundsteg auf 28 mm fest und füllt einen Satzspiegel von 156 × 225 mm mit 22 Zeilen zu 28 Zeichen in 三号. cjk.grid übernimmt die Zahlen, wie sie sind: 28 Geviert zu 15,75 pt ergeben 155,6 mm, und 22 Zeilen zu 29 pt ergeben 225,1 mm (Zeichenraster). Das 三号 von Word misst 16 pt, was die Zeile 158 mm lang machte, breiter als der Satzspiegel der Norm; 15,75 pt ist der Wert aus den Tabellen der Setzer. Mit eingeschaltetem Raster sind die Ränder Mindestwerte. Diese lassen 0,02 mm zum Verteilen, sodass der Satzspiegel dort bleibt, wo die Norm ihn hinsetzt. Die Norm nennt 仿宋 für den Text; Fontsource hat keine Fangsong-Schrift, daher steht der Text in der Song von Noto Serif SC (siehe Häufige Fehler).
Jedes Satzzeichen nimmt eine ganze Zelle ein und gibt nichts davon ab, sodass die Zeichen in einer chinesischen Zeile in den Spalten des Rasters stehen, das die Norm zeichnet. Arabische Ziffern sind schmaler als eine Zelle, daher fallen in einer Zeile wie 版心156毫米×225毫米 die Zeichen nach den Ziffern zwischen die Spalten. Fällt ein Komma auf die 29. Zelle, hängt hangingPunctuation: 'allow' es in den Rand, statt ein Zeichen in die nächste Zeile zu schieben und den Rest auszutreiben (Hängende Interpunktion).

#2 · Vier Gliederungsebenen, je eine Zeile
// One line each at the body size, nothing above or below: every head stays on the grid.
const level = (n, fontFamily, fontWeight, numberingTemplate) => ({ level: n, fontFamily,
fontWeight, numberingTemplate, numberSeparator: '', fontSize: pt(BODY), lineHeight: pt(LEAD),
marginTop: pt(0), marginBottom: pt(0), firstLineIndent: em(2) }); // 空二字,回行顶格
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})'),
];
Die Norm setzt 一、 in 黑体, (一) in 楷体 und 1. sowie (1) in der Textschrift, alle in Textgröße. {2:一} setzt die informelle chinesische Zahl in der Schrift von locale (Nummerierung). Mit Größe und Zeilenabstand des Textes und ohne Abstände nimmt eine Überschrift eine Rasterzeile ein, und der Text darunter bleibt ebenfalls im Raster. firstLineIndent: em(2) rückt jede Überschrift um zwei Zellen ein, in Geviert des Textes gezählt, und eine Überschrift, die in eine zweite Zeile läuft, beginnt diese am Rand, wie die Norm es will; die Nummer folgt dem Einzug, sodass Inhaltsverzeichnis und PDF-Lesezeichen 一、 ohne vorangestellte Leerzeichen lesen (Einstellungen pro Ebene).
#3 · Der Briefkopf, Zeile für Zeile
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' } }; // a notice opens on the next page
Der Briefkopf ist das Design der H1, deren Attribute Exemplarnummer, Absender und Aktenzeichen tragen: Die Konfiguration enthält das Briefpapier, das Markdown das Dokument. lineTop(n) liefert die Oberkante der Rasterzeile n, sodass jedes Element dort steht, wo die Norm es zählt: das Aktenzeichen zwei Leerzeilen unter dem Namen des Absenders, der Titel zwei Leerzeilen unter der roten Linie. Bei 46 pt reicht die Farbe des Namens von 35 mm unter der Oberkante des Satzspiegels bis zum Fuß der Zeile 5, was die Zeilen 6 und 7 frei lässt und das Aktenzeichen auf Zeile 8 setzt; bei 48 pt reichte er in Zeile 6 hinein und schöbe den Rest der Seite eine Zeile tiefer. Das \\ in der Überschrift bricht den Titel nach 关于举办 um, eine kurze Zeile über einer langen: ein Trapez, eine der beiden Formen, die die Norm erlaubt (die andere ist eine Raute). minHeight reserviert 13 Zeilen und marginBottom eine weitere, sodass der Empfänger auf Zeile 15 beginnt (Spannweite und erweitertes Design).
#4 · Pagina unter dem Satzspiegel
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 der Fußzeile ist die Oberkante des Containers der Fuß des Satzspiegels, sodass offset.y vom Text aus nach unten misst (Kopf- und Fußzeilen). Die Striche stehen 7 mm darunter und eine Zelle 四号 vom Außenrand entfernt: parity setzt die ungeraden Seitenzahlen rechts und die geraden links. Der Kopfsteg setzt {subtitle} aus dem Frontmatter. Diese Zeile und das Impressum unter dem 版记 sind alles, was sich zwischen der englischen und der spanischen Ausgabe ändert.
#5 · Die Anlage und der 版记
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),
] } },
};
Die Überschrift der Anlage hat einen eigenen Stil: eine neue Seite, 附件 auf Zeile 1, der Titel auf Zeile 3 und kein Abstand unter den drei Zeilen, die sie reserviert. Die leere Zeile 4 ist der Abstand, den jede eingebundene Ressource über sich hält (inlineResourceGap, unter Layout), sodass die obere Linie der Tabelle auf Zeile 5 sitzt. Der 版记 gehört zum selben Design und wird auf den beiden Rasterzeilen gezeichnet, die am Fuß des Satzspiegels enden. Seine Elemente haben reserve: false, sodass sie dem Raum der Überschrift nichts hinzufügen und die Tabelle nicht an ihnen vorbei nach unten geschoben wird. Die Norm setzt den 版记 auf die letzte Seite, nach den Anlagen; bei einer einseitigen Anlage ist das die Seite der Anlage. Eine längere Anlage bräuchte einen 版记, den eine Überschrift auf ihrer letzten Seite zeichnet.
#6 · Der Unterschriftenblock
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) },
];
Eine Mitteilung ohne Siegel setzt den Namen des Absenders zwei Zellen vom rechten Rand eingerückt und das Datum darunter, zwei Zellen weiter rechts beginnend. Ein Datum, das länger ist als der Name, endet stattdessen zwei Zellen vor dem rechten Rand, und der Name rückt nach links, um die Stufe von zwei Zellen zu wahren. Unseres misst 6,9 Geviert, eine Spur kürzer als die 7 des Namens, also begänne es nach dem Buchstaben der Regel zwei Zellen rechts vom Namen und endete 0,14 Geviert vor dem Rand. Wir lesen die Regel nach ihrem Zweck, einem Datum, das nie bis an den Rand läuft, und nehmen den zweiten Fall: Das Datum endet knapp über zwei Zellen vor dem Rand. indent setzt beide Zeilen auf ganze Zellen, 17 und 19 Geviert vom Rand (Absatzformate).
Das ganze Rezept
// ═══ 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.25.0 import { buildDocument, withLoadedFonts, renderPageToCanvas, 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 = { width: mm(210), height: mm(297), dpi: 150, 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. const level = (n, fontFamily, fontWeight, numberingTemplate) => ({ level: n, fontFamily, fontWeight, numberingTemplate, numberSeparator: '', fontSize: pt(BODY), lineHeight: pt(LEAD), marginTop: pt(0), marginBottom: pt(0), firstLineIndent: em(2) }); // 空二字,回行顶格 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' } }; // a notice opens on the next page // #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 = () => ({ 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] }, 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`---Markdown-Beispiel · 81 Zeilen · content.en.md
title: "示例市出版协会关于举办2026年中文排版实务培训班的通知" subtitle: "样张 · A specimen set to GB/T 9704—2012 · The association, its staff and this notice are fictitious" --- # 示例市出版协会关于举办 \\ 2026年中文排版实务培训班的通知 {copy="000001" issuer="示例市出版协会" number="示出协〔2026〕7号"} :::paragraphs{style="flush"} 各会员单位: ::: 为提高会员单位排版人员的业务水平,协会定于2026年10月举办中文排版实务培训班。现将有关事项通知如下: ## 培训时间 2026年10月20日至22日,共3天。参训人员于10月19日14:00至18:00到示例市图书大厦一楼大厅报到,报到时请出示单位介绍信。 ## 培训地点 示例市图书大厦五楼报告厅(示例市文昌路88号)。 ## 培训内容 ### 排版规范 #### 公文格式 学习《党政机关公文格式》(GB/T 9704—2012),重点掌握以下两项内容: ##### 版心与字体 版心156毫米×225毫米,每页22行,每行28字,正文一般用3号仿宋体字。 ##### 页码与版记 页码用4号半角阿拉伯数字,单页居右,双页居左;版记置于公文最后一页。 #### 标点符号 学习《标点符号用法》(GB/T 15834—2011),掌握标点在行首行末的禁则和两个标点连用时的处理。 ### 实务操作 按字数和行数设定版心,完成书刊样张和公文样张各一份,由教员逐页讲评。 ## 参加对象和名额 各会员单位从事编辑、校对和排版工作的人员,可自愿报名,每个单位限报2人,全市共80个名额,报满为止。 ## 报名办法 报名时间为即日起至2026年10月10日,可任选以下一种方式报名。 ### 网上报名 登录示例市出版协会网站“培训报名”栏目,填写报名表并上传近一年排版的书刊或公文一份。 ### 书面报名 报名表加盖单位公章后,送至协会秘书处(示例市图书大厦十二楼)。 ## 其他事项 培训不收取费用,食宿费用回原单位报销。参训人员须自带笔记本电脑。联系人:协会秘书处周明远、陈思齐。 :::paragraphs{style="annexes"} 附件:培训日程安排 ::: :::paragraphs{style="signature"} 示例市出版协会 ::: :::paragraphs{style="date"} 2026年9月28日 ::: (此件公开发布) # 培训日程安排 {style="annex" label="附件" cc="示例市新闻出版局,示例市图书馆学会。" office="示例市出版协会秘书处" printed="2026年9月28日印发" colophon="Noto Serif SC, Noto Sans SC and LXGW WenKai TC (SIL OFL) · A fictitious notice written for the recipe"} ::resource{id="schedule"}`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build '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); // The text face also sets the folios and the imprint's label, 抄送. 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 withLoadedFonts(() => buildDocument({ markdown, resources }, config()), { ...kitFonts(FONTS), text: markdown }); showPages(doc, { title: t({ en: 'A Chinese official document to GB/T 9704', es: 'Un documento oficial chino según la GB/T 9704' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);Kit · core, fonts, viewer, pdf, cjk: in jedem Rezept gleich · 458 Zeilen
// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · cjk v2 ── 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 = `${id}/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { let at; cjkSlices.cache.set(css, fontsourceAt(id).then((a) => fetch(at = a + css.slice(id.length + 1))) .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], at).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` the screen sets code point `cp` in, if any: the * last one loadCjkFonts adds whose range holds it. A numbered file that * repeats a letter of latin is added after latin, and wins it. */ function cjkSliceFor(slices, cp) { const holds = (slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi); return slices.reduce((last, slice) => (holds(slice) ? slice : last), undefined); } /** 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 the engine's later round * 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 the * screen set its pages' characters in (`request.codePoints`), each marked * with its unicode-range and its place in the order loadCjkFonts added * them: postext-pdf sets a character two files hold in the one added * last, as the browser does. Any other family gets the latin file * fontsourceProvider fetches (the "pdf" block) and, when the face sets * letters only latin-ext has, that file too. */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return cjkLatinPdfFiles(family, weight, style, request); const meta = await fontsourceMeta(family); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const slices = await cjkSlices(family, w, s); const picked = new Set(); for (const cp of request?.codePoints ?? []) { const slice = cjkSliceFor(slices, cp); if (slice) picked.add(slice); } if (!picked.size) picked.add(slices[0]); return Promise.all(slices.map(async (slice, order) => { if (!picked.has(slice)) return null; const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer())); return Object.assign(bytes, { unicodeRange: slice.range, order }); })).then((files) => files.filter(Boolean)); } /** A Latin family set next to the CJK faces: its latin file, then its * latin-ext file when the face sets letters only latin-ext has (ō ū in * Hepburn rōmaji, ǎ in pinyin), the file loadFonts adds on screen for * them, each marked with its unicode-range and its place in that order: * the PDF sets a character in the file the browser took it from. A face * Fontsource ships without latin-ext, or whose file does not come, gets * latin alone, and the PDF names the letters it lacks. */ async function cjkLatinPdfFiles(family, weight, style, request) { const meta = await fontsourceMeta(family); const beyond = [...(request?.codePoints ?? [])].some(cjkLatinExtOnly); if (!beyond || !meta?.subsets?.includes('latin-ext')) return fontsourceProvider(family, weight, style); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const id = fontsourceId(family); const url = `${await fontsourceAt(id)}files/${id}-latin-ext-${w}-${s}.woff2`; const [latin, ext] = await Promise.all([fontsourceProvider(family, weight, style), fetch(url) .then(async (res) => (res.ok ? decompressWoff2(new Uint8Array(await res.arrayBuffer())) : null), () => null)]); const ranges = kitRanges(); return ext ? [Object.assign(latin, { unicodeRange: ranges.latin, order: 0 }), Object.assign(ext, { unicodeRange: ranges['latin-ext'], order: 1 })] : latin; } /** Whether code point `cp` is in Fontsource's latin-ext file and not in * its latin file: Latin Extended-A and -B, IPA, the spacing modifiers and * Latin Extended Additional (loadFonts's test for latin-ext), less the * few latin holds too (ı Œ œ ʻ ʼ ˆ ˚ ˜). */ function cjkLatinExtOnly(cp) { if (!((cp >= 0x100 && cp <= 0x2ff) || (cp >= 0x1e00 && cp <= 0x1eff))) return false; return ![0x131, 0x152, 0x153, 0x2bb, 0x2bc, 0x2c6, 0x2da, 0x2dc].includes(cp); } /** showPages for a book bound on either edge. A right-bound book (the * document says so: doc.binding is 'right' for page.binding 'right' and * for vertical text) lies on the desk as it opens: page 1 alone on the * left of the spine, then [3 | 2], the spine shade on each page's inner * edge. `binding` ('left' | 'right') overrides the document's. */ function showBook(docs, { binding, ...options } = {}) { const count = showPages(docs, options); const right = (binding ?? [docs].flat()[0]?.binding) === 'right'; if (!document.getElementById('pt-kit-cjk')) { // The pages keep direction ltr: a canvas draws text in the direction its // element inherits, and under rtl each run would end where the engine // starts it, its brackets mirrored. document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk"> .pt-spread[dir="rtl"] canvas { direction: ltr; } .pt-spread[dir="rtl"] figure:first-child canvas { box-shadow: inset 14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } </style>`); } // Each pair stays [verso, recto] in the page; right to left, the verso // sits on the right. Phones stack the pages in reading order either way. for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr'; document.getElementById('pages').dataset.binding = right ? 'right' : 'left'; return count; } // ─── /Kit ───────────────────────────────────────────────────────────────────────
Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)
Varianten
#Aufeinandertreffende Satzzeichen stauchen
Paare wie ),und 》( nehmen anderthalb Zellen statt zwei ein, wie in den meisten Büchern; die Zeichen danach verlassen die Spalten des Rasters.
- punctuationWidth: 'fullwidth', compressAdjacent: false, trimLineStart: false,
+ punctuationWidth: 'fullwidth', compressAdjacent: true, trimLineStart: true,#Das Raster beim Setzen der Seite zeigen
Der Canvas zeichnet für jede Zelle des Rasters von 28 × 22 ein graues Quadrat, und das PDF lässt es weg, sofern renderToPdf nicht characterGrid: true erhält.
- grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
+ grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES, show: true },Häufige Fehler
Häufiger Fehler
Fontsource hat keine Fangsong-Schrift (仿宋)
GB/T 9704 und die meisten Festlandformulare setzen ihren Text in 仿宋, einer Song-Schrift mit den dünnen, leicht ansteigenden Strichen des Pinsels. Keine der chinesischen Familien bei Fontsource ist eine Fangsong, ein Rezept setzt den Text also in der vorhandenen Song-Schrift, Noto Serif SC, und vermerkt das. Eine Fangsong, für die Sie eine Lizenz besitzen, kommt als eigene Schrift hinzu: Registrieren Sie ihre Datei mit FontFace, nennen Sie die Familie in bodyText.fontFamily und geben Sie renderToPdf einen fontProvider, der ihre Bytes zurückgibt. Chinesische, japanische und koreanische Schriften →
Häufiger Fehler
Chinesische Schnitte werden in Teilstücken geladen, über den Block cjk
Fontsource liefert eine chinesische, japanische oder koreanische Familie als etwa hundert Dateien pro Schriftstärke aus, jede für einen Zeichenbereich. loadFonts holt nur die Datei latin, am Bildschirm stammen die Han-Zeichen also aus einer Systemschrift und werden falsch gemessen, und fontsourceProvider gibt dem PDF diese latin-Datei, die sie als leere Kästchen druckt. Führen Sie den Kit-Block cjk auf, rufen Sie loadCjkFonts(FONTS, markdown) nach loadFonts auf (einmal pro Stimme, mit dem Text, den sie setzt, wenn das Buch mehrere CJK-Schriften verwendet), und geben Sie renderToPdf fontProvider: cjkPdfProvider: Beide nehmen die Dateien, die die Zeichen des Textes enthalten. Chinesische, japanische und koreanische Schriften →
Häufiger Fehler
Zeichnen Sie das Dokument mit zh-Hans oder zh-Hant aus, nicht mit LANG
Die Ausgaben eines Rezepts sind en und es, ein chinesisches Beispiel ist aber in beiden Chinesisch: `locale: LANG` würde es als Englisch oder Spanisch auszeichnen, seine lateinischen Wörter trennen, seine Abbildungen mit Figure oder Figura beschriften und dem PDF die falsche Sprache geben. Seit postext 1.30 ist die Auszeichnung auch die Sprache, in der der Text geformt wird (gemessen, gezeichnet und eingebettet). Unter einer englischen oder spanischen Auszeichnung setzt eine chinesische Schrift daher ihre Ziffern und die mit lateinischem Text geteilte Interpunktion in anderen Formen, und die Zeilen brechen an anderer Stelle um. Schreiben Sie die Auszeichnung selbst: 'zh-Hans' (Festlandkonventionen: GB-Zeilenumbruch, Kaiming-Interpunktion) oder 'zh-Hant' (Taiwan: zentrierte Interpunktion voller Breite); 'zh-HK' für Hongkong. Ein bloßes 'zh' gilt als vereinfacht, Festland. Ein japanisches Beispiel erhält 'ja' (Häufiger Fehler ja-locale-tag). Chinesischer Zeilenumbruch →
Layoutwarnung · cjkGridClamped
Zeichenraster zu groß
Ursache. `cjk.grid` verlangt mehr Zeichen pro Zeile oder Zeilen pro Seite, als die Seitenränder bei Grundschriftgröße und Zeilenabstand Platz lassen, das Raster wird daher mit dem Maximum gesetzt, das passt.
Lösung. Senken Sie `charsPerLine` oder `linesPerPage`, verkleinern Sie die Ränder (sie sind Mindestwerte) oder setzen Sie ein kleineres `bodyText.fontSize` oder `lineHeight`. Doku →
- Eine Blocksatzzeile verteilt ihren Überschuss auf die chinesischen Zeichen und jedes Leerzeichen darin, und das Leerzeichen nimmt mehr als seinen Anteil: Auf Seite 2 öffnete sich das in GB/T 15834 fast doppelt so weit wie das in GB/T 9704 darüber. Der Inhalt schreibt beide Nummern mit einem geschützten Leerzeichen (U+00A0), das jede Nummer zusammenhält, sodass der Überschuss zwischen die Zeichen geht.
Nachweise
- Rezept
- Ignacio Ferro
- Text
- A notice from a fictitious publishers’ association, written for the recipe in the form GB/T 9704—2012 sets out · Postext Cookbook · CC BY 4.0
- Schriften
- Noto Serif SC (SIL OFL 1.1) · Noto Sans SC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


