# Column arithmetic as each country writes it

> A maths worksheet whose sums are - 864 ÷ 4 lines in the Markdown, set digit by digit in squares, long division as each country's schools lay it out.

- HTML version: https://postext.dev/en/cookbook/column-arithmetic-by-country
- Recipe Nº 170 · Textbooks · Level 3 (Advanced) · Outputs: Canvas, PDF, Live controls
- Genres: Workbooks & exercises, Textbooks
- Requires postext ≥ 1.59.0, postext-pdf ≥ 1.59.0 · tested with 1.59.0, postext-pdf 1.59.0 on 2026-10-24
- Pages: [1](https://postext.dev/cookbook/column-arithmetic-by-country/en/p01.webp?v=7c77fd20), [2](https://postext.dev/cookbook/column-arithmetic-by-country/en/p02.webp?v=7c77fd20), [1](https://postext.dev/cookbook/column-arithmetic-by-country/en/p03.webp?v=7c77fd20), [2](https://postext.dev/cookbook/column-arithmetic-by-country/en/p04.webp?v=7c77fd20)
- PDF: https://postext.dev/cookbook/column-arithmetic-by-country/en/column-arithmetic-by-country.pdf?v=7c77fd20
- Open in Sandbox: https://postext.dev/en/sandbox#recipe=column-arithmetic-by-country&lang=en (.postext: https://postext.dev/cookbook/column-arithmetic-by-country/en/column-arithmetic-by-country.postext)
- Last updated: 2026-10-24
- Other languages: [es](https://postext.dev/es/cookbook/column-arithmetic-by-country.md), [ca](https://postext.dev/ca/cookbook/column-arithmetic-by-country.md), [de](https://postext.dev/de/cookbook/column-arithmetic-by-country.md), [fr](https://postext.dev/fr/cookbook/column-arithmetic-by-country.md), [it](https://postext.dev/it/cookbook/column-arithmetic-by-country.md), [pt](https://postext.dev/pt/cookbook/column-arithmetic-by-country.md), [zh](https://postext.dev/zh/cookbook/column-arithmetic-by-country.md), [ja](https://postext.dev/ja/cookbook/column-arithmetic-by-country.md), [ar](https://postext.dev/ar/cookbook/column-arithmetic-by-country.md)

## In short

A page of sums for nine-year-olds in Spanish, English, French and German. Each country writes long division its own way, and the sheet follows it; a switch shows the teacher's copy with every answer filled in.

## What you'll build

Unit 3 of a Year 4 calculation book, *The four operations*, in four editions. A row of mental sums in three columns, then additions, subtractions and multiplications written one digit to a square with a row of squares for the answer, then long division and a word problem on squared paper. The division is where the editions part: the English sheet sets the divisor outside the ⟌ bracket with the quotient on top; the Spanish and French ones put the divisor in an L to the right of the dividend and the quotient under it; the German one writes `864 : 4 =` on one line with room for the working below, and its products with a dot. The decimal comma or point of the mental sums follows the language too. A switch above the pages turns the pupil's sheet into the teacher's copy, every answer printed in orange in its square.

**This recipe answers:**

- How do I set long division the way each country's schools write it?
- How do I make a teacher's edition with the answers printed in their places from the pupil's book?

## The short answer

Operations in squares, long division as the edition's schools set it.

```js
// script.js, lines 26–42
// `- 864 : 4` in the Markdown is one operation; the style sets it digit by digit in squares
// of 7.2 mm, a cell for each figure of the answer. Long division follows the country: the
// divisor in an L right of the dividend in Spain and France (potence), `864 : 4 =` on one
// line in Germany, the bracket ⟌ in English-speaking schools.
const calculo = {
  id: 'calculo',
  number: { shape: 'disc', background: col('accent'), fontFamily: LABEL, fontWeight: 700 },
  instruction: { bold: true, color: col('ink') },
  grid: { cell: mm(9), fontFamily: TEXT, fontSize: pt(15), rule: col('rule'),
    color: col('ink') },
  arithmetic: { columns: 4,
    division: t({ es: 'potence', fr: 'potence', de: 'inline', en: 'bracket' }) },
  blank: { look: 'box', width: em(3.2), color: col('rule'), weight: pt(0.8) },
  area: { kind: 'grid', spacing: mm(5), color: col('rule'), weight: pt(0.4) },
  answers: { color: col('accent'), fontFamily: LABEL }, // the teacher's copy, in the accent
  marginTop: mm(7), marginBottom: mm(2), itemSpacing: em(0.5),
};
```

## Ingredients

**Teaches**

- [Operations in columns](https://postext.dev/en/docs/exercises.md#operations-in-columns): Operations in columns (+ − ×) with a cell for each digit, and long division set the way the language's schools set it: the bracket ⟌, the potence of Spain, France and Italy, or Germany's 864 : 4 = in a row.
- [Teacher's edition](https://postext.dev/en/docs/exercises.md#teachers-edition): exercises.edition: 'teacher' prints every answer in its own slot in a second colour, rings the right options and draws the matching lines: the teacher's book from the pupil's text.
- [Exercise styles](https://postext.dev/en/docs/exercises.md#exercise-styles): exerciseStyles: how a family of exercises prints — the number (disc, square, label), the instruction, option labels, blanks, true/false boxes, dots to join, the word bank, writing areas, grids, activity icons and difficulty marks.

**Also uses**

- [Exercises](https://postext.dev/en/docs/exercises.md#exercises)
- [Gaps and word banks](https://postext.dev/en/docs/exercises.md#gaps-and-word-banks)
- [Open questions and writing areas](https://postext.dev/en/docs/exercises.md#open-questions-and-writing-areas)
- [Designed openers](https://postext.dev/en/docs/configuration-headings.md#span-and-advanced-design)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Semantic colour palette](https://postext.dev/en/docs/configuration-fonts-colors-viewers.md#color-palette)
- [Hyphenation and document language](https://postext.dev/en/docs/justification.md#supported-locales)
- [Pages on a canvas](https://postext.dev/en/docs/configuration-programmatic-usage.md#rendering-a-page-to-a-bitmap)
- [PDF export](https://postext.dev/en/docs/configuration-pdf-output.md#generating-pdfs)
- [Paragraph styles](https://postext.dev/en/docs/configuration-styles.md#paragraph-styles)
- [Columns in a box or in the text](https://postext.dev/en/docs/document-format-directives.md#columns)
- [Solutions](https://postext.dev/en/docs/exercises.md#solutions)
- [Fonts embedded in the PDF](https://postext.dev/en/docs/configuration-pdf-output.md#why-a-font-provider)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration-text.md#body-text), [`colorPalette`](https://postext.dev/en/docs/configuration-fonts-colors-viewers.md#color-palette), `exerciseStyles`, `exercises`, [`footer`](https://postext.dev/en/docs/configuration-headers-footers.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration-headers-footers.md#headers--footers), [`headings`](https://postext.dev/en/docs/configuration-headings.md#headings), [`layout`](https://postext.dev/en/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/en/docs/configuration-language.md#hyphenation), [`orderedLists`](https://postext.dev/en/docs/configuration-lists-math.md#ordered-lists), [`page`](https://postext.dev/en/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration-styles.md#paragraph-styles)

**APIs**

- [`buildDocument`](https://postext.dev/en/docs/configuration-programmatic-usage.md#building-a-document), [`buildDocumentWithFonts`](https://postext.dev/en/docs/configuration-programmatic-usage.md#loading-fonts-before-layout), [`decompressWoff2`](https://postext.dev/en/docs/configuration-pdf-output.md#browser-font-provider-fontsource--woff2), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration-programmatic-usage.md#rendering-a-page-to-a-bitmap), [`renderToPdf`](https://postext.dev/en/docs/configuration-pdf-output.md#generating-pdfs)

**Typefaces**

- Fira Sans (OFL-1.1), Lexend (OFL-1.1)

## Method

### 1 · Write the sums as lines of the Markdown

```js
// script.js, lines 26–42
// `- 864 : 4` in the Markdown is one operation; the style sets it digit by digit in squares
// of 7.2 mm, a cell for each figure of the answer. Long division follows the country: the
// divisor in an L right of the dividend in Spain and France (potence), `864 : 4 =` on one
// line in Germany, the bracket ⟌ in English-speaking schools.
const calculo = {
  id: 'calculo',
  number: { shape: 'disc', background: col('accent'), fontFamily: LABEL, fontWeight: 700 },
  instruction: { bold: true, color: col('ink') },
  grid: { cell: mm(9), fontFamily: TEXT, fontSize: pt(15), rule: col('rule'),
    color: col('ink') },
  arithmetic: { columns: 4,
    division: t({ es: 'potence', fr: 'potence', de: 'inline', en: 'bracket' }) },
  blank: { look: 'box', width: em(3.2), color: col('rule'), weight: pt(0.8) },
  area: { kind: 'grid', spacing: mm(5), color: col('rule'), weight: pt(0.4) },
  answers: { color: col('accent'), fontFamily: LABEL }, // the teacher's copy, in the accent
  marginTop: mm(7), marginBottom: mm(2), itemSpacing: em(0.5),
};
```

An `:::exercise{type="arithmetic"}` takes one operation per list item, `- 864 ÷ 4`, in the signs its country writes (`÷`, `:`, `×`, `·`). The engine works out the answer, the quotient and the remainder, and the style decides how the sum is drawn: squares of 7.2 mm, and `arithmetic.division` picked per edition with `t()`. A mental-arithmetic row is an ordinary gaps exercise whose list sits in a `:::columns{count=3}` group, each `[[70]]` a box as wide as the others.

### 2 · Head the sheet

```js
// script.js, lines 46–67
const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const field = (id, attr, x, width) => [
  { kind: 'text', id, content: `{attr.${attr}}`, fontFamily: LABEL, fontSize: pt(9.5),
    fontWeight: 600, color: col('muted'), align: 'left',
    placement: at('container', 'top-left', x, 26) },
  { kind: 'rule', id: `${id}-line`, direction: 'horizontal', thickness: pt(0.75),
    color: col('rule'), placement: at('container', 'top-left', x + 17, 31, { width: mm(width) }) },
];
const head = { enabled: true, minHeight: mm(36), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('accent'), borderRadius: mm(3) },
    placement: at('container', 'top-left', 0, 0, { width: 'fill', height: mm(19) }) },
  { kind: 'text', id: 'unit', content: '{attr.unidad}', fontFamily: LABEL, fontSize: pt(9),
    fontWeight: 700, color: col('accent'), align: 'center', verticalAlign: 'middle',
    textTransform: 'uppercase', letterSpacing: pt(1.2), lineHeight: 1.1,
    box: { backgroundColor: col('paper'), borderRadius: mm(3.5) },
    placement: at('container', 'top-left', 5, 5, { width: mm(26), height: mm(9) }) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: LABEL, fontSize: pt(21),
    fontWeight: 700, color: col('paper'), align: 'left', lineHeight: 1.1,
    placement: at('container', 'top-left', 37, 5.2) },
  ...field('name', 'nombre', 0, 100), ...field('date', 'fecha', 124, 38),
] } };
```

The unit's level-1 heading draws the orange band, the pill and the title, and the name and date fields from its attributes (`nombre="Name" fecha="Date"`), so every edition labels them in its own language.

### 3 · Switch between the pupil's sheet and the teacher's copy

```js
// script.js, lines 180–194
const form = document.getElementById('controls');
const labels = t({ es: ['Alumno', 'Profesor'], en: ['Pupil', 'Teacher'],
  fr: ['Élève', 'Enseignant'], de: ['Schüler', 'Lehrkraft'] });
form.querySelectorAll('label span').forEach((span, k) => { span.textContent = labels[k]; });
function render() {
  // The same config, edited in place: the next build sees the edition the form asks for.
  config.exercises.edition = form.elements.edition.value;
  const doc = buildDocument({ markdown }, config); // the faces are loaded by now
  showPages(doc, { title });
  document.querySelectorAll('#pt-actions a, [data-postext-pdf]').forEach((old) => old.remove());
  offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider }),
    `${RECIPE}-${LANG}-${config.exercises.edition}.pdf`);
}
form.addEventListener('change', render);
render();
```

`exercises.edition: 'teacher'` prints every answer in its slot in the colour `answers.color` gives, here the accent: the boxes of the mental sums, the digits of each result, the quotient and remainder, the model answer of the word problem. The form above the pages edits the config in place and builds again; the fonts are already loaded, so the plain `buildDocument` is enough. The capture builds both sheets first, so the published pages show the pupil's sheet and then the teacher's.

## The whole recipe

The files below are composed from the recipe's folder, with the sample text and the Cookbook's shared kit inlined. To run them as one page, put the HTML in `<body>`, the CSS in a `<style>` element and the script in a `<script type="module">`; or paste each into the matching panel of a new CodePen (JS as a module). The script imports postext from esm.sh, so there is nothing to install or build.

- Source folder: https://github.com/drnachio/postext/tree/main/cookbook/column-arithmetic-by-country

### index.html

```html
<form id="controls" aria-label="Edition">
  <label><input type="radio" name="edition" value="student" checked> <span></span></label>
  <label><input type="radio" name="edition" value="teacher"> <span></span></label>
</form>
<main id="pages"></main>
```

### style.css

```css
/* The edition switch above the desk: two pills in the sheet's orange. */
#controls {
  display: flex; justify-content: center; gap: 8px; padding: 14px 16px; background: #16181d;
  font: 600 14px/1.2 Lexend, system-ui, sans-serif;
}
#controls label {
  display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 18px;
  border-radius: 22px; color: #f4f1ea; border: 1px solid #5f6670; cursor: pointer;
}
#controls label:has(input:checked) { background: #c4511c; border-color: #c4511c; }
#controls input { accent-color: #fff; }
```

### script.js

```js
// ═══ Postext Cookbook · Nº 170 · Column arithmetic as each country writes it ═══════
// https://postext.dev/en/cookbook/column-arithmetic-by-country
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Fira Sans, Lexend (SIL OFL 1.1) · Needs postext ≥ 1.59.0
import { buildDocumentWithFonts, buildDocument, renderPageToCanvas } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the worksheet ('es' | 'en' | 'fr' | 'de')
const RECIPE = 'column-arithmetic-by-country';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink, one orange accent, a pale tint, the grey of squared paper
const palette = {
  ink: '#1f2328', accent: '#c4511c', tint: '#fbeee6', rule: '#b9c0c8', muted: '#5f6670',
  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
const [TEXT, LABEL] = ['Fira Sans', 'Lexend'];

// #region answer: operations in squares, long division as the edition's schools set it
// `- 864 : 4` in the Markdown is one operation; the style sets it digit by digit in squares
// of 7.2 mm, a cell for each figure of the answer. Long division follows the country: the
// divisor in an L right of the dividend in Spain and France (potence), `864 : 4 =` on one
// line in Germany, the bracket ⟌ in English-speaking schools.
const calculo = {
  id: 'calculo',
  number: { shape: 'disc', background: col('accent'), fontFamily: LABEL, fontWeight: 700 },
  instruction: { bold: true, color: col('ink') },
  grid: { cell: mm(9), fontFamily: TEXT, fontSize: pt(15), rule: col('rule'),
    color: col('ink') },
  arithmetic: { columns: 4,
    division: t({ es: 'potence', fr: 'potence', de: 'inline', en: 'bracket' }) },
  blank: { look: 'box', width: em(3.2), color: col('rule'), weight: pt(0.8) },
  area: { kind: 'grid', spacing: mm(5), color: col('rule'), weight: pt(0.4) },
  answers: { color: col('accent'), fontFamily: LABEL }, // the teacher's copy, in the accent
  marginTop: mm(7), marginBottom: mm(2), itemSpacing: em(0.5),
};
// #endregion

// #region head: the unit pill, the title and the lines for a name and a date
const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const field = (id, attr, x, width) => [
  { kind: 'text', id, content: `{attr.${attr}}`, fontFamily: LABEL, fontSize: pt(9.5),
    fontWeight: 600, color: col('muted'), align: 'left',
    placement: at('container', 'top-left', x, 26) },
  { kind: 'rule', id: `${id}-line`, direction: 'horizontal', thickness: pt(0.75),
    color: col('rule'), placement: at('container', 'top-left', x + 17, 31, { width: mm(width) }) },
];
const head = { enabled: true, minHeight: mm(36), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('accent'), borderRadius: mm(3) },
    placement: at('container', 'top-left', 0, 0, { width: 'fill', height: mm(19) }) },
  { kind: 'text', id: 'unit', content: '{attr.unidad}', fontFamily: LABEL, fontSize: pt(9),
    fontWeight: 700, color: col('accent'), align: 'center', verticalAlign: 'middle',
    textTransform: 'uppercase', letterSpacing: pt(1.2), lineHeight: 1.1,
    box: { backgroundColor: col('paper'), borderRadius: mm(3.5) },
    placement: at('container', 'top-left', 5, 5, { width: mm(26), height: mm(9) }) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: LABEL, fontSize: pt(21),
    fontWeight: 700, color: col('paper'), align: 'left', lineHeight: 1.1,
    placement: at('container', 'top-left', 37, 5.2) },
  ...field('name', 'nombre', 0, 100), ...field('date', 'fecha', 124, 38),
] } };
// #endregion

const config = {
  locale: t({ es: 'es', en: 'en-gb', fr: 'fr', de: 'de' }),
  colorPalette,
  page: { sizePreset: 'custom', width: mm(210), height: mm(297), dpi: 150,
    margins: { top: mm(18), bottom: mm(20), left: mm(18), right: mm(18), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(18), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), hyphenation: { enabled: false },
    optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: { fontFamily: LABEL, color: col('ink'), fontWeight: 700, levels: [
    { level: 1, fontSize: pt(21), breakBefore: { parity: 'any' }, marginTop: pt(0),
      marginBottom: mm(1), advancedDesign: head },
  ] },
  exerciseStyles: [calculo],
  exercises: { edition: 'student' }, // the switch above the pages sets 'teacher'
  orderedLists: { fontFamily: LABEL, numberFormat: 'arabic', color: col('accent'),
    fontWeight: 600, marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [{ id: 'colofon', fontFamily: TEXT, fontSize: pt(7.5), lineHeight: pt(10),
    color: col('muted'), firstLineIndent: pt(0), marginTop: mm(6) }],
  header: { elements: [] },
  footer: { elements: [{ kind: 'text', id: 'folio', content: '{pageNumber}', fontFamily: LABEL,
    fontSize: pt(9), fontWeight: 700, color: col('accent'), align: 'center',
    placement: at('page', 'bottom', 0, -11) }] },
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Maths 4"
subtitle: "Calculation book"
---

# The four operations {unidad="Unit 3" nombre="Name" fecha="Date"}

:::exercise{type="gaps"}
Work these out in your head and write the answer.

:::columns{count=3}
1. 40 + 30 = [[70]]
2. 90 − 25 = [[65]]
3. 7 × 8 = [[56]]
4. 63 ÷ 9 = [[7]]
5. 250 + 750 = [[1000]]
6. 1000 − 400 = [[600]]
7. 0.5 + 0.25 = [[0.75]]
8. 1.5 × 2 = [[3]]
9. 4.8 ÷ 2 = [[2.4]]
:::
:::

:::exercise{type="arithmetic"}
Write the numbers in columns and add.

- 4567 + 2385
- 3528 + 1764
- 806 + 1497
:::

:::exercise{type="arithmetic"}
Subtract, then check your answer with an addition.

- 7302 − 2865
- 5040 − 1376
- 913 − 458
:::

:::exercise{type="arithmetic"}
Multiply.

- 437 × 6
- 1209 × 8
- 365 × 7
:::

:::exercise{type="arithmetic"}
Divide. When the division does not come out exactly, write the remainder too.

- 864 ÷ 4
- 1575 ÷ 5
- 917 ÷ 7
- 638 ÷ 3
:::

:::exercise{type="open" lines="6"}
A box of colouring pencils costs £3.75. How much do four boxes cost? If you pay with a £20 note, how much change do you get?

:::solution
Four boxes cost £3.75 × 4 = £15. Your change is £20 − £15 = £5.
:::
:::

:::paragraphs{style="colofon"}
Set in Fira Sans and Lexend (SIL OFL) · Original text (CC BY 4.0)
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Fira Sans': ['400', '700'], Lexend: ['600', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const title = t({ es: 'Cuaderno de cálculo', en: 'Calculation book', fr: 'Cahier de calcul',
  de: 'Rechenheft' });
// The pupil's sheet first, then the teacher's copy: the capture shows both.
await buildDocumentWithFonts({ markdown }, config, kitFonts(FONTS));
config.exercises.edition = 'teacher';
buildDocument({ markdown }, config);

// #region live: a switch between the pupil's sheet and the teacher's copy
const form = document.getElementById('controls');
const labels = t({ es: ['Alumno', 'Profesor'], en: ['Pupil', 'Teacher'],
  fr: ['Élève', 'Enseignant'], de: ['Schüler', 'Lehrkraft'] });
form.querySelectorAll('label span').forEach((span, k) => { span.textContent = labels[k]; });
function render() {
  // The same config, edited in place: the next build sees the edition the form asks for.
  config.exercises.edition = form.elements.edition.value;
  const doc = buildDocument({ markdown }, config); // the faces are loaded by now
  showPages(doc, { title });
  document.querySelectorAll('#pt-actions a, [data-postext-pdf]').forEach((old) => old.remove());
  offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider }),
    `${RECIPE}-${LANG}-${config.exercises.edition}.pdf`);
}
form.addEventListener('change', render);
render();
// #endregion

// ─── 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 and asks the kit
// for Fontsource's files, the ones the PDF embeds too.

/** buildDocumentWithFonts' options: the resolver, and FONTS' faces loaded
 *  with the config's. 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 files with their ranges: latin, then latin-ext and greek when
 *  `text` needs them (kitSubsetsFor). Null for a face Fontsource lacks. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(await fontsourceWeights(family, style)).includes(weight)) 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],
  }));
}

/** Each file's unicode-range (a function: the kit runs after the pen's code). */
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: loads `faces` (as FONTS) for the
 *  sample `text`; `optional` skips a face Fontsource lacks. Files are added in
 *  the order asked, the last winning a shared character. Resolves to the count. */
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 && (await fontsourceWeights(family, style)).includes(weight))) 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 face (fonts.check() says yes to any family). */
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, id = fontsourceId(family)) {
  return (fontsourceMeta.c ??= {})[id] ??= fetch(`https://api.fontsource.org/v1/fonts/${id}`)
    .then((res) => (res.ok ? res.json() : null), () => null);
}

/** The weights the package ships in `style`: the API pairs none (no Libre
 *  Caslon 700 italic file), so italics are read from latin-italic.css. */
function fontsourceWeights(family, style, id = fontsourceId(family)) {
  return (fontsourceWeights.c ??= {})[id + style] ??= fontsourceMeta(family).then(async (m) => {
    if (!m?.styles.includes(style)) return m ? [] : [400, 700];
    const css = style === 'italic' ? await fetch(`${await fontsourceAt(id)}latin-italic.css`).then((r) => (r.ok ? r.text() : ''), () => '') : '';
    const w = [...css.matchAll(/weight: (\d+)/g)].map((x) => +x[1]);
    return w.length ? w : m.weights;
  });
}

// ─── 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 in that style, 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 s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const shipped = meta ? await fontsourceWeights(family, s) : [];
  const weights = shipped.length ? shipped : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  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 ───────────────────────────────────────────────────────────────────────
```

## Variations

### Put the answers upside down at the foot

A puzzle-book habit: each exercise's key, turned 180° under it.

```diff
-  exercises: { edition: 'student' }, // the switch above the pages sets 'teacher'
+  exercises: { edition: 'student', solutions: { placement: 'inverted', layout: 'key' } },
```

### Divide under the bracket in every edition

Japanese and Chinese schools also use the bracket; the default for a language you do not list is its own.

```diff
-    division: t({ es: 'potence', fr: 'potence', de: 'inline', en: 'bracket' }) },
+    division: 'bracket' },
```

## Pitfalls

- Operations take whole numbers: a sum with decimals such as `12,5 + 3,75` is not read and prints as written. Keep decimals in the mental-arithmetic gaps, where the answer is text.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Type: Fira Sans (OFL-1.1), Lexend (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 168 · Primary workbook unit of activities](https://postext.dev/en/cookbook/primary-workbook-activities.md): A four-page science workbook unit whose eleven activities are :::exercise fences in the Markdown, printed by one exercise style: discs, bank, dots to join. · Level 2 (Intermediate) · Workbooks & exercises, Textbooks
- [Nº 172 · A Japanese drill with the answers upside down](https://postext.dev/en/cookbook/japanese-drill-upside-down-answers.md): Two pages of a Japanese drill series, arithmetic across and kanji in columns, each exercise followed by its answers printed upside down. · Level 2 (Intermediate) · Workbooks & exercises, Textbooks
- [Nº 174 · A puzzle page with the answers upside down](https://postext.dev/en/cookbook/puzzle-page-upside-down-answers.md): A children's magazine puzzle page: crossword, word search, riddle, story to order, matching and a mixed-up fact on cards, each with its answers upside down. · Level 2 (Intermediate) · Magazines & zines, Workbooks & exercises
