انتقل إلى المحتوى الرئيسي
الوصفة رقم 82

دليل الوصفات · الفصل 1 · الصفحة والشبكة

مستند رسمي صيني وفق GB/T 9704

إشعار من أربع صفحات A4 وفق المعيار الوطني: شبكة 28 × 22 حرفًا بحجم 三号، وترويسة حمراء، وعناوين 一、(一)1.(1)، وملحق، وأرقام صفحات «— 1 —».

في هذه الصفحة
المُخرَج
Canvas · PDF
المستوى
متوسط
Postext
اختُبرت مع Postext 1.9.0
تتطلب ≥ 1.9.0 · postext-pdf ≥ 1.9.0
الترخيص
حُدّثت في 29 سبتمبر 2026
الكود MIT · النص CC BY 4.0
  • نموذج باللغة الإنجليزية: لا توجد طبعة عربية بعد
  • مقاس القص 210 × 297 مم
  • عمود واحد
  • Noto Serif SC 15.8/29
  • LXGW WenKai TC
  • Noto Sans SC
  • 4 صفحات
  • المستوى
  • Postext 1.9.0
  • أُخرجت في 9 ms
  • أسطر الكود: 180

باختصار

إشعار صيني من أربع صفحات على طراز المستندات الحكومية. يتبع القواعد الوطنية لهذه الأوراق: عنوان أحمر، وأقسام مرقّمة، وأرقام صفحات بشكل محدد.

ما الذي ستنضده

إشعار من جمعية ناشرين متخيَّلة في مدينة متخيَّلة، 示例市 («مدينة المثال»)، منضَّد كما تنضّد المكاتب في البر الصيني أوراقها وفق GB/T 9704—2012، 《党政机关公文格式》. يقع في أربع صفحات A4، في كل سطر 28 حرفًا وفي كل صفحة 22 سطرًا. تنفتح الصفحة 1 باسم الجهة المُصدِرة بالأحمر، ورقم المستند فوق خط أحمر، وعنوان من سطرين بحجم 二号. العناوين مرقّمة 一、(一)1.(1)، ولكل مستوى خطّه. ويقع التوقيع والتاريخ إلى اليمين، ويحمل ملحق في صفحة خاصة به الـ 版记 بين خطوط في أسفلها. وأرقام الصفحات تُقرأ «— 1 —»، على اليمين في الصفحات الفردية وعلى اليسار في الزوجية. وكل ورقة تقول في هامشها العلوي إنها نموذج. وتؤدي وصفة رسالة عمل وفق DIN 5008 المهمة نفسها وفق معيار أوروبي.

تجيب هذه الوصفة عن

  • كيف أُخرج وثيقة رسمية صينية وفق GB/T 9704، بترويستها الحمراء وشبكتها وأرقام صفحاتها؟
  • كيف أضبط منطقة النص بالحروف، بعدد محدد من الحروف في السطر وعدد محدد من الأسطر في الصفحة؟
  • كيف أرقّم الفصول 第一回، 第二回 بالأعداد الصينية؟

الجواب المختصر

script.js · الأسطر 28–48في الكود الكامل
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES }, // 28 ems × 22 lines
  // Every mark takes a whole cell, as on the standard's grid, and never gives any of it up.
  punctuationWidth: 'fullwidth', compressAdjacent: false, trimLineStart: false,
  hangingPunctuation: 'allow', // a , that may not open a line hangs past the 28th cell
  latinSpacing: em(0), // 〔2026〕7号 and 2026年9月28日 set solid, as the standard prints them
};
const page = {
  // 144 dpi is 2 px to the point: the 29 pt lines add up with no rounding (see Pitfalls).
  width: mm(210), height: mm(297), dpi: 144, backgroundColor: col('paper'),
  // With the grid on, margins are minimums. These leave 28 × 22 cells and 0.02 mm to share,
  // so the type area sits 37 mm under the head and 28 mm from the binding edge.
  margins: { top: mm(TOP), bottom: mm(297 - TOP - AREA.h - 0.02), left: mm(INNER),
    right: mm(210 - INNER - AREA.w - 0.02), mirror: true },
};
const bodyText = {
  fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', // a short line spreads between its characters, never between words
  firstLineIndent: em(2), indentAfterHeading: true, // 左空二字,回行顶格
};

المكونات

الخطوط
Noto Serif SC, Noto Sans SC, LXGW WenKai TC (SIL OFL 1.1)
الأصول
لا شيء: كل صورة مرسومة بالكود

طريقة التحضير

#1 · منطقة نص تُعدّ بالخلايا

الشيفرة في الجواب المختصر أعلاه. يحدد GB/T 9704 الهامش العلوي بـ 37 مم وهامش التجليد بـ 28 مم، ويملأ منطقة نص بقياس 156 × 225 مم بـ 22 سطرًا من 28 حرفًا بحجم 三号. يأخذ cjk.grid الأرقام كما هي: 28 em من 15.75 pt تساوي 155.6 مم، و22 سطرًا من 29 pt تساوي 225.1 مم (شبكة الأحرف). حجم 三号 في Word هو 16 pt، وهو يجعل السطر 158 مم، أعرض من منطقة النص في المعيار؛ أما 15.75 pt فهي القيمة الواردة في جداول المنضّدين. حين تكون الشبكة مفعّلة تصير الهوامش حدودًا دنيا. وهذه الهوامش تترك 0.02 مم للتوزيع، فتبقى منطقة النص حيث يضعها المعيار. يسمّي المعيار خط 仿宋 للمتن؛ ولا يوجد في Fontsource خط Fangsong، فيُضبط النص بخط Song من Noto Serif SC (انظر «أخطاء شائعة»).

كل علامة تأخذ خلية كاملة ولا تتنازل عن شيء منها، ففي سطر صيني تقف الحروف في أعمدة الشبكة التي يرسمها المعيار. والأرقام العربية أضيق من الخلية، ففي سطر مثل 版心156毫米×225毫米 تقع الحروف التي بعد الأرقام بين الأعمدة. وحين تقع فاصلة في الخلية التاسعة والعشرين، يعلّقها hangingPunctuation: 'allow' في الهامش بدل أن يدفع حرفًا إلى السطر التالي ويباعد بين البقية (علامات الترقيم المعلّقة).

الصفحة 2: الفاصلة بعد 各一份 تملأ الخلية الثامنة والعشرين؛ والفاصلة بعد 可自愿报名 تتدلى في خلية تاسعة وعشرين، خلف حافة منطقة النص، ويبدأ السطر التالي عند الهامش.

#2 · أربعة مستويات من العناوين، سطر لكل منها

script.js · الأسطر 52–62في الكود الكامل
// One line each at the body size, nothing above or below: every head stays on the grid.
// Headings have no indent of their own, so two ideographic spaces open each template.
const level = (n, fontFamily, fontWeight, numberingTemplate) => ({ level: n, fontFamily,
  fontWeight, numberingTemplate, numberSeparator: '', fontSize: pt(BODY), lineHeight: pt(LEAD),
  marginTop: pt(0), marginBottom: pt(0) });
const levels = [
  level(2, HEI, 500, '  {2:一}、'), // 一 is the informal numeral in the document's script
  level(3, KAI, 400, '  ({3:一})'),
  level(4, SONG, 400, '  {4}.'),
  level(5, SONG, 400, '  ({5})'),
];

يضبط المعيار 一、 بخط 黑体، و(一) بخط 楷体، و1. و(1) بخط المتن، وكلها بحجم النص. يطبع {2:一} العدد الصيني غير الرسمي بكتابة locale (الترقيم). ومع حجم المتن وتباعد أسطره وبلا هوامش، يأخذ العنوان سطرًا واحدًا من الشبكة، ويبقى النص تحته على الشبكة أيضًا. ليس للعناوين إزاحة خاصة بها، فالمسافتان الإيديوغرافيتان في بداية كل قالب هما الخليتان اللتان يتركهما المعيار (تجاوزات كل مستوى).

#3 · الترويسة سطرًا سطرًا

script.js · الأسطر 66–92في الكود الكامل
const LINE = LEAD * MM; // 10.23 mm
const lineTop = (n) => (n - 1) * LINE; // mm from the top of the type area to grid line n
const face = (fontFamily, size, fontWeight, colour, lineHeight = LEAD / size) => ({
  fontFamily, fontSize: pt(size), fontWeight, color: col(colour), lineHeight });
// A text y mm down the type area, x mm in from its edge, as wide as the type area.
const text = (id, content, look, y, { edge = 'top-left', x = 0, width = AREA.w, ...more } = {},
) => ({ kind: 'text', id, content, ...look, align: 'center', overflow: 'wrap', ...more,
  placement: { anchor: { to: 'container', edge }, offset: { x: mm(x), y: mm(y) },
    size: { width: width === 'auto' ? 'auto' : mm(width) } } }); // wrap, not '…'
const rule = (id, y, thickness, colour = 'ink', reserve = false) => ({ kind: 'rule', id, reserve,
  direction: 'horizontal', thickness, color: col(colour), placement: { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(y) }, size: { width: mm(AREA.w) } } });
const PAD = { left: pt(5), right: pt(5) }; // the specimen stamp's: not a GB/T 9704 element
const letterhead = { enabled: true, minHeight: pt(13 * LEAD), slot: { elements: [
  text('copy', '{attr.copy}', face(SONG, BODY, 400, 'ink'), lineTop(1), { align: 'left' }),
  text('stamp', '样 张', face(HEI, BODY, 500, 'red', 1.3), lineTop(1) + 1.5, { edge: 'top-right',
    width: 'auto', box: { borderColor: col('red'), borderWidth: pt(1), padding: PAD } }),
  // The name's ink starts 35 mm down, 0.07 em over its box; at 46 pt it ends in line 5.
  text('issuer', '{attr.issuer}文件', face(SONG, 46, 900, 'red', 1), 35 + 0.07 * 46 * MM),
  // The number two blank lines under the name; the red rule 4 mm under its characters.
  text('number', '{attr.number}', face(SONG, BODY, 400, 'ink'), lineTop(8)),
  rule('red-rule', lineTop(8) + ((LEAD + BODY) / 2) * MM + 4, mm(0.5), 'red', true),
  text('title', '{titleText}', face(SONG, 22, 900, 'ink'), lineTop(12)), // 二号, 2 lines under
] } };
const notice = { level: 1, span: 'page', advancedDesign: letterhead,
  marginTop: pt(0), marginBottom: pt(LEAD), // 空一行: a blank line, then the addressee
  breakBefore: { enabled: true, parity: 'any' } }; // gotcha: headings-drop-h1-break

الترويسة هي تصميم الـ H1، وسماته تحمل رقم النسخة والجهة المُصدِرة ورقم المستند: الإعداد يحمل القرطاسية، وMarkdown يحمل المستند. تعطي lineTop(n) أعلى سطر الشبكة n، فيقف كل عنصر حيث يعدّه المعيار: الرقم بعد سطرين فارغين تحت اسم الجهة المُصدِرة، والعنوان بعد سطرين فارغين تحت الخط الأحمر. بحجم 46 pt يمتد حبر الاسم من 35 مم تحت أعلى منطقة النص إلى أسفل السطر 5، فيبقى السطران 6 و7 فارغين ويقع الرقم على السطر 8؛ وبحجم 48 pt كان سيدخل السطر 6 ويدفع بقية الصفحة سطرًا إلى الأسفل. يكسر \\ في العنوان عنوانَ المستند بعد 关于举办، سطر قصير فوق سطر طويل: شبه منحرف، أحد الشكلين اللذين يسمح بهما المعيار (والآخر معيَّن). يحجز minHeight ثلاثة عشر سطرًا وmarginBottom سطرًا آخر، فتبدأ الجهة المخاطَبة على السطر 15 (الامتداد والتصميم المتقدم).

#4 · أرقام الصفحات تحت منطقة النص

script.js · الأسطر 96–105في الكود الكامل
const FOLIO = 14; // pt: 四号
const folio = (id, parity, edge, x) => ({ kind: 'text', id, parity, content: '— {pageNumber} —',
  ...face(SONG, FOLIO, 400, 'ink', 1), align: edge.endsWith('left') ? 'left' : 'right',
  overflow: 'clip', placement: { anchor: { to: 'container', edge }, // the type area's foot
    offset: { x: pt(x), y: mm(7 - (FOLIO / 2) * MM) } } }); // the dashes 7 mm down
const footer = { elements: [folio('recto', 'odd', 'top-right', -FOLIO),
  folio('verso', 'even', 'top-left', FOLIO)] };
const header = { elements: [{ kind: 'text', id: 'specimen', content: '{subtitle}', // 样张 …
  ...face(HEI, 7.5, 400, 'muted', 1.2), align: 'center', overflow: 'wrap',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(16) } } }] };

في التذييل تكون الحافة العليا للحاوية هي أسفل منطقة النص، فيقيس offset.y نزولًا من النص (الترويسات والتذييلات). تقف الشرطتان على بعد 7 مم تحته، وعلى بعد خلية واحدة بحجم 四号 من الحافة الخارجية: يضع parity أرقام الصفحات الفردية على اليمين والزوجية على اليسار. ويطبع الهامش العلوي {subtitle} من البيانات التمهيدية (frontmatter). هذا السطر وبيانات الطبع تحت الـ 版记 هما كل ما يتغير بين الطبعة الإنجليزية والإسبانية.

#5 · الملحق والـ 版记

script.js · الأسطر 109–127في الكود الكامل
const IMPRINT = AREA.h - 2 * LINE; // mm: two rows of the grid, ending on the type area's foot
const small = face(SONG, FOLIO, 400, 'ink', LEAD / FOLIO); // 四号
const inset = { x: FOLIO * MM, width: AREA.w - 2 * FOLIO * MM, reserve: false }; // 左右各空一字
const annex = {
  id: 'annex', numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' },
  span: 'page', marginTop: pt(0), marginBottom: pt(0), // line 4 is the table's float gap
  advancedDesign: { enabled: true, minHeight: pt(3 * LEAD), slot: { elements: [
    text('label', '{attr.label}', face(HEI, BODY, 500, 'ink'), lineTop(1), { align: 'left' }),
    text('title', '{titleText}', face(SONG, 22, 900, 'ink'), lineTop(3)),
    // reserve: false keeps the 版记 out of the room the heading takes: the table goes on.
    rule('imprint-top', IMPRINT, mm(0.35)),
    text('cc', '抄送:{attr.cc}', small, IMPRINT, { ...inset, align: 'left' }),
    rule('imprint-mid', IMPRINT + LINE, mm(0.25)),
    text('office', '{attr.office}', small, IMPRINT + LINE, { ...inset, align: 'left' }),
    text('printed', '{attr.printed}', small, IMPRINT + LINE, { ...inset, align: 'right' }),
    rule('imprint-foot', AREA.h - 0.35 / 2, mm(0.35)),
    text('colophon', '{attr.colophon}', face(HEI, 7, 400, 'muted'), AREA.h + 16, inset),
  ] } },
};

يأخذ عنوان الملحق نمطًا خاصًا به: صفحة جديدة، و附件 على السطر 1، والعنوان على السطر 3، ولا هامش تحت الأسطر الثلاثة التي يحجزها. السطر 4 الفارغ هو فجوة العنصر العائم التي يحفظها كل مورد مضمَّن فوقه (inlineResourceGap، تحت التخطيط)، فيقع الخط العلوي للجدول على السطر 5. والـ 版记 جزء من التصميم نفسه، مرسوم على سطري الشبكة اللذين ينتهيان عند أسفل منطقة النص. لعناصره reserve: false، فلا تضيف شيئًا إلى المساحة التي يأخذها العنوان، ولا يُدفع الجدول إلى ما بعدها. يضع المعيار الـ 版记 في الصفحة الأخيرة، بعد الملاحق؛ ومع ملحق من صفحة واحدة تكون هي صفحة الملحق. والملحق الأطول يحتاج إلى أن يرسم الـ 版记 عنوانٌ في صفحته الأخيرة.

#6 · كتلة التوقيع

script.js · الأسطر 131–137في الكود الكامل
const paragraphStyles = [
  { id: 'flush', firstLineIndent: pt(0) }, // 主送机关:居左顶格
  { id: 'annexes', marginTop: pt(LEAD) }, // 附件说明:正文下空一行,左空二字
  // The date, 6.9 ems, starts two cells right of the name and ends two short: 右空二字.
  { id: 'signature', indent: em(17), firstLineIndent: pt(0), marginTop: pt(LEAD) },
  { id: 'date', indent: em(19), firstLineIndent: pt(0) },
];

الإشعار الذي لا يحمل ختمًا يضع اسم الجهة المُصدِرة على بعد خليتين من اليمين والتاريخ تحته، يبدأ أبعد بخليتين إلى اليمين. والتاريخ الأطول من الاسم ينتهي قبل الحافة اليمنى بخليتين بدلًا من ذلك، وينتقل الاسم إلى اليسار ليحفظ فرق الخليتين. تاريخنا 6.9 em، أقصر قليلًا من الاسم البالغ 7، فبحرف القاعدة كان سيبدأ على بعد خليتين يمين الاسم وينتهي على بعد 0.14 em من الهامش. قرأنا القاعدة بما وُضعت له، تاريخ لا يصل أبدًا إلى الحافة، وأخذنا الحالة الثانية: ينتهي التاريخ قبل الحافة بما يزيد قليلًا على خليتين. يضع indent السطرين على خلايا كاملة، على بعد 17 و19 em من الهامش (أنماط الفقرات).

الوصفة كاملة

Sandbox
// ═══ Postext Cookbook · Nº 082 · A Chinese official document to GB/T 9704 ═════════
// https://postext.dev/en/cookbook/chinese-official-document
// Code: MIT · Text: a fictitious notice written for the recipe (CC BY 4.0) · Pictures: none
// Fonts: Noto Serif SC, Noto Sans SC, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.9.0
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, parseTSV, mergeCells,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'chinese-official-document';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// GB/T 9704 prints everything black but the letterhead: the issuer's name and a rule in red.
const palette = { ink: '#161616', red: '#d2161e', muted: '#8a8580', paper: '#ffffff' };
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.red })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const SONG = 'Noto Serif SC'; // 宋: the text (in place of 仿宋, see the write-up) and 小标宋
const HEI = 'Noto Sans SC'; // 黑: the 一、 heads, the annex label, the specimen stamp
const KAI = 'LXGW WenKai TC'; // 楷: the (一) heads
const MM = 25.4 / 72; // mm per pt
const [BODY, LEAD, CHARS, LINES] = [15.75, 29, 28, 22]; // 三号 on 29 pt lines, 28 × 22 of them
const AREA = { w: CHARS * BODY * MM, h: LINES * LEAD * MM }; // 155.6 × 225.1 mm: the 版心
const [TOP, INNER] = [37, 28]; // mm: 天头 and 订口, the head and binding margins

// #region answer: A4, 28 characters × 22 lines of 三号, full-width marks on the grid
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES }, // 28 ems × 22 lines
  // Every mark takes a whole cell, as on the standard's grid, and never gives any of it up.
  punctuationWidth: 'fullwidth', compressAdjacent: false, trimLineStart: false,
  hangingPunctuation: 'allow', // a , that may not open a line hangs past the 28th cell
  latinSpacing: em(0), // 〔2026〕7号 and 2026年9月28日 set solid, as the standard prints them
};
const page = {
  // 144 dpi is 2 px to the point: the 29 pt lines add up with no rounding (see Pitfalls).
  width: mm(210), height: mm(297), dpi: 144, backgroundColor: col('paper'),
  // With the grid on, margins are minimums. These leave 28 × 22 cells and 0.02 mm to share,
  // so the type area sits 37 mm under the head and 28 mm from the binding edge.
  margins: { top: mm(TOP), bottom: mm(297 - TOP - AREA.h - 0.02), left: mm(INNER),
    right: mm(210 - INNER - AREA.w - 0.02), mirror: true },
};
const bodyText = {
  fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', // a short line spreads between its characters, never between words
  firstLineIndent: em(2), indentAfterHeading: true, // 左空二字,回行顶格
};
// #endregion

// #region levels: 一、 in Hei, (一) in Kai, 1. and (1) in the text face, all 三号 on the grid
// One line each at the body size, nothing above or below: every head stays on the grid.
// Headings have no indent of their own, so two ideographic spaces open each template.
const level = (n, fontFamily, fontWeight, numberingTemplate) => ({ level: n, fontFamily,
  fontWeight, numberingTemplate, numberSeparator: '', fontSize: pt(BODY), lineHeight: pt(LEAD),
  marginTop: pt(0), marginBottom: pt(0) });
const levels = [
  level(2, HEI, 500, '  {2:一}、'), // 一 is the informal numeral in the document's script
  level(3, KAI, 400, '  ({3:一})'),
  level(4, SONG, 400, '  {4}.'),
  level(5, SONG, 400, '  ({5})'),
];
// #endregion

// #region letterhead: the 版头 of page 1, placed line by line on the grid
const LINE = LEAD * MM; // 10.23 mm
const lineTop = (n) => (n - 1) * LINE; // mm from the top of the type area to grid line n
const face = (fontFamily, size, fontWeight, colour, lineHeight = LEAD / size) => ({
  fontFamily, fontSize: pt(size), fontWeight, color: col(colour), lineHeight });
// A text y mm down the type area, x mm in from its edge, as wide as the type area.
const text = (id, content, look, y, { edge = 'top-left', x = 0, width = AREA.w, ...more } = {},
) => ({ kind: 'text', id, content, ...look, align: 'center', overflow: 'wrap', ...more,
  placement: { anchor: { to: 'container', edge }, offset: { x: mm(x), y: mm(y) },
    size: { width: width === 'auto' ? 'auto' : mm(width) } } }); // wrap, not '…'
const rule = (id, y, thickness, colour = 'ink', reserve = false) => ({ kind: 'rule', id, reserve,
  direction: 'horizontal', thickness, color: col(colour), placement: { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(y) }, size: { width: mm(AREA.w) } } });
const PAD = { left: pt(5), right: pt(5) }; // the specimen stamp's: not a GB/T 9704 element
const letterhead = { enabled: true, minHeight: pt(13 * LEAD), slot: { elements: [
  text('copy', '{attr.copy}', face(SONG, BODY, 400, 'ink'), lineTop(1), { align: 'left' }),
  text('stamp', '样 张', face(HEI, BODY, 500, 'red', 1.3), lineTop(1) + 1.5, { edge: 'top-right',
    width: 'auto', box: { borderColor: col('red'), borderWidth: pt(1), padding: PAD } }),
  // The name's ink starts 35 mm down, 0.07 em over its box; at 46 pt it ends in line 5.
  text('issuer', '{attr.issuer}文件', face(SONG, 46, 900, 'red', 1), 35 + 0.07 * 46 * MM),
  // The number two blank lines under the name; the red rule 4 mm under its characters.
  text('number', '{attr.number}', face(SONG, BODY, 400, 'ink'), lineTop(8)),
  rule('red-rule', lineTop(8) + ((LEAD + BODY) / 2) * MM + 4, mm(0.5), 'red', true),
  text('title', '{titleText}', face(SONG, 22, 900, 'ink'), lineTop(12)), // 二号, 2 lines under
] } };
const notice = { level: 1, span: 'page', advancedDesign: letterhead,
  marginTop: pt(0), marginBottom: pt(LEAD), // 空一行: a blank line, then the addressee
  breakBefore: { enabled: true, parity: 'any' } }; // gotcha: headings-drop-h1-break
// #endregion

// #region folios: “— 1 —” in 四号, 7 mm under the type area, a cell in from the outer edge
const FOLIO = 14; // pt: 四号
const folio = (id, parity, edge, x) => ({ kind: 'text', id, parity, content: '— {pageNumber} —',
  ...face(SONG, FOLIO, 400, 'ink', 1), align: edge.endsWith('left') ? 'left' : 'right',
  overflow: 'clip', placement: { anchor: { to: 'container', edge }, // the type area's foot
    offset: { x: pt(x), y: mm(7 - (FOLIO / 2) * MM) } } }); // the dashes 7 mm down
const footer = { elements: [folio('recto', 'odd', 'top-right', -FOLIO),
  folio('verso', 'even', 'top-left', FOLIO)] };
const header = { elements: [{ kind: 'text', id: 'specimen', content: '{subtitle}', // 样张 …
  ...face(HEI, 7.5, 400, 'muted', 1.2), align: 'center', overflow: 'wrap',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(16) } } }] };
// #endregion

// #region annex: the annex on a page of its own, the 版记 at the foot of that last page
const IMPRINT = AREA.h - 2 * LINE; // mm: two rows of the grid, ending on the type area's foot
const small = face(SONG, FOLIO, 400, 'ink', LEAD / FOLIO); // 四号
const inset = { x: FOLIO * MM, width: AREA.w - 2 * FOLIO * MM, reserve: false }; // 左右各空一字
const annex = {
  id: 'annex', numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' },
  span: 'page', marginTop: pt(0), marginBottom: pt(0), // line 4 is the table's float gap
  advancedDesign: { enabled: true, minHeight: pt(3 * LEAD), slot: { elements: [
    text('label', '{attr.label}', face(HEI, BODY, 500, 'ink'), lineTop(1), { align: 'left' }),
    text('title', '{titleText}', face(SONG, 22, 900, 'ink'), lineTop(3)),
    // reserve: false keeps the 版记 out of the room the heading takes: the table goes on.
    rule('imprint-top', IMPRINT, mm(0.35)),
    text('cc', '抄送:{attr.cc}', small, IMPRINT, { ...inset, align: 'left' }),
    rule('imprint-mid', IMPRINT + LINE, mm(0.25)),
    text('office', '{attr.office}', small, IMPRINT + LINE, { ...inset, align: 'left' }),
    text('printed', '{attr.printed}', small, IMPRINT + LINE, { ...inset, align: 'right' }),
    rule('imprint-foot', AREA.h - 0.35 / 2, mm(0.35)),
    text('colophon', '{attr.colophon}', face(HEI, 7, 400, 'muted'), AREA.h + 16, inset),
  ] } },
};
// #endregion

// #region styles: the addressee flush left, the name and the date to the right
const paragraphStyles = [
  { id: 'flush', firstLineIndent: pt(0) }, // 主送机关:居左顶格
  { id: 'annexes', marginTop: pt(LEAD) }, // 附件说明:正文下空一行,左空二字
  // The date, 6.9 ems, starts two cells right of the name and ends two short: 右空二字.
  { id: 'signature', indent: em(17), firstLineIndent: pt(0), marginTop: pt(LEAD) },
  { id: 'date', indent: em(19), firstLineIndent: pt(0) },
];
// #endregion

// The annex's table: the days, the sessions and who teaches them, every cell centred.
const SCHEDULE = [['日期', '时间', '内容', '主讲'],
  ['10月20日', '上午9:00—11:30', '公文格式国家标准解读', '林 岚'],
  ['', '下午14:00—16:30', '版心、字体字号与行距', '周明远'],
  ['10月21日', '上午9:00—11:30', '标题层次、序数与页码', '陈思齐'],
  ['', '下午14:00—16:30', '书刊横排与竖排样张', '林 岚'],
  ['10月22日', '上午9:00—11:30', '公文样张排版实操', '周明远'],
  ['', '下午14:00—16:30', '上机考核与讲评', '全体教员']];
const cells = parseTSV(SCHEDULE.map((row) => row.join('\t')).join('\n')).rows
  .map((row) => row.map((c) => ({ ...c, align: 'center', verticalAlign: 'middle' })));
// Each date spans its two rows (gotcha: merged-cells-hiddenby). Widths in 四号 ems, 31.5 in all.
const schedule = [1, 3, 5].reduce((model, row) => mergeCells(model, { start: { row, col: 0 },
  end: { row: row + 1, col: 0 } }), { rows: cells, headerRowCount: 1,
  columnWidths: [5.6, 9.6, 11.3, 5] });
const resources = [{ id: 'schedule', typeId: 'schedule', kind: 'table', createdAt: 0,
  updatedAt: 0, placement: { position: 'here' }, table: { model: schedule } }];
const resourceTypes = [{ id: 'schedule', name: '日程', shortLabel: '', numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // no label, no number

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'zh-Hans', // written out, never LANG (gotcha: cjk-locale-tag)
  colorPalette, page, layout: { layoutType: 'single' }, cjk, bodyText, resourceTypes,
  headings: { fontFamily: SONG, fontWeight: 400, color: col('ink'), levels: [notice, ...levels],
    balancing: { enabled: false } }, // no lines added over heads, no paragraph set loose
  headingStyles: [annex], paragraphStyles, header, footer,
  tableStyle: { borderColor: col('ink'), borderWidth: pt(0.75), cellPadding: mm(2),
    headerBackgroundEnabled: false, headerBold: false, headerFontFamily: HEI, bodyFontFamily: SONG,
    headerFontSize: pt(FOLIO), bodyFontSize: pt(FOLIO), // 四号
    headerColor: col('ink'), bodyColor: col('ink') },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
نموذج Markdown · أسطر: 81 · content.en.mdtitle: "示例市出版协会关于举办2026年中文排版实务培训班的通知" subtitle: "样张 · A specimen set to GB/T 9704—2012 · The association, its staff and this notice are fictitious" --- # 示例市出版协会关于举办 \\ 2026年中文排版实务培训班的通知 {copy="000001" issuer="示例市出版协会" number="示出协〔2026〕7号"} :::paragraphs{style="flush"} 各会员单位: ::: 为提高会员单位排版人员的业务水平,协会定于2026年10月举办中文排版实务培训班。现将有关事项通知如下: ## 培训时间 2026年10月20日至22日,共3天。参训人员于10月19日14:00至18:00到示例市图书大厦一楼大厅报到,报到时请出示单位介绍信。 ## 培训地点 示例市图书大厦五楼报告厅(示例市文昌路88号)。 ## 培训内容 ### 排版规范 #### 公文格式 学习《党政机关公文格式》(GB/T 9704—2012),重点掌握以下两项内容: ##### 版心与字体 版心156毫米×225毫米,每页22行,每行28字,正文一般用3号仿宋体字。 ##### 页码与版记 页码用4号半角阿拉伯数字,单页居右,双页居左;版记置于公文最后一页。 #### 标点符号 学习《标点符号用法》(GB/T 15834—2011),掌握标点在行首行末的禁则和两个标点连用时的处理。 ### 实务操作 按字数和行数设定版心,完成书刊样张和公文样张各一份,由教员逐页讲评。 ## 参加对象和名额 各会员单位从事编辑、校对和排版工作的人员,可自愿报名,每个单位限报2人,全市共80个名额,报满为止。 ## 报名办法 报名时间为即日起至2026年10月10日,可任选以下一种方式报名。 ### 网上报名 登录示例市出版协会网站“培训报名”栏目,填写报名表并上传近一年排版的书刊或公文一份。 ### 书面报名 报名表加盖单位公章后,送至协会秘书处(示例市图书大厦十二楼)。 ## 其他事项 培训不收取费用,食宿费用回原单位报销。参训人员须自带笔记本电脑。联系人:协会秘书处周明远、陈思齐。 :::paragraphs{style="annexes"} 附件:培训日程安排 ::: :::paragraphs{style="signature"} 示例市出版协会 ::: :::paragraphs{style="date"} 2026年9月28日 ::: (此件公开发布) # 培训日程安排 {style="annex" label="附件" cc="示例市新闻出版局,示例市图书馆学会。" office="示例市出版协会秘书处" printed="2026年9月28日印发" colophon="Noto Serif SC, Noto Sans SC and LXGW WenKai TC (SIL OFL) · A fictitious notice written for the recipe"} ::resource{id="schedule"}
`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Noto Serif SC': ['400', '900'], // SONG: the text, number, folios, 版记; the name, the titles 'Noto Sans SC': ['400', '500'], // HEI: the head line, table heads; the 一、 heads, label, stamp 'LXGW WenKai TC': ['400'], // KAI: the (一) heads }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // Each voice loads the files of what it sets, template numerals too (gotcha: cjk-fonts-slices). const lines = (re) => (markdown.match(re) ?? []).join(''); const [table, heads] = [SCHEDULE.flat().join(''), SCHEDULE[0].join('')]; await loadFonts(FONTS, markdown); await loadCjkFonts({ [SONG]: ['400'] }, `${markdown}${table}0123456789—.()`); await loadCjkFonts({ [SONG]: ['900'] }, `${lines(/^# .*$/gm)}文件`); await loadCjkFonts({ [HEI]: ['400'] }, `${lines(/^subtitle: .*$|colophon="[^"]*"/gm)}${heads}`); await loadCjkFonts({ [HEI]: ['500'] }, `${lines(/^## .*$/gm)}一二三四五六七八九十、附件样 张`); await loadCjkFonts({ [KAI]: ['400'] }, `${lines(/^### .*$/gm)}一二三四五六七八九十()`); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'A Chinese official document to GB/T 9704', es: 'Un documento oficial chino según la GB/T 9704' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);
العُدّة · core, fonts, viewer, pdf, cjk: نفسها في كل وصفة · أسطر: 422// ─── 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 ───────────────────────────────────────────────────────────────────────

يعمل ملف script.js المجمّع كما هو: الصقه في سكربت الوحدة (module) لأي صفحة، أو افتح الوصفة على CodePen. مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)

تنويعات

#اضغط العلامات المتجاورة

أزواج مثل ),و》( تأخذ خلية ونصفًا بدل خليتين، كما في أغلب الكتب؛ وتخرج الحروف التي بعدها عن أعمدة الشبكة.

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

#أظهر الشبكة أثناء ضبط الصفحة

ترسم اللوحة (Canvas) مربعًا رماديًا لكل خلية من شبكة 28 × 22، ويُسقطه PDF إلا إذا تلقّى renderToPdf الخيار characterGrid: true.

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

أخطاء شائعة

خطأ شائع

لا يوفّر Fontsource خط Fangsong (仿宋)

يضع المعيار GB/T 9704 ومعظم نماذج البر الرئيسي نصوصها بخط 仿宋، وهو خط Song مرسوم بضربات الفرشاة الرفيعة الصاعدة قليلًا. ولا توجد بين العائلات الصينية في Fontsource أي عائلة Fangsong، فتضع الوصفة النص بخط Song المتاح، Noto Serif SC، وتذكر ذلك. أما خط Fangsong الذي تملك ترخيصه فيدخل خطًا خاصًا بك: سجّل ملفه بـ FontFace، وسمِّ العائلة في bodyText.fontFamily، وأعطِ renderToPdf مزوّد fontProvider يعيد بايتاته. الخطوط الصينية واليابانية والكورية →

خطأ شائع

على شبكة الحروف، أوقف موازنة الأعمدة

تملأ موازنة الأعمدة الصفحة التي تنتهي قصيرة، كما حين يترك عنوانٌ مُبقًى مع نصه أسطرًا فارغة في الأسفل، بإضافة أسطر شبكة فوق العناوين وبتوسيع فقرة بمقدار سطر. والسطر الصيني الموسَّع يباعد بين حروفه (0.13 em في صفحة GB/T 9704، وهو أبعد كثيرًا من balancing.maxTracking)، فتخرج الحروف من أعمدة الشبكة وتخرج العناوين من أسطرها. الصفحة المحسوبة بالخلايا تنتهي قصيرة بدلًا من ذلك: اضبط headings.balancing: { enabled: false }. شبكة المحارف →

خطأ شائع

الخطوط الصينية تُحمَّل شرائح، عبر الكتلة cjk

يقدّم Fontsource العائلة الصينية أو اليابانية أو الكورية في نحو مئة ملف لكل وزن، يغطي كل منها نطاقًا من الحروف. لا يجلب loadFonts إلا ملف latin، فتأتي حروف الهان على الشاشة من خط النظام وتُقاس خطأً، ويسلّم fontsourceProvider ملف latin ذاك إلى PDF، فيطبعها مربعات فارغة. اذكر كتلة الأدوات cjk، واستدعِ loadCjkFonts(FONTS, markdown) بعد loadFonts (مرة لكل صوت، مع النص الذي يضعه، حين يستخدم الكتاب عدة خطوط CJK)، وأعطِ renderToPdf القيمة fontProvider: cjkPdfProvider: كلاهما يأخذ الملفات التي تحتوي حروف النص. الخطوط الصينية واليابانية والكورية →

خطأ شائع

اوسم المستند بـ zh-Hans أو zh-Hant، لا بـ LANG

نسختا الوصفة هما en وes، لكن المثال الصيني صيني في كلتيهما: `locale: LANG` سيسمه بالإنجليزية أو الإسبانية، فيقسّم كلماته اللاتينية بالواصلة، ويسمّي أشكاله Figure أو Figura، ويعطي PDF لغة خاطئة. اكتب الوسم بنفسك: 'zh-Hans' (أعراف البر الرئيسي: كسر الأسطر وفق GB، وترقيم Kaiming) أو 'zh-Hant' (تايوان: ترقيم بعرض كامل متمركز)؛ و'zh-HK' لهونغ كونغ. أما 'zh' المجرّد فيُقرأ صينية مبسطة على أعراف البر الرئيسي. تقسيم الأسطر في النص الصيني →

خطأ شائع

أي كائن headings يُلغي فاصل الصفحة قبل H1

ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد. فصول تبدأ في صفحة فردية →

خطأ شائع

الخلايا المدمجة تحتاج إلى خلايا نائبة hiddenBy: استخدم mergeCells

تُوزَّع الخلايا بحسب موضعها في مصفوفة الصف، فالخلية المدمجة تحتاج إلى خلايا نائبة معلَّمة بـ hiddenBy حيث تمتد؛ وحذفها، كما يفعل HTML، يزيح كل الأعمدة التالية. ابنِ الدمج بـ mergeCells. جداول من البيانات →

خطأ شائع

فيض نص التصميم افتراضيًا 'ellipsis-end'

عنصر نص التصميم الذي لا يتسع له عرضه ينتهي افتراضيًا بعلامة الحذف. اضبط overflow: 'wrap' للعناوين التي يجب أن تنكسر على أسطر أكثر. النصوص والخطوط والمربعات في تصاميم الصفحة →

خطأ شائع

حمّل كل أوجه الخط قبل الإخراج

يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها. الخطوط قبل الإخراج →

تحذير الإخراج · cjkGridClamped

شبكة حروف أكبر مما يتسع

السبب. `cjk.grid` يطلب حروفًا في السطر أو أسطرًا في الصفحة أكثر مما تتسع له هوامش الصفحة بحجم النص الأساسي وارتفاع السطر، فتُضبط الشبكة بأكبر عدد يتسع.

الحل. اخفض `charsPerLine` أو `linesPerPage`، أو ضيّق الهوامش (فهي حدود دنيا)، أو اضبط `bodyText.fontSize` أو `lineHeight` أصغر. التوثيق →

  • بدقة 150 dpi يبلغ السطر ذو 29 pt ما مقداره 60.41666… px. حين وقع عنوان وفقرة من سطرين على آخر ثلاثة أسطر في صفحة، جمع الفحص الذي يُبقي العنوان مع نصه أسطرَ الشبكة ناقصة بـ 10⁻¹³ px، فأرسل العنوان إلى الصفحة التالية وترك ثلاثة أسطر فارغة. وبدقة 144 dpi تساوي النقطة بكسلين والمجاميع دقيقة، فتحتفظ الصفحة بأسطرها الـ 22.
  • السطر المضبوط يوزّع فائضه بين الحروف الصينية وأي مسافة يحملها، والمسافة تأخذ أكثر من نصيبها: في الصفحة 2 انفتحت المسافة في GB/T 15834 إلى ما يقارب ضعف عرض المسافة في GB/T 9704 فوقها. يكتب المحتوى الرقمين كليهما بمسافة غير قابلة للكسر (U+00A0)، تُبقي كل رقم قطعة واحدة، فيذهب الفائض إلى ما بين الحروف.

الحقوق

الوصفة
Ignacio Ferro
النص
  • 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
الخطوط
Noto Serif SC (SIL OFL 1.1) · Noto Sans SC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1)
SandboxPDF