跳到主要内容
食谱编号171

排版食谱 · 第12章 · 教科书

中文练习册:学生用书与教师用书

一本语文和数学练习册:一、二、分大题,题后带( )括号,还有田字格和竖式。同一份文本排两次,一本给学生,一本给老师。

本页内容
输出
Canvas · PDF
难度
中级
Postext
已用Postext 1.59.0测试
需要≥ 1.59.0 · postext-pdf ≥ 1.59.0
许可证
更新于2026年10月24日
代码MIT · 文本CC BY 4.0
  • 成品尺寸184 × 260 mm
  • 1栏
  • Noto Serif SC 10.5/21
  • LXGW WenKai TC
  • Noto Sans SC
  • 8页
  • 难度
  • Postext 1.59.0
  • 排版用时124 ms
  • 162行代码

简单来说

一本中国小学的练习册,有语文和数学两个单元。同一份文本排出两本书:学生用书留着空,单元末附参考答案;教师用书把每个答案用红字写在原处。

成品一览

一本二年级练习册的两个单元,16开:语文单元“春天来了”,数学单元讲100以内的加减法。每个单元开头是一条水彩画色带、一个科目标签和一行班级、姓名、得分,接着是中国练习册常见的各种大题:看拼音写词语,圈出正确读音,题干后带( )、A. B. C. D. 排成一行的选择题,行末带括号的判断题,填空,量词和事物之间的连线,在田字格里写一句话,四栏口算,每位数字一个格的竖式,还有一道应用题。分值写在每道题的题目要求里,学生读题时就能看到。同一份 Markdown 排两次:学生用书在每个单元末附参考答案,教师用书把每个答案用红色楷体写在原处。

这道食谱解答

  • 怎样排中文选择题:题干后的( )括号和一行排开的 A. B. C. D. 选项?
  • 怎样由学生用书生成答案印在原位的教师用书?
  • 怎样给开放题配上法国 Seyès 格线、方格或田字格书写区?
  • 怎样按各国学校的写法排长除法竖式?

简短回答

script.js · 第36–65行在完整代码中
// The exercise style is the form; the Markdown holds every word, the points included. The
// same text builds both editions: `edition: 'teacher'` writes each answer into its blank,
// bracket and square in red Kai, rings the right options and draws the matching lines.
const lianxi = {
  id: 'lianxi',
  number: { format: 'simp-chinese-informal', suffix: '、', fontFamily: HEI, color: col('green') },
  instruction: { fontFamily: HEI, bold: false }, // 黑体, not bold: the points stay quiet
  // 选择题: A. B. C. D. in a row, a ( ) after the stem for the letter.
  options: { labels: 'alpha-upper', suffix: '.', layout: 'row', bracket: true },
  trueFalse: { look: 'bracket' }, // 判断题: ( ) at the end of the line, √ or ×
  blank: { look: 'line', color: col('ink') },
  area: { kind: 'tianzige', color: col('rule') }, // 写话 on 田字格
  match: { color: col('green') },
  grid: { fontFamily: SONG, rule: col('rule') },
  answers: { color: col('red'), fontFamily: KAI },
  solutions: { box: 'daan' },
  marginTop: em(1.2), marginBottom: em(0.6),
};
const exerciseStyles = [
  lianxi,
  // 看拼音写词语: a box under each syllable, as wide as its word.
  { id: 'pinyin', extends: 'lianxi', blank: { look: 'box', width: 'answer', color: col('rule') } },
  // 在( )里填: the bracket is in the text, the blank inside it draws nothing.
  { id: 'kuohao', extends: 'lianxi', blank: { look: 'none', width: 'answer' } },
];
let edition = 'student'; // the second build below prints the teacher's book
// The pupil's book: 参考答案 where a :::solutions line stands, at the end of each unit.
// The teacher's book: every answer on the page, so no key at the end.
const exercises = () => ({ edition,
  solutions: { placement: edition === 'teacher' ? 'none' : 'directive' } });

用料

类型
Noto Serif SC, Noto Sans SC, LXGW WenKai TC(SIL OFL 1.1)
素材
  • market-1600.jpg
  • spring-1600.jpg
  • A Chinese village in early spring: willows, swallows, peach blossom and two children on a path between rice fields. (Generated With Diffusion Models, 原创)
  • Two children count oranges, apples and pears at a grandmother's fruit stall, a cat on a stool beside them. (Generated With Diffusion Models, 原创)

做法

#1 · 所有大题用一个样式

代码见上方的简短回答。大题用中文数字编号,后面跟全角顿号“、”,所以编号后不加空格;题目要求用黑体,不加粗,括号里的分值读起来就是要求的一部分。options.bracket 在每个题干后写出( ),教师用书里正确的字母就填在里面。两个小样式继承主样式:pinyin 把每个空画成和所填词语一样宽的方框,kuohao 什么也不画,因为“在( )里填”的括号本来就在文本里。

#2 · 学生用书和教师用书出自同一份文本

script.js · 第334–338行在完整代码中
const build = () => withLoadedFonts(() => buildDocument({ markdown, resources }, config()),
  { ...kitFonts(FONTS), text: markdown });
const pupil = await build();
edition = 'teacher';
const docs = [pupil, await build()];

配置是一个函数,所以每次排版都拿到自己的对象,Sandbox 的包里保存的是学生用书那一份。edition: 'teacher' 用 answers 的颜色和字体写出答案:词语写在方框里,字母写在括号里,判断题里写 √ 和 ×,竖式的格里写数字,例句一格一个字。学生用书在每个单元末 :::solutions 那一行印出参考答案;教师用书用不着。

#3 · 带水彩画的单元开头

script.js · 第69–87行在完整代码中
const BAND = 184 / 2.4; // mm: the painting's height at the page's width
const opener = { enabled: true, minHeight: mm(BAND - 22 + 23), slot: { elements: [
  { kind: 'image', id: 'picture', resourceId: '{attr.pic}', decorative: true, reserve: false,
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: mm(184), height: mm(BAND) } } },
  { kind: 'text', id: 'subject', content: '{attr.kicker}', fontFamily: HEI, fontSize: pt(11),
    fontWeight: 700, color: col('paper'), letterSpacing: pt(2), align: 'center',
    box: { backgroundColor: col('green'), borderRadius: mm(1.5),
      padding: { top: mm(0.8), bottom: mm(0.8), left: mm(2.4), right: mm(2) } },
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(0), y: mm(BAND - 22 + 6) } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: HEI, fontSize: pt(24),
    fontWeight: 700, color: col('ink'), align: 'left',
    placement: { anchor: { to: '#subject', edge: 'right-of' },
      offset: { x: mm(4), y: mm(-2.6) } } },
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.2), color: col('green'),
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(0), y: mm(BAND - 22 + 19) }, size: { width: 'fill' } } },
] } };

画锚定在页面上,所以不论单页还是双页都铺到上边的纸边;科目标签和标题从版心顶部开始定位,落在画的下沿之下。两个单元共用这个设计,只在标题属性 kicker 和 pic 上不同。

#4 · 页码放在页脚

script.js · 第91–101行在完整代码中
const foot = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content,
  parity, fontFamily: HEI, fontSize: pt(8), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-10) } }, ...extra });
const footer = { elements: [
  foot('folio-v', '{pageNumber}', 'even', 'bottom-left', 16,
    { fontWeight: 700, color: col('green') }),
  foot('series', '{title}', 'even', 'bottom-left', 24),
  foot('unit', '{chapterTitle}', 'odd', 'bottom-right', -24, { align: 'right' }),
  foot('folio-r', '{pageNumber}', 'odd', 'bottom-right', -16,
    { fontWeight: 700, color: col('green'), align: 'right' }),
] };

中国的练习册把页码放在页脚。丛书名取自 Markdown 的 front matter({title}),单元名取自单元标题({chapterTitle})。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 171 · A Chinese exercise book, pupil's and teacher's ═══
// https://postext.dev/en/cookbook/chinese-exercise-book
// Code: MIT · Text: original (CC BY 4.0) · Pictures: generated with diffusion models
// Fonts: Noto Serif SC, Noto Sans SC, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.59.0
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 frame; the exercise book is Chinese in all
const RECIPE = 'chinese-exercise-book';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a green exercise book, and the red of the teacher's pen
const palette = {
  ink: '#1f2523', // text: a green-tinted near-black
  green: '#2c7350', // the one accent: section numbers, the subject tab, the squares
  red: '#d0352c', // the teacher's answers
  tint: '#e6f2ea', // the subject tab's ground, the answers box
  rule: '#8fb9a2', // 田字格, the matching dots' lines
  muted: '#68736d', // folios, the series line
  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.green, model: 'hex' } },
];
// #endregion
// 宋 for the text, 黑 for the instructions and labels, 楷 for what the teacher writes.
const [SONG, HEI, KAI] = ['Noto Serif SC', 'Noto Sans SC', 'LXGW WenKai TC'];
const CHARS = 40; // characters per line: 40 × 10.5 pt = 148 mm
const SIZE = 10.5; // pt: 五号, the size of a school book's text

// #region answer: sections 一、二、 that print the pupil's book or the teacher's
// The exercise style is the form; the Markdown holds every word, the points included. The
// same text builds both editions: `edition: 'teacher'` writes each answer into its blank,
// bracket and square in red Kai, rings the right options and draws the matching lines.
const lianxi = {
  id: 'lianxi',
  number: { format: 'simp-chinese-informal', suffix: '、', fontFamily: HEI, color: col('green') },
  instruction: { fontFamily: HEI, bold: false }, // 黑体, not bold: the points stay quiet
  // 选择题: A. B. C. D. in a row, a ( ) after the stem for the letter.
  options: { labels: 'alpha-upper', suffix: '.', layout: 'row', bracket: true },
  trueFalse: { look: 'bracket' }, // 判断题: ( ) at the end of the line, √ or ×
  blank: { look: 'line', color: col('ink') },
  area: { kind: 'tianzige', color: col('rule') }, // 写话 on 田字格
  match: { color: col('green') },

组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗ (在新标签页中打开)

变化

#把参考答案印在章末

不写 :::solutions 行时,chapterEnd 把每个单元的答案收在下一个一级标题之前。

-  solutions: { placement: edition === 'teacher' ? 'none' : 'directive' } });
+  solutions: { placement: edition === 'teacher' ? 'none' : 'chapterEnd' } });

#在空旁边标读音

拼音识字课本把拼音作为 ruby 标在字的上方,而空里目前还放不了 ruby。

常见问题

易错点

中文字体通过cjk块分片加载

Fontsource把一个中文、日文或韩文字体家族按每个字重约一百个文件提供,每个文件覆盖一段字符范围。loadFonts只获取latin文件,所以屏幕上的汉字来自系统字体,测量不准;fontsourceProvider交给PDF的也是这个latin文件,汉字印出来是空框。列出kit中的cjk块,在loadFonts之后调用loadCjkFonts(FONTS, markdown)(书中用到几种CJK字体时,每种字体调用一次,并传入它所排的文字),并给renderToPdf传fontProvider: cjkPdfProvider:两者都会取用包含文中字符的那些文件。 中日韩字体 →

  • 空的上方暂时还不能标读音:[[…]] 里的 ruby 属于隐藏的答案,田字格区域又占满整行。所以“看拼音写词语”一题把每个音节单独排一行,方框排在下面,用 :::columns 组每行排四个。
  • 楷体只排答案,所以只加载空里和参考答案里的字;如果楷体还要排别处的字,必须把这些字加进那段文本。

致谢

文本
原创文字, CC BY 4.0
图片
  • A Chinese village in early spring: willows, swallows, peach blossom and two children on a path between rice fields. · Generated With Diffusion Models · 原创
  • Two children count oranges, apples and pears at a grandmother's fruit stall, a cat on a stool beside them. · Generated With Diffusion Models · 原创
字体
Noto Serif SC (SIL OFL 1.1) · Noto Sans SC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1)
沙盒