简单来说
一本儿童写字练习册,按语言排了十次。每课先把一个词写出来,再用点画出来让孩子描,最后留出空行。字体和格纸都照那个国家的学校。
成品一览
一本一年级写字练习册的十二页,A4 开本,共十个版本,也就是十本不同的书。每种语言用本国学校教的连笔字,取自 Google 的 Playwrite 字族(GB J、ES、FR Trad、DE VA、IT Trad、BR)或 Playpen Sans Arabic,写在那里的孩子平时用的纸上:西班牙的 pauta,带红色页边线的法国 Seyès 方格,中间有一条黄带的德国 Lineatur,巴西的 caligrafia 横线。中文版写在田字格里,孩子描浅红色的字(描红),拼音写在四线三格里;日文版写在带点线十字的マス格里,描灰色的字(なぞり書き)。课文跟着各自的拼写和书写规则走:西班牙语的 ñ 和不发音的 h,加泰罗尼亚语的 l·l,德语的 ß 和名词大写,中文的笔画和笔顺,日语的助词は、を、へ,阿拉伯语一个字母的四种形式和不与后一个字母相连的字母。每一行都从范字到点字,再到空白。二十九幅画用扩散模型生成,所有版本共用。
这道食谱解答
- 怎样做一本写字练习册:先有范字,再描点或描红,最后留空格,用各国学校的格线?
- 怎样做练习页:填空横线、答题框、词库、清单?
简短回答
// ### Repasa y escribe {w="nina" model="niña"} draws four rows on the edition's ruling: the
// model and dotted copies, a row of dots, the model and one copy, and an empty row. The
// ruling and the dots are one picture per block ('trazo-' + w, drawn under #region art); the
// models are live text set on its baselines. A design text's baseline falls 0.8 of its line
// below the box top, so a box of line L set at baseline − 0.8 L puts the letters on the line.
const G = CJK ? null : geometry(ED.ruling, ED.u); // rows, baselines and the face's size
const LABEL_H = 7; // mm: the block's label above its ruling
const model = (id, row, extra = {}) => text(id, '{attr.model}', HAND, G.size / 0.3528,
at('container', 'top-left', 0, LABEL_H + G.base[row] - 0.8 * G.line,
{ width: mm(W) }), { lineHeight: mm(G.line), overflow: 'clip', ...extra });
const wordBlock = (rows, again = true) => ({ enabled: true,
minHeight: mm(LABEL_H + G.height(rows)), slot: { elements: [label('label'),
image('trazo', 'trazo-{attr.w}', at('container', 'top-left', 0, LABEL_H,
box(W, G.height(rows)))),
model('model-1', 0),
...(again ? [model('model-3', 2)] : []), // a sentence is written out once
] } });
用料
- 功能
- 设计过的章首页标题属性页面设计中的图片标题样式封面、扉页和版权页页面设计中的文字、线条和框行内标签标注框语义调色板断词与文档语言按页面角色显示书眉对称页边距阿拉伯文字体中日韩字体右翻书从右向左的文字假名、汉字与罗马字在Canvas上绘制页面导出PDF
- 类型
- Andika, Lexend, Playwrite GB J, Playwrite ES, Playwrite FR Trad, Playwrite DE VA, Playwrite IT Trad, Playwrite BR, LXGW WenKai TC, Noto Serif SC, Noto Sans SC, Klee One, Zen Maru Gothic, Playpen Sans Arabic, Noto Naskh Arabic, Baloo Bhaijaan 2(SIL OFL 1.1)
- 素材
apple-480.jpgbee-480.jpgbird-480.jpgboat-480.jpgbutterfly-480.jpgcake-480.jpgcat-480.jpgcover-1600.jpgdog-480.jpgduck-480.jpgfire-480.jpgfish-480.jpgflower-480.jpggrip-480.jpghouse-480.jpgicecream-480.jpgkey-480.jpgkite-480.jpgmedal-480.jpgmoon-480.jpgmountain-480.jpgowl-480.jpgpenguin-480.jpgshoe-480.jpgspider-480.jpgsun-480.jpgtraces-ar.jsontraces-ca.jsontraces-de.jsontraces-en.jsontraces-es.jsontraces-fr.jsontraces-it.jsontraces-pt.jsontree-480.jpgumbrella-480.jpgwater-480.jpg- Two children writing in notebooks at a school table, a cat asleep on a chair beside them. (Generated With Diffusion Models, 原创)
- A smiling sun. (Generated With Diffusion Models, 原创)
- A crescent moon with three stars. (Generated With Diffusion Models, 原创)
- A ginger cat sitting. (Generated With Diffusion Models, 原创)
- A brown and white puppy. (Generated With Diffusion Models, 原创)
- An orange fish with blue stripes. (Generated With Diffusion Models, 原创)
- A honey bee flying. (Generated With Diffusion Models, 原创)
- A little black spider on a thread. (Generated With Diffusion Models, 原创)
- A red apple. (Generated With Diffusion Models, 原创)
- A golden key. (Generated With Diffusion Models, 原创)
- A red child's shoe. (Generated With Diffusion Models, 原创)
- A small house with a red roof. (Generated With Diffusion Models, 原创)
- A small sailing boat. (Generated With Diffusion Models, 原创)
- A yellow duckling. (Generated With Diffusion Models, 原创)
- A colourful kite. (Generated With Diffusion Models, 原创)
- A birthday cake with three candles. (Generated With Diffusion Models, 原创)
- A penguin. (Generated With Diffusion Models, 原创)
- An owl on a branch. (Generated With Diffusion Models, 原创)
- A red umbrella in the rain. (Generated With Diffusion Models, 原创)
- An ice-cream cone with two scoops. (Generated With Diffusion Models, 原创)
- A blue and orange butterfly. (Generated With Diffusion Models, 原创)
- A leafy tree. (Generated With Diffusion Models, 原创)
- A snowy mountain. (Generated With Diffusion Models, 原创)
- A daisy. (Generated With Diffusion Models, 原创)
- A small blue bird on a twig. (Generated With Diffusion Models, 原创)
- Splashing water. (Generated With Diffusion Models, 原创)
- A small campfire. (Generated With Diffusion Models, 原创)
- A child's hand holding a pencil with the tripod grip. (Generated With Diffusion Models, 原创)
- A golden star medal with coloured pencils. (Generated With Diffusion Models, 原创)
- The dotted letters of the en edition, traced along the outlines of Playwrite GB J Thin. (Ignacio Ferro, 原创)
- The dotted letters of the es edition, traced along the outlines of Playwrite ES Thin. (Ignacio Ferro, 原创)
- The dotted letters of the ca edition, traced along the outlines of Playwrite ES Thin. (Ignacio Ferro, 原创)
- The dotted letters of the fr edition, traced along the outlines of Playwrite FR Trad Thin. (Ignacio Ferro, 原创)
- The dotted letters of the de edition, traced along the outlines of Playwrite DE VA Thin. (Ignacio Ferro, 原创)
- The dotted letters of the it edition, traced along the outlines of Playwrite IT Trad Thin. (Ignacio Ferro, 原创)
- The dotted letters of the pt edition, traced along the outlines of Playwrite BR Thin. (Ignacio Ferro, 原创)
- The dotted letters of the ar edition, traced along the outlines of Playpen Sans Arabic Thin. (Ignacio Ferro, 原创)
做法
#1 · 给每个版本配上学校字体和格纸
// hand: the school model the children copy; ruling: the paper they copy it on; u: its
// x-height in mm (the height of an a), the unit every ruling is drawn in; rise: the room its
// capitals need above a title. The Chinese text is in Song: the Kai face, cut for Hong Kong
// and Taiwan, centres its commas, so it sets only the titles and the squares.
const EDITIONS = {
en: { hand: 'Playwrite GB J', ruling: 'gb', u: 3.6 },
es: { hand: 'Playwrite ES', ruling: 'pauta', u: 3.6 },
ca: { hand: 'Playwrite ES', ruling: 'pauta', u: 3.6 },
fr: { hand: 'Playwrite FR Trad', ruling: 'seyes', u: 3, rise: 5 },
de: { hand: 'Playwrite DE VA', ruling: 'lineatur', u: 3.6 },
it: { hand: 'Playwrite IT Trad', ruling: 'righe', u: 3.6 },
pt: { hand: 'Playwrite BR', ruling: 'caligrafia', u: 3.2 },
zh: { hand: 'LXGW WenKai TC', ruling: 'tian', text: 'Noto Serif SC',
label: 'Noto Sans SC', pinyin: 'Andika', cell: 16 },
ja: { hand: 'Klee One', ruling: 'masu', text: 'Klee One', label: 'Zen Maru Gothic', cell: 16 },
ar: { hand: 'Playpen Sans Arabic', ruling: 'satr', u: 3.6,
text: 'Noto Naskh Arabic', label: 'Baloo Bhaijaan 2' },
};
const ED = { text: 'Andika', label: 'Lexend', rise: 0, ...EDITIONS[LANG] };
const [HAND, TEXT, LABEL] = [ED.hand, ED.text, ED.label];
const CJK = ED.cell !== undefined; // a grid of squares, one character to each
const RTL = LANG === 'ar';
const LW = CJK ? 700 : 600; // the labels' weight: the CJK label faces ship no 600
// Labels in tracked capitals, where the script has capitals.
const caps = (n) => (!CJK && !RTL ? { textTransform: 'uppercase', letterSpacing: pt(n) } : {});
u 是以毫米计的 x 字高,每种书写格都以它为单位来画:大多数版本是 3.6 mm,法文版是 3 mm,因为 Seyès 的细线之间正好相隔一个 x 字高。Playwrite 各款的 x 字高都是字身的一半,所以范字的字号是 u / 0.5,书写格的线就落在字体自身比例给上伸部和下伸部定下的位置。中文正文用宋体(Noto Serif SC):LXGW WenKai TC 按港台的印法把逗号放在字身正中,所以楷体只排标题和田字格。
#2 · 范字用活的文字排在书写格上
// ### Repasa y escribe {w="nina" model="niña"} draws four rows on the edition's ruling: the
// model and dotted copies, a row of dots, the model and one copy, and an empty row. The
// ruling and the dots are one picture per block ('trazo-' + w, drawn under #region art); the
// models are live text set on its baselines. A design text's baseline falls 0.8 of its line
// below the box top, so a box of line L set at baseline − 0.8 L puts the letters on the line.
const G = CJK ? null : geometry(ED.ruling, ED.u); // rows, baselines and the face's size
const LABEL_H = 7; // mm: the block's label above its ruling
const model = (id, row, extra = {}) => text(id, '{attr.model}', HAND, G.size / 0.3528,
at('container', 'top-left', 0, LABEL_H + G.base[row] - 0.8 * G.line,
{ width: mm(W) }), { lineHeight: mm(G.line), overflow: 'clip', ...extra });
const wordBlock = (rows, again = true) => ({ enabled: true,
minHeight: mm(LABEL_H + G.height(rows)), slot: { elements: [label('label'),
image('trazo', 'trazo-{attr.w}', at('container', 'top-left', 0, LABEL_H,
box(W, G.height(rows)))),
model('model-1', 0),
...(again ? [model('model-3', 2)] : []), // a sentence is written out once
] } });
一个写字块是一个三级标题,它的设计画一张图,即书写格和点字副本,再用学校字体把范字作为文字排在图上。设计文字的基线落在文字框顶部以下 0.8 个行高处,所以范字的框放在这一行的基线减去 0.8 个行高的位置,字母就站在书写格画出的线上。行高是十个 x 字高,这样框的裁切边落在基线下方 2 u,g 的尾巴也放得下。标题的文字(英文版是 Trace and write)成为写字块的标签;w 指定图片,model 指定要写的词。
#3 · 用字体自己的轮廓画出点字
Postext 可以把字母排成彩色、空心或浅色,但不能把它画成沿笔画排列的一串点。这些点在排书之前就画好,取自每款 Playwrite 的 Thin 字重的轮廓:沿每条轮廓每隔 0.2 个 x 字高取一个点,只有在这个间距的 0.8 倍以内没有别的点时才保留,所以细笔画的两条边只留下一行点。i 上的点和重音符号仍是小小的实心形状。每个版本的点,连同副本和范字的步进宽度,都存在 traces-<lang>.json 里,再由 pen 把副本沿各行排开:第一行范字后排满副本,第二行整行都是点字,第三行范字后只跟一个副本,最后一行留空。
#4 · 在格子里描字
// ### 写一写 {c1="日" c2="月"}: each character in the first square of its row, three copies to
// trace in the pale red (描红) or grey (なぞり書き) of the next squares, then empty ones. A
// Han or kana character is one em wide, so tracking of (square − em) sets one per square.
const [CELL, EM] = [ED.cell ?? 16, (ED.cell ?? 16) * 0.72]; // mm: the square, its character
const SQUARES = Math.floor(W / CELL);
const row = (n, y, content, x, color) => text(`r${n}-${x}`, content, HAND, EM / 0.3528,
at('container', 'top-left', x * CELL + (CELL - EM) / 2, y + (CELL - EM) / 2 + EM * 0.06,
box(W - x * CELL, EM)),
{ lineHeight: mm(EM), letterSpacing: mm(CELL - EM), overflow: 'clip', color: col(color) });
// A sentence (####) takes three rows: written out, traced, and a row to write it alone.
const squareBlock = (rows, sentence) => ({ enabled: true, minHeight: mm(LABEL_H + rows * CELL),
slot: { elements: [label('label'), image('grid', `squares-${rows}`,
at('container', 'top-left', 0, LABEL_H, box(SQUARES * CELL, rows * CELL))),
...(sentence ? [row(0, LABEL_H, '{attr.model}', 0, 'ink'),
row(1, LABEL_H + CELL, '{attr.model}', 0, 'trace')] : Array.from({ length: rows }, (_, n) => [
row(n, LABEL_H + n * CELL, `{attr.c${n + 1}}`, 0, 'ink'),
row(n, LABEL_H + n * CELL, `{attr.c${n + 1}}{attr.c${n + 1}}{attr.c${n + 1}}`, 1, 'trace'),
]).flat()),
] } });
// Pinyin on four lines and three spaces (四线三格), ##### {model="bā"}: the syllable, copies
// to trace in the pale red, the syllable again and room after it.
const P = ED.pinyin ? geometry('sixian', 3.6) : null;
const pinyin = (id, n, x, content, color) => text(id, content, ED.pinyin, P.size / 0.3528,
at('container', 'top-left', x, LABEL_H + P.base[n] - 0.8 * P.line, { width: mm(W - x) }),
{ lineHeight: mm(P.line), overflow: 'clip', color: col(color) });
const copies = Array(5).fill('{attr.model}').join('\u00a0\u00a0\u00a0\u00a0');
const pinyinBlock = P && { enabled: true, minHeight: mm(LABEL_H + P.height(3)),
slot: { elements: [label('label'),
image('lines', 'sixian', at('container', 'top-left', 0, LABEL_H,
box(W, P.height(3)))),
pinyin('p1', 0, 0, '{attr.model}', 'ink'), pinyin('p1-copies', 0, 36, copies, 'trace'),
pinyin('p2', 1, 0, copies, 'trace'), pinyin('p3', 2, 0, '{attr.model}', 'ink'),
] } };
一个汉字或假名宽一个字身,所以把字距设为格宽减去字身,每格正好放一个字,和字帖一样。每行第一格是深色的字,后三格是同一个字的浅红描红字(日文版为灰色),其余格子空着。一个句子(####)占三行:先写出来,再描,最后空着,标点也独占一格。拼音行用拉丁文各版的四线格,字体是 Andika,它的单层 a 和 g 正是孩子学的写法。
#5 · 阿拉伯文版什么也不用镜像
阿拉伯文版和其他版本共用全部设计。排在从右到左页面文本流里的标题设计由引擎镜像,所以字母圆块、范字和字典的第一幅图都会移到右边;只有点字副本画在图片里面,要在代码里从右边缘排起,热身的波浪线也从右向左走。locale: 'ar' 让书在右边装订。
完整食谱
// ═══ Postext Cookbook · Nº 161 · Handwriting workbook in ten scripts ═══════════════ // https://postext.dev/en/cookbook/handwriting-workbook // Code: MIT · Text: original (CC BY 4.0) · Pictures: generated with diffusion models // Fonts: Playwrite, Playpen Sans Arabic, Klee One and others (SIL OFL) · Needs postext ≥ 1.42.1 import { buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'zh'; // @lang: the language of the workbook (en es ca fr de it pt zh ja ar) const RECIPE = 'handwriting-workbook'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: a navy ink, one coral accent, the sun and the pale fields of the bands const palette = { ink: '#22304a', // text and the models accent: '#bf4630', // kickers, labels, folios sun: '#f4b73f', // the boxes' stripe tint: '#fdf0da', // opener bands, boxes rule: '#7f9ccc', // the ruling dots: '#4d5b76', // the dotted letters trace: LANG === 'ja' ? '#b4bccb' : '#e7a9a0', // traced over: 描红 in red, なぞり書き in grey 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: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } }, ]; // #endregion // #region editions: each language its school's script, its ruling and its faces // hand: the school model the children copy; ruling: the paper they copy it on; u: its // x-height in mm (the height of an a), the unit every ruling is drawn in; rise: the room its // capitals need above a title. The Chinese text is in Song: the Kai face, cut for Hong Kong // and Taiwan, centres its commas, so it sets only the titles and the squares. const EDITIONS = { en: { hand: 'Playwrite GB J', ruling: 'gb', u: 3.6 }, es: { hand: 'Playwrite ES', ruling: 'pauta', u: 3.6 }, ca: { hand: 'Playwrite ES', ruling: 'pauta', u: 3.6 }, fr: { hand: 'Playwrite FR Trad', ruling: 'seyes', u: 3, rise: 5 }, de: { hand: 'Playwrite DE VA', ruling: 'lineatur', u: 3.6 }, it: { hand: 'Playwrite IT Trad', ruling: 'righe', u: 3.6 }, pt: { hand: 'Playwrite BR', ruling: 'caligrafia', u: 3.2 }, zh: { hand: 'LXGW WenKai TC', ruling: 'tian', text: 'Noto Serif SC', label: 'Noto Sans SC', pinyin: 'Andika', cell: 16 }, ja: { hand: 'Klee One', ruling: 'masu', text: 'Klee One', label: 'Zen Maru Gothic', cell: 16 }, ar: { hand: 'Playpen Sans Arabic', ruling: 'satr', u: 3.6, text: 'Noto Naskh Arabic', label: 'Baloo Bhaijaan 2' }, }; const ED = { text: 'Andika', label: 'Lexend', rise: 0, ...EDITIONS[LANG] }; const [HAND, TEXT, LABEL] = [ED.hand, ED.text, ED.label]; const CJK = ED.cell !== undefined; // a grid of squares, one character to each const RTL = LANG === 'ar'; const LW = CJK ? 700 : 600; // the labels' weight: the CJK label faces ship no 600 // Labels in tracked capitals, where the script has capitals. const caps = (n) => (!CJK && !RTL ? { textTransform: 'uppercase', letterSpacing: pt(n) } : {}); // #endregion const W = 176; // mm: the type area's width, and every practice block's // A design laid in the flow of a right-to-left page is mirrored by the engine, so 'left' is // the start side in every edition: the Arabic book needs no coordinates of its own. const box = (width, height) => ({ width: mm(width), height: mm(height) }); const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); const text = (id, content, fontFamily, size, placement, extra = {}) => ({ kind: 'text', id, content, fontFamily, fontSize: pt(size), color: col('ink'), align: 'left', placement, ...extra }); const image = (id, resourceId, placement) => ({ kind: 'image', id, resourceId, placement, decorative: true }); const label = (id) => text(id, '{titleText}', LABEL, 9, at('container', 'top-left'), { fontWeight: LW, color: col('accent'), ...caps(1.2) }); // #region answer: a practice block: the model in the school face, then dots, then nothing // ### Repasa y escribe {w="nina" model="niña"} draws four rows on the edition's ruling: the // model and dotted copies, a row of dots, the model and one copy, and an empty row. The // ruling and the dots are one picture per block ('trazo-' + w, drawn under #region art); the // models are live text set on its baselines. A design text's baseline falls 0.8 of its line // below the box top, so a box of line L set at baseline − 0.8 L puts the letters on the line. const G = CJK ? null : geometry(ED.ruling, ED.u); // rows, baselines and the face's size const LABEL_H = 7; // mm: the block's label above its ruling const model = (id, row, extra = {}) => text(id, '{attr.model}', HAND, G.size / 0.3528, at('container', 'top-left', 0, LABEL_H + G.base[row] - 0.8 * G.line, { width: mm(W) }), { lineHeight: mm(G.line), overflow: 'clip', ...extra }); const wordBlock = (rows, again = true) => ({ enabled: true, minHeight: mm(LABEL_H + G.height(rows)), slot: { elements: [label('label'), image('trazo', 'trazo-{attr.w}', at('container', 'top-left', 0, LABEL_H, box(W, G.height(rows)))), model('model-1', 0), ...(again ? [model('model-3', 2)] : []), // a sentence is written out once ] } }); // #endregion // #region squares: the Chinese and Japanese blocks, a character to a square // ### 写一写 {c1="日" c2="月"}: each character in the first square of its row, three copies to // trace in the pale red (描红) or grey (なぞり書き) of the next squares, then empty ones. A // Han or kana character is one em wide, so tracking of (square − em) sets one per square. const [CELL, EM] = [ED.cell ?? 16, (ED.cell ?? 16) * 0.72]; // mm: the square, its character const SQUARES = Math.floor(W / CELL); const row = (n, y, content, x, color) => text(`r${n}-${x}`, content, HAND, EM / 0.3528, at('container', 'top-left', x * CELL + (CELL - EM) / 2, y + (CELL - EM) / 2 + EM * 0.06, box(W - x * CELL, EM)), { lineHeight: mm(EM), letterSpacing: mm(CELL - EM), overflow: 'clip', color: col(color) }); // A sentence (####) takes three rows: written out, traced, and a row to write it alone. const squareBlock = (rows, sentence) => ({ enabled: true, minHeight: mm(LABEL_H + rows * CELL), slot: { elements: [label('label'), image('grid', `squares-${rows}`, at('container', 'top-left', 0, LABEL_H, box(SQUARES * CELL, rows * CELL))), ...(sentence ? [row(0, LABEL_H, '{attr.model}', 0, 'ink'), row(1, LABEL_H + CELL, '{attr.model}', 0, 'trace')] : Array.from({ length: rows }, (_, n) => [ row(n, LABEL_H + n * CELL, `{attr.c${n + 1}}`, 0, 'ink'), row(n, LABEL_H + n * CELL, `{attr.c${n + 1}}{attr.c${n + 1}}{attr.c${n + 1}}`, 1, 'trace'), ]).flat()), ] } }); // Pinyin on four lines and three spaces (四线三格), ##### {model="bā"}: the syllable, copies // to trace in the pale red, the syllable again and room after it. const P = ED.pinyin ? geometry('sixian', 3.6) : null; const pinyin = (id, n, x, content, color) => text(id, content, ED.pinyin, P.size / 0.3528, at('container', 'top-left', x, LABEL_H + P.base[n] - 0.8 * P.line, { width: mm(W - x) }), { lineHeight: mm(P.line), overflow: 'clip', color: col(color) }); const copies = Array(5).fill('{attr.model}').join('\u00a0\u00a0\u00a0\u00a0'); const pinyinBlock = P && { enabled: true, minHeight: mm(LABEL_H + P.height(3)), slot: { elements: [label('label'), image('lines', 'sixian', at('container', 'top-left', 0, LABEL_H, box(W, P.height(3)))), pinyin('p1', 0, 0, '{attr.model}', 'ink'), pinyin('p1-copies', 0, 36, copies, 'trace'), pinyin('p2', 1, 0, copies, 'trace'), pinyin('p3', 2, 0, '{attr.model}', 'ink'), ] } }; // #endregion // #region opener: a tinted band with the lesson's letters in a disc and its picture const BAND = 40; // mm const opener = { enabled: true, minHeight: mm(BAND + 7), slot: { elements: [ { kind: 'box', id: 'band', decorative: true, style: { backgroundColor: col('tint'), borderRadius: mm(5) }, placement: at('container', 'top-left', 0, 0, box(W, BAND)) }, text('letters', '{attr.letras}', HAND, CJK ? 30 : 26, at('container', 'top-left', 5, 5, box(40, 30)), { align: 'center', verticalAlign: 'middle', color: col('accent'), overflow: 'clip', box: { backgroundColor: col('paper'), borderRadius: mm(15) } }), text('kicker', '{attr.kicker}', LABEL, 9, at('container', 'top-left', 52, 8), { fontWeight: LW, color: col('accent'), ...caps(1.2) }), text('title', '{titleText}', HAND, CJK ? 26 : 24, at('container', 'top-left', 52, 15 + ED.rise, { width: mm(80) }), { lineHeight: 1.25 }), { kind: 'box', id: 'card', decorative: true, style: { backgroundColor: col('paper'), borderRadius: mm(4) }, placement: at('container', 'top-right', -4, 3, box(34, 34)) }, image('picture', '{attr.pic}', at('container', 'top-right', -5, 4, box(32, 32))), ] } }; // #endregion // #region pages: the cover, the certificate and the picture dictionary // The cover: a painted scene to the bleed, the title in the school face, a line for a name. const COVER = 178; // mm from the top edge: the foot of the painting const cover = { id: 'portada', header: { elements: [] }, footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [ image('scene', 'cover', at('page', 'top-left', 0, 0, { width: 'fill', height: mm(COVER) })), text('kicker', '{attr.kicker}', LABEL, 11, at('page', 'top', 0, COVER + 10), { align: 'center', fontWeight: LW, color: col('accent'), ...caps(1.6) }), text('title', '{titleText}', HAND, CJK ? 48 : 46, at('page', 'top', 0, COVER + 18 + ED.rise), { align: 'center', lineHeight: 1.2 }), text('name', '{attr.nombre}', LABEL, 12, at('page', 'top', -W / 2 + 24, COVER + 58), { color: col('ink') }), { kind: 'rule', id: 'name-line', direction: 'horizontal', thickness: pt(1), color: col('rule'), placement: at('page', 'top', 14, COVER + 64, { width: mm(110) }) }, text('colophon', '{attr.colofon}', LABEL, 7, at('page', 'top', 0, 262, { width: mm(W) }), { align: 'center', color: col('dots'), lineHeight: 1.4 }), ] } } }; // The last page: the same frame as a certificate, the child's name and the date to fill in. const diploma = { id: 'diploma', header: { elements: [] }, footer: { elements: [] }, advancedDesign: { enabled: true, minHeight: mm(250), slot: { elements: [ { kind: 'box', id: 'frame', decorative: true, style: { borderColor: col('sun'), borderWidth: pt(5), borderRadius: mm(8), backgroundColor: col('paper') }, placement: at('container', 'top-left', 0, 4, box(W, 244)) }, image('picture', 'medal', at('container', 'top', 0, 22, box(70, 70))), text('title', '{titleText}', HAND, CJK ? 40 : 38, at('container', 'top', 0, 104, { width: mm(W - 20) }), { align: 'center', color: col('accent'), lineHeight: 1.2 }), text('body', '{attr.texto}', TEXT, 15, at('container', 'top', 0, 136, { width: mm(130) }), { align: 'center', lineHeight: 1.45 }), ...[0, 1].map((n) => text(`field-${n}`, `{attr.${['nombre', 'fecha'][n]}}`, LABEL, 11, at('container', 'top-left', 28, 186 + n * 22), { color: col('dots') })), ...[0, 1].map((n) => ({ kind: 'rule', id: `line-${n}`, direction: 'horizontal', thickness: pt(1), color: col('rule'), placement: at('container', 'top-left', 28, 192 + n * 22, { width: mm(W - 56) }) })), ] } } }; // The picture dictionary, ## {p1="cat" w1="gato" …}: four pictures to a row, each word under // its picture in the school face, and a line to copy it on. const SLOT = W / 4; const dictionary = { enabled: true, minHeight: mm(58), slot: { elements: [0, 1, 2, 3].flatMap((n) => [ image(`p${n}`, `{attr.p${n + 1}}`, at('container', 'top-left', n * SLOT + (SLOT - 34) / 2, 0, box(34, 34))), text(`w${n}`, `{attr.w${n + 1}}`, HAND, CJK ? 20 : 17, at('container', 'top-left', n * SLOT, 36, box(SLOT, 10)), { align: 'center', overflow: 'clip' }), { kind: 'rule', id: `l${n}`, direction: 'horizontal', thickness: pt(0.75), color: col('rule'), placement: at('container', 'top-left', n * SLOT + 4, 54, { width: mm(SLOT - 8) }) }, ]) } }; // #endregion // The folio in an accent disc at the outer foot, the book's title beside it. const foot = (parity, edge, side) => [ text(`folio-${parity}`, '{pageNumber}', LABEL, 10, { anchor: { to: 'page', edge }, offset: { x: mm(18 * side), y: mm(-9) }, size: box(8, 8) }, { parity, align: 'center', verticalAlign: 'middle', fontWeight: 700, color: col('paper'), lineHeight: 1.17, box: { backgroundColor: col('accent'), borderRadius: mm(4) } }), text(`title-${parity}`, '{title} · {subtitle}', LABEL, 7.5, { anchor: { to: 'page', edge }, offset: { x: mm(29 * side), y: mm(-11) } }, { parity, color: col('dots'), align: side > 0 ? 'left' : 'right', ...caps(1) }), ]; const config = { // Hyphenation, punctuation, direction and binding follow the edition's language. locale: t({ en: 'en-gb', es: 'es', ca: 'ca', fr: 'fr', de: 'de', it: 'it', pt: 'pt-BR', zh: 'zh-Hans', ja: 'ja', ar: 'ar' }), resourceTypes: [{ id: 'dibujo', name: 'Dibujo', shortLabel: '', captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', defaultPlacement: { position: 'here' } }], colorPalette, page: { sizePreset: 'custom', width: mm(210), height: mm(297), dpi: 150, margins: { top: mm(16), bottom: mm(22), left: mm(18), right: mm(16), mirror: true } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(CJK ? 13.5 : 14), lineHeight: pt(RTL ? 25 : 20), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: RTL || CJK ? 'justify' : 'left', firstLineIndent: pt(0), paragraphSpacing: true, hyphenation: { enabled: !CJK && !RTL }, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true, }, headings: { fontFamily: HAND, color: col('ink'), fontWeight: 400, levels: [ { level: 1, breakBefore: { enabled: true, parity: 'any' }, marginTop: pt(0), marginBottom: mm(3), advancedDesign: opener }, { level: 2, marginTop: mm(3), marginBottom: mm(3), advancedDesign: dictionary }, { level: 3, marginTop: mm(4), marginBottom: mm(2), advancedDesign: CJK ? squareBlock(4) : wordBlock(4) }, { level: 4, marginTop: mm(4), marginBottom: mm(2), advancedDesign: CJK ? squareBlock(3, true) : wordBlock(3, false) }, ...(P ? [{ level: 5, marginTop: mm(4), marginBottom: mm(2), advancedDesign: pinyinBlock }] : []), ], }, headingStyles: [cover, diploma], chipStyles: [ // a blank to write a letter in, and a letter tile { id: 'hueco', fontFamily: LABEL, background: col('paper'), borderColor: col('rule'), borderWidth: pt(1), borderRadius: mm(1.2), paddingX: mm(4), paddingY: em(0.12) }, { id: 'letra', fontFamily: HAND, background: col('tint'), borderColor: col('sun'), borderWidth: pt(1), borderRadius: mm(1.6), paddingX: mm(2), paddingY: em(0.08) }], calloutStyles: [{ id: 'recuerda', background: col('tint'), borderRadius: mm(3), stripe: { enabled: true, side: 'left', width: pt(4), color: col('sun') }, padding: { top: mm(3), right: mm(5), bottom: mm(3.5), left: mm(5) }, titleStyle: { fontFamily: LABEL, fontSize: pt(9), fontWeight: LW, color: col('accent'), ...caps(1.2) }, body: { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(RTL ? 21 : 17), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0) } }], header: { elements: [] }, footer: { elements: [...foot('even', 'bottom-left', 1), ...foot('odd', 'bottom-right', -1)] }, }; // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 123行 · content.zh.md
title: "我会写字" subtitle: "写字练习册 一年级" --- # 我会写字 {style="portada" kicker="写字练习册 · 一年级 上册" nombre="我的名字" colofon="字体:LXGW WenKai TC、Noto Sans SC、Andika、Lexend(SIL OFL) · 原创文字(CC BY 4.0) · 插图由扩散模型生成"} # 写字以前 {kicker="写字姿势" letras="一" pic="grip"} 身子坐正,双脚放平。写字时记住三个“一”:眼睛离书本一尺,胸口离桌子一拳,手指离笔尖一寸。拿笔不要太紧,一笔一画慢慢写。 ### 基本笔画:横、竖、撇、点 {c1="一" c2="丨" c3="丿" c4="丶"} ### 笔画组成的字 {c1="十" c2="八" c3="人" c4="入"} # 一二三 {kicker="第一课" letras="一二三" pic="bird"} 写“一、二、三”的时候,横要平。上面的横短一点,下面的横长一点。写“十、土、王”,**先横后竖**。 ### 写一写 {c1="一" c2="二" c3="三" c4="十"} ### 写一写 {c1="土" c2="王" c3="五" c4="六"} :::callout{type="recuerda" title="记一记"} 笔顺规则:**先横后竖**,从上到下。 ::: # 日月山水 {kicker="第二课" letras="日月" pic="sun"} 很久以前,人们照着太阳、月亮、高山和流水的样子造了字。看看图,再看看字,它们像不像? ### 写一写 {c1="日" c2="月" c3="山" c4="水"} ### 写一写 {c1="火" c2="木" c3="田" c4="口"} :::callout{type="recuerda" title="记一记"} 笔顺规则:**先外后内再封口**,像“日”和“口”。 ::: # 木林森 {kicker="第三课" letras="木林" pic="tree"} 一个“木”是一棵树,两个“木”是“林”,三个“木”是“森”。“木”在左边时,最后一笔捺变成点,叫**木字旁**。 ### 写一写 {c1="木" c2="林" c3="森" c4="树"} ### 写一写 {c1="大" c2="天" c3="小" c4="少"} :::callout{type="recuerda" title="记一记"} 笔顺规则:**先撇后捺**,像“人、大、天”。 ::: # 拼音 b p m f {kicker="第四课" letras="b p" pic="fish"} 汉语拼音帮我们读准字音。拼音写在四线三格里:**b、p、m、f** 都占中格,b 的竖要顶到上格,p 的竖要伸到下格。 ##### 写一写 {model="b p"} ##### 写一写 {model="m f"} 读一读:bà ba 爸爸 mā ma 妈妈 pó po 婆婆 fó 佛 :::callout{type="recuerda" title="记一记"} 右下半圆 **b b b**,右上半圆 **p p p**。 ::: # 四个声调 {kicker="第五课" letras="ā á" pic="moon"} 普通话有四个声调。声调符号标在韵母上:一声平,二声扬,三声拐弯,四声降。 ##### 写一写 {model="mā má"} ##### 写一写 {model="mǎ mà"} 读一读:mā 妈 má 麻 mǎ 马 mà 骂 :::callout{type="recuerda" title="记一记"} 妈妈 **mā ma** 骑马 **qí mǎ**,马慢 **mǎ màn**,妈妈骂马 **mā ma mà mǎ**。 ::: # 我和你 {kicker="第六课" letras="我你" pic="house"} “我、你、他”都是说人的字。“你”和“他”左边是**单人旁**,写得窄一些,给右边留出地方。 ### 写一写 {c1="我" c2="你" c3="他" c4="们"} ### 写一写 {c1="爸" c2="妈" c3="好" c4="家"} :::callout{type="recuerda" title="记一记"} 左右结构的字,左窄右宽,两边要靠紧。 ::: # 小动物 {kicker="第七课" letras="鱼鸟" pic="cat"} “鱼、鸟、马、牛”也是照着动物的样子造的字。“猫”和“狗”的左边是**反犬旁**,三笔写成:撇、弯钩、撇。 ### 写一写 {c1="鱼" c2="鸟" c3="马" c4="牛"} ### 写一写 {c1="羊" c2="虫" c3="猫" c4="狗"} :::callout{type="recuerda" title="记一记"} 笔顺规则:**先中间后两边**,像“小”和“水”。 ::: # 我的第一本字典 {kicker="看图写词" letras="字" pic="apple"} 读一读,在横线上把词语抄一遍。 ## 太阳、月亮、小猫、小鱼 {p1="sun" w1="太阳" p2="moon" w2="月亮" p3="cat" w3="小猫" p4="fish" w4="小鱼"} ## 房子、小船、小鸭、钥匙 {p1="house" w1="房子" p2="boat" w2="小船" p3="duck" w3="小鸭" p4="key" w4="钥匙"} ## 鞋子、苹果、蜜蜂、大树 {p1="shoe" w1="鞋子" p2="apple" w2="苹果" p3="bee" w3="蜜蜂" p4="tree" w4="大树"} # 抄写句子 {kicker="第八课" letras="句" pic="kite"} 一句话写完,要加上句号“。”。每个字占一个格,标点也占一个格。 #### 抄一抄 {model="我爱写字。"} #### 抄一抄 {model="小鸟在唱歌。"} #### 抄一抄 {model="月亮出来了。"} # 我会写字了! {style="diploma" texto="你学会了基本笔画和笔顺规则,写了日、月、山、水,还认识了拼音和四个声调。" nombre="姓名" fecha="日期"}`; // content.<lang>.md, inlined by the Cookbook // #region art: the edition's ruling drawn in code, the dots from the face's own outlines // Each country's paper, in x-heights (u) above and below the baseline. lines: [height, // weight] pairs; band: a filled band; seyes: the French grid of 1 u squares, a heavy line // every 4 u, verticals every 4 u; Seyès and the Italian page rule a red margin where the // writing starts. function geometry(style, u) { const R = { pauta: { above: 2, below: 1, gap: 1, lines: [[-1, 0.4], [0, 0.9], [1, 0.9], [2, 0.4]] }, gb: { above: 2, below: 1, gap: 0.8, lines: [[-0.8, 0.5], [0, 0.9], [1, 0.5, 'dash'], [1.8, 0.5]] }, lineatur: { above: 2, below: 1, gap: 1, lines: [[-1, 0.6], [0, 0.6], [1, 0.6], [2, 0.6]], band: [0, 1, '#fdf3c7'] }, righe: { above: 2, below: 1, gap: 1, lines: [[-1, 0.4], [0, 0.8], [1, 0.8], [2, 0.4]], margin: true }, caligrafia: { above: 2.4, below: 1.4, gap: 0.8, lines: [[-1.3, 0.4], [0, 0.9], [1, 0.9], [2.3, 0.4]], band: [0, 1, '#e8f0fb'] }, satr: { above: 2, below: 1, gap: 1, lines: [[-1, 0.4], [0, 0.9], [1, 0.5, 'dash'], [2, 0.4]] }, seyes: { above: 4, below: 2, gap: -2, seyes: true }, // a row on every heavy line sixian: { above: 2, below: 1, gap: 1, lines: [[-1, 0.6], [0, 0.9], [1, 0.6], [2, 0.6]] }, }[style]; const pitch = (R.above + R.below + R.gap) * u; const base = [0, 1, 2, 3].map((n) => n * pitch + R.above * u); // The faces set their x-height at half the em, Playpen Sans Arabic its tooth at 0.4. const xh = { satr: 0.4, sixian: 0.508 }[style] ?? 0.5; // Andika's x-height for pinyin // line: a tall box for the models, whose clip falls 2 u under the baseline. return { ...R, u, pitch, base, size: u / xh, line: u * 10, height: (rows) => (rows - 1) * pitch + (R.above + R.below) * u }; } const n2 = (v) => +v.toFixed(2); function ruling(g, rows) { const out = []; const h = g.height(rows); const hline = (y, w, dash) => out.push(`<path d="M0 ${n2(y)}H${W}" stroke="${palette.rule}" ` + `stroke-width="${n2(w * 0.3528)}" stroke-opacity="${w < 0.6 ? 0.55 : 1}"` + `${dash ? ' stroke-dasharray="1.2 1"' : ''}/>`); if (g.seyes) { for (let k = 0; k <= h / g.u + 1e-6; k++) hline(k * g.u, k % 4 === 0 ? 0.9 : 0.35); for (let x = 4 * g.u; x <= W; x += 4 * g.u) out.push(`<path d="M${n2(x)} 0V${n2(h)}" ` + `stroke="${palette.rule}" stroke-width=".12"/>`); out.push(`<path d="M.2 0V${n2(h)}" stroke="#d9534f" stroke-width=".3"/>`); return out.join(''); } for (const b of g.base.slice(0, rows)) { if (g.band) out.push(`<rect x="0" y="${n2(b - g.band[1] * g.u)}" width="${W}" ` + `height="${n2((g.band[1] - g.band[0]) * g.u)}" fill="${g.band[2]}"/>`); for (const [k, w, dash] of g.lines) hline(b - k * g.u, w, dash); } if (g.margin) out.push(`<path d="M.2 0V${n2(h)}" stroke="#d9534f" stroke-width=".3"/>`); return out.join(''); } // The dots of a word: TRACES holds, for each model, the dots along the outlines of the face's // thin cut (one row of dots, the two sides of a stroke merged) as a path of zero-length, // round-capped segments, its advance, and the model's advance in the regular cut, all in mm. function dotted(word, x, y) { const t = TRACES.words[word]; return `<g transform="translate(${n2(x)} ${n2(y)})"><path d="${t.d}" stroke="${palette.dots}" ` + `stroke-width="${TRACES.dot}" stroke-linecap="round" fill="none"/>` + (t.f ? `<path d="${t.f}" fill="${palette.dots}"/>` : '') + '</g>'; } // Warm-up strokes: curves drawn by code, dotted at the same pitch as the letters. const PATTERNS = { // s runs along the stroke; x and y in 2 u, from the baseline up lines: (s) => [Math.floor(s) * 0.5, s % 1], // sticks, each drawn from the foot up waves: (s) => [s * 0.5, (1 - Math.cos(s * Math.PI)) / 4], // waves in the x-height band loops: (s) => [s * 0.5 - 0.2 * Math.sin(2 * Math.PI * s), (1 - Math.cos(2 * Math.PI * s)) / 2], }; function pattern(name, x0, x1, base, u) { const f = PATTERNS[name]; const pts = []; let prev = null; let acc = 0; for (let s = 0; s < 400; s += 0.002) { const [px, py] = f(s); const p = [x0 + px * u * 2, base - py * u * 2]; if (p[0] > x1) break; acc += prev ? Math.hypot(p[0] - prev[0], p[1] - prev[1]) : TRACES.pitch; prev = p; if (acc < TRACES.pitch) continue; pts.push(`M${n2(RTL ? W - p[0] : p[0])} ${n2(p[1])}h.01`); acc = 0; } return `<path d="${pts.join('')}" stroke="${palette.dots}" stroke-width="${TRACES.dot}" ` + 'stroke-linecap="round" fill="none"/>'; } // One block's picture: the ruling, then the dotted copies laid out from the start side. A word // (###): copies after the model, a row of copies, one copy after the model, an empty row. A // sentence (####): one copy under the model, then an empty row. A warm-up pattern: two rows // of it and the start of a third. function traceSvg(g, w, word, sentence) { const rows = sentence ? 3 : 4; const gap = 3 * g.u; const parts = [ruling(g, rows)]; const copies = (row, from, count = 99) => { const t = TRACES.words[word]; for (let x = from, k = 0; x + t.a <= W && k < count; x += t.a + gap, k++) { parts.push(dotted(word, RTL ? W - x - t.a : x, g.base[row])); } }; if (PATTERNS[w]) { [W, W, W * 0.4].forEach((end, row) => parts.push(pattern(w, 2, end - 2, g.base[row], g.u))); } else if (sentence) copies(1, 0, 1); else { const lead = TRACES.words[word].m + gap; copies(0, lead); copies(1, 0); copies(2, lead, 1); } const h = g.height(rows); return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${n2(h * 10)}" ` + `viewBox="0 0 ${W} ${n2(h)}">${parts.join('')}</svg>`; } // The pictures the practice blocks name: one per block, read off the headings that ask for one, // or the edition's squares and its pinyin lines. async function practicePictures(add) { const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" ` + `height="${n2(h * 10)}" viewBox="0 0 ${w} ${n2(h)}">${body}</svg>`; const blocks = CJK ? [] : markdown.matchAll(/^(#{3,4}) .*?w="([^"]+)"(?: model="([^"]+)")?/gm); for (const [, level, w, word] of blocks) { await loadSvg(`trazo-${w}.svg`, traceSvg(G, w, word ?? '', level === '####')); add(`trazo-${w}`, `trazo-${w}.svg`, 'svg', W * 10, G.height(level === '####' ? 3 : 4) * 10); } if (P) { await loadSvg('sixian.svg', svg(W, P.height(3), ruling(P, 3))); add('sixian', 'sixian.svg', 'svg', W * 10, P.height(3) * 10); } if (CJK) for (const rows of [3, 4]) { await loadSvg(`squares-${rows}.svg`, squaresSvg(rows)); add(`squares-${rows}`, `squares-${rows}.svg`, 'svg', SQUARES * CELL * 10, rows * CELL * 10); } } // The squares: 田字格 in red with a dashed cross, or マス with a dotted cross in grey-blue. function squaresSvg(rows) { const c = CELL; const n = SQUARES; const red = ED.ruling === 'tian'; const ink = red ? '#d0574b' : palette.rule; const cross = Array.from({ length: rows }, (_, r) => Array.from({ length: n }, (_, k) => `M${k * c + c / 2} ${r * c + 0.6}V${r * c + c - 0.6}M${k * c + 0.6} ${r * c + c / 2}` + `H${k * c + c - 0.6}`).join('')).join(''); const grid = Array.from({ length: n + 1 }, (_, k) => `M${k * c} 0V${rows * c}`).join('') + Array.from({ length: rows + 1 }, (_, r) => `M0 ${r * c}H${n * c}`).join(''); return `<svg xmlns="http://www.w3.org/2000/svg" width="${n * c * 10}" height="${rows * c * 10}" ` + `viewBox="0 0 ${n * c} ${rows * c}"><path d="${cross}" fill="none" stroke="${ink}" ` + `stroke-opacity=".55" stroke-width=".2" stroke-dasharray="${red ? '.9 .6' : '.25 .55'}"/>` + `<path d="${grid}" fill="none" stroke="${ink}" stroke-width=".35"/></svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Andika: ['400', '700'], // the text: drawn for children learning to read Lexend: ['400', '600', '700'], // labels, kickers, folios // The school models, one per country: Google's Playwrite families follow each one. 'Playwrite GB J': ['400'], 'Playwrite ES': ['400'], 'Playwrite FR Trad': ['400'], 'Playwrite DE VA': ['400'], 'Playwrite IT Trad': ['400'], 'Playwrite BR': ['400'], 'LXGW WenKai TC': ['400'], // 楷: the Chinese titles and squares 'Noto Serif SC': ['400', '700'], // 宋: the Chinese text 'Noto Sans SC': ['400', '700'], // 黑: Chinese labels 'Klee One': ['400'], // 教科書体: the Japanese text and its squares 'Zen Maru Gothic': ['400', '700'], // Japanese labels 'Playpen Sans Arabic': ['400'], // the Arabic model 'Noto Naskh Arabic': ['400', '700'], // the Arabic text, vocalised 'Baloo Bhaijaan 2': ['400', '600', '700'], // Arabic labels }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // The edition loads its own faces only: its school face, its text and its labels. const used = [HAND, TEXT, LABEL, ED.pinyin]; for (const family of Object.keys(FONTS)) if (!used.includes(family)) delete FONTS[family]; await loadFonts(FONTS, markdown); await loadCjkFonts(FONTS, markdown); await loadArabicFonts(FONTS, markdown); const TRACES = CJK ? null : await (await fetch({ en: asset('traces-en.json'), es: asset('traces-es.json'), ca: asset('traces-ca.json'), fr: asset('traces-fr.json'), de: asset('traces-de.json'), it: asset('traces-it.json'), pt: asset('traces-pt.json'), ar: asset('traces-ar.json') }[LANG])).json(); const resources = []; // every picture, painted or drawn const add = (id, fileId, kind, width, height, more = {}) => resources.push({ id, kind, typeId: 'dibujo', createdAt: 0, updatedAt: 0, [kind]: { fileId, width, height, ...more } }); // The paintings the edition names (pic="…", p1="…"), from one shared set, at their pixels. const named = new Set(['medal', ...[...markdown.matchAll(/\b(?:pic|p\d)="(\w+)"/g)].map((m) => m[1])]); await Promise.all([['cover', 'cover-1600.jpg', 1600, 1356], ...[...named].map((id) => [id, `${id}-480.jpg`, 480, 480])].map(async ([id, file, w, h]) => { await loadImage(file, asset(file)); add(id, file, 'bitmap', w, h, { format: 'jpeg' }); })); await practicePictures(add); // the rulings, the dots and the squares (#region art) const doc = await withLoadedFonts(() => buildDocument({ markdown, resources }, config), { ...kitFonts(FONTS), text: markdown }); showBook(doc, { title: t({ en: 'Handwriting workbook', es: 'Cuaderno de caligrafía', ca: 'Quadern de cal·ligrafia', fr: 'Cahier d’écriture', de: 'Schreibheft', it: 'Quaderno di scrittura', pt: 'Caderno de caligrafia', zh: '写字练习册', ja: 'かきかたれんしゅうちょう', ar: 'كراسة الخط' }) }); offerPdf(() => renderToPdf(doc, { resourceBytes: imageBytes, fontProvider: { zh: cjkPdfProvider, ja: cjkPdfProvider, ar: arabicPdfProvider }[LANG] ?? fontsourceProvider }), `${RECIPE}-${LANG}.pdf`);工具包 · core, fonts, viewer, pdf, images, cjk, arabic:每道食谱都相同 · 613行
// ─── 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 · images v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 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 · arabic v2 ── Arabic-script faces · postext.dev/cookbook // Fontsource ships an Arabic family as one file per subset and weight: the // `arabic` file holds the letters, the harakat, the Arabic-Indic digits, the // Arabic punctuation and the presentation forms; `latin` and `latin-ext` // hold the rest. loadFonts loads the latin files; this block adds the arabic // file of every Arabic family, for the canvas and for the PDF, which shapes // the letters with HarfBuzz from the same bytes. /** The code points of Fontsource's `arabic` subset, as its stylesheets * declare them (the same unicode-range the browser picks the file by). A * function, not a const: the kit is inlined after the recipe's top-level * awaits, and a const read before its line throws, where a function * declaration is hoisted. */ function arabicRange() { return 'U+0600-06FF,U+0750-077F,U+0870-088E,U+0890-0891,U+0897-08E1,U+08E3-08FF,' + 'U+200C-200E,U+2010-2011,U+204F,U+2E41,U+FB50-FDFF,U+FE70-FE74,U+FE76-FEFC,U+102E0-102FB,' + 'U+10E60-10E7E,U+10EC2-10EC4,U+10EFC-10EFF,U+1EE00-1EEFF'; } /** Whether code point `cp` is in the arabic file. */ function inArabicRange(cp) { inArabicRange.ranges ??= arabicRange().split(',').map((part) => { const [lo, hi = lo] = part.slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return inArabicRange.ranges.some(([lo, hi]) => cp >= lo && cp <= hi); } /** Whether Fontsource serves `family` with an `arabic` subset. Fails when * the API does not answer: an Arabic face taken for a Latin one would set * its letters in a system face. */ async function isArabicFamily(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?.includes('arabic'); } /** The arabic file of a face. */ async function arabicFileUrl(family, weight, style) { const id = fontsourceId(family); return `${await fontsourceAt(id)}files/${id}-arabic-${weight}-${style}.woff2`; } /** faces = { Amiri: ['400', '700'] }, as for loadFonts, after it: the whole * FONTS object may be passed, its families without an arabic subset are * left alone. Adds the arabic file of every listed weight of each Arabic * family (the latin files come from loadFonts) and loads it. `text` is * the sample: fails when it holds an Arabic-script character the arabic * file does not cover. List every weight the pages set in Arabic: a weight * left to the engine's later round gets the latin file only, and its Arabic letters * fall back to a system face. Resolves to the number of files loaded. */ async function loadArabicFonts(faces, text = '') { kitStatus('Loading fonts…'); let loaded = 0; try { const outside = [...new Set(text)].filter((ch) => /\p{Script=Arabic}/u.test(ch) && !inArabicRange(ch.codePointAt(0))); if (outside.length) throw new Error(`Fontsource's arabic files have no ${outside.slice(0, 12).join(' ')}`); for (const [family, specs] of Object.entries(faces)) { if (!(await isArabicFamily(family))) continue; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const face = new FontFace(family, `url(${await arabicFileUrl(family, weight, style)}) format('woff2')`, { weight: String(weight), style, unicodeRange: arabicRange() }); document.fonts.add(await face.load().catch(() => { throw new Error(`Fontsource has no arabic file for ${family} ${weight} ${style}`); })); loaded++; } } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with Arabic faces: a family with an * arabic subset gets its arabic file when its pages set Arabic letters * (`request.codePoints`), then its latin file, and its latin-ext file for * the letters beyond latin (transliteration: ā ḥ ʿ), each marked with its * unicode-range and its place in the order the screen added them (latin * and latin-ext by loadFonts, then arabic): the arabic file also has a * space and brackets, which the browser takes from latin, the file whose * range holds them, and postext-pdf follows it. Any other family goes to * fontsourceProvider (the "pdf" block). */ async function arabicPdfProvider(family, weight, style, request) { if (!(await isArabicFamily(family))) return fontsourceProvider(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 wanted = [...(request?.codePoints ?? [])]; const id = fontsourceId(family); const subsets = !wanted.length || wanted.some(inArabicRange) ? ['arabic', 'latin'] : ['latin']; if (meta.subsets.includes('latin-ext') && wanted.some((cp) => /[Ā-˿Ḁ-ỿ]/u.test(String.fromCodePoint(cp)))) { subsets.push('latin-ext'); } const at = await fontsourceAt(id); return Promise.all(subsets.map(async (subset) => { const url = `${at}files/${id}-${subset}-${w}-${s}.woff2`; const res = await fetch(url); if (!res.ok) throw new Error(`Fontsource file ${url} (${res.status})`); const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer())); return Object.assign(bytes, { unicodeRange: subset === 'arabic' ? arabicRange() : kitRanges()[subset], order: ['latin', 'latin-ext', 'greek', 'arabic'].indexOf(subset) }); })); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗ (在新标签页中打开)
变化
#改用美国的连笔字
Playwrite 为许多学校范本都做了字族。要用美国的连笔字,换掉英文版的字体,再重新生成它的点。
- en: { hand: 'Playwrite GB J', ruling: 'gb', u: 3.6 },
+ en: { hand: 'Playwrite US Trad', ruling: 'gb', u: 3.6 },#隔一条 Seyès 粗线写一行
刚入学的孩子常常隔一条粗线写一行。把法文版各行的间距设为八条线。
- seyes: { above: 4, below: 2, gap: -2, seyes: true }, // a row on every heavy line
+ seyes: { above: 4, below: 2, gap: 2, seyes: true },常见问题
- 设计文字在自己的框边被裁掉。只有一行高的框会切掉范字里 g 和 p 的下伸部:给它很大的行高,再从基线出发定位。
loadCjkFonts为整个示例加载每一款字体:只要某款中文字体缺了文中任何地方用到的一个字,加载就会失败。版权页里的字体名要用拉丁字母写。
致谢
- 文本
- 原创文字, CC BY 4.0
- 图片
- Two children writing in notebooks at a school table, a cat asleep on a chair beside them. · Generated With Diffusion Models · 原创
- A smiling sun. · Generated With Diffusion Models · 原创
- A crescent moon with three stars. · Generated With Diffusion Models · 原创
- A ginger cat sitting. · Generated With Diffusion Models · 原创
- A brown and white puppy. · Generated With Diffusion Models · 原创
- An orange fish with blue stripes. · Generated With Diffusion Models · 原创
- A honey bee flying. · Generated With Diffusion Models · 原创
- A little black spider on a thread. · Generated With Diffusion Models · 原创
- A red apple. · Generated With Diffusion Models · 原创
- A golden key. · Generated With Diffusion Models · 原创
- A red child's shoe. · Generated With Diffusion Models · 原创
- A small house with a red roof. · Generated With Diffusion Models · 原创
- A small sailing boat. · Generated With Diffusion Models · 原创
- A yellow duckling. · Generated With Diffusion Models · 原创
- A colourful kite. · Generated With Diffusion Models · 原创
- A birthday cake with three candles. · Generated With Diffusion Models · 原创
- A penguin. · Generated With Diffusion Models · 原创
- An owl on a branch. · Generated With Diffusion Models · 原创
- A red umbrella in the rain. · Generated With Diffusion Models · 原创
- An ice-cream cone with two scoops. · Generated With Diffusion Models · 原创
- A blue and orange butterfly. · Generated With Diffusion Models · 原创
- A leafy tree. · Generated With Diffusion Models · 原创
- A snowy mountain. · Generated With Diffusion Models · 原创
- A daisy. · Generated With Diffusion Models · 原创
- A small blue bird on a twig. · Generated With Diffusion Models · 原创
- Splashing water. · Generated With Diffusion Models · 原创
- A small campfire. · Generated With Diffusion Models · 原创
- A child's hand holding a pencil with the tripod grip. · Generated With Diffusion Models · 原创
- A golden star medal with coloured pencils. · Generated With Diffusion Models · 原创
- The dotted letters of the en edition, traced along the outlines of Playwrite GB J Thin. · Ignacio Ferro · 原创
- The dotted letters of the es edition, traced along the outlines of Playwrite ES Thin. · Ignacio Ferro · 原创
- The dotted letters of the ca edition, traced along the outlines of Playwrite ES Thin. · Ignacio Ferro · 原创
- The dotted letters of the fr edition, traced along the outlines of Playwrite FR Trad Thin. · Ignacio Ferro · 原创
- The dotted letters of the de edition, traced along the outlines of Playwrite DE VA Thin. · Ignacio Ferro · 原创
- The dotted letters of the it edition, traced along the outlines of Playwrite IT Trad Thin. · Ignacio Ferro · 原创
- The dotted letters of the pt edition, traced along the outlines of Playwrite BR Thin. · Ignacio Ferro · 原创
- The dotted letters of the ar edition, traced along the outlines of Playpen Sans Arabic Thin. · Ignacio Ferro · 原创
- 字体
- Andika (SIL OFL 1.1) · Lexend (SIL OFL 1.1) · Playwrite GB J (SIL OFL 1.1) · Playwrite ES (SIL OFL 1.1) · Playwrite FR Trad (SIL OFL 1.1) · Playwrite DE VA (SIL OFL 1.1) · Playwrite IT Trad (SIL OFL 1.1) · Playwrite BR (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1) · Noto Serif SC (SIL OFL 1.1) · Noto Sans SC (SIL OFL 1.1) · Klee One (SIL OFL 1.1) · Zen Maru Gothic (SIL OFL 1.1) · Playpen Sans Arabic (SIL OFL 1.1) · Noto Naskh Arabic (SIL OFL 1.1) · Baloo Bhaijaan 2 (SIL OFL 1.1)


