Notes › Life Essays › How many characters fit on a copybook page: the default A4 gives 20, not 100 ÷ 4 = 25

How many characters fit on a copybook page: the default A4 gives 20, not 100 ÷ 4 = 25

A first-grader needed a tracing sheet, and the status bar read 每页 10 行 · 每行 10 格, which is 10 rows of 10 cells, so 100 cells on the sheet. One group of 范字 + 描红 plus 2 blanks eats 4 cells, so the arithmetic says 100 ÷ 4 = 25 and I typed 25 characters. The printer handed back two sheets with 5 characters hanging on the second one: the default A4 layout puts 20 characters on a page, not the 25 that 100 ÷ 4 promises. Only after I replayed the packing code did I see that it never performs that division.

Characters per page = floor(cells per row ÷ group width) × rows per page

The packing loop at lines 711-734 squeezes one group at a time into the row it is working on, and the moment "one more group would pass the cells per row" (line 717) it moves the whole group to a fresh row. A group never crosses a row boundary, and the cells left over at the end of a row are simply thrown away.

The default layout (A4 portrait, 16 mm cells, 10 mm margins, pinyin switched on, an info band on every page) measures cols=10 and rowsPerPage=10, and the group width of the mtb pattern is 4, so floor(10 ÷ 4) × 10 = 20: 2 groups fit in a row and 2 cells stay empty. The 5 groups I had counted on are exactly the 2 cells × 10 rows = 20 cell positions that can never receive content. The description block on the children handwriting copybook generator is written around that plain division.

Changing the pattern changes the group width

12 mm cells together with the narrow 6 mm margin push a row up to 14 cells and a page up to 13 rows, while the same parameters on A5 leave only 7 cells and 7 rows. All five layouts were run once in Node and once in headless Chrome, and the grids came out identical.

LayoutCells per rowRows per pageGroups per row / leftoversCharacters per page
A4 default mtb10102 groups / 2 spare20
A4 default mb10103 groups / 1 spare30
A4 default m (model fills the row)101010 groups / 0 spare100
A4 12 mm cells + 6 mm margin14133 groups / 2 spare39
A5 default parameters mtb771 group / 3 spare7

Switching pinyin off only moves the row count

The pinyin block is a horizontal strip whose height = cell width × 0.4, which is 6.4 mm for 16 mm cells (lines 662, 671-672). Turn it off and a page holds 13 rows, so 26 characters; set the info band to 不显示 and it is 11 rows, so 22 characters; drop both and you land on 14 rows, 28 characters. The status bar (lines 1067-1069) reads 每页 10 行 · 每行 10 格 · 格宽 16 mm · 练习位 44, meaning 10 rows, 10 cells per row, 16 mm cells and 44 practice slots, and it never states how many characters a page carries.

Three limits, each guarding a different thing

The first limit, 600, counts non-whitespace characters (lines 742-744): 600 characters interleaved with spaces already measure 1,199 raw and are still not cut; the cut arrives at 640 characters (raw 1,279), where the notice 内容超过 600 个字,已截断。 appears, in other words the content passed 600 characters and was truncated (line 1075).

The second one, 40 pages, is really a row budget, because maxRows = rowsPerPage × 40 (lines 759-761). The default layout gets a budget of 400 rows, and 600 characters only fill 300 rows, so the notice 已达到 40 页上限, "the 40 page ceiling was reached", can never be printed in the default layout.

A group wider than the cells per row opens a third path: A5 with 30 mm cells and the wide 15 mm margin gives cols=3 and rows=3, and the slice(0, cols) on line 716 chops the trailing blank off the group, so 11 characters spread over 11 rows on 4 pages. The first half of that notice says that only 3 cells fit in a row, a 4 cell group does not, and the surplus blanks were discarded.

Making the 40-page notice reachable

A5 with 20 mm cells and the mtb pattern gives rowsPerPage 5 and maxRows 200. From 201 characters onward the notice fires and 1 character is lost; at 600 characters the content is sliced to 200 rows and 400 characters go in one strike, while pageCount still reports 40 and none of the discarded rows show up in the preview.

0 0 1 50 400 199 chars 200 chars 201 chars 250 chars 600 chars Dropped characters Row budget 200

Fig. 1: how much is dropped against the row budget of 200.

What the #py: cell accepts and what it refuses

The page hint (line 73) tells you to start a new line with #py: yin1 yue4, and whether that ever lands inside a cell depends on the guard on line 586, /^[a-z]+[0-9]$/:

  • Only a pure ASCII syllable followed by a digit tone converts: tian1 becomes tiān and lve4 becomes lüè;
  • Letters that already carry a tone mark pass through untouched, and the full-width colon #py:, the upper-case #PY: and numbered input such as 1. tian1 2. di2 are all accepted;
  • Mixed spellings do not convert at all and the digit is printed inside the cell: lüe4, yuè4, TIAN1.

Calling applyToneMarks directly with lüe4 leaves nothing but è, because the regular expression on line 377 does not accept ü inside its letter class; the guard on line 586 keeps that case out of the interface, so on the page the only symptom is a leftover digit.

The pinyin table holds 2,639 keys with 44 duplicates

Object.keys(PINYIN_MAP).length is 2,639: lines 303-350 literally list 2,676 entries and 44 keys are written twice (2,632 unique ones remain), after which lines 353-359 patch in 7 high-frequency characters the first pass had missed (粒 得 区 我 为 而 西). The later write wins, so 9 duplicates resolve to the same reading and do no harm, while for 35 pairs whose two readings differ the surviving one is often not the common one — 了 comes out as liǎo because le0 is written earlier on line 325, 阿 as ē, and 省 as xǐng. To check a reading, run the same text through the Han to pinyin converter first, since both pages read one and the same table.

Spaces do not separate groups, and punctuation and digits each take a cell

In Han mode, lines 567-578 discard every whitespace character, so 天 地, 天地, the two characters split by a newline and the two split by a tab are four equivalent inputs: "a space takes no cell" is right, "use spaces to group" is not. Only the pinyin and digit branch splits on whitespace (lines 605-614, and line 609 does not even count a tab): ni hao is 2 units while nihao is 1. The quick word list 词语练习, whose content is 朋友 同学 …, therefore packs as 21 single characters in a row.

Punctuation and digits cost one cell each: 123 456 is 6 units, and the , and 。 of the preset 静夜思 each take a cell of their own. Nothing is guessed for a character the table does not carry: 魃鑫龘弼黉 leave the pinyin blank and are counted into the miss notice (line 1081), and characters such as ^丨丶 are counted as misses too (measured 2).

Blanks after a full-row model do not start a new row

The hint for mrow says that the blank count equals the number of tracing rows, but lines 696-709 append b copies of a blank string as long as the content to the tail of the model stream and then let the packer run across it continuously. Under the default 10 columns, 11 characters with a blank count of 1 come out as MMMMMMMMMM / M········· / ··, and with a blank count of 8 the total is 99 cells = 11×9.

What the export sizes and the PDF actually contain

Measured on one A4 page carrying 11 characters: JPEG 514,478 bytes, PNG 950,158 bytes, which is 1.85 times larger, and the PDF the tool writes itself 515,288 bytes with a container of 810 bytes. Every PDF page is a single bitmap: the content stream is only a 36-character q … cm /ImN Do Q and holds no text operators at all (lines 1450, 1454). Export renders at 12 px/mm = 305 dpi, direct printing uses 8 px/mm ≈ 203 dpi, and past 15 pages it falls back to 6 px/mm ≈ 152 dpi (lines 470-472, 1255).

Matching the page copy back to the code, item by item

The passage at lines 2205-2209 of config/tools.php, replayed against the current template, gives 33 checks: 28 agree and 5 conflict. The hard-coded 2639, the 20 / 30 / 100 / 39 characters per page, 13 rows with pinyin off, the 7×7×1 on A5, and 305 / 203 / 152 dpi all hit. Every one of the 5 conflicts is about scope, because the copy overstates grouping on whitespace, "the common reading", "all accepted", 40 pages, and one tracing row per blank. The other side of that pinyin data is in the article on annotating Han characters with pinyin.

Three traps that are easy to step into

Q1: the gap set to 0 adds a column, does that buy another group?

It does not. With the gap at 0, cols becomes 11, and 11 ÷ 4 still floors to 2 groups, so a page still carries 20 characters.

Q2: which part of the 600 characters is dropped first?

The notice only says the content was truncated (line 1075); it never names a starting position, and the dropped rows are missing from the preview as well.

Q3: can I learn the count per page without mental arithmetic?

The page will not hand it to you. Export one version and count the last page: by default 20 characters per page, so 25 characters split across two pages. For longer text switch to mb, which carries 30 characters per page.

📎 Related articles

A day on both the weekend list and the make-up list: which rule the workday tool obeys 2026-10-27 A Countdown Day Is 86400 Seconds, So DST Can Steal a Day 2026-10-27 Where the world clock offset comes from: one regex, and a hardcoded hour count that ignores daylight saving 2026-10-27 One calculation prints two body fat percentages: 6.1 points apart by default, 0.7 per 1 cm of waist 2026-10-26 A photo rotated 90° lies flat again on another device: how the EXIF flag and a real rotation split the work 2026-10-22