Saltar al contingut principal
Recepta número 80

Receptari · Capítol 2 · Text i tipografia

Un llibre de lectura vertical amb zhuyin a la dreta

Una lliçó d'un llibre escolar de Taiwan en vertical: {守株|ㄕㄡˇ|ㄓㄨ} posa el zhuyin en columna a la dreta de cada caràcter; a dalt, dibuixos i notes.

En aquesta pàgina
Sortida
Canvas · PDF
Nivell
Avançat
Postext
Provada amb Postext 1.9.2
Requereix ≥ 1.9.0 · postext-pdf ≥ 1.9.0
Llicència
Actualitzada el 30 de set. del 2026
Codi MIT · Text CC BY 4.0

pp. 86–87 · 1–2 de 4

  • Mostra en castellà: encara no hi ha edició en català
  • Format 184 × 260 mm
  • Columna i mitja, espai entre columnes de 11,3 mm
  • Iansui 16/32
  • Noto Sans TC
  • Noto Serif TC
  • 4 pàgines
  • Nivell
  • Postext 1.9.2
  • Compost en 18 ms
  • 183 línies de codi

En poques paraules

Un llibre de lectura escolar de Taiwan, escrit en columnes de dalt a baix. Cada caràcter xinès porta al costat una petita guia de pronúncia, perquè els nens llegeixin paraules que encara no coneixen.

Què compondràs

La lliçó 12 del llibre novè de 國語, un llibre taiwanès de lectura de cinquè de primària, inventat: dues faules del Han Feizi, la del pagès de Song que espera una altra llebre al costat d'una soca i la de l'home de Zheng que es fia més de la seva mesura que dels seus peus. A Taiwan aquests llibres es componen en vertical, en lletra Kai, amb el zhuyin de cada caràcter en una columna a la seva dreta, perquè el nen llegeixi un caràcter abans d'aprendre'l. La lliçó s'obre en un plec amb el llom a la dreta. A dalt, un pis guarda els dibuixos, les notes, l'autor i els caràcters nous; a baix va la lliçó en Iansui de 16 pt, 23 caràcters per línia. La seva parella en escriptura llatina és el llibre de text amb columna al marge, la columna lateral de flotants del qual és aquí el pis de dalt, girat un quart de volta.

Aquesta recepta respon a

  • Com poso el zhuyin al costat de cada caràcter d'un text xinès vertical?
  • Com componc un llibre xinès en vertical, enquadernat per la dreta?

La resposta curta

script.js · línies 36–57al codi complet
const layout = {
  writingMode: 'vertical-rl', // lines run down the page, read from the right; bound on the right
  layoutType: 'oneAndHalf', // two tiers: the text below, pictures and notes above
  sideColumnRole: 'floats', sideColumnSide: 'left', // 'left' is the top tier in vertical text
  sideColumnPercent: 34,
  gutterWidth: pt(2 * SIZE),
};
const cjk = {
  // The lower tier: 23 characters down, 13 lines across the page.
  grid: { enabled: true, charsPerLine: 23, linesPerPage: 13 },
  // Readings at half the text size; zhuyin sets its symbols at 60 % of that, 0.3 em,
  // so three symbols fit beside one character, and beside each of two in a row.
  ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) },
};
// The line pitch is twice the size: a gap of one em, which the zhuyin and its tone
// marks half fill. clreq asks for 1.5 em; one em keeps 13 lines of 23 on the page.
const LINE = 2 * SIZE;
const bodyText = {
  fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
};

Ingredients

Tipografia
Iansui, LXGW WenKai TC, Noto Serif TC, Noto Sans TC (SIL OFL 1.1)
Recursos
  • plate-1-1500.jpg
  • plate-2-1500.jpg
  • The farmer waiting by the stump, a brush painting (Generated With Diffusion Models, original)
  • The man of Zheng hurrying home from the shoe stall, a brush painting (Generated With Diffusion Models, original)

Elaboració

#1 · Una columna de zhuyin al costat de cada caràcter

El codi és la resposta curta de més amunt. {宋人|ㄙㄨㄥˋ|ㄖㄣˊ} dona a cada caràcter la seva lectura, una per cada |; la puntuació queda fora de les claus, així que 。 i , no en porten cap. En text vertical, una lectura en bopomofo va a la dreta de la columna, amb els signes l'un sota l'altre, la marca de to a la dreta de l'últim i el punt del to neutre damunt del primer. A la meitat del cos del text, cjk.ruby.fontSize deixa els signes en 0,3 em, així que tres signes caben al costat d'un caràcter, i dues lectures així seguides, com a 就像, guarden entre elles almenys un quart de signe sense eixamplar cap dels dos caràcters; amb 0,6 em, ㄇㄧㄥˊ és més alta que 明 i separa els dos 明 de 明明. L'interlineat de 32 pt deixa un buit d'un quadratí entre línies, que el zhuyin i els seus tons omplen a mitges. clreq demana 1,5 em quan el zhuyin va entre línies verticals; això són 40 pt d'interlineat i deu línies per pàgina, així que aquesta pàgina es queda en un quadratí per encabir-hi 13 línies de 23, i cada lectura queda a uns 8 pt de la línia veïna.

#2 · Dibuixos i notes al pis de dalt

script.js · línies 61–75al codi complet
const resourceTypes = [{ id: 'plate', name: '插圖', shortLabel: '圖', numberingTemplate: '{n}',
  resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // no prefix, no caption
const box = (id, title, body) => ({ id, title, background: col('tint'),
  padding: { top: mm(3), right: mm(3), bottom: mm(3), left: mm(3) },
  titleStyle: { fontFamily: HEI, fontSize: pt(11), fontWeight: 700, color: col('accent') },
  body: { color: col('ink'), firstLineIndent: pt(0), textAlign: 'left', boldColor: col('ink'),
    italicColor: col('ink'), ...body } });
// Zhuyin is 0.3 em of the text it reads: at 13 pt the notes' readings are 3.9 pt.
const calloutStyles = [ // fenced :::callout{type="notes" span="side"} in the text
  box('notes', '注釋', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24) }),
  box('author', '作者', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24),
    textAlign: 'justify', firstLineIndent: em(2) }),
  box('chars', '生字', { fontFamily: KAI, fontSize: pt(22), lineHeight: pt(40),
    textAlign: 'center' }),
];

En text vertical, les columnes d'una maquetació oneAndHalf són pisos, i sideColumnSide: 'left' posa a dalt el que només admet flotants. ::resource{id="plate-1"} al text hi envia el primer dibuix; el tipus plate no té prefix de peu i els dibuixos no porten peu, així que a sota no s'imprimeix res. Un requadre obert amb span="side" cau al pis a partir de la línia on s'obre, i el seu text corre cap avall dins del pis. Les notes de les dues faules van en un sol requadre, numerades correlativament; s'obre després del primer original, que acaba a la pàgina 86, i omple el pis de la pàgina 87, així que les notes de cada faula queden al plec del seu original. El zhuyin fa 0,3 em del text que llegeix i cjk.ruby és un ajust per a tot el llibre, així que les notes i el requadre de l'autor van en 13 pt perquè les seves lectures no baixin de 3,9 pt.

#3 · Quatre fonts, cadascuna carregada amb el seu text

script.js · línies 270–288al codi complet
// {株|ㄓㄨ}: the characters are the text, the readings go to the zhuyin face.
const BOXES = /^:::callout\{type="(?:notes|author)"[^}]*\}\n([\s\S]*?)^:::$/gm;
const bases = (md) => md.replace(/\{([^|{}]+)((?:\|[^|{}]+)+)\}/g, '$1');
const readings = [...markdown.matchAll(/\{[^|{}]+((?:\|[^|{}]+)+)\}/g)].map((m) => m[1]).join('');
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [KAI]: ['400'] }, bases(markdown.replace(BOXES, '')), { vertical: true });
const notesText = [...markdown.matchAll(BOXES)].map((m) => m[1]).join('\n');
await loadCjkFonts({ [MING]: ['400'] }, bases(notesText), { vertical: true });
await loadCjkFonts({ [ZHUYIN]: ['400'] }, readings.replaceAll('|', ''), { vertical: true });
await loadCjkFonts({ [HEI]: ['400', '700'] }, LABELS, { vertical: true });
await Promise.all(Object.values(plates).map((fileId) => loadImage(fileId, asset(fileId))));
// Lesson 12 of a reader: page 86 is a verso, so the lesson opens on a spread.
const continuation = { pageIndexOffset: 1, pageNumbering: { startAt: 86 } };
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showBook(doc, { title: t({ en: 'A vertical reader with zhuyin',
  es: 'Un libro de lectura vertical con zhuyin' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);

La lliçó va en Iansui, una lletra Kai dibuixada a partir de Klee One amb les formes estàndard de Taiwan, les que els nens aprenen a escriure. Les lectures van en LXGW WenKai TC, el punt de to neutre de la qual continua rodó a 4,8 pt; les notes, en Noto Serif TC, i les etiquetes, en Noto Sans TC. Cada font carrega els fitxers que tenen els caràcters que compon: les lectures se separen dels seus caràcters i els requadres de notes van només a la font Ming. vertical: true hi afegeix les formes verticals, perquè 「」 i 、 quedin bé a la columna. pageIndexOffset: 1 fa de la pàgina 86 una pàgina parella, així que la lliçó s'obre en un plec.

#4 · La pestanya i els folis a la vora exterior

script.js · línies 79–104al codi complet
// The verso lies on the right of the spread, so even pages carry both at the right edge.
const tab = (parity, edge) => [
  { kind: 'box', id: `tab-${parity}`, parity, pages: 'all',
    placement: { anchor: { to: 'page', edge }, offset: { y: mm(24) },
      size: { width: mm(9), height: mm(64) } }, style: { backgroundColor: col('accent') } },
  { kind: 'text', id: `unit-${parity}`, content: '第三單元 寓言故事', parity, pages: 'all',
    writingMode: 'vertical-rl', fontFamily: HEI, fontSize: pt(9), fontWeight: 700,
    color: col('paper'), align: 'center', verticalAlign: 'middle',
    placement: { anchor: { to: `#tab-${parity}`, edge: 'align-top' },
      size: { width: mm(9), height: mm(64) } } },
];
const foot = (id, parity, edge, x, content, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'all', fontFamily: HEI, fontSize: pt(8),
  color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-9) } },
  ...extra,
});
const folio = { fontSize: pt(9), fontWeight: 700 };
const header = { elements: [...tab('even', 'top-right'), ...tab('odd', 'top-left')] };
const footer = {
  elements: [
    foot('folio-even', 'even', 'bottom-right', -16, '{pageNumber}', folio),
    foot('book', 'even', 'bottom-right', -26, '國語 第九冊'),
    foot('folio-odd', 'odd', 'bottom-left', 16, '{pageNumber}', folio),
    foot('lesson', 'odd', 'bottom-left', 26, '{chapterTitle}'),
  ],
};

En un llibre amb el llom a la dreta, la pàgina parella queda a la dreta del plec, així que les parelles porten la pestanya de la unitat i el foli a la vora dreta i les senars a l'esquerra. La pestanya és una caixa amb un element de text a sobre; writingMode: 'vertical-rl' a l'element compon 第三單元 寓言故事 de dalt a baix, mentre que els folis i les línies del peu continuen en horitzontal.

La recepta completa

Sandbox
// ═══ Postext Cookbook · Nº 080 · A vertical reader with zhuyin to the right ═════════
// https://postext.dev/en/cookbook/zhuyin-vertical-reader
// Code: MIT · Text: Han Feizi, zh.wikisource (CC BY-SA 4.0) · Pictures: diffusion models
// Fonts: Iansui, LXGW WenKai TC, Noto Serif TC, Noto Sans TC (SIL OFL 1.1) · Needs postext ≥ 1.9.0
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: semantic colours, every one linked by id
const palette = {
  ink: '#2b2520', // text and zhuyin
  accent: '#b83f28', // lesson title, labels, the unit's tab (4.9:1 on the tint)
  tint: '#f7efdf', // the boxes of the upper tier
  muted: '#72675b', // lead, folios, colophon
  paper: '#ffffff', // the lettering on the tab
};
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: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
// #endregion
const KAI = 'Iansui'; // 楷: the lesson, drawn to Taiwan's standard forms (為, not 爲)
const ZHUYIN = 'LXGW WenKai TC'; // the readings: a round dot for the neutral tone
const MING = 'Noto Serif TC'; // 明: notes and the author box
const HEI = 'Noto Sans TC'; // 黑: labels, folios, the tab
const SIZE = 16; // the text size (三號)

// #region answer: vertical text with a zhuyin column right of every character
const layout = {
  writingMode: 'vertical-rl', // lines run down the page, read from the right; bound on the right
  layoutType: 'oneAndHalf', // two tiers: the text below, pictures and notes above
  sideColumnRole: 'floats', sideColumnSide: 'left', // 'left' is the top tier in vertical text
  sideColumnPercent: 34,
  gutterWidth: pt(2 * SIZE),
};
const cjk = {
  // The lower tier: 23 characters down, 13 lines across the page.
  grid: { enabled: true, charsPerLine: 23, linesPerPage: 13 },
  // Readings at half the text size; zhuyin sets its symbols at 60 % of that, 0.3 em,
  // so three symbols fit beside one character, and beside each of two in a row.
  ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) },
};
// The line pitch is twice the size: a gap of one em, which the zhuyin and its tone
// marks half fill. clreq asks for 1.5 em; one em keeps 13 lines of 23 on the page.
const LINE = 2 * SIZE;
const bodyText = {
  fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
};
// #endregion

// #region upper-tier: pictures without a caption line, boxes of notes set down the tier
const resourceTypes = [{ id: 'plate', name: '插圖', shortLabel: '圖', numberingTemplate: '{n}',
  resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // no prefix, no caption
const box = (id, title, body) => ({ id, title, background: col('tint'),
  padding: { top: mm(3), right: mm(3), bottom: mm(3), left: mm(3) },
  titleStyle: { fontFamily: HEI, fontSize: pt(11), fontWeight: 700, color: col('accent') },
  body: { color: col('ink'), firstLineIndent: pt(0), textAlign: 'left', boldColor: col('ink'),
    italicColor: col('ink'), ...body } });
// Zhuyin is 0.3 em of the text it reads: at 13 pt the notes' readings are 3.9 pt.
const calloutStyles = [ // fenced :::callout{type="notes" span="side"} in the text
  box('notes', '注釋', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24) }),
  box('author', '作者', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24),
    textAlign: 'justify', firstLineIndent: em(2) }),
  box('chars', '生字', { fontFamily: KAI, fontSize: pt(22), lineHeight: pt(40),
    textAlign: 'center' }),
];
// #endregion

// #region furniture: the unit's tab and the folios, on the outer edge of a right-bound book
// The verso lies on the right of the spread, so even pages carry both at the right edge.
const tab = (parity, edge) => [
  { kind: 'box', id: `tab-${parity}`, parity, pages: 'all',
    placement: { anchor: { to: 'page', edge }, offset: { y: mm(24) },
      size: { width: mm(9), height: mm(64) } }, style: { backgroundColor: col('accent') } },
  { kind: 'text', id: `unit-${parity}`, content: '第三單元 寓言故事', parity, pages: 'all',
    writingMode: 'vertical-rl', fontFamily: HEI, fontSize: pt(9), fontWeight: 700,
    color: col('paper'), align: 'center', verticalAlign: 'middle',
    placement: { anchor: { to: `#tab-${parity}`, edge: 'align-top' },
      size: { width: mm(9), height: mm(64) } } },
];
const foot = (id, parity, edge, x, content, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'all', fontFamily: HEI, fontSize: pt(8),
  color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-9) } },
  ...extra,
});
const folio = { fontSize: pt(9), fontWeight: 700 };
const header = { elements: [...tab('even', 'top-right'), ...tab('odd', 'top-left')] };
const footer = {
  elements: [
    foot('folio-even', 'even', 'bottom-right', -16, '{pageNumber}', folio),
    foot('book', 'even', 'bottom-right', -26, '國語 第九冊'),
    foot('folio-odd', 'odd', 'bottom-left', 16, '{pageNumber}', folio),
    foot('lesson', 'odd', 'bottom-left', 26, '{chapterTitle}'),
  ],
};
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'zh-Hant', // Taiwan: full-width punctuation, centred in its cell (gotcha: cjk-locale-tag)
  colorPalette,
  resourceTypes,
  page: {
    sizePreset: 'custom', width: mm(184), height: mm(260), dpi: 150, // 16開
    margins: { top: mm(24), bottom: mm(18), left: mm(18), right: mm(16), mirror: true },
  },
  layout,
  cjk,
  bodyText,
  headings: {
    fontFamily: KAI, color: col('ink'), fontWeight: 400, // Iansui has one weight
    levels: [
      // The lesson and fable numbers are typed in the headings: a numberingTemplate would
      // join the number to the title's first reading (see the recipe's workarounds).
      { level: 1, fontSize: pt(30), lineHeight: pt(2 * LINE), color: col('accent'),
        breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0) },
      { level: 2, fontSize: pt(20), lineHeight: pt(2 * LINE), marginTop: pt(LINE),
        marginBottom: pt(0) },
      { level: 3, fontFamily: HEI, fontWeight: 700, fontSize: pt(13), lineHeight: pt(LINE),
        color: col('accent'), marginTop: pt(LINE / 2), marginBottom: pt(0) },
    ],
  },
  // 想一想 and 語文天地: exercise heads in the label face, one line tall.
  headingStyles: [{ id: 'drill', fontFamily: HEI, fontWeight: 700, fontSize: pt(13),
    lineHeight: pt(LINE), color: col('accent'), marginTop: pt(LINE / 2), marginBottom: pt(0) }],
  orderedLists: { numberFormat: 'trad-chinese-informal', separator: '、', color: col('accent'),
    fontFamily: HEI, fontWeight: 700, marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [
    { id: 'lead', fontFamily: KAI, fontSize: pt(14), lineHeight: pt(LINE), color: col('muted'),
      textAlign: 'justify', firstLineIndent: em(0) },
    { id: 'plain', fontFamily: KAI, fontSize: pt(14), lineHeight: pt(LINE), color: col('ink'),
      textAlign: 'justify', firstLineIndent: em(2) },
    { id: 'idiom', fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'),
      boldColor: col('accent'), boldFontWeight: 400, textAlign: 'justify', // bold in colour only
      firstLineIndent: em(0) },
    { id: 'colophon', fontFamily: HEI, fontSize: pt(7), lineHeight: pt(11), color: col('muted'),
      textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LINE) },
  ],
  calloutStyles,
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`# 第十二課 {寓言兩則|ㄩˋ|ㄧㄢˊ|ㄌㄧㄤˇ|ㄗㄜˊ}
Mostra en Markdown · 91 línies · content.es.md :::paragraphs{style="lead"} {寓言用一個短短的故事|ㄩˋ|ㄧㄢˊ|ㄩㄥˋ|ㄧ|˙ㄍㄜ|ㄉㄨㄢˇ|ㄉㄨㄢˇ|˙ㄉㄜ|ㄍㄨˋ|˙ㄕ},{說出一個道理|ㄕㄨㄛ|ㄔㄨ|ㄧ|˙ㄍㄜ|ㄉㄠˋ|ㄌㄧˇ}。{這一課的兩則寓言|ㄓㄜˋ|ㄧ|ㄎㄜˋ|˙ㄉㄜ|ㄌㄧㄤˇ|ㄗㄜˊ|ㄩˋ|ㄧㄢˊ},{都選自戰國時代韓非所寫的|ㄉㄡ|ㄒㄩㄢˇ|ㄗˋ|ㄓㄢˋ|ㄍㄨㄛˊ|ㄕˊ|ㄉㄞˋ|ㄏㄢˊ|ㄈㄟ|ㄙㄨㄛˇ|ㄒㄧㄝˇ|˙ㄉㄜ}《{韓非子|ㄏㄢˊ|ㄈㄟ|ㄗˇ}》。{讀的時候想一想|ㄉㄨˊ|˙ㄉㄜ|ㄕˊ|ㄏㄡˋ|ㄒㄧㄤˇ|ㄧ|ㄒㄧㄤˇ}:{故事裡的人|ㄍㄨˋ|˙ㄕ|ㄌㄧˇ|˙ㄉㄜ|ㄖㄣˊ},{錯在哪裡|ㄘㄨㄛˋ|ㄗㄞˋ|ㄋㄚˇ|ㄌㄧˇ}? ::: ::resource{id="plate-1"} ## 一 {守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ} {宋人有耕田者|ㄙㄨㄥˋ|ㄖㄣˊ|ㄧㄡˇ|ㄍㄥ|ㄊㄧㄢˊ|ㄓㄜˇ},{田中有株|ㄊㄧㄢˊ|ㄓㄨㄥ|ㄧㄡˇ|ㄓㄨ},{兔走觸株|ㄊㄨˋ|ㄗㄡˇ|ㄔㄨˋ|ㄓㄨ},{折頸而死|ㄓㄜˊ|ㄐㄧㄥˇ|ㄦˊ|ㄙˇ},{因釋其耒而守株|ㄧㄣ|ㄕˋ|ㄑㄧˊ|ㄌㄟˇ|ㄦˊ|ㄕㄡˇ|ㄓㄨ},{冀復得兔|ㄐㄧˋ|ㄈㄨˋ|ㄉㄜˊ|ㄊㄨˋ},{兔不可復得|ㄊㄨˋ|ㄅㄨˋ|ㄎㄜˇ|ㄈㄨˋ|ㄉㄜˊ},{而身為宋國笑|ㄦˊ|ㄕㄣ|ㄨㄟˊ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄒㄧㄠˋ}。 :::callout{type="notes" span="side"} ①{株|ㄓㄨ}:露出地面的樹根。 ②{走|ㄗㄡˇ}:跑。 ③{觸|ㄔㄨˋ}:碰,撞。 ④{釋|ㄕˋ}:放下。 ⑤{耒|ㄌㄟˇ}:古代翻土用的農具。 ⑥{冀|ㄐㄧˋ}:希望。 ⑦{為宋國笑|ㄨㄟˊ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄒㄧㄠˋ}:被宋國人譏笑。 ⑧{且置履|ㄑㄧㄝˇ|ㄓˋ|ㄌㄩˇ}:將要買鞋。 ⑨{度|ㄉㄨㄛˋ}:量長短。 ⑩{坐|ㄗㄨㄛˋ}:同「座」,座位。 ⑪{操|ㄘㄠ}:拿,帶著。 ⑫{度|ㄉㄨˋ}:量好的尺碼。 ⑬{反|ㄈㄢˇ}:同「返」,回去。 ⑭{罷|ㄅㄚˋ}:結束,散了。 ⑮{寧|ㄋㄧㄥˊ}:寧可。 ::: ### {語譯|ㄩˇ|ㄧˋ} :::paragraphs{style="plain"} {宋國有一個種田的人|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄧㄡˇ|ㄧ|˙ㄍㄜ|ㄓㄨㄥˋ|ㄊㄧㄢˊ|˙ㄉㄜ|ㄖㄣˊ},{田裡有一截樹樁|ㄊㄧㄢˊ|ㄌㄧˇ|ㄧㄡˇ|ㄧ|ㄐㄧㄝˊ|ㄕㄨˋ|ㄓㄨㄤ}。{有一天|ㄧㄡˇ|ㄧ|ㄊㄧㄢ},{一隻兔子跑過來|ㄧ|ㄓ|ㄊㄨˋ|˙ㄗ|ㄆㄠˇ|ㄍㄨㄛˋ|˙ㄌㄞ},{一頭撞在樹樁上|ㄧ|ㄊㄡˊ|ㄓㄨㄤˋ|ㄗㄞˋ|ㄕㄨˋ|ㄓㄨㄤ|ㄕㄤˋ},{折斷脖子死了|ㄓㄜˊ|ㄉㄨㄢˋ|ㄅㄛˊ|˙ㄗ|ㄙˇ|˙ㄌㄜ}。{這個人就放下手裡的農具|ㄓㄜˋ|˙ㄍㄜ|ㄖㄣˊ|ㄐㄧㄡˋ|ㄈㄤˋ|ㄒㄧㄚˋ|ㄕㄡˇ|ㄌㄧˇ|˙ㄉㄜ|ㄋㄨㄥˊ|ㄐㄩˋ},{天天守在樹樁旁邊|ㄊㄧㄢ|ㄊㄧㄢ|ㄕㄡˇ|ㄗㄞˋ|ㄕㄨˋ|ㄓㄨㄤ|ㄆㄤˊ|ㄅㄧㄢ},{希望再撿到兔子|ㄒㄧ|ㄨㄤˋ|ㄗㄞˋ|ㄐㄧㄢˇ|ㄉㄠˋ|ㄊㄨˋ|˙ㄗ}。{兔子再也沒有來過|ㄊㄨˋ|˙ㄗ|ㄗㄞˋ|ㄧㄝˇ|ㄇㄟˊ|ㄧㄡˇ|ㄌㄞˊ|ㄍㄨㄛˋ},{他自己倒成了宋國人的笑話|ㄊㄚ|ㄗˋ|ㄐㄧˇ|ㄉㄠˋ|ㄔㄥˊ|˙ㄌㄜ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄖㄣˊ|˙ㄉㄜ|ㄒㄧㄠˋ|ㄏㄨㄚˋ}。 ::: ::resource{id="plate-2"} ## 二 {鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ} {鄭人有且置履者|ㄓㄥˋ|ㄖㄣˊ|ㄧㄡˇ|ㄑㄧㄝˇ|ㄓˋ|ㄌㄩˇ|ㄓㄜˇ},{先自度其足而置之其坐|ㄒㄧㄢ|ㄗˋ|ㄉㄨㄛˋ|ㄑㄧˊ|ㄗㄨˊ|ㄦˊ|ㄓˋ|ㄓ|ㄑㄧˊ|ㄗㄨㄛˋ},{至之市而忘操之|ㄓˋ|ㄓ|ㄕˋ|ㄦˊ|ㄨㄤˋ|ㄘㄠ|ㄓ}。{已得履|ㄧˇ|ㄉㄜˊ|ㄌㄩˇ},{乃曰|ㄋㄞˇ|ㄩㄝ}:「{吾忘持度|ㄨˊ|ㄨㄤˋ|ㄔˊ|ㄉㄨˋ}。」{反歸取之|ㄈㄢˇ|ㄍㄨㄟ|ㄑㄩˇ|ㄓ}。{及反|ㄐㄧˊ|ㄈㄢˇ},{市罷|ㄕˋ|ㄅㄚˋ},{遂不得履|ㄙㄨㄟˋ|ㄅㄨˋ|ㄉㄜˊ|ㄌㄩˇ}。{人曰|ㄖㄣˊ|ㄩㄝ}:「{何不試之以足|ㄏㄜˊ|ㄅㄨˋ|ㄕˋ|ㄓ|ㄧˇ|ㄗㄨˊ}?」{曰|ㄩㄝ}:「{寧信度|ㄋㄧㄥˊ|ㄒㄧㄣˋ|ㄉㄨˋ},{無自信也|ㄨˊ|ㄗˋ|ㄒㄧㄣˋ|ㄧㄝˇ}。」 ### {語譯|ㄩˇ|ㄧˋ} :::paragraphs{style="plain"} {鄭國有個人要買鞋子|ㄓㄥˋ|ㄍㄨㄛˊ|ㄧㄡˇ|˙ㄍㄜ|ㄖㄣˊ|ㄧㄠˋ|ㄇㄞˇ|ㄒㄧㄝˊ|˙ㄗ}。{他先在家裡量好自己腳的大小|ㄊㄚ|ㄒㄧㄢ|ㄗㄞˋ|ㄐㄧㄚ|˙ㄌㄧ|ㄌㄧㄤˊ|ㄏㄠˇ|ㄗˋ|ㄐㄧˇ|ㄐㄧㄠˇ|˙ㄉㄜ|ㄉㄚˋ|ㄒㄧㄠˇ},{把量好的尺碼放在座位上|ㄅㄚˇ|ㄌㄧㄤˊ|ㄏㄠˇ|˙ㄉㄜ|ㄔˇ|ㄇㄚˇ|ㄈㄤˋ|ㄗㄞˋ|ㄗㄨㄛˋ|ㄨㄟˋ|ㄕㄤˋ}。{到了市場|ㄉㄠˋ|˙ㄌㄜ|ㄕˋ|ㄔㄤˊ},{卻忘了把尺碼帶在身上|ㄑㄩㄝˋ|ㄨㄤˋ|˙ㄌㄜ|ㄅㄚˇ|ㄔˇ|ㄇㄚˇ|ㄉㄞˋ|ㄗㄞˋ|ㄕㄣ|ㄕㄤˋ}。{他已經挑好了鞋子|ㄊㄚ|ㄧˇ|ㄐㄧㄥ|ㄊㄧㄠ|ㄏㄠˇ|˙ㄌㄜ|ㄒㄧㄝˊ|˙ㄗ},{才說|ㄘㄞˊ|ㄕㄨㄛ}:「{我忘了帶尺碼|ㄨㄛˇ|ㄨㄤˋ|˙ㄌㄜ|ㄉㄞˋ|ㄔˇ|ㄇㄚˇ}。」{就轉身回家去拿|ㄐㄧㄡˋ|ㄓㄨㄢˇ|ㄕㄣ|ㄏㄨㄟˊ|ㄐㄧㄚ|ㄑㄩˋ|ㄋㄚˊ}。{等他再回到市場|ㄉㄥˇ|ㄊㄚ|ㄗㄞˋ|ㄏㄨㄟˊ|ㄉㄠˋ|ㄕˋ|ㄔㄤˊ},{市場已經散了|ㄕˋ|ㄔㄤˊ|ㄧˇ|ㄐㄧㄥ|ㄙㄢˋ|˙ㄌㄜ},{終於沒買到鞋子|ㄓㄨㄥ|ㄩˊ|ㄇㄟˊ|ㄇㄞˇ|ㄉㄠˋ|ㄒㄧㄝˊ|˙ㄗ}。{有人問他|ㄧㄡˇ|ㄖㄣˊ|ㄨㄣˋ|ㄊㄚ}:「{為什麼不用腳試穿呢|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄅㄨˋ|ㄩㄥˋ|ㄐㄧㄠˇ|ㄕˋ|ㄔㄨㄢ|˙ㄋㄜ}?」{他說|ㄊㄚ|ㄕㄨㄛ}:「{我寧可相信尺碼|ㄨㄛˇ|ㄋㄧㄥˊ|ㄎㄜˇ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄔˇ|ㄇㄚˇ},{也不相信自己的腳|ㄧㄝˇ|ㄅㄨˋ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄗˋ|ㄐㄧˇ|˙ㄉㄜ|ㄐㄧㄠˇ}。」 ::: ## {想一想|ㄒㄧㄤˇ|ㄧ|ㄒㄧㄤˇ} {style="drill"} 1. {宋國的農夫為什麼成了別人的笑話|ㄙㄨㄥˋ|ㄍㄨㄛˊ|˙ㄉㄜ|ㄋㄨㄥˊ|ㄈㄨ|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄔㄥˊ|˙ㄌㄜ|ㄅㄧㄝˊ|ㄖㄣˊ|˙ㄉㄜ|ㄒㄧㄠˋ|ㄏㄨㄚˋ}? 2. {鄭國人明明到了市場|ㄓㄥˋ|ㄍㄨㄛˊ|ㄖㄣˊ|ㄇㄧㄥˊ|ㄇㄧㄥˊ|ㄉㄠˋ|˙ㄌㄜ|ㄕˋ|ㄔㄤˊ},{為什麼沒買到鞋子|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄇㄟˊ|ㄇㄞˇ|ㄉㄠˋ|ㄒㄧㄝˊ|˙ㄗ}? 3. {這兩個人做錯的地方|ㄓㄜˋ|ㄌㄧㄤˇ|˙ㄍㄜ|ㄖㄣˊ|ㄗㄨㄛˋ|ㄘㄨㄛˋ|˙ㄉㄜ|ㄉㄧˋ|ㄈㄤ},{有什麼相同|ㄧㄡˇ|ㄕㄣˊ|˙ㄇㄜ|ㄒㄧㄤ|ㄊㄨㄥˊ}? :::callout{type="author" span="side"} {韓非|ㄏㄢˊ|ㄈㄟ},戰國時代韓國的貴族,大約生於西元前二八〇年,死於西元前二三三年。他和{李斯|ㄌㄧˇ|ㄙ}都是{荀子|ㄒㄩㄣˊ|ㄗˇ}的學生,說話口吃,卻很會寫文章。後人把他的文章編成《韓非子》,書中有許多有趣的寓言故事。 ::: ## {語文天地|ㄩˇ|ㄨㄣˊ|ㄊㄧㄢ|ㄉㄧˋ} {style="drill"} 「{守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}」{與|ㄩˇ}「{鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}」{後來都成了成語|ㄏㄡˋ|ㄌㄞˊ|ㄉㄡ|ㄔㄥˊ|˙ㄌㄜ|ㄔㄥˊ|ㄩˇ}。 :::paragraphs{style="idiom"} **{守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}**:{比喻不肯努力|ㄅㄧˇ|ㄩˋ|ㄅㄨˋ|ㄎㄣˇ|ㄋㄨˇ|ㄌㄧˋ},{只想白白得到好處|ㄓˇ|ㄒㄧㄤˇ|ㄅㄞˊ|ㄅㄞˊ|ㄉㄜˊ|ㄉㄠˋ|ㄏㄠˇ|ㄔㄨˋ}。{例|ㄌㄧˋ}:{考試前不讀書|ㄎㄠˇ|ㄕˋ|ㄑㄧㄢˊ|ㄅㄨˋ|ㄉㄨˊ|ㄕㄨ},{只希望考的題目剛好都會|ㄓˇ|ㄒㄧ|ㄨㄤˋ|ㄎㄠˇ|˙ㄉㄜ|ㄊㄧˊ|ㄇㄨˋ|ㄍㄤ|ㄏㄠˇ|ㄉㄡ|ㄏㄨㄟˋ},{這就是守株待兔|ㄓㄜˋ|ㄐㄧㄡˋ|ㄕˋ|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}。 **{鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}**:{比喻只相信死板的規定|ㄅㄧˇ|ㄩˋ|ㄓˇ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄙˇ|ㄅㄢˇ|˙ㄉㄜ|ㄍㄨㄟ|ㄉㄧㄥˋ},{不看實際的情形|ㄅㄨˋ|ㄎㄢˋ|ㄕˊ|ㄐㄧˋ|˙ㄉㄜ|ㄑㄧㄥˊ|ㄒㄧㄥˊ}。{例|ㄌㄧˋ}:{買衣服不試穿|ㄇㄞˇ|ㄧ|˙ㄈㄨ|ㄅㄨˋ|ㄕˋ|ㄔㄨㄢ},{只看尺寸|ㄓˇ|ㄎㄢˋ|ㄔˊ|˙ㄘㄨㄣ},{就像鄭人買履|ㄐㄧㄡˋ|ㄒㄧㄤˋ|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}。 ::: :::callout{type="chars" span="side"} {株|ㄓㄨ} {耒|ㄌㄟˇ} {冀|ㄐㄧˋ} {履|ㄌㄩˇ} {罷|ㄅㄚˋ} {遂|ㄙㄨㄟˋ} ::: :::paragraphs{style="colophon"} Compuesto en Iansui, LXGW WenKai TC, Noto Serif TC y Noto Sans TC (SIL OFL). Texto: Han Feizi, capítulos :sideways[49] y :sideways[32], Wikisource en chino, revisiones 2642850 y 2327662 (CC BY-SA 4.0). Zhuyin cotejado con el Diccionario Revisado de Chino Mandarín (Ministerio de Educación, Taiwán); versiones modernas, notas y ejercicios escritos para esta receta (CC BY 4.0). :::
`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Iansui: ['400'], 'LXGW WenKai TC': ['400'], 'Noto Serif TC': ['400'], 'Noto Sans TC': ['400', '700'], }; // What the label face sets: the tab, the foot of the page, box titles and exercise heads. const LABELS = '第三單元寓言故事國語第九冊十二課兩則注釋作者生字語譯想一文天地、0123456789'; // #region art: two brush paintings for the upper tier, JPEGs in assets/ cut to 15 : 7 const plates = { 'plate-1': 'plate-1-1500.jpg', 'plate-2': 'plate-2-1500.jpg' }; // #endregion const altText = { 'plate-1': '守株待兔:農夫坐在樹樁旁,農具丟在田裡,一隻兔子跑遠了。', 'plate-2': '鄭人買履:鄭國人從鞋攤走回家,量好的尺碼還放在家門口的凳子上。', }; const resources = Object.entries(plates).map(([id, fileId]) => ({ id, typeId: 'plate', kind: 'bitmap', createdAt: 0, updatedAt: 0, altText: altText[id], placement: { span: 'side' }, bitmap: { fileId, format: 'jpeg', width: 1500, height: 700 } })); // declared at their pixels // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: each voice loads with the text it sets; vertical forms for the canvas // {株|ㄓㄨ}: the characters are the text, the readings go to the zhuyin face. const BOXES = /^:::callout\{type="(?:notes|author)"[^}]*\}\n([\s\S]*?)^:::$/gm; const bases = (md) => md.replace(/\{([^|{}]+)((?:\|[^|{}]+)+)\}/g, '$1'); const readings = [...markdown.matchAll(/\{[^|{}]+((?:\|[^|{}]+)+)\}/g)].map((m) => m[1]).join(''); await loadFonts(FONTS, markdown); await loadCjkFonts({ [KAI]: ['400'] }, bases(markdown.replace(BOXES, '')), { vertical: true }); const notesText = [...markdown.matchAll(BOXES)].map((m) => m[1]).join('\n'); await loadCjkFonts({ [MING]: ['400'] }, bases(notesText), { vertical: true }); await loadCjkFonts({ [ZHUYIN]: ['400'] }, readings.replaceAll('|', ''), { vertical: true }); await loadCjkFonts({ [HEI]: ['400', '700'] }, LABELS, { vertical: true }); await Promise.all(Object.values(plates).map((fileId) => loadImage(fileId, asset(fileId)))); // Lesson 12 of a reader: page 86 is a verso, so the lesson opens on a spread. const continuation = { pageIndexOffset: 1, pageNumbering: { startAt: 86 } }; const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showBook(doc, { title: t({ en: 'A vertical reader with zhuyin', es: 'Un libro de lectura vertical con zhuyin' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // #endregion
Kit · core, fonts, viewer, pdf, images, cjk: igual a totes les receptes · 457 línies// ─── 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 · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── 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 ───────────────────────────────────────────────────────────────────────

L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)

Variants

#Imprimeix el zhuyin en un altre color

Alguns llibres imprimeixen les lectures més clares que el text, perquè els caràcters destaquin.

-  ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) },
+  ruby: { fontFamily: ZHUYIN, fontSize: em(0.5), color: col('muted') },

#Posa les lectures sobre text horitzontal

La cartilla amb pinyin posa una síl·laba sobre cada caràcter en línies horitzontals, com les primeres cartilles de la Xina continental i Hong Kong.

Errors freqüents

Error freqüent

Les lectures s'imprimeixen al text, no en dissenys, peus ni cel·les

Les lectures ruby es dibuixen en paràgrafs, títols, elements de llista, cites i requadres. Un element de text d'un disseny (una obertura, una capçalera, una etiqueta), un peu, una nota a peu de pàgina i una cel·la de taula imprimeixen els caràcters base sense les seves lectures. Un títol que necessita el seu pinyin és un títol sense disseny propi: dibuixa el que l'envolta (una banda, el número de la lliçó) amb el disseny del títol anterior, els elements del qual amb reserve: false es pinten sota el text d'una obertura span: 'page'. Ruby: lectures en pinyin i zhuyin →

Error freqüent

En text vertical la columna lateral és un pis, i 'left' és el de dalt

Amb layout.writingMode 'vertical-rl' la pàgina és una pàgina horitzontal girada un quart de volta, de manera que la columna lateral d'una disposició oneAndHalf passa a ser un pis que travessa la pàgina: sideColumnSide 'left' la posa a dalt, on van els comentaris del marge superior (眉批), i 'right' al peu. 'outer' i 'inner' es llegeixen com a 'right' i 'left' (un pis no té vora exterior), de manera que el valor 'outer' de la columna de notes d'un llibre de text envia les notes al peu. sideColumnPercent és una fracció de l'alçada de la pàgina. Columna al marge per a flotants →

Error freqüent

Les fonts xineses es carreguen per fragments, amb el bloc cjk

Fontsource serveix una família xinesa, japonesa o coreana en un centenar de fitxers per pes, cadascun amb un interval de caràcters. loadFonts només baixa el fitxer latin, així que a la pantalla els caràcters xinesos surten d'una font del sistema i es mesuren malament, i fontsourceProvider lliura al PDF aquest fitxer latin, que els imprimeix com a caixes buides. Afegeix el bloc cjk del kit, crida loadCjkFonts(FONTS, markdown) després de loadFonts (una vegada per veu, amb el text que compon, si el llibre fa servir diverses fonts xineses) i passa a renderToPdf fontProvider: cjkPdfProvider: tots dos agafen els fitxers que contenen els caràcters del text. Fonts xineses, japoneses i coreanes →

Error freqüent

Un llibre enquadernat per la dreta mostra els seus plecs amb showBook

En un llibre enquadernat per la dreta (text àrab, hebreu o persa, xinès vertical, o page.binding 'right') la pàgina 1 continua sent senar, però queda a l'esquerra del llom, i els plecs es llegeixen [3 | 2]. showPages compon tots els llibres com si s'enquadernessin per l'esquerra; showBook, del bloc book (el bloc cjk porta la mateixa funció), llegeix doc.binding i reflecteix els plecs. capture.hero continua anomenant un plec en ordre de lectura, [parella, senar]: [2, 3]. Llibres enquadernats per la dreta →

Error freqüent

Etiqueta el document zh-Hans o zh-Hant, no amb LANG

Les edicions d'una recepta són en i es, però una mostra xinesa és xinesa en totes dues: `locale: LANG` l'etiquetaria com a anglès o castellà, separaria les seves paraules llatines, anomenaria Figure o Figura les seves figures i donaria al PDF una llengua equivocada. Escriu tu l'etiqueta: 'zh-Hans' (convencions de la Xina continental: tall GB, puntuació Kaiming) o 'zh-Hant' (Taiwan: puntuació d'amplada completa centrada); 'zh-HK' per a Hong Kong. Un 'zh' tot sol es llegeix com a xinès simplificat continental. Tall de línies en xinès →

  • Pren les lectures del diccionari de Taiwan. Les eines fetes sobre diccionaris del continent, pypinyin entre elles, donen 度 duó, 寧 nìng, 時候 shíhou i 市場 shìchǎng; el Diccionari Revisat de Xinès Mandarí del Ministeri d'Educació, que segueixen els llibres de Taiwan, llegeix ㄉㄨㄛˋ, ㄋㄧㄥˊ, ㄕˊ ㄏㄡˋ i ㄕˋ ㄔㄤˊ, i posa en to neutre la segona síl·laba de 故事, 家裡 i 過來. moedict.tw el serveix paraula per paraula. Manté 一 i 不 en el seu to propi, i aquesta lliçó també.

  • Dibuixa uns quants caràcters en cada font abans de barrejar-les. LXGW WenKai TC segueix les formes heretades i dibuixa 為 (U+70BA) com 爲, així que una lliçó composta amb aquesta font imprimia 爲宋國笑 al text i 為宋國笑 a la nota en Ming del mateix plec. Iansui dibuixa el 為 estàndard.

  • Els números de la lliçó i de les faules s'escriuen als títols (# 第十二課 {寓言兩則|…}). Un numberingTemplate uneix el número al primer caràcter del títol, i quan aquest caràcter porta lectura, la lectura se centra sobre el número i el caràcter junts.

  • Calcula què ha de cabre al pis de dalt de cada pàgina i obre cada requadre on la seva pàgina tingui lloc. El requadre de l'autor s'obre abans de 語文天地 perquè comenci el pis de la pàgina 89, i el dels caràcters nous, obert després de les frases fetes, va darrere. Un requadre o un dibuix que no troba lloc abans que acabi el text surt en una pàgina sense res a sota, i el següent es queda fora sense avís, així que comprova que tots apareixen a les pàgines.

  • Iansui té un sol gruix. Els títols destaquen per la mida i els lemes de les frases fetes pel color: l'estil idiom fixa boldFontWeight: 400 amb l'accent com a boldColor, així que **…** canvia el color i no la lletra.

Crèdits

Text
Imatges
  • The farmer waiting by the stump, a brush painting · Generated With Diffusion Models · original
  • The man of Zheng hurrying home from the shoe stall, a brush painting · Generated With Diffusion Models · original
Fonts
Iansui (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1) · Noto Serif TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1)
SandboxPDF