Skip to main content
Recipe number 171

Cookbook · Chapter 12 · Textbooks

A Chinese exercise book, pupil's and teacher's

A 练习册 in Chinese and maths: sections 一、二、, ( ) brackets, 田字格 and sums in columns, built twice from one text, for the pupil and the teacher.

On this page
Output
Canvas · PDF
Postext
Tested with Postext 1.59.0
Needs ≥ 1.59.0 · postext-pdf ≥ 1.59.0
Licence
Updated 24 Oct 2026
Code MIT · Text CC BY 4.0
  • Foreign-language sample: no English edition yet
  • Trim 184 × 260 mm
  • 1 column
  • Noto Serif SC 10.5/21
  • LXGW WenKai TC
  • Noto Sans SC
  • 8 pages
  • Level
  • Postext 1.59.0
  • Laid out in 124 ms
  • 162 lines of code

In short

A Chinese primary-school exercise book: two units of language and maths questions. The same text makes the pupil's copy, with blanks and an answer key at the end, and the teacher's copy, with every answer written in red where it goes.

What you'll build

Two units of a second-year 练习册 on a 16开 page: a language unit, 春天来了, and a maths unit on sums up to 100. Each opens on a watercolour band, a subject tab and a line for class, name and score, then runs through the sections a Chinese workbook prints: 看拼音写词语, readings to ring, 选择题 with a ( ) after the stem and A. B. C. D. in a row, 判断题 with a bracket at the end of the line, gaps, 连线 between measure words and things, a sentence to write on 田字格, 口算 in four columns, 竖式 with a box for each digit and a word problem. The points sit in each instruction, as the pupil reads them. The same Markdown is laid out twice: the pupil's book, with 参考答案 at the end of each unit, and the teacher's book, with every answer written in red Kai in its place.

This recipe answers

  • How do I set Chinese test questions: a ( ) bracket after the stem and A. B. C. D. options in a row?
  • How do I make a teacher's edition with the answers printed in their places from the pupil's book?
  • How do I give an open question French Seyès ruling, squared paper or 田字格 squares to write in?
  • How do I set long division the way each country's schools write it?

The short answer

script.js · lines 36–65in full code
// 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' } });

Sections 一、二、 that print the pupil's book or the teacher's

Ingredients

Type
Noto Serif SC, Noto Sans SC, LXGW WenKai TC (SIL OFL 1.1)
Assets
  • 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, original)
  • Two children count oranges, apples and pears at a grandmother's fruit stall, a cat on a stool beside them. (Generated With Diffusion Models, original)

Method

#1 · One style for every section

The code is the short answer above. Sections are counted in Chinese numerals with a full-width 、 after them, so no word space follows the number; the instruction is set in the Hei face, unbolded, so the points in brackets read as part of it. options.bracket writes the ( ) after each stem, and the right letter goes into it in the teacher's book. The two small styles extend the main one: pinyin draws each blank as a box as wide as its word, kuohao draws nothing, because the bracket of 在( )里填 is in the text.

#2 · The pupil's book and the teacher's from one text

script.js · lines 334–338in full code
const build = () => withLoadedFonts(() => buildDocument({ markdown, resources }, config()),
  { ...kitFonts(FONTS), text: markdown });
const pupil = await build();
edition = 'teacher';
const docs = [pupil, await build()];

The configuration is a function, so each build gets its own object and the Sandbox bundle keeps the pupil's one. edition: 'teacher' writes the answers in the colour and face of answers: the words in their boxes, the letters in their brackets, √ and × in the 判断题, the digits in the 竖式 cells, the model sentence one character to a square. The pupil's book keeps the key for the :::solutions line at the end of each unit; the teacher's book needs none.

#3 · A unit opener with its painting

script.js · lines 69–87in full code
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' } } },
] } };

The painting is anchored to the page, so it runs to the top edge on a recto and on a verso alike; the subject tab and the title are placed from the top of the text block, below the painting's foot. Both units share the design and differ in their heading attributes, kicker and pic.

#4 · Folios at the foot

script.js · lines 91–101in full code
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' }),
] };

Chinese workbooks put the folio at the foot. The series title comes from the Markdown front matter ({title}), the unit's from its heading ({chapterTitle}).

The whole recipe

Sandbox
// ═══ 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') },

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

Variations

Without :::solutions lines, chapterEnd collects each unit's key before the next level-1 heading.

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

#Write the readings beside the blanks

The pinyin primer sets pinyin over its characters as ruby, which a blank cannot hold yet.

Pitfalls

Pitfall

Chinese faces load by slices, through the cjk block

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

  • A reading cannot stand over a blank yet: a ruby inside [[…]] is part of the hidden answer, and a 田字格 area takes the whole measure. The 看拼音写词语 section sets each syllable on its own line and the box under it, four to a row in a :::columns group.
  • The Kai face sets only the answers, so it loads the characters of the gaps and the model answers alone; a character it sets anywhere else must be added to that text.

Credits

Text
Original prose, CC BY 4.0
Images
  • A Chinese village in early spring: willows, swallows, peach blossom and two children on a path between rice fields. · Generated With Diffusion Models · original
  • Two children count oranges, apples and pears at a grandmother's fruit stall, a cat on a stool beside them. · Generated With Diffusion Models · original
Fonts
Noto Serif SC (SIL OFL 1.1) · Noto Sans SC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1)
Sandbox