# Cuentas en columna como las escribe cada país

> Una ficha de cálculo cuyas cuentas son líneas - 864 : 4 del Markdown, cifra a cifra en casillas, con la división como la hacen las escuelas de cada país.

- Versión HTML: https://postext.dev/es/cookbook/column-arithmetic-by-country
- Receta N.º 170 · Libros de texto · Nivel 3 (Avanzado) · Salidas: Canvas, PDF, Controles en vivo
- Géneros: Cuadernos y ejercicios, Libros de texto
- Requiere postext ≥ 1.59.0, postext-pdf ≥ 1.59.0 · probada con 1.59.0, postext-pdf 1.59.0 el 2026-10-24
- Páginas: [1](https://postext.dev/cookbook/column-arithmetic-by-country/es/p01.webp?v=745a9023), [2](https://postext.dev/cookbook/column-arithmetic-by-country/es/p02.webp?v=745a9023), [1](https://postext.dev/cookbook/column-arithmetic-by-country/es/p03.webp?v=745a9023), [2](https://postext.dev/cookbook/column-arithmetic-by-country/es/p04.webp?v=745a9023)
- PDF: https://postext.dev/cookbook/column-arithmetic-by-country/es/column-arithmetic-by-country.pdf?v=745a9023
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=column-arithmetic-by-country&lang=es (.postext: https://postext.dev/cookbook/column-arithmetic-by-country/es/column-arithmetic-by-country.postext)
- Última actualización: 2026-10-24
- Otros idiomas: [en](https://postext.dev/en/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)

## En pocas palabras

Una página de cuentas para niños de nueve años en español, inglés, francés y alemán. Cada país escribe la división a su manera y la ficha lo sigue; un interruptor muestra la copia del profesor con todas las respuestas.

## Lo que vas a componer

La unidad 3 de un cuaderno de cálculo de 4.º de Primaria, *Las cuatro operaciones*, en cuatro ediciones. Una fila de cálculo mental en tres columnas; sumas, restas y multiplicaciones escritas con una cifra por casilla y una fila de casillas para el resultado; divisiones y un problema en cuadrícula. En la división se separan las ediciones: la española y la francesa ponen el divisor en una caja a la derecha del dividendo y el cociente debajo; la inglesa lo deja fuera del signo ⟌, la «casita», con el cociente encima; la alemana escribe `864 : 4 =` en una línea con sitio debajo para los pasos, y sus productos con un punto. La coma o el punto decimal del cálculo mental sigue también al idioma. Un interruptor sobre las páginas convierte la ficha del alumno en la copia del profesor, con cada respuesta en naranja en su casilla.

**Esta receta responde a:**

- ¿Cómo compongo la división tal como la escriben las escuelas de cada país?
- ¿Cómo saco la edición del profesor, con las respuestas impresas en su sitio, del mismo libro del alumno?

## La respuesta corta

```js
// script.js, líneas 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),
};
```

## Ingredientes

**Enseña**

- [Operaciones en columna](https://postext.dev/es/docs/exercises.md#operaciones-en-vertical): Operaciones en columna (+ − ×) con una casilla para cada cifra, y la división larga tal como la escriben las escuelas de cada idioma: la casita ⟌ inglesa, la caja de España, Francia e Italia o la alemana 864 : 4 = en línea.
- [Edición del profesor](https://postext.dev/es/docs/exercises.md#edición-del-profesor): exercises.edition: 'teacher' imprime cada respuesta en su sitio en un segundo color, rodea las opciones correctas y traza las líneas de unir: el libro del profesor a partir del texto del alumno.
- [Estilos de ejercicio](https://postext.dev/es/docs/exercises.md#estilos-de-ejercicio): exerciseStyles: cómo se imprime una familia de ejercicios: el número (disco, cuadrado, rótulo), el enunciado, las letras de las opciones, los huecos, las casillas de V/F, los puntos para unir, el banco de palabras, las zonas de escritura, las cuadrículas, los iconos de actividad y la dificultad.

**También usa**

- [Ejercicios](https://postext.dev/es/docs/exercises.md#ejercicios)
- [Huecos y bancos de palabras](https://postext.dev/es/docs/exercises.md#huecos-y-banco-de-palabras)
- [Preguntas abiertas y zonas de escritura](https://postext.dev/es/docs/exercises.md#preguntas-abiertas-y-espacio-para-escribir)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration-headings.md#apertura-a-toda-página-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores)
- [Separación silábica e idioma del documento](https://postext.dev/es/docs/justification.md#idiomas-admitidos)
- [Páginas en un canvas](https://postext.dev/es/docs/configuration-programmatic-usage.md#renderizar-una-página-como-mapa-de-bits)
- [Exportación a PDF](https://postext.dev/es/docs/configuration-pdf-output.md#generación-de-pdf)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo)
- [Columnas en un recuadro o en el texto](https://postext.dev/es/docs/document-format-directives.md#columns)
- [Soluciones](https://postext.dev/es/docs/exercises.md#soluciones)
- [Fuentes incrustadas en el PDF](https://postext.dev/es/docs/configuration-pdf-output.md#por-qué-un-proveedor-de-fuentes)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration-text.md#texto-base), [`colorPalette`](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores), `exerciseStyles`, `exercises`, [`footer`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), [`header`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), [`headings`](https://postext.dev/es/docs/configuration-headings.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration-page-layout.md#disposición), [`locale`](https://postext.dev/es/docs/configuration-language.md#separación-silábica), [`orderedLists`](https://postext.dev/es/docs/configuration-lists-math.md#listas-ordenadas), [`page`](https://postext.dev/es/docs/configuration-page-layout.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration-programmatic-usage.md#construir-un-documento), [`buildDocumentWithFonts`](https://postext.dev/es/docs/configuration-programmatic-usage.md#cargar-las-fuentes-antes-de-componer), [`decompressWoff2`](https://postext.dev/es/docs/configuration-pdf-output.md#proveedor-de-fuentes-en-el-navegador-fontsource--woff2), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration-programmatic-usage.md#renderizar-una-página-como-mapa-de-bits), [`renderToPdf`](https://postext.dev/es/docs/configuration-pdf-output.md#generación-de-pdf)

**Tipografías**

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

## Elaboración

### 1 · Escribe las cuentas como líneas del Markdown

```js
// script.js, líneas 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),
};
```

Un `:::exercise{type="arithmetic"}` lleva una operación por elemento de lista, `- 864 : 4`, con los signos que escribe su país (`:`, `÷`, `×`, `·`). El motor calcula el resultado, el cociente y el resto, y el estilo decide cómo se dibuja la cuenta: casillas de 7,2 mm y `arithmetic.division` elegida por edición con `t()`. La fila de cálculo mental es un ejercicio de huecos corriente cuya lista va en un grupo `:::columns{count=3}`, cada `[[70]]` una casilla del mismo ancho que las demás.

### 2 · Encabeza la ficha

```js
// script.js, líneas 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),
] } };
```

El título de nivel 1 de la unidad dibuja la banda naranja, la pastilla y el título, y los campos de nombre y fecha a partir de sus atributos (`nombre="Nombre" fecha="Fecha"`), así que cada edición los rotula en su idioma.

### 3 · Cambia entre la ficha del alumno y la copia del profesor

```js
// script.js, líneas 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'` imprime cada respuesta en su sitio con el color de `answers.color`, aquí el de acento: las casillas del cálculo mental, las cifras de cada resultado, el cociente y el resto, la respuesta modelo del problema. El formulario sobre las páginas cambia la configuración en su sitio y vuelve a componer; las fuentes ya están cargadas, así que basta con `buildDocument`. La captura compone antes las dos fichas, de modo que las páginas publicadas muestran la del alumno y luego la del profesor.

## La receta completa

Los archivos de abajo se componen a partir de la carpeta de la receta, con el texto de ejemplo y el kit común del Recetario ya incluidos. Para ejecutarlos como una sola página, pon el HTML en `<body>`, el CSS en un elemento `<style>` y el script en un `<script type="module">`; o pega cada uno en el panel correspondiente de un pen nuevo de CodePen (el JS como módulo). El script importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: 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 = 'es'; // @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: "Matemáticas 4"
subtitle: "Cuaderno de cálculo"
---

# Las cuatro operaciones {unidad="Unidad 3" nombre="Nombre" fecha="Fecha"}

:::exercise{type="gaps"}
Calcula mentalmente y escribe el resultado.

:::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"}
Coloca las cifras en columna y suma.

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

:::exercise{type="arithmetic"}
Resta y comprueba con una suma.

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

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

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

:::exercise{type="arithmetic"}
Divide. Cuando la división no sea exacta, escribe también el resto.

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

:::exercise{type="open" lines="6"}
Una caja de lápices de colores cuesta 3,75 €. ¿Cuánto cuestan cuatro cajas? Si pagas con un billete de 20 €, ¿cuánto te devuelven?

:::solution
Cuatro cajas cuestan 3,75 € × 4 = 15 €. Te devuelven 20 € − 15 € = 5 €.
:::
:::

:::paragraphs{style="colofon"}
Compuesto en Lexend y Fira Sans (SIL OFL) · Texto original (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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Pon las soluciones boca abajo al pie

Una costumbre de los cuadernos de pasatiempos: la clave de cada ejercicio, girada 180° debajo de él.

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

### Divide con la casita en todas las ediciones

Las escuelas japonesas y chinas también usan la casita ⟌; un idioma que no aparece en la lista toma la suya propia.

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

## Errores frecuentes

- Las operaciones son de números enteros: una cuenta con decimales como `12,5 + 3,75` no se lee y se imprime tal cual. Deja los decimales en los huecos del cálculo mental, donde la respuesta es texto.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipografías: Fira Sans (OFL-1.1), Lexend (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 168 · Unidad de un cuaderno de actividades de Primaria](https://postext.dev/es/cookbook/primary-workbook-activities.md): Una unidad de cuaderno de Ciencias con once actividades :::exercise que imprime un solo estilo: números en círculos, banco y puntos para unir. · Nivel 2 (Intermedio) · Cuadernos y ejercicios, Libros de texto
- [N.º 172 · Ejercicios japoneses con las soluciones del revés](https://postext.dev/es/cookbook/japanese-drill-upside-down-answers.md): Dos páginas de una serie de ejercicios japonesa, la de cuentas en horizontal y la de kanji en columnas, con las soluciones del revés bajo cada ejercicio. · Nivel 2 (Intermedio) · Cuadernos y ejercicios, Libros de texto
- [N.º 174 · Una página de pasatiempos con las soluciones del revés](https://postext.dev/es/cookbook/puzzle-page-upside-down-answers.md): La página de pasatiempos de una revista infantil: crucigrama, sopa de letras, adivinanza, historia que ordenar y más, en tarjetas con las soluciones del revés. · Nivel 2 (Intermedio) · Revistas y fanzines, Cuadernos y ejercicios
