Skip to main content
Recipe number 78

Cookbook · Chapter 6 · Boxes & notes

Red-ink commentary in the line and the head margin

A vertical Chinese commentary page: the manuscript's side comments folded into two red rows inside the line, head-margin comments in a tier above the text.

On this page
Output
Canvas · PDF
Postext
Tested with Postext 1.9.0
Needs ≥ 1.9.0 · postext-pdf ≥ 1.9.0
Licence
Updated 29 Sept 2026
Code MIT · Text CC BY 4.0

pp. 四–五 · 4–5 of 6

  • Trim 184 × 260 mm
  • Column and a half, 8.5 mm gutter
  • Noto Serif TC 12/21
  • LXGW WenKai TC
  • Noto Sans TC
  • 6 pages
  • Level
  • Postext 1.9.0
  • Laid out in 10 ms
  • 159 lines of code

What you'll build

The opening of chapter 1 of the Jiaxu manuscript of The Story of the Stone, the copy that carries Zhiyan Zhai's commentary, reset as six pages of a commentary edition on a 16开 page, 184 × 260 mm, set vertically and bound on the right. Cao Xueqin's text runs in Noto Serif TC, 36 characters down each column and 18 columns to the page. The manuscript writes the excerpt's 61 side comments (側批) in red beside the columns. Postext sets nothing between two columns, so this edition moves each one into the line, folded into two vermilion rows at half the size right after the words it glosses, the way printed commentary editions set their 雙行夾批. The ten head-margin comments (眉批) stand in red Kai in a 42 mm tier above the text, over the passage they discuss, so a reader follows the text and the commentary in one pass.

This recipe answers

  • How do I set a commentary edition: small two-line notes inside the line and comments in the head margin?
  • How do I set a Chinese book vertically, bound on the right?

The short answer

script.js · lines 38–65in full code
const layout = {
  writingMode: 'vertical-rl', // columns down the page, read from the right; bound on the right
  layoutType: 'oneAndHalf', // a text tier and a narrow tier for the comments
  sideColumnRole: 'floats', // no text runs into it: only boxes fenced with span="side"
  sideColumnSide: 'left', // in vertical text 'left' is the top tier: the head margin (天頭)
  sideColumnPercent: 19, // the grid rounds it to 10 characters of the body, 42 mm
  gutterWidth: mm(8), // rounded to 2 characters
  columnRule: { enabled: true, lineWidth: pt(0.5), color: col('rule') },
};
const cjk = {
  grid: { enabled: true, charsPerLine: 36, linesPerPage: 18 }, // 版心: 36 字 × 18 行
  // :warichu[…] folds a side comment into two rows at half the body size, in red
  // and with no brackets, as the manuscripts write their 雙行夾批.
  warichu: { color: col('vermilion') },
};
// A 眉批 is :::callout{type="meipi" span="side"}: span="side" sends it to the head
// margin, level with the column the text has reached at its fence, so the fence goes
// just before the passage it discusses (gotcha: side-box-starts-at-fence).
const meipi = {
  id: 'meipi', backgroundEnabled: false,
  border: { enabled: false }, stripe: { enabled: false },
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  // 17 characters of 7 pt down the 120 pt tier, a column every 10 pt. The next comment
  // starts on a text column at least one body column (21 pt) further on, whatever the margin.
  snapToGrid: false, marginBottom: pt(0),
  body: { fontFamily: KAI, fontSize: pt(7), lineHeight: pt(10), color: col('vermilion'),
    textAlign: 'left', firstLineIndent: pt(0) },
};

Side comments inside the line, head-margin comments above it

Ingredients

Type
Noto Serif TC, LXGW WenKai TC, Noto Sans TC (SIL OFL 1.1)
Assets
None: every picture is drawn in code

Method

#1 · Turn the side column into the head margin

The code is the short answer above. A vertical page is a horizontal page turned a quarter turn, so the side column of a column-and-a-half layout becomes a tier across the page, and sideColumnSide: 'left' puts that tier at the head (Vertical writing). The character grid rounds sideColumnPercent: 19 to 10 characters of the body, 120 pt, and the gutter to two; the column rule becomes the hairline between the comments and the text. The comment style has no box: 7 pt Kai, 17 characters to a column, a size at which none of the ten comments ends on a column of one character, and snapToGrid: false so the comments stack at their own 10 pt pitch instead of the body's 21 pt.

Both kinds of comment are marked where the manuscript has them. A side comment is a :warichu[…] span, which cjk.warichu colours; a head-margin comment is a box fenced with span="side" just before the passage it discusses:

:::callout{type="meipi" span="side"}
妙!自謂落墮情根,故無補天之用。
:::
 
原來女媧氏煉石補天之時,:warichu[補天濟世,勿認真,用常言。]于大荒山:warichu[荒唐也。]…

#2 · Set the chapter's number and couplet down the first columns

script.js · lines 69–81in full code
const opener = { enabled: true, minHeight: mm(46), slot: { elements: [
  { kind: 'text', id: 'book', content: '{title}', fontFamily: HEI, fontWeight: 500,
    fontSize: pt(10), letterSpacing: pt(3), color: col('vermilion'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' } } },
  { kind: 'text', id: 'number', content: '{titleText}', fontFamily: SONG, fontWeight: 700,
    fontSize: pt(30), letterSpacing: pt(6), color: col('ink'), align: 'left',
    placement: { anchor: { to: '#book', edge: 'below' }, offset: { x: em(2), y: mm(4) } } },
  ...['a', 'b'].map((half, i) => ({ kind: 'text', id: `couplet-${half}`,
    content: `{attr.${half}}`, fontFamily: SONG, fontSize: pt(15), letterSpacing: pt(3),
    color: col('ink'), align: 'left',
    placement: { anchor: { to: i ? '#couplet-a' : '#number', edge: 'below' },
      offset: { x: i ? pt(0) : em(4), y: mm(i ? 1.5 : 4) } } })),
] } };

On a vertical page an opener is laid out in the flow's frame, so its text runs down, below means to the left and an x offset moves an element down the column. The book's title comes first in red Hei, then 第一回 at 30 pt, lowered two of its own characters, then the two halves of the couplet from the heading attributes {a="…" b="…"}, lowered again, so the number and the couplet step down the page as in a printed 回目. minHeight: mm(46) keeps a clear column between the couplet and the text.

#3 · Run the heads down the fore-edge

script.js · lines 85–96in full code
// 'outer' is the margin away from the spine: a recto's left, a verso's right.
const edge = (id, content, edgeOf, y, extra = {}) => ({ kind: 'text', id, content,
  writingMode: 'vertical-rl', fontFamily: HEI, fontWeight: 500, fontSize: pt(9.5),
  letterSpacing: pt(2), color: col('muted'), overflow: 'clip', align: 'left',
  placement: { anchor: { to: 'outer', edge: edgeOf }, offset: { y: em(y) } }, ...extra });
const header = { elements: [
  // Four characters below the head: the book on a verso, the chapter on a recto,
  // never on the opener.
  edge('head-verso', '{title}', 'top', 4, { pages: 'body', parity: 'even' }),
  edge('head-recto', '{chapterTitle}', 'top', 4, { pages: 'body', parity: 'odd' }),
  edge('folio', '{pageNumber}', 'bottom', -5), // 一, 二, 三…: page.pageNumbering below
] };

These are the fore-edge heads of Vertical text elements: anchor.to: 'outer' is the margin away from the spine, the left of a recto and the right of a verso in a right-bound book, and writingMode: 'vertical-rl' sets the text down the page. The type area here includes the comment tier, so "four characters below the head" is measured from the top of the red comments. trad-chinese-informal prints the folios 一 to 六.

#4 · Load each voice with the text it sets

script.js · lines 250–262in full code
// Song sets the whole sample and its bold only 第一回, Kai the head-margin comments and
// the edition's note, Hei the running heads, the folios and the colophon
// (gotcha: cjk-fonts-slices).
const blocks = (style) => [...markdown.matchAll(
  new RegExp(`:::(?:callout|paragraphs)\\{[^}]*"${style}"[^}]*\\}\\n([^]*?)\\n:::`, 'g'))]
  .map((m) => m[1]).join('');
const numerals = '一二三四五六七八九十';
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [SONG]: ['400'] }, markdown, { vertical: true });
await loadCjkFonts({ [SONG]: ['700'] }, '第一回'); // the bold weight sets three characters
await loadCjkFonts({ [KAI]: FONTS[KAI] }, blocks('meipi') + blocks('note'), { vertical: true });
await loadCjkFonts({ [HEI]: FONTS[HEI] }, `脂硯齋重評石頭記第一回${numerals}${blocks('colophon')}`,
  { vertical: true });

Fontsource serves each Chinese face as about a hundred files per weight. Song sets the whole sample in its regular weight and only 第一回 in bold, Kai only the head-margin comments and the edition's note, and Hei only the heads, the folios and the colophon, so each call fetches the files its own characters need. vertical: true also loads each face's vertical punctuation for the canvas.

#5 · Two inks

script.js · lines 18–31in full code
const palette = {
  ink: '#231d18', // the text: a warm black
  vermilion: '#b0362a', // 朱: every comment, the chapter's kicker; 5.5 : 1 on the paper
  rule: '#b9a78f', // the line under the head margin
  muted: '#6f6356', // folios, the colophon
  paper: '#f7f1e3',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to main-color: aimed at the vermilion, nothing prints blue.
  { id: 'main-color', name: 'vermilion (defaults)',
    value: { hex: palette.vermilion, model: 'hex' } },
];

The page prints in two colours, as the manuscript was written: a warm black for the text and a vermilion for every comment, the chapter's kicker and nothing else. The vermilion reads at 5.5 : 1 on the paper, enough for the 6 pt rows of the side comments. main-color points at it, so a default the config does not restate prints red, never blue.

The whole recipe

Sandbox
// ═══ Postext Cookbook · Nº 078 · Red-ink commentary in the line and the head margin ═══
// https://postext.dev/en/cookbook/red-ink-commentary
// Code: MIT · Text: 脂硯齋重評石頭記, Jiaxu manuscript, Wikisource (CC BY-SA 4.0) · Pictures: none
// Fonts: Noto Serif TC, LXGW WenKai TC, Noto Sans TC (SIL OFL 1.1) · Needs postext ≥ 1.9.0
// The first chapter of the Stone as a commentary edition: the text set vertically, the
// comments the manuscript writes beside the columns folded into two small red rows inside
// the line, the head-margin comments in red Kai above the columns they gloss.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, loadVerticalAlternates,
} 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 = 'red-ink-commentary';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: black ink and vermilion on a warm paper
const palette = {
  ink: '#231d18', // the text: a warm black
  vermilion: '#b0362a', // 朱: every comment, the chapter's kicker; 5.5 : 1 on the paper
  rule: '#b9a78f', // the line under the head margin
  muted: '#6f6356', // folios, the colophon
  paper: '#f7f1e3',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to main-color: aimed at the vermilion, nothing prints blue.
  { id: 'main-color', name: 'vermilion (defaults)',
    value: { hex: palette.vermilion, model: 'hex' } },
];
// #endregion
const [SONG, KAI, HEI] = ['Noto Serif TC', 'LXGW WenKai TC', 'Noto Sans TC']; // 宋, 楷, 黑
const BODY = 12; // pt, 小四: the notes fold to two rows of 6 pt in the same em
const LEAD = 21; // pt between columns, 1.75 × the body

// #region answer: side comments inside the line, head-margin comments above it
const layout = {
  writingMode: 'vertical-rl', // columns down the page, read from the right; bound on the right
  layoutType: 'oneAndHalf', // a text tier and a narrow tier for the comments
  sideColumnRole: 'floats', // no text runs into it: only boxes fenced with span="side"
  sideColumnSide: 'left', // in vertical text 'left' is the top tier: the head margin (天頭)
  sideColumnPercent: 19, // the grid rounds it to 10 characters of the body, 42 mm
  gutterWidth: mm(8), // rounded to 2 characters
  columnRule: { enabled: true, lineWidth: pt(0.5), color: col('rule') },
};
const cjk = {
  grid: { enabled: true, charsPerLine: 36, linesPerPage: 18 }, // 版心: 36 字 × 18 行
  // :warichu[…] folds a side comment into two rows at half the body size, in red
  // and with no brackets, as the manuscripts write their 雙行夾批.
  warichu: { color: col('vermilion') },
};
// A 眉批 is :::callout{type="meipi" span="side"}: span="side" sends it to the head
// margin, level with the column the text has reached at its fence, so the fence goes
// just before the passage it discusses (gotcha: side-box-starts-at-fence).
const meipi = {
  id: 'meipi', backgroundEnabled: false,
  border: { enabled: false }, stripe: { enabled: false },
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  // 17 characters of 7 pt down the 120 pt tier, a column every 10 pt. The next comment
  // starts on a text column at least one body column (21 pt) further on, whatever the margin.
  snapToGrid: false, marginBottom: pt(0),
  body: { fontFamily: KAI, fontSize: pt(7), lineHeight: pt(10), color: col('vermilion'),
    textAlign: 'left', firstLineIndent: pt(0) },
};
// #endregion

// #region opener: the chapter's number and its couplet (回目), set down the first columns
const opener = { enabled: true, minHeight: mm(46), slot: { elements: [
  { kind: 'text', id: 'book', content: '{title}', fontFamily: HEI, fontWeight: 500,
    fontSize: pt(10), letterSpacing: pt(3), color: col('vermilion'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' } } },
  { kind: 'text', id: 'number', content: '{titleText}', fontFamily: SONG, fontWeight: 700,
    fontSize: pt(30), letterSpacing: pt(6), color: col('ink'), align: 'left',
    placement: { anchor: { to: '#book', edge: 'below' }, offset: { x: em(2), y: mm(4) } } },
  ...['a', 'b'].map((half, i) => ({ kind: 'text', id: `couplet-${half}`,
    content: `{attr.${half}}`, fontFamily: SONG, fontSize: pt(15), letterSpacing: pt(3),
    color: col('ink'), align: 'left',
    placement: { anchor: { to: i ? '#couplet-a' : '#number', edge: 'below' },
      offset: { x: i ? pt(0) : em(4), y: mm(i ? 1.5 : 4) } } })),
] } };
// #endregion

// #region fore-edge: the book's title and the folio down the outer margin
// 'outer' is the margin away from the spine: a recto's left, a verso's right.
const edge = (id, content, edgeOf, y, extra = {}) => ({ kind: 'text', id, content,
  writingMode: 'vertical-rl', fontFamily: HEI, fontWeight: 500, fontSize: pt(9.5),
  letterSpacing: pt(2), color: col('muted'), overflow: 'clip', align: 'left',
  placement: { anchor: { to: 'outer', edge: edgeOf }, offset: { y: em(y) } }, ...extra });
const header = { elements: [
  // Four characters below the head: the book on a verso, the chapter on a recto,
  // never on the opener.
  edge('head-verso', '{title}', 'top', 4, { pages: 'body', parity: 'even' }),
  edge('head-recto', '{chapterTitle}', 'top', 4, { pages: 'body', parity: 'odd' }),
  edge('folio', '{pageNumber}', 'bottom', -5), // 一, 二, 三…: page.pageNumbering below
] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs by identity
  locale: 'zh-Hant', // Taiwan conventions: centred full-width marks (gotcha: cjk-locale-tag)
  colorPalette, layout, cjk, header,
  footer: { elements: [] },
  page: {
    sizePreset: 'custom', width: mm(184), height: mm(260), dpi: 150, // 16开
    backgroundColor: col('paper'),
    // Minimums: the grid centres its type area in what they leave. left is the inner
    // margin, which a right-bound recto has on its right.
    margins: { top: mm(16), bottom: mm(22), left: mm(20), right: mm(22), mirror: true },
    pageNumbering: { format: 'trad-chinese-informal' },
  },
  bodyText: {
    fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
    // A paragraph may open in a page's last column, so every page sets its 18. The
    // orphan rule stays on: a paragraph's last column never stands alone at the head of
    // a page (孤行不成頁).
    avoidWidows: false,
  },
  headings: {
    fontFamily: SONG, color: col('ink'),
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    levels: [{ level: 1, fontSize: pt(30), breakBefore: { enabled: true, parity: 'any' },
      advancedDesign: opener }],
  },
  paragraphStyles: [
    // The two poems: each line a paragraph, four characters down, never spread.
    { id: 'verse', textAlign: 'left', firstLineIndent: em(4) },
    // The edition's note, two characters lower than the text (低二格), in Kai.
    { id: 'note', fontFamily: KAI, indent: em(2), firstLineIndent: pt(0),
      marginTop: pt(LEAD) },
    // The colophon, in the edition's language: Latin runs sideways down a vertical line.
    { id: 'colophon', fontFamily: HEI, fontWeight: 500, fontSize: pt(7.5),
      lineHeight: pt(LEAD), color: col('muted'), textAlign: 'left', indent: em(2),
      firstLineIndent: pt(0) },
  ],
  calloutStyles: [meipi],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown sample · 97 lines · content.en.mdtitle: "脂硯齋重評石頭記" author: "曹雪芹" --- # 第一回 {a="甄士隱夢幻識通靈" b="賈雨村風塵懷閨秀"} 列位看官:你道此書從何而來?說起根由雖近荒唐,:warichu[自占地步。] :warichu[自首荒唐,妙!]細諳則深有趣味。待在下將此來歷註明,方使閱者了然不惑。 :::callout{type="meipi" span="side"} 妙!自謂落墮情根,故無補天之用。 ::: 原來女媧氏煉石補天之時,:warichu[補天濟世,勿認真,用常言。]于大荒山:warichu[荒唐也。]無稽崖:warichu[無稽也。]煉成高經十二丈、:warichu[總應十二釵。]方經二十四丈:warichu[照應副十二釵。]頑石三萬六千五百零一塊。媧皇氏只用了三萬六千五百塊,:warichu[合週天之數。]只單單的剩了一塊未用,:warichu[剩了這一塊便生出這許多故事。使當日雖不以此補天,就該去補地之坑陷,使地平坦,而不得有此一部鬼話。]便棄在此山青埂峰下。誰知此石自經煆煉之後,靈性已通,:warichu[煆煉後性方通,甚哉!人生不能學也。]因見衆石俱得補天,獨自己無材不堪入選,遂自怨自嘆,日夜悲號慚愧。 一日,正當嗟悼之際,俄見一僧一道遠遠而來,生得骨格不凡,丰神迥別,說說笑笑來至峰下,坐于石邊高談快論。先是說些雲山霧海神仙玄幻之事,後便說到紅塵中榮華富貴。此石聽了,不覺打動凡心,也想要到人間去享一享這榮華富貴,但自恨粗蠢,不得已,便口吐人言,:warichu[竟有人問口生於何處,其無心肝,可咲可恨之極。]向那僧道說道:「大師,弟子蠢物,:warichu[豈敢豈敢。]不能見禮了。適聞二位談那人世間榮耀繁華,心切慕之。弟子質雖粗蠢,:warichu[豈敢豈敢。]性却稍通,况見二師仙形道體,定非凡品,必有補天濟世之材,利物濟人之德。如蒙發一點慈心,攜帶弟子得入紅塵,在那富貴場中、溫柔鄉裏受享幾年,自當永佩洪恩,萬劫不忘也。」二仙師聽畢,齊憨笑道:「善哉,善哉!那紅塵中有却有些樂事,但不能永遠依恃,况又有『美中不足,好事多魔』八箇字緊相連屬,瞬息間則又樂極悲生,人非物換,究竟是到頭一夢,萬境歸空。:warichu[四句乃一部之總綱。]到不如不去的好。」這石凡心已熾,那里聽得進這話去,乃復苦求再四。二仙知不可強制,乃嘆道:「此亦靜極思動,無中生有之數也。既如此,我們便携你去受享受享,只是到不得意時,切莫後悔。」石道:「自然,自然。」那僧又道:「若說你性靈,却又如此質蠢,並更無奇貴之處,如此也只好踮脚而已。:warichu[煆煉過尚與人踮腳,不學者又當如何?]也罷,我如今大施佛法助你助,待劫終之日,復還本質,以了此案。:warichu[妙!佛法亦湏償還,况世人之償乎?近之賴債者來看此句。所謂遊戲筆墨也。]你道好否?」石頭聽了,感謝不盡。那僧便念咒書符,大展幻:warichu[明點「幻」字。好!]術,將一塊大石登時變成一塊鮮明瑩潔的美玉,且又縮成扇墜大小的可佩可拿。:warichu[奇詭險怪之文,有如髯蘇《石鐘》《赤璧》用幻處。]那僧托于掌上,笑道:「形體到也是個寳物了!:warichu[自愧之語。]還只沒有實在的好處,:warichu[妙極!今之金玉其外敗絮其中者,見此大不歡喜。]湏得在鐫上數字,使人一見便知是奇物方妙。:warichu[世上原宜假,不宜真也。諺云:「一日賣了三千假,三日賣不出一個真。」信哉!]然後好携你到那昌明隆盛之邦,:warichu[伏長安大都。]詩禮簪〔纓〕之族,:warichu[伏榮國府。]花柳繁華地,:warichu[伏大觀園。]溫柔富貴鄉:warichu[伏紫芸軒。]去安身樂業。」:warichu[何不再添一句云「擇個絕世情痴作主人」?] :::callout{type="meipi" span="side"} 昔子房後謁黃石公,惟見一石。子房當時恨不能隨此石去。余亦恨不能隨此石而去也。聊供閱者一笑。 ::: 石頭聽了,喜不能禁,乃問:「不知賜了弟子那幾件奇處,:warichu[可知若果有奇貴之處,自己亦不知者。若自以奇貴而居,究竟是無真奇貴之人。]又不知携了弟子到何地方?望乞明示,使弟子不惑。」那僧笑道:「你且莫問,日後自然明白的。」說着,便袖了這石,同那道人飄然而去,竟不知投奔何方何舍。 後來,不知又過了幾世幾劫,因有個空空道人訪道求仙,忽從這大荒山無稽崖青埂峰下經過,忽見一大石上字跡分明,編述歷歷。空空道人乃從頭一看,原來就是無材補天,幻形入世,:warichu[八字便是作者一生慚恨。]蒙茫茫大士、渺渺真人携入紅塵,歷盡離合悲歡炎凉世態的一叚故事。後面又有一首偈云: :::paragraphs{style="verse"} 無材可去補蒼天,:warichu[書之本旨。] 枉入紅塵若許年。:warichu[慚愧之言,嗚咽如聞。] 此係身前身後事, 倩誰記去作奇傳? ::: 詩後便是此石墮落之鄉,投胎之處,親自經歷的一叚陳跡故事。其中家庭閨閣瑣事,以及閑情詩詞倒還全備,或:warichu[「或」字謙得好。]可適趣觧悶,然朝代年紀,地輿邦國,却反失落無考。:warichu[若用此套者,胸中必無好文字,手中斷無新筆墨。據余說,卻大有考證。] 空空道人遂向石頭說道:「石兄,你這一叚故事,據你自己說有些趣味,故編寫在此,意欲問世傳奇。據我看來,第一件,無朝代年紀可考,:warichu[先駁得妙。]第二件,並無大賢大忠理朝廷治風俗的善政,:warichu[將世人欲駁之腐言預先代人駁盡。妙!]其中只不過幾箇異樣的女子,或情或痴,或小才微善,亦無班姑蔡女之德能。我總抄去,恐世人不愛看呢。」 石頭笑荅道:「我師何太痴耶!若云無朝代可考,今我師竟假借漢唐等年紀添綴,又有何難?:warichu[所以答的好。]但我想,歷來野史,皆蹈一轍,莫如我這不借此套者,反到新奇別致,不過只取其事體情理罷了,又何必拘拘於朝代年紀哉!再者,世井俗人喜看理治之書者甚少,愛看適趣閒文者特多。歷代野史,或訕謗君相,或貶人妻女,:warichu[先批其大端。]姦滛凶惡,不可勝數。更有一種風月筆墨,其滛穢污臭,塗毒筆墨,壞人子弟,又不可勝數。至若佳人才子等書,則又千部共出一套,且其中終不能不涉于滛濫,以致滿紙潘安子建、西子文君,不過作者要寫出自己的那兩首情詩艶賦來,故假擬出男女二人名姓,又必傍出一小人其間撥亂,亦如劇中之小丑然。且嬛婢開口即者也之乎,非文即理。故逐一看去,悉皆自相矛盾,大不近情理之話。 :::callout{type="meipi" span="side"} 事則實事,然亦叙得有間架、有曲折、有順逆、有映帶、有隱有見、有正有閏,以至草蛇灰線、空谷傳聲、一擊兩鳴、明修棧道、暗度陳倉、雲龍霧雨、兩山對峙、烘雲托月、背面傳粉、千皴萬染諸奇。書中之秘法,亦不復少。余亦于逐回中搜剔刳剖明白注釋以待高明,再批示誤謬。 ::: :::callout{type="meipi" span="side"} 開卷一篇立意,真打破歷來小說窠臼。閱其筆則是《莊子》《離騷》之亞。 ::: :::callout{type="meipi" span="side"} 斯亦太過。 ::: 「竟不如我半世親睹親聞的這幾個女子,雖不敢說強似前代書中所有之人,但事跡原委,亦可以消愁破悶,也有幾首歪詩熟話,可以噴飯供酒。至若離合悲歡,興衰際遇,則又追踪攝跡,不敢稍加穿鑿,徒為供人之目而反失其真傳者。今之人,貧者日為衣食所累,富者又懷不足之心,總一時稍閒,又有貪滛戀色、好貸尋愁之事,那里去有工夫看那理治之書?所以我這一叚事,也不愿世人稱奇道妙,也不定要世人喜悅檢讀,:warichu[轉得更好。]只愿他們當那醉餘飽卧之時,或避世去愁之際,把此一玩,豈不省了此壽命筋力?就比那謀虛逐妄,去也省了口舌是非之害,腿脚奔忙之苦。再者,亦令世人換新眼目,不比那些胡牽亂扯,忽離忽遇,滿紙才人淑女、子建文君、紅娘小玉等通共熟套之舊稿。我師意為何如?」:warichu[余代空空道人答曰:「不獨破愁醒盹,且有大益。」] :::callout{type="meipi" span="side"} 雪芹舊有《風月寳鑑》之書,乃其弟棠村序也。今棠村已逝,余覩新懷舊,故仍因之。 ::: :::callout{type="meipi" span="side"} 若云雪芹披閱增刪,然後開卷至此這一篇楔子又係誰撰?足見作者之筆式狡猾之甚。後文如此處者不少。這正是作者用画家烟雲糢糊處,觀者萬不可被作者瞞蔽了去,方是巨眼。 ::: 空空道人聽如此說,思忖半晌,將這《石頭記》:warichu[本名。]再檢閱一遍,:warichu[這空空道人也太小心了,想亦世之一腐儒耳。]因見上面雖有些指奸責佞貶惡誅邪之語,:warichu[亦斷不可少。]亦非傷時罵世之旨,:warichu[要緊句。]及至君仁臣良父慈子孝,凡倫常所關之處,皆是稱功頌德,眷眷無窮,實非別書之可比。雖其中大旨談情,亦不過實錄其事,又非假擬妄稱,:warichu[要緊句。]一味滛邀艶約、私訂偷盟之可比。因毫不干涉時世,:warichu[要緊句。]方從頭至尾抄錄回來,問世傳奇。因空見色,由色生情,傳情入色,自色悟空,遂易名為情僧,改《石頭記》為《情僧錄》。至吳玉峰題曰《紅樓夢》。東魯孔梅溪則題曰《風月寶鑑》。後因曹雪芹于悼紅軒中披閱十載,增刪五次,纂成目錄,分出章回,則題曰《金陵十二釵》,並題一絕云: :::callout{type="meipi" span="side"} 能觧者方有辛酸之淚,哭成此書。壬午除夕,書未成,芹為淚盡而逝。余嘗哭芹,淚亦待盡。每意覓青埂峯再問石兄,余不遇獺頭和尚何!悵悵! ::: :::callout{type="meipi" span="side"} 今而後惟愿造化主再出一芹一脂,是書何本,余二人亦大快遂心于九泉矣。甲午八日淚筆。 ::: :::paragraphs{style="verse"} 滿紙荒唐言,一把辛酸淚! 都云作者痴,誰解其中味?:warichu[此是第一首標題詩。] ::: 至脂硯齋甲戌抄閱再評,仍用《石頭記》。 出則既明,且看石上是何故事。按那石上書云::warichu[以石上所記之文。] 當日地陷東南,這東南一隅有處曰姑蘇,:warichu[是金陵。]有城曰閶門者,最是紅塵中一二等富貴風流之地。:warichu[妙極!是石頭口氣,惜米顛不遇此石。]這閶門外有個十里街,:warichu[開口失云勢利,是伏甄、封二姓之事。]街內有個仁清巷,:warichu[又言人情,總為士隱火後伏筆。]巷內有個古廟,因地方窄狹,:warichu[世路寬平者甚少。亦鑿。]人皆呼作葫蘆廟。:warichu[糊塗也,故假語從此具焉。] :::callout{type="meipi" span="side"} 真。後之甄寶玉亦借此音,後不注。 ::: 廟傍住着一家鄉宦,:warichu[不出榮國大族,先寫鄉宦小家,從小至大,是此書章法。]姓甄,名費,:warichu[廢。]字士隱。:warichu[托言將真事隱去也。]嫡妻封氏,:warichu[風。因風俗來。]情性賢淑,深明禮義。:warichu[八字正是寫日後之香菱,見其根源不凡。]家中雖無甚富貴,然本地便也推他為望族了。:warichu[本地推為望族,寕、榮則天下推為望族,叙事有層落。]只因這甄士隱禀性恬淡,不以功名為念,:warichu[自是羲皇上人,便可作是書之朝代年紀矣。總寫香菱根基,原與正十二釵無異。]每日只以觀花修竹,酌酒吟詩為樂,到是神仙一流人品。只是一件不足:如今年已半百,膝下無兒,:warichu[所謂「美中不足」也。]只有一女,乳名英蓮,:warichu[設云「應怜」也。]年方三歲。 :::paragraphs{style="note"} 本編說明 正文與批語據甲戌本《脂硯齋重評石頭記》第一回,錄文取自維基文庫。朱筆側批改作雙行夾批,排在所批文字之下;眉批仍居天頭,列在所批一段之上。標點從錄文,分段稍有調整。原本空缺之字據他本補入,外加六角括號,如「詩禮簪〔纓〕之族」。字庫未收之異體字,如縂、竒、冩,改用通行字。 ::: :::paragraphs{style="colophon"} Set in Noto Serif TC, LXGW WenKai TC and Noto Sans TC (SIL OFL) · Text: the Jiaxu manuscript, Wikisource transcription (CC BY-SA 4.0) :::
`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Noto Serif TC': ['400', '700'], // 宋: the text and the notes in the line; bold 第一回 'LXGW WenKai TC': ['400'], // 楷: the head-margin comments, the edition's note 'Noto Sans TC': ['500'], // 黑: the book's title, the folios, the colophon }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region voices: each face loads the files of the characters it sets // Song sets the whole sample and its bold only 第一回, Kai the head-margin comments and // the edition's note, Hei the running heads, the folios and the colophon // (gotcha: cjk-fonts-slices). const blocks = (style) => [...markdown.matchAll( new RegExp(`:::(?:callout|paragraphs)\\{[^}]*"${style}"[^}]*\\}\\n([^]*?)\\n:::`, 'g'))] .map((m) => m[1]).join(''); const numerals = '一二三四五六七八九十'; await loadFonts(FONTS, markdown); await loadCjkFonts({ [SONG]: ['400'] }, markdown, { vertical: true }); await loadCjkFonts({ [SONG]: ['700'] }, '第一回'); // the bold weight sets three characters await loadCjkFonts({ [KAI]: FONTS[KAI] }, blocks('meipi') + blocks('note'), { vertical: true }); await loadCjkFonts({ [HEI]: FONTS[HEI] }, `脂硯齋重評石頭記第一回${numerals}${blocks('colophon')}`, { vertical: true }); // #endregion const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); showBook(doc, { title: t({ en: 'A red-ink commentary edition', es: 'Una edición comentada en rojo' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);
Kit · core, fonts, viewer, pdf, cjk: the same in every recipe · 422 lines// ─── 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 ───────────────────────────────────────────────────────────────────────

The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗

Variations

For a single-colour printing, set the notes in black between brackets and the head-margin comments in the muted ink; the brackets are set at the text size before a note's first row and after its last.

-  warichu: { color: col('vermilion') },
+  warichu: { color: col('ink'), open: '〔', close: '〕' },
-  body: { fontFamily: KAI, fontSize: pt(7), lineHeight: pt(10), color: col('vermilion'),
+  body: { fontFamily: KAI, fontSize: pt(7), lineHeight: pt(10), color: col('muted'),

#Compare it with a Latin-script edition

The same technique on a horizontal page is the annotated classic with margin glosses: the side column stands at the fore-edge (sideColumnSide: 'outer'), and each gloss sits beside the paragraph it explains instead of above it.

Pitfalls

Pitfall

A side box starts level with the block after its fence

In postext 1.4.1 a span: 'side' box stands in the side column at the height the text has reached at its fence, on the next grid line, and under any box already there. Fence a gloss just before the paragraph it explains: fenced after it, the gloss starts beside the next paragraph. A box that would run past the column's foot slides up until its foot sits on the column's foot, as far as the box above it allows; one that still does not fit waits for the side column of the next page. Margin notes →

Pitfall

In vertical text the side column is a tier, and 'left' is the top one

With layout.writingMode 'vertical-rl' the page is a horizontal page turned a quarter turn, so the side column of a oneAndHalf layout becomes a tier across the page: sideColumnSide 'left' puts it at the head, the place of head-margin comments (眉批), and 'right' at the foot. 'outer' and 'inner' read as 'right' and 'left' (a tier has no outer edge), so the 'outer' of a textbook's margin column sends the notes to the foot. sideColumnPercent is a share of the page's height. Margin column for floats →

Pitfall

Chinese faces load by slices, through the cjk block

Fontsource serves a Chinese, Japanese or Korean family as about a hundred files per weight, each covering a range of characters. loadFonts fetches only the latin file, so on screen the Han characters come from a system face and measure wrong, and fontsourceProvider hands the PDF that latin file, which prints them as empty boxes. List the cjk kit block, call loadCjkFonts(FONTS, markdown) after loadFonts (once per voice, with the text it sets, when the book uses several CJK faces) and give renderToPdf fontProvider: cjkPdfProvider: both take the files that hold the text's characters. Chinese, Japanese and Korean fonts →

Pitfall

A right-bound book shows its spreads with showBook

In a book bound on the right (vertical Chinese, or page.binding 'right') page 1 is still the recto, but it lies on the left of the spine, and the pairs read [3 | 2]. showPages lays every book out left-bound; showBook from the cjk block reads doc.binding and mirrors the pairs. capture.hero still names a spread in reading order, [verso, recto]: [2, 3]. Books bound on the right →

Pitfall

Tag the document zh-Hans or zh-Hant, not with LANG

A recipe's editions are en and es, but a Chinese sample is Chinese in both: `locale: LANG` would tag it English or Spanish, hyphenate its Latin words, label its figures Figure or Figura and give the PDF the wrong language. Write the tag yourself: 'zh-Hans' (mainland conventions: GB line breaking, Kaiming punctuation) or 'zh-Hant' (Taiwan: full-width centred punctuation); 'zh-HK' for Hong Kong. A bare 'zh' reads as Simplified, mainland. Chinese line breaking →

Pitfall

Any headings object switches off the H1 page break

By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →

  • A head-margin comment stands level with the column the text has reached at its fence, so the fence goes just before the passage it discusses; fenced after it, the comment starts beside the next paragraph. A fence sits between two blocks, never inside a paragraph, so where the manuscript comments on a phrase deep inside a long one, this edition breaks the paragraph there: before 石頭聽了, before 廟傍住着, and once inside the stone's speech, before 竟不如我, where the new paragraph opens with 「 again as a quotation that runs over two paragraphs does.
  • The tier holds what its page holds. A comment too long for the rest of it moves back to the right until its last column meets the left edge of the text, and one that still does not fit waits for the next page, with every comment fenced after it. Each comment starts on a text column and leaves a body column clear after the one before it. The manuscript's three comments on the stone's speech take eleven columns of the tier: fenced before 今之人, where the transcription has them, the last of them, 斯亦太過, waited for page 5 and pushed the comments there one step on. This edition breaks the speech one sentence earlier, before 竟不如我, and all three stand on page 4.
  • With avoidWidows on, a paragraph that would open in the last column of a page moves on whole, the page ends a column short and a comment fenced before that paragraph stays behind. Chinese books let a paragraph open in any column, so avoidWidows: false fills every page with its 18. avoidOrphans stays on: a paragraph's last column alone at the head of a page (孤行) is what a Chinese editor avoids.
  • Two side comments written one after the other fold into rows that read as one note. The Markdown keeps an ideographic space (U+3000) between them.
  • Noto Serif TC has no glyph for some manuscript forms, such as 縂, 竒 and 数. The canvas fills them from a system face, and the PDF leaves them blank and lists them under C25. This edition sets their standard forms and keeps the variants the faces have.

Credits

Text
  • 脂硯齋重評石頭記 (The Story of the Stone, re-annotated by Zhiyan Zhai), Jiaxu manuscript (1754), chapter 1 from its opening to the Zhen household, with the commentary; Wikisource transcription, revision 2644544 · Cao Xueqin and Zhiyan Zhai; transcription by Wikisource editors · CC BY-SA 4.0
  • The note on this edition (本編說明) and the paragraph breaks added for the head-margin comments · Ignacio Ferro · CC BY 4.0
Fonts
Noto Serif TC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1)
SandboxPDF