Zum Hauptinhalt springen
Rezept Nummer 77

Rezepte · Kapitel 2 · Schrift und Text

Eigennamen- und Buchtitelstriche, vertikal und horizontal

Die Grundannalen des Xiang Yu mit :name und :book, vertikal auf einer rechts gebundenen Seite, dann horizontal: Striche erst links der Namen, dann darunter.

Auf dieser Seite
Ausgabe
Canvas · PDF
Stufe
Mittel
Postext
Getestet mit Postext 1.31.0
Erfordert ≥ 1.25.0 · postext-pdf ≥ 1.25.0
Lizenz
Aktualisiert am 10. Okt. 2026
Code MIT · Text CC BY 4.0

S. 二–三 · 2–3 von 3

  • Englisches Beispiel: noch keine deutsche Ausgabe
  • Endformat 140 × 203 mm
  • 1 Spalte
  • Noto Serif TC 10,5/18
  • LXGW WenKai TC
  • Noto Sans TC
  • 3 Seiten
  • Stufe
  • Postext 1.30.0
  • In 79 ms gesetzt
  • 168 Zeilen Code

Kurz gesagt

Musterseiten aus einem klassischen chinesischen Geschichtswerk. Sie zeigen, wie man Namen mit einem geraden roten Strich und Buchtitel mit einem welligen markiert, in vertikalem wie horizontalem Text.

Was Sie setzen

Drei Musterseiten für ein Lesebuch zu den Aufzeichnungen des Chronisten, im Endformat 140 × 203 mm eines chinesischen Buchs 大32開. Die Passage ist der Anfang der Grundannalen des Xiang Yu in der vollen Interpunktion einer klassischen Ausgabe: ein gerader Strich neben jeder Person, jedem Ort, Staat und jeder Dynastie, ein welliger Strich neben jedem Buch- und Kapiteltitel, beide in Zinnoberrot. Seite 1 setzt die Vorbemerkung und die Konventionen des Herausgebers in der Kai-Schrift. Die Seiten 2 und 3 setzen dasselbe Markdown zweimal, die Seite hinunter und über sie hinweg, sodass die Doppelseite zeigt, wohin die Marken kommen: links der Namen in vertikalen Zeilen, darunter in horizontalen. Das Buch ist rechts gebunden, und ein indigoblaues Band trägt jeden Titel. Nº 044, Lycidas im Text von 1645, hebt seine Namen auf europäische Art hervor, kursiv.

Dieses Rezept beantwortet

  • Wie kennzeichne ich Eigennamen und Buchtitel in chinesischem Text mit einer geraden und einer Wellenlinie?
  • Warum druckt meine chinesische Hervorhebung in einer geneigten Schrift, und was verwende ich stattdessen?
  • Wie setze ich ein chinesisches Buch vertikal und rechts gebunden?

Die kurze Antwort

script.js · Zeilen 45–62im vollständigen Code
// :name[項梁] draws the proper-name line, :book[史記] the book-title mark, :dots[…] dots.
const cjk = {
  bookTitleMark: 'wavy', // zh-Hant's default; mainland editions of the classics use it too
  annotationColor: col('mark'), // unset, the marks print in the colour of the text
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
};
// The book runs down the page, bound on the right: the lines stand left of the names.
const layout = { layoutType: 'single', writingMode: 'vertical-rl' };
// # 項羽本紀 {style="across"} sets the same Markdown across the page, 28 characters to
// the line: the lines run under the names. Its margins set its grid; the book's grid
// counts down the page.
const MEASURE = 28 * SIZE * PT; // mm
const across = {
  id: 'across',
  layout: { layoutType: 'single', writingMode: 'horizontal-tb' },
  margins: { top: mm(SIDE), bottom: mm(H - SIDE - 25 * LEAD * PT), // 25 lines
    left: mm((W - MEASURE) / 2), right: mm((W - MEASURE) / 2) },
};

Zutaten

Schrift
Noto Serif TC, Noto Sans TC, LXGW WenKai TC (SIL OFL 1.1)
Dateien
Keine: Jedes Bild wird im Code gezeichnet

Zubereitung

#1 · Das Markup markiert, die Schreibrichtung platziert

Den Code finden Sie oben unter Die kurze Antwort. :name[項梁] und :book[史記] behalten ihre Zeichen im Text, sodass Suche, kopierter Text und PDF 項梁 und 史記 lesen, und fügen die Marken daneben hinzu (Chinesische Zeichen, Ruby und Warichu). Auf welcher Seite sie stehen, entscheidet allein die Schreibrichtung: layout.writingMode: 'vertical-rl' setzt die Striche links der Zeichen, und der Überschriftenstil across, dessen Layout 'horizontal-tb' ist, setzt sie darunter. bookTitleMark: 'wavy' ist das, was ein Dokument in zh-Hant von Haus aus bekommt; es steht ausgeschrieben da, weil ein Wechsel zu zh-Hans die Titel sonst in 《》 setzen würde. Zwei sich berührende Marken derselben Art, wie in 東漢班固 oder 史記項羽本紀, geben an den aufeinandertreffenden Enden je ein Achtelgeviert ab, sodass die Striche als zwei Namen lesbar sind.

#2 · Zeilenabstand mit Platz für die Marken

script.js · Zeilen 32–41im vollständigen Code
const [SONG, HEI, KAI] = ['Noto Serif TC', 'Noto Sans TC', 'LXGW WenKai TC']; // 宋, 黑, 楷
const PT = 25.4 / 72; // mm in a point
const [W, H] = [140, 203]; // mm: 大32開
const SIZE = 10.5; // pt: 五號
// A gap of 7.5 pt (0.71 em) between lines: a line of marks needs half an em of it, dots on
// one side and lines on the other five eighths. At 15 pt the build reports every marked
// paragraph (cjkMarksExceedLeading: gap 0.43 em).
const LEAD = 18; // pt
const [CHARS, LINES] = [42, 17]; // the vertical grid: characters down a line, lines across
const SIDE = (W - LINES * LEAD * PT) / 2; // mm: the side margins of a vertical page

Die Marken leben im Zwischenraum zwischen den Zeilen und ändern nie den Zeilenabstand (Zeichen, Ruby und Warichu). Bei 10,5 pt auf 18 pt beträgt der Zwischenraum 0,71 Geviert, was die Namensstriche einer Spalte und die Punkte der nächsten aufnimmt, wie bei der fünften Konvention auf Seite 1. Das Raster zählt die Seite in Zeichen: 42 in jeder Zeile nach unten und 17 Zeilen nebeneinander, und es verschiebt die Ränder so, dass der Satzspiegel genau das ist: 25,7 mm Rand am Kopf, 21,7 mm am Fuß (Zeichenraster).

#3 · Ein Auftakt, der sich mit der Seite dreht

script.js · Zeilen 66–89im vollständigen Code
// The grid centres the text between the minimum margins; the band stops at its foot.
const FOOT = 20 + (H - 24 - 20 - CHARS * SIZE * PT) / 2; // mm, with minimums of 24 and 20
const BAND = SIDE + 3.5 * LEAD * PT; // mm from the trim, which is the right edge down the page
const onBand = { align: 'left', overflow: 'wrap' }; // design text centres and cuts by default
const next = (id, y) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  minHeight: pt(4 * LEAD), // the text starts on the fifth line, half a line clear of the band
  slot: { elements: [
    // Across the page the band's length runs past the trim; down it, it ends at the text's
    // foot, clear of the folio.
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: mm(H - FOOT), height: mm(BAND) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: HEI,
      fontWeight: 700, fontSize: pt(8), letterSpacing: pt(1.6), color: col('tint'),
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(6 - SIDE) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: SONG,
      fontWeight: 700, fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3),
      color: col('paper'), placement: next('kicker', 2) },
    { kind: 'text', id: 'byline', content: '{attr.byline}', ...onBand, fontFamily: KAI,
      fontSize: pt(10), color: col('tint'), placement: next('title', 2) },
  ] },
};

Das Design einer Überschrift wird im Fluss seiner Seite angelegt, daher dient ein Satz von Elementen beiden Richtungen (Vertikale Schrift). Oben links am Anschnitt verankert, liegt das Band quer über dem Kopf der horizontalen Seite und senkrecht am rechten Rand der vertikalen, wo das Lesen beginnt. Dachzeile, Titel und Autorenzeile stapeln sich ebenfalls im Fluss: untereinander auf der horizontalen Seite, nebeneinander von rechts nach links auf der vertikalen. Die Länge des Bandes wird entlang des Flusses gemessen: 181,3 mm lassen es am Fuß des vertikalen Textes enden, frei von der Pagina, und über das Endformat der horizontalen Seite hinauslaufen.

#4 · Seitenzahlen für ein rechts gebundenes Buch

script.js · Zeilen 93–102im vollständigen Code
const foot = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  fontFamily: HEI, fontSize: pt(7), color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-11) } } });
const folio = { fontFamily: SONG, fontSize: pt(9) };
// Bound on the right, a recto (odd) lies left of the spine: its outer edge is its left one.
const footer = { elements: [
  foot('folio-odd', '{pageNumber}', 'odd', 'bottom-left', SIDE, folio),
  foot('folio-even', '{pageNumber}', 'even', 'bottom-right', -SIDE, folio),
  foot('slug', '{attr.slug}', 'all', 'bottom', 0, { letterSpacing: pt(0.4) }),
] };

Vertikaler Text bindet das Buch rechts (Bindung): Seite 1 liegt links vom Bund, und die Außenkante einer rechten Seite ist ihre linke. Die ungerade Seitenzahl kommt nach unten links und die gerade nach unten rechts, umgekehrt wie in einem westlichen Buch, und pageNumbering.format: 'trad-chinese-informal' druckt sie als 一, 二, 三. Die Zeile dazwischen kommt aus einem Attribut jeder Überschrift, slug="…"; zusammen mit dem Impressum ist sie alles, was sich zwischen der englischen und der spanischen Ausgabe ändert.

#5 · Eine zweite Druckfarbe

script.js · Zeilen 17–29im vollständigen Code
const palette = {
  ink: '#221e1b', // the text
  band: '#23394b', // the opener band and the small heads
  mark: '#b5412c', // the name, title and emphasis marks: the second ink
  tint: '#c7d2da', // kickers and bylines on the band
  muted: '#6f6a64', // folios, slug lines, the colophon
  paper: '#ffffff',
};
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' } })),
  { id: 'main-color', name: 'band (defaults)', value: { hex: palette.band, model: 'hex' } },
];

Eine klassische Ausgabe druckt ihre Marken in der Farbe des Textes. Ein Lesebuch für Studierende kann sie in einer zweiten Farbe drucken, damit sie sich von den Strichen der Zeichen abheben: annotationColor verknüpft sie mit dem Eintrag mark der Palette, dem Zinnoberrot eines Kommentars in roter Tinte, und eine Änderung dieses Eintrags färbt jeden Strich und jeden Punkt des Buchs um.

Das ganze Rezept

Sandbox
// ═══ Postext Cookbook · Nº 077 · Proper-name and book-title marks, across and down ═════
// https://postext.dev/en/cookbook/proper-name-marks
// Code: MIT · Text: Sima Qian, Shiji 7 (PD) · Punctuation, headnote, conventions: CC BY 4.0
// Fonts: Noto Serif TC, Noto Sans TC, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// Specimen pages for a classical reader: the opening of the Basic Annals of Xiang Yu with
// its proper-name lines and wavy title lines, set down the page and then across it.
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the slug lines and the colophon ('en' | 'es')
const RECIPE = 'proper-name-marks';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the indigo of a thread-bound cover, vermilion for the marks
const palette = {
  ink: '#221e1b', // the text
  band: '#23394b', // the opener band and the small heads
  mark: '#b5412c', // the name, title and emphasis marks: the second ink
  tint: '#c7d2da', // kickers and bylines on the band
  muted: '#6f6a64', // folios, slug lines, the colophon
  paper: '#ffffff',
};
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' } })),
  { id: 'main-color', name: 'band (defaults)', value: { hex: palette.band, model: 'hex' } },
];
// #endregion
// #region type: 五號 on 18 pt, 42 characters by 17 lines down the page
const [SONG, HEI, KAI] = ['Noto Serif TC', 'Noto Sans TC', 'LXGW WenKai TC']; // 宋, 黑, 楷
const PT = 25.4 / 72; // mm in a point
const [W, H] = [140, 203]; // mm: 大32開
const SIZE = 10.5; // pt: 五號
// A gap of 7.5 pt (0.71 em) between lines: a line of marks needs half an em of it, dots on
// one side and lines on the other five eighths. At 15 pt the build reports every marked
// paragraph (cjkMarksExceedLeading: gap 0.43 em).
const LEAD = 18; // pt
const [CHARS, LINES] = [42, 17]; // the vertical grid: characters down a line, lines across
const SIDE = (W - LINES * LEAD * PT) / 2; // mm: the side margins of a vertical page
// #endregion

// #region answer: marks by the markup, their side by the writing mode
// :name[項梁] draws the proper-name line, :book[史記] the book-title mark, :dots[…] dots.
const cjk = {
  bookTitleMark: 'wavy', // zh-Hant's default; mainland editions of the classics use it too
  annotationColor: col('mark'), // unset, the marks print in the colour of the text
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
};
// The book runs down the page, bound on the right: the lines stand left of the names.
const layout = { layoutType: 'single', writingMode: 'vertical-rl' };
// # 項羽本紀 {style="across"} sets the same Markdown across the page, 28 characters to
// the line: the lines run under the names. Its margins set its grid; the book's grid
// counts down the page.
const MEASURE = 28 * SIZE * PT; // mm
const across = {
  id: 'across',
  layout: { layoutType: 'single', writingMode: 'horizontal-tb' },
  margins: { top: mm(SIDE), bottom: mm(H - SIDE - 25 * LEAD * PT), // 25 lines
    left: mm((W - MEASURE) / 2), right: mm((W - MEASURE) / 2) },
};
// #endregion

// #region opener: one design both ways: a band over the text across, right of it down
// The grid centres the text between the minimum margins; the band stops at its foot.
const FOOT = 20 + (H - 24 - 20 - CHARS * SIZE * PT) / 2; // mm, with minimums of 24 and 20
const BAND = SIDE + 3.5 * LEAD * PT; // mm from the trim, which is the right edge down the page
const onBand = { align: 'left', overflow: 'wrap' }; // design text centres and cuts by default
const next = (id, y) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  minHeight: pt(4 * LEAD), // the text starts on the fifth line, half a line clear of the band
  slot: { elements: [
    // Across the page the band's length runs past the trim; down it, it ends at the text's
    // foot, clear of the folio.
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: mm(H - FOOT), height: mm(BAND) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: HEI,
      fontWeight: 700, fontSize: pt(8), letterSpacing: pt(1.6), color: col('tint'),
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(6 - SIDE) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: SONG,
      fontWeight: 700, fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3),
      color: col('paper'), placement: next('kicker', 2) },
    { kind: 'text', id: 'byline', content: '{attr.byline}', ...onBand, fontFamily: KAI,
      fontSize: pt(10), color: col('tint'), placement: next('title', 2) },
  ] },
};
// #endregion

// #region folios: Chinese numerals at the outer foot, the slug line in the middle
const foot = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  fontFamily: HEI, fontSize: pt(7), color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-11) } } });
const folio = { fontFamily: SONG, fontSize: pt(9) };
// Bound on the right, a recto (odd) lies left of the spine: its outer edge is its left one.
const footer = { elements: [
  foot('folio-odd', '{pageNumber}', 'odd', 'bottom-left', SIDE, folio),
  foot('folio-even', '{pageNumber}', 'even', 'bottom-right', -SIDE, folio),
  foot('slug', '{attr.slug}', 'all', 'bottom', 0, { letterSpacing: pt(0.4) }),
] };
// #endregion

// The front page sets the headnote and the conventions in the Kai face. A section's
// bodyStyle sets its list numbers bold unless its orderedLists say otherwise.
const front = { id: 'front', bodyStyle: { fontFamily: KAI, orderedLists: { fontWeight: 400 } } };

const config = () => ({
  locale: 'zh-Hant', // Taiwan: full-width centred punctuation (gotcha: cjk-locale-tag)
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(W), height: mm(H), dpi: 150,
    // Minimums: the grid centres its 42 × 17 characters, 天頭 25.7 mm over 地腳 21.7 mm.
    margins: { top: mm(24), bottom: mm(20), left: mm(SIDE), right: mm(SIDE), mirror: true },
    pageNumbering: { format: 'trad-chinese-informal' }, // 一, 二, 三
  },
  layout,
  cjk,
  bodyText: {
    fontFamily: SONG, fontSize: pt(SIZE), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
  },
  headings: {
    fontFamily: HEI, fontWeight: 700, color: col('band'),
    levels: [
      // parity 'any': the H1 opens on the next page, either side (the default waits for a recto).
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        advancedDesign: opener, marginBottom: pt(0) },
      // No space above: 題解 sits on the fifth line, where the other pages' text starts;
      // a :::space line sets 凡例 apart from the headnote.
      { level: 2, fontSize: pt(SIZE), lineHeight: pt(LEAD), letterSpacing: pt(2),
        marginTop: pt(0), marginBottom: pt(0) },
    ],
  },
  headingStyles: [front, across],
  // No gap after 一、: the text starts two ems in, on the grid, like a paragraph's.
  orderedLists: { numberFormat: 'trad-chinese-informal', separator: '、', fontFamily: KAI,
    fontWeight: 400, color: col('ink'), gap: em(0), marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [
    { id: 'colophon', fontFamily: HEI, fontSize: pt(7), lineHeight: pt(10), color: col('muted'),
      textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  header: { elements: [] }, // every page opens a section: the band is its head
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// The Chinese is the same in both editions; the slug lines and the colophon change.
const markdown = String.raw`---
Markdown-Beispiel · 43 Zeilen · content.en.mdtitle: "史記選讀" author: "司馬遷" --- # 史記選讀 {style="front" kicker="版式樣張" byline="題解 凡例" slug="Specimen sheet: the headnote and the conventions"} ## 題解 :book[項羽本紀]是:book[史記]第七卷,記述:name[秦]末:name[項羽]起兵、分封諸侯,以至兵敗:name[垓下]、自刎:name[烏江]的始末。:name[司馬遷]將這位未曾稱帝的人物列入「本紀」,與帝王並列。本樣張節錄篇首,寫:name[項羽]的出身與少年志氣。:name[東漢]:name[班固]:book[漢書]:book[陳勝項籍傳]敘述同一段事,文字大多沿用:book[史記]。 :::space ## 凡例 1. 正文依維基文庫所收:book[史記]卷七錄文;標點、分段與題解為本樣張所加。 2. 人名、地名、國名、朝代名旁加專名號,直排標於字左,橫排標於字下。 3. 書名、篇名旁加浪線書名號,位置與專名號相同,如:book[史記]:book[項羽本紀]。 4. 兩個專名緊相連接時各自畫線,相接處各縮八分之一字,如:name[楚]:name[漢]、:name[東漢]:name[班固]。 5. 需要強調的字旁加著重號,直排在字右,橫排在字下,如:dots[萬人敵]。 # 項羽本紀 {kicker="樣張一 直排" byline="〔漢〕司馬遷" slug="Specimen 1, down the page: the lines stand left of the names"} :name[項籍]者,:name[下相]人也,字:name[羽]。初起時,年二十四。其季父:name[項梁]。:name[梁]父即:name[楚]將:name[項燕],為:name[秦]將:name[王翦]所戮者也。:name[項氏]世世為:name[楚]將,封於:name[項],故姓:name[項氏]。 :name[項籍]少時,學書不成,去學劍,又不成。:name[項梁]怒之。:name[籍]曰:「書,足以記名姓而已;劍,一人敵,不足學。學萬人敵。」於是:name[項梁]乃教:name[籍]兵法。:name[籍]大喜,略知其意,又不肯竟學。 :name[項梁]嘗有:name[櫟陽]逮,乃請:name[蘄]獄掾:name[曹咎]書抵:name[櫟陽]獄掾:name[司馬欣],以故事得已。:name[項梁]殺人,與:name[籍]避仇於:name[吳中],:name[吳中]賢士大夫皆出:name[項梁]下。每:name[吳中]有大繇役及喪,:name[項梁]常為主辦,陰以兵法部勒賓客及子弟,以是知其能。 :name[秦始皇帝]游:name[會稽],渡:name[浙江],:name[梁]與:name[籍]俱觀。:name[籍]曰:「彼可取而代也!」:name[梁]掩其口曰:「毋妄言,族矣。」:name[梁]以此奇:name[籍]。:name[籍]長八尺餘,力能扛鼎,才氣過人,雖:name[吳中]子弟,皆已憚:name[籍]矣。 # 項羽本紀 {style="across" kicker="樣張二 橫排" byline="〔漢〕司馬遷" slug="Specimen 2, across the page: the lines run under the names"} :name[項籍]者,:name[下相]人也,字:name[羽]。初起時,年二十四。其季父:name[項梁]。:name[梁]父即:name[楚]將:name[項燕],為:name[秦]將:name[王翦]所戮者也。:name[項氏]世世為:name[楚]將,封於:name[項],故姓:name[項氏]。 :name[項籍]少時,學書不成,去學劍,又不成。:name[項梁]怒之。:name[籍]曰:「書,足以記名姓而已;劍,一人敵,不足學。學萬人敵。」於是:name[項梁]乃教:name[籍]兵法。:name[籍]大喜,略知其意,又不肯竟學。 :name[項梁]嘗有:name[櫟陽]逮,乃請:name[蘄]獄掾:name[曹咎]書抵:name[櫟陽]獄掾:name[司馬欣],以故事得已。:name[項梁]殺人,與:name[籍]避仇於:name[吳中],:name[吳中]賢士大夫皆出:name[項梁]下。每:name[吳中]有大繇役及喪,:name[項梁]常為主辦,陰以兵法部勒賓客及子弟,以是知其能。 :name[秦始皇帝]游:name[會稽],渡:name[浙江],:name[梁]與:name[籍]俱觀。:name[籍]曰:「彼可取而代也!」:name[梁]掩其口曰:「毋妄言,族矣。」:name[梁]以此奇:name[籍]。:name[籍]長八尺餘,力能扛鼎,才氣過人,雖:name[吳中]子弟,皆已憚:name[籍]矣。 :::paragraphs{style="colophon"} Set in Noto Serif TC, Noto Sans TC and LXGW WenKai TC (SIL Open Font License). Text: Sima Qian, Shiji, chapter 7, from zh.wikisource (public domain). Punctuation, paragraphs, headnote and conventions written for this specimen (CC BY 4.0). :::
`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // A Chinese face comes in slices: each voice loads the files of the text it sets // (gotcha: cjk-fonts-slices). Kai sets the front page and the bylines, Hei the small heads. const FONTS = { 'Noto Serif TC': ['400', '700'], 'Noto Sans TC': ['400', '700'], 'LXGW WenKai TC': ['400'], }; const heads = markdown.match(/^#.*$/gm).join('\n'); // titles, kickers, bylines, slug lines const preface = markdown.slice(0, markdown.indexOf('\n# ', markdown.indexOf('# ') + 2)); const colophon = markdown.slice(markdown.lastIndexOf(':::paragraphs')); const NUMERALS = '一二三四五六七八九十、'; // folios and list numbers, which the text may lack // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadCjkFonts({ [SONG]: ['400'] }, markdown + NUMERALS, { vertical: true }); // The bold sets the two titles, which have no punctuation, so it loads no vertical forms. // A browser that ignores the forms' feature settings would otherwise take a twin of the // bold files for vertical forms and set the text's brackets upright in the regular. await loadCjkFonts({ [SONG]: ['700'] }, heads); await loadCjkFonts({ [HEI]: ['400', '700'] }, heads + colophon, { vertical: true }); await loadCjkFonts({ [KAI]: ['400'] }, preface + heads + NUMERALS, { vertical: true }); const doc = await withLoadedFonts(() => buildDocument({ markdown }, config()), { ...kitFonts(FONTS), text: markdown }); showBook(doc, { title: t({ en: 'Name and title marks, down and across', es: 'Marcas de nombre y de título, en vertical y en horizontal' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);
Kit · core, fonts, viewer, pdf, cjk: in jedem Rezept gleich · 458 Zeilen// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · cjk v2 ── Chinese, Japanese and Korean books · postext.dev/cookbook // Fontsource ships a CJK family as about a hundred files per weight, each // declared in its stylesheet with the unicode-range it covers. The screen // loads the files the sample touches; the PDF gets the same files for the // characters its pages set in each face, and embeds each as a subset. // A book bound on the right (vertical text) is shown with its spreads // mirrored: page 1 alone on the left of the spine, then [3 | 2]. /** The files of a Fontsource face, read from its stylesheet: { url, range, * ranges }, the last declared first (the order the browser tries them in). */ function cjkSlices(family, weight, style) { cjkSlices.cache ??= new Map(); const id = fontsourceId(family); const css = `${id}/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { let at; cjkSlices.cache.set(css, fontsourceAt(id).then((a) => fetch(at = a + css.slice(id.length + 1))) .then((res) => { if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style} (${res.status})`); return res.text(); }) .then((text) => [...text.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => { const range = /unicode-range:\s*([^;]+);/.exec(rule)?.[1].trim() ?? 'U+0-10FFFF'; const ranges = range.split(',').map((part) => { const [lo, hi = lo] = part.trim().slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return { url: new URL(/url\(([^)]+?\.woff2)\)/.exec(rule)[1], at).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` the screen sets code point `cp` in, if any: the * last one loadCjkFonts adds whose range holds it. A numbered file that * repeats a letter of latin is added after latin, and wins it. */ function cjkSliceFor(slices, cp) { const holds = (slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi); return slices.reduce((last, slice) => (holds(slice) ? slice : last), undefined); } /** Whether Fontsource serves `family` as a Chinese, Japanese or Korean * family (its subsets name the script). Fails when the API does not * answer: a CJK face taken for a Latin one would paint in a system face. */ async function isCjkFamily(family) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); return !!meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset)); } /** faces = { 'Noto Serif TC': ['400', '700'] }, as for loadFonts: the * whole FONTS object may be passed, its other families are left to * loadFonts. Adds one FontFace per file of each CJK face with its * unicodeRange, then loads the files `text` touches. `text` is what the * faces set: the sample for the text face; a book in several voices calls * it once per voice (loadCjkFonts({ 'LXGW WenKai TC': ['400'] }, quotes)), * so the heading and quotation faces fetch and check only their own * characters. Fails when a character of `text` is in no file of a face. * List every weight the pages use: a weight left to the engine's later round * gets the latin file only. With { vertical: true } it also loads each * family's vertical forms (brackets, quotes, pause marks) for the canvas, * which needs loadVerticalAlternates imported from postext. Resolves to * the number of files loaded. */ async function loadCjkFonts(faces, text, { vertical = false } = {}) { kitStatus('Loading fonts…'); let loaded = 0; try { if (vertical && typeof loadVerticalAlternates !== 'function') { throw new Error('loadCjkFonts(…, { vertical: true }) needs loadVerticalAlternates imported from postext'); } for (const [family, specs] of Object.entries(faces)) { if (!(await isCjkFamily(family))) continue; const twin = []; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const slices = await cjkSlices(family, weight, style); const missing = [...new Set(text)].filter((ch) => /\S/.test(ch) && !cjkSliceFor(slices, ch.codePointAt(0))); if (missing.length) { throw new Error(`${family} ${spec} has no file for ${missing.slice(0, 12).join(' ')}: ` + `give each face the text it sets (loadCjkFonts({ '${family}': ['${spec}'] }, text))`); } for (const slice of slices) { document.fonts.add(new FontFace(family, `url(${slice.url}) format('woff2')`, { weight: String(weight), style, unicodeRange: slice.range })); twin.push({ source: slice.url, weight: String(weight), style, unicodeRange: slice.range }); } const font = `${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`; loaded += (await document.fonts.load(font, text)).length; if (!document.fonts.check(font, text)) throw new Error(`${family} ${spec} did not load for the sample`); } // The same files under a twin name with the `vert` feature on: the // canvas paints the punctuation of vertical lines with it. if (vertical && twin.length) await loadVerticalAlternates(family, twin); } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with CJK faces: a family whose * Fontsource subsets are Chinese, Japanese or Korean gets the files the * screen set its pages' characters in (`request.codePoints`), each marked * with its unicode-range and its place in the order loadCjkFonts added * them: postext-pdf sets a character two files hold in the one added * last, as the browser does. Any other family gets the latin file * fontsourceProvider fetches (the "pdf" block) and, when the face sets * letters only latin-ext has, that file too. */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return cjkLatinPdfFiles(family, weight, style, request); const meta = await fontsourceMeta(family); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const slices = await cjkSlices(family, w, s); const picked = new Set(); for (const cp of request?.codePoints ?? []) { const slice = cjkSliceFor(slices, cp); if (slice) picked.add(slice); } if (!picked.size) picked.add(slices[0]); return Promise.all(slices.map(async (slice, order) => { if (!picked.has(slice)) return null; const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer())); return Object.assign(bytes, { unicodeRange: slice.range, order }); })).then((files) => files.filter(Boolean)); } /** A Latin family set next to the CJK faces: its latin file, then its * latin-ext file when the face sets letters only latin-ext has (ō ū in * Hepburn rōmaji, ǎ in pinyin), the file loadFonts adds on screen for * them, each marked with its unicode-range and its place in that order: * the PDF sets a character in the file the browser took it from. A face * Fontsource ships without latin-ext, or whose file does not come, gets * latin alone, and the PDF names the letters it lacks. */ async function cjkLatinPdfFiles(family, weight, style, request) { const meta = await fontsourceMeta(family); const beyond = [...(request?.codePoints ?? [])].some(cjkLatinExtOnly); if (!beyond || !meta?.subsets?.includes('latin-ext')) return fontsourceProvider(family, weight, style); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const id = fontsourceId(family); const url = `${await fontsourceAt(id)}files/${id}-latin-ext-${w}-${s}.woff2`; const [latin, ext] = await Promise.all([fontsourceProvider(family, weight, style), fetch(url) .then(async (res) => (res.ok ? decompressWoff2(new Uint8Array(await res.arrayBuffer())) : null), () => null)]); const ranges = kitRanges(); return ext ? [Object.assign(latin, { unicodeRange: ranges.latin, order: 0 }), Object.assign(ext, { unicodeRange: ranges['latin-ext'], order: 1 })] : latin; } /** Whether code point `cp` is in Fontsource's latin-ext file and not in * its latin file: Latin Extended-A and -B, IPA, the spacing modifiers and * Latin Extended Additional (loadFonts's test for latin-ext), less the * few latin holds too (ı Œ œ ʻ ʼ ˆ ˚ ˜). */ function cjkLatinExtOnly(cp) { if (!((cp >= 0x100 && cp <= 0x2ff) || (cp >= 0x1e00 && cp <= 0x1eff))) return false; return ![0x131, 0x152, 0x153, 0x2bb, 0x2bc, 0x2c6, 0x2da, 0x2dc].includes(cp); } /** showPages for a book bound on either edge. A right-bound book (the * document says so: doc.binding is 'right' for page.binding 'right' and * for vertical text) lies on the desk as it opens: page 1 alone on the * left of the spine, then [3 | 2], the spine shade on each page's inner * edge. `binding` ('left' | 'right') overrides the document's. */ function showBook(docs, { binding, ...options } = {}) { const count = showPages(docs, options); const right = (binding ?? [docs].flat()[0]?.binding) === 'right'; if (!document.getElementById('pt-kit-cjk')) { // The pages keep direction ltr: a canvas draws text in the direction its // element inherits, and under rtl each run would end where the engine // starts it, its brackets mirrored. document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk"> .pt-spread[dir="rtl"] canvas { direction: ltr; } .pt-spread[dir="rtl"] figure:first-child canvas { box-shadow: inset 14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } </style>`); } // Each pair stays [verso, recto] in the page; right to left, the verso // sits on the right. Phones stack the pages in reading order either way. for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr'; document.getElementById('pages').dataset.binding = right ? 'right' : 'left'; return count; } // ─── /Kit ───────────────────────────────────────────────────────────────────────

Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)

Varianten

#Die Titel in 《》 setzen

Bücher aus Festlandchina außerhalb der Klassiker setzen Titel in Klammern: Das Markdown bleibt, wie es ist, und die Klammern werden als Satzzeichen des Textes gedruckt.

-  bookTitleMark: 'wavy', // zh-Hant's default; mainland editions of the classics use it too
+  bookTitleMark: 'brackets', // 《史記》《項羽本紀》

#Die Marken in der Farbe des Textes drucken

Lassen Sie annotationColor weg, und jede Marke nimmt die Farbe des Blocks an, den sie markiert.

-  annotationColor: col('mark'), // unset, the marks print in the colour of the text

Häufige Fehler

Häufiger Fehler

Zeichnen Sie das Dokument mit zh-Hans oder zh-Hant aus, nicht mit LANG

Die Ausgaben eines Rezepts sind en und es, ein chinesisches Beispiel ist aber in beiden Chinesisch: `locale: LANG` würde es als Englisch oder Spanisch auszeichnen, seine lateinischen Wörter trennen, seine Abbildungen mit Figure oder Figura beschriften und dem PDF die falsche Sprache geben. Seit postext 1.30 ist die Auszeichnung auch die Sprache, in der der Text geformt wird (gemessen, gezeichnet und eingebettet). Unter einer englischen oder spanischen Auszeichnung setzt eine chinesische Schrift daher ihre Ziffern und die mit lateinischem Text geteilte Interpunktion in anderen Formen, und die Zeilen brechen an anderer Stelle um. Schreiben Sie die Auszeichnung selbst: 'zh-Hans' (Festlandkonventionen: GB-Zeilenumbruch, Kaiming-Interpunktion) oder 'zh-Hant' (Taiwan: zentrierte Interpunktion voller Breite); 'zh-HK' für Hongkong. Ein bloßes 'zh' gilt als vereinfacht, Festland. Ein japanisches Beispiel erhält 'ja' (Häufiger Fehler ja-locale-tag). Chinesischer Zeilenumbruch →

Häufiger Fehler

Chinesische Schnitte werden in Teilstücken geladen, über den Block cjk

Fontsource liefert eine chinesische, japanische oder koreanische Familie als etwa hundert Dateien pro Schriftstärke aus, jede für einen Zeichenbereich. loadFonts holt nur die Datei latin, am Bildschirm stammen die Han-Zeichen also aus einer Systemschrift und werden falsch gemessen, und fontsourceProvider gibt dem PDF diese latin-Datei, die sie als leere Kästchen druckt. Führen Sie den Kit-Block cjk auf, rufen Sie loadCjkFonts(FONTS, markdown) nach loadFonts auf (einmal pro Stimme, mit dem Text, den sie setzt, wenn das Buch mehrere CJK-Schriften verwendet), und geben Sie renderToPdf fontProvider: cjkPdfProvider: Beide nehmen die Dateien, die die Zeichen des Textes enthalten. Chinesische, japanische und koreanische Schriften →

Häufiger Fehler

Ein rechts gebundenes Buch zeigt seine Doppelseiten mit showBook

In einem rechts gebundenen Buch (arabischer, hebräischer oder persischer Text, vertikales Chinesisch oder page.binding 'right') ist Seite 1 weiterhin die rechte Seite (recto), liegt aber links vom Rücken, und die Paare lesen sich [3 | 2]. showPages legt jedes Buch links gebunden aus; showBook aus dem Block book (der Block cjk enthält dieselbe Funktion) liest doc.binding und spiegelt die Paare. capture.hero benennt eine Doppelseite weiterhin in Lesereihenfolge, [verso, recto]: [2, 3]. Rechts gebundene Bücher →

Häufiger Fehler

Chinesisch kennt keine Kursive

Die CJK-Familien bringen keine Kursive mit, und die chinesische Typografie hebt mit Punkten neben den Zeichen hervor, nicht mit einer Neigung. In einem als Chinesisch ausgezeichneten Dokument setzt *…* Betonungspunkte auf die enthaltenen chinesischen Zeichen und behält die Kursive für die lateinischen Wörter bei (cjk.emphasis: 'dots', der Standard für Chinesisch); :dots[…] setzt sie überall. Mit cjk.emphasis: 'italic' oder in einem als en oder es ausgezeichneten Dokument lässt *…* den Canvas die aufrechten Glyphen neigen, ein synthetisches Oblique, das die chinesische Typografie nie verwendet (das PDF fällt auf den aufrechten Schnitt zurück): Behalten Sie die chinesische Sprachauszeichnung bei oder setzen Sie die Hervorhebung fett oder in einer Kai-Schrift (LXGW WenKai) über ein Absatz- oder Chipformat. Chinesische, japanische und koreanische Schriften →

Layoutwarnung · cjkMarksExceedLeading

Zeichen bedrängen die nächste Zeile

Ursache. Ein Absatz mit Betonungspunkten oder Linien für Eigennamen und Buchtitel hat einen Zeilenzwischenraum unter einem halben Geviert (fünf Achteln bei Zeichen auf beiden Seiten): Die Zeichen liegen im Durchschuss, dessen Höhe sich für sie nie ändert, und berühren daher die nächste Zeile.

Lösung. Geben Sie dem Absatz ein Absatzformat mit größerem Zeilenabstand oder erhöhen Sie `bodyText.lineHeight`. Doku →

  • Eine von einem Design gezeichnete Überschrift druckt ihren Titel über das Design, und Design-Text lässt die Marken weg: # :book[項羽本紀] zeigte im Band keine Wellenlinie. Auch Kolumnentitel, Legenden und Tabellenzellen drucken ihren Text ohne Marken.
  • Postext hält bei cjkMarksExceedLeading nicht an: Der Build führt die Warnung in doc.contentWarnings, und das Bedienfeld Prüfungen der Sandbox zeigt sie, aber die Seiten werden trotzdem gedruckt, mit Marken, die sich an die nächste Zeile drängen. Wenn Sie den Zeilenabstand ändern, lesen Sie diese Liste; bei 15 pt enthält sie jeden markierten Absatz dieser Seiten.

Nachweise

Text
  • The opening of the Basic Annals of Xiang Yu (項羽本紀), Records of the Grand Historian (史記), chapter 7; characters from the zh.wikisource transcription · Sima Qian (司馬遷) · gemeinfrei
  • The punctuation, the paragraphs and the marks of the passage, the headnote (題解), the conventions (凡例), the slug lines and the colophon · Ignacio Ferro · CC BY 4.0
Schriften
Noto Serif TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1)
SandboxPDF