Saltar al contenido principal
Receta número 22

Recetario · Capítulo 6 · Recuadros y notas

Ficha con cajas de respuesta y banco de palabras

Una ficha de ciencias de cuatro páginas: cajas de respuesta blancas en tarjetas verde claro, a 2 mm de cada pregunta, y chips para bancos de palabras y huecos.

  • Formato 200 × 260 mm
  • 1 columna
  • Andika 12/17
  • Baloo 2
  • Fredoka
  • 4 páginas
  • Nivel
  • Postext 1.4.1
  • Compuesto en 8 ms
  • 180 líneas de código

Lo que vas a componer

La ficha 3A de Semillero Ciencias, una colección inventada para 3.º de Primaria, en cuatro páginas de 200 × 260 mm. La banda verde lleva la unidad en una etiqueta amarilla, el título y dos campos blancos para el nombre y la fecha; al lado, una planta de guisante en un disco blanco tiene sus seis partes numeradas en amarillo. Cada actividad es un título numerado sobre una tarjeta verde claro. Las tarjetas llevan cajas de respuesta blancas con una pequeña indicación gris, bancos de palabras en chips amarillos, huecos con contorno en las frases, una tabla para unir alimentos con partes de la planta y círculos para colorear. Cada caja queda 2 mm bajo su pregunta y 5 mm sobre lo que la sigue, y cada tarjeta, 9 mm sobre lo que venga después. El texto se compone en Andika, que SIL diseñó para niños que aprenden a leer.

Esta receta responde a

  • ¿Cómo anido recuadros, como una ficha de ejercicios con cajas de respuesta, con un espaciado exacto?
  • ¿Cómo hago una ficha de ejercicios: líneas para rellenar, cajas de respuesta, bancos de palabras, listas de comprobación?
  • ¿Cómo hago chips en línea: teclas, etiquetas, bancos de palabras para ejercicios?
  • ¿Cómo pongo imágenes o iconos dentro de las celdas de una tabla?

La respuesta corta

script.js · líneas 24–56en el código completo
// In the Markdown a box inside a box is a fence inside a fence (each ::: closes one):
//   :::callout{type="card"}
//   What is inside the pea pods?
//   :::callout{type="answer"}
//   Write here               ← a prompt first: a box of :::space alone collapses
//   :::space{lines=3}           (gotcha: space-dropped-box-top)
//   :::
//   :::
const [ASK, AFTER, HEAD, NEXT] = [2, 5, 3, 9]; // mm: question→box, box→next, heading→card
const card = {
  id: 'card', background: col('tint'), borderRadius: mm(4),
  padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
  marginTop: mm(HEAD), marginBottom: mm(NEXT), // NEXT: under a card, to whatever follows
  // Off the grid, or what follows a card drops to the next grid line. Only the outer card can
  // set it: a nested box ignores snapToGrid, span and placement (gotcha: nested-callout-limits).
  snapToGrid: false,
  body: { paragraphSpacing: false }, // the boxes' margins do all the spacing
  lists: { bulletChar: '', // no bullet: in the checklist a circle chip opens each item
    gap: mm(2), itemSpacing: pt(7) }, columnGap: mm(6),
};
// A nested box's margins collapse with its neighbours', the larger winning (ASK, AFTER); the
// last box's marginBottom falls inside the card's padding instead of adding to it.
const nested = (id, look) => ({ id, background: col('paper'), borderRadius: mm(2.5),
  marginTop: mm(ASK), marginBottom: mm(AFTER), ...look });
const answer = nested('answer', { border: { enabled: true, color: col('rule'), width: pt(0.75) },
  padding: { top: mm(1.6), right: mm(3), bottom: mm(2), left: mm(3) },
  // A small grey prompt on a full 17 pt line; :::space counts in these body lines.
  body: { fontFamily: LABEL, fontSize: pt(8), color: col('muted') } });
const bank = nested('bank', { title: t({ en: 'Word bank', es: 'Banco de palabras' }),
  padding: { top: mm(2.2), right: mm(3), bottom: mm(2.4), left: mm(3) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('leaf'),
    textTransform: 'uppercase', letterSpacing: pt(1.3), gap: mm(1.6) } });
// config() plugs in all three: calloutStyles: [card, answer, bank].

Ingredientes

Tipografía
Andika, Baloo 2, Fredoka (SIL OFL 1.1)
Recursos
Ninguno: todas las imágenes se dibujan en código

Elaboración

#1 · Saca también los títulos de la rejilla

script.js · líneas 158–166en el código completo
  headings: { fontFamily: DISPLAY, snapToGrid: false, // or what follows snaps back to the grid
    balancing: { enabled: false }, // no extra space above headings to fill out a page
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener() },
      { level: 2, numberingTemplate: '{2}', marginTop: mm(0), marginBottom: mm(0),
        advancedDesign: activity },
    ] },

En la respuesta corta, los márgenes de cada caja se funden con los de sus vecinas y cuenta solo el mayor. Los 5 mm de un banco de palabras se imponen a los 2 mm que la lista lleva encima, y los 5 mm de la última caja de respuesta caen dentro de los 5 mm de relleno inferior de la tarjeta, sin sumarse a ellos. Si los títulos siguen en la rejilla, lo que viene detrás de cada uno vuelve a la siguiente línea de 17 pt, y un título de nivel 2 reserva entre 10,8 y 14,1 mm en lugar de los 8,6 mm de su disco; headings.snapToGrid: false deja la reserva en 8,6 mm. Poner snapToGrid en los estilos anidados no cambia nada, porque solo la tarjeta exterior puede salir de la rejilla (recuadros anidados). El equilibrado de columnas está desactivado porque añade espacio sobre los títulos para rellenar la página; en la página 2 pondría 33 mm sobre la actividad 3 en lugar de 9.

#2 · Haz de cada hueco un chip

script.js · líneas 60–74en el código completo
const chip = (id, fill, look) => ({ id, fontFamily: LABEL, background: col(fill),
  borderWidth: pt(0), borderColor: col('leaf'), borderRadius: em(1), ...look });
// An empty chip holds a U+2060 word joiner, as one of spaces prints its markup (gotcha:
// empty-chip). Its box is a band 1.25 em tall, so paddingX ROUND makes it a circle.
const [EMPTY, ROUND, OUTLINE] = ['\u2060', em(0.625), pt(0.9)];
// A blank is all padding, 2 × paddingX wide and 19.6 pt tall: taller than the 17 pt line, so
// the card's itemSpacing sets its items 24 pt apart (gotcha: chip-overlap).
const blank = (id, width) => chip(id, 'paper', { borderWidth: OUTLINE, borderRadius: mm(1.4),
  paddingX: mm(width / 2), paddingY: em(0.12), fontSize: em(1.15) });
const chipStyles = [
  chip('word', 'sun', { bold: true, paddingX: em(0.6), paddingY: em(0.08), gap: em(0.45) }),
  blank('blank', 34), blank('wide', 38), // mm: in a sentence; alone in a column
  chip('dot', 'leaf', { paddingX: ROUND, fontSize: em(0.6) }),
  chip('tick', 'paper', { borderWidth: OUTLINE, paddingX: ROUND, fontSize: em(1.1), gap: em(0.5) }),
];

Un chip es una caja indivisible dentro de la línea; su relleno se pinta por fuera de la línea sin hacerla más alta. Un banco de palabras es una fila de chips dentro de un recuadro anidado bank. Los huecos, los puntos para unir y los círculos para colorear también son chips, y solo llevan dentro un unidor de palabras (U+2060), porque en postext 1.4.1 un chip de espacios imprime su propio marcado (chips en línea). La anchura la fija paddingX: un hueco mide 34 mm dentro de una frase y 38 mm cuando va solo en su columna, y un círculo lleva a cada lado la mitad de su altura de 1,25 em. En la lista de comprobación, el círculo ocupa el sitio de la viñeta (bulletChar: ''). Con 19,6 pt de alto, un hueco es más alto que la línea de 17 pt, y por eso cada uno va en su propio elemento de lista, a 24 pt del siguiente. En la 1.4.1, el Sandbox sigue mostrando el aviso «Los chips tocan la línea siguiente» para blank, wide y tick, porque compara cada chip con el interlineado de 17 pt y no cuenta los 7 pt que separan los elementos de la lista.

#3 · Numera las actividades con un diseño de título

script.js · líneas 78–92en el código completo
const DISC_H2 = 8.6; // mm: the disc and the title are this tall, so the heading reserves this
const numberDisc = { kind: 'text', id: 'num', content: '{number}', // numberingTemplate '{2}'
  fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 800, color: col('paper'),
  box: { backgroundColor: col('leaf'), borderRadius: mm(DISC_H2 / 2) }, // centred both ways
  placement: { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(DISC_H2), height: mm(DISC_H2) } } };
const activityTitle = { kind: 'text', id: 'title', content: '{titleText}',
  fontFamily: DISPLAY, fontSize: pt(17), fontWeight: 700, color: col('ink'), align: 'left',
  overflow: 'wrap', placement: { anchor: { to: '#num', edge: 'right-of' },
    offset: { x: mm(3) }, size: { width: mm(140), height: mm(DISC_H2) } } };
const activity = { enabled: true, slot: { elements: [numberDisc, activityTitle] } };
// ## How did I do? {style="review"} is not an activity: it has no number and a star for a disc.
const review = { id: 'review', numbered: false, advancedDesign: { ...activity, slot: {
  elements: [{ kind: 'image', id: 'num', resourceId: 'star', placement: numberDisc.placement },
    activityTitle] } } };

Un título de nivel 2 con advancedDesign conserva su sitio en la columna, y en lugar de su texto se dibuja el diseño: un disco con {number}, que imprime el numberingTemplate del nivel, y {titleText} a su derecha (span y diseño avanzado). El disco y el título miden 8,6 mm de alto, y fuera de la rejilla el título reserva justo esa altura, de modo que el diseño no necesita minHeight. Como los números salen del contador del nivel 2, si intercalas una actividad en el Markdown, las siguientes cambian de número. La autoevaluación usa un estilo de título con numbered: false, que la deja fuera de la cuenta, y en el sitio del disco lleva una estrella dibujada en SVG.

#4 · Pon los dibujos en las celdas de la tabla

script.js · líneas 128–144en el código completo
const tableStyles = [{ id: 'match', borderRadius: mm(3), // no header row, so no header fill
  headerBackgroundEnabled: false, bodyBackgroundEnabled: true, bodyBackground: col('paper'),
  bodyFontFamily: LABEL, bodyFontSize: pt(13), bodyColor: col('ink'), cellPadding: mm(1.4),
  // White rules on the white panel: unseen, but they hide the canvas's seams between cells.
  rules: 'grid', borderColor: col('paper'), borderWidth: pt(1) }];
const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra });
const dotCell = () => cell(`:chip[${EMPTY}]{style="dot"}`, { align: 'center' });
const matchRow = (icon, food, part) => [cell('', { image: { resourceId: icon, width: 0.76 },
  align: 'center' }), cell(food), dotCell(), cell(''), dotCell(),
cell(`:chip[${part}]{style="word"}`)];
const sheetType = { id: 'sheet', name: 'Worksheet item', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // no caption, no number
// Set 'here' in its card, after a :::space (gotcha: box-embed-no-gap); built once FOODS exist.
const foods = () => ({ id: 'foods', typeId: 'sheet', kind: 'table', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' }, table: { styleId: 'match', model: {
    columnWidths: [0.14, 0.25, 0.06, 0.31, 0.06, 0.18], // the widest gap: room to draw a line
    rows: FOODS.map((name, r) => matchRow(ICONS[r], name, PARTS[r])) } } });

TableCell.image dibuja un recurso dentro de su celda, aquí al 76 % del ancho de la celda, y la fila crece para que quepa. verticalAlign: 'middle' centra respecto al dibujo el nombre del alimento, los puntos y el chip. La columna más ancha, el 31 % de la tabla, queda vacía para que el alumno trace la línea. El estilo con nombre redondea el marco de la tabla y recorta a esa forma el relleno blanco del cuerpo. Los filetes de la cuadrícula también son blancos: el lienzo deja un hilo claro entre celdas rellenas, y un filete blanco de 1 pt sobre el panel blanco lo tapa (estilos de tabla con nombre). Su tipo de recurso tiene el captionPrefix vacío y la tabla no lleva texto de pie, así que no se imprime ningún pie. placement: { position: 'here' } coloca la tabla donde está su línea ::resource, dentro de la tarjeta.

La receta completa

// ═══ Postext Cookbook · Nº 022 · Worksheet with answer boxes and a word bank ══════
// https://postext.dev/en/cookbook/worksheet-answer-boxes
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Andika, Baloo 2, Fredoka (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'worksheet-answer-boxes';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#243040', muted: '#5d6975', paper: '#ffffff', // type; prompts; boxes
  leaf: '#2f7d4a', sun: '#f4b43a', soil: '#8a5a36', // the accent; words to pick; drawings
  tint: '#e5f1e7', rule: '#a9c9b1' }; // the activity cards; answer-box outlines
// Design elements read the hex and ignore the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the leaf, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.leaf })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Andika', 'Baloo 2', 'Fredoka']; // Andika: for early readers
const [TOP, SIDE] = [18, 16]; // mm; a sheet printed one-sided, so the margins do not mirror

// #region answer: cards that hold white answer boxes, stacked exactly, off the grid
// In the Markdown a box inside a box is a fence inside a fence (each ::: closes one):
//   :::callout{type="card"}
//   What is inside the pea pods?
//   :::callout{type="answer"}
//   Write here               ← a prompt first: a box of :::space alone collapses
//   :::space{lines=3}           (gotcha: space-dropped-box-top)
//   :::
//   :::
const [ASK, AFTER, HEAD, NEXT] = [2, 5, 3, 9]; // mm: question→box, box→next, heading→card
const card = {
  id: 'card', background: col('tint'), borderRadius: mm(4),
  padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
  marginTop: mm(HEAD), marginBottom: mm(NEXT), // NEXT: under a card, to whatever follows
  // Off the grid, or what follows a card drops to the next grid line. Only the outer card can
  // set it: a nested box ignores snapToGrid, span and placement (gotcha: nested-callout-limits).
  snapToGrid: false,
  body: { paragraphSpacing: false }, // the boxes' margins do all the spacing
  lists: { bulletChar: '', // no bullet: in the checklist a circle chip opens each item
    gap: mm(2), itemSpacing: pt(7) }, columnGap: mm(6),
};
// A nested box's margins collapse with its neighbours', the larger winning (ASK, AFTER); the
// last box's marginBottom falls inside the card's padding instead of adding to it.
const nested = (id, look) => ({ id, background: col('paper'), borderRadius: mm(2.5),
  marginTop: mm(ASK), marginBottom: mm(AFTER), ...look });
const answer = nested('answer', { border: { enabled: true, color: col('rule'), width: pt(0.75) },
  padding: { top: mm(1.6), right: mm(3), bottom: mm(2), left: mm(3) },
  // A small grey prompt on a full 17 pt line; :::space counts in these body lines.
  body: { fontFamily: LABEL, fontSize: pt(8), color: col('muted') } });
const bank = nested('bank', { title: t({ en: 'Word bank', es: 'Banco de palabras' }),
  padding: { top: mm(2.2), right: mm(3), bottom: mm(2.4), left: mm(3) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('leaf'),
    textTransform: 'uppercase', letterSpacing: pt(1.3), gap: mm(1.6) } });
// config() plugs in all three: calloutStyles: [card, answer, bank].
// #endregion

// #region chips: words to pick, blanks to fill, dots to join, circles to colour
const chip = (id, fill, look) => ({ id, fontFamily: LABEL, background: col(fill),
  borderWidth: pt(0), borderColor: col('leaf'), borderRadius: em(1), ...look });
// An empty chip holds a U+2060 word joiner, as one of spaces prints its markup (gotcha:
// empty-chip). Its box is a band 1.25 em tall, so paddingX ROUND makes it a circle.
const [EMPTY, ROUND, OUTLINE] = ['\u2060', em(0.625), pt(0.9)];
// A blank is all padding, 2 × paddingX wide and 19.6 pt tall: taller than the 17 pt line, so
// the card's itemSpacing sets its items 24 pt apart (gotcha: chip-overlap).
const blank = (id, width) => chip(id, 'paper', { borderWidth: OUTLINE, borderRadius: mm(1.4),
  paddingX: mm(width / 2), paddingY: em(0.12), fontSize: em(1.15) });
const chipStyles = [
  chip('word', 'sun', { bold: true, paddingX: em(0.6), paddingY: em(0.08), gap: em(0.45) }),
  blank('blank', 34), blank('wide', 38), // mm: in a sentence; alone in a column
  chip('dot', 'leaf', { paddingX: ROUND, fontSize: em(0.6) }),
  chip('tick', 'paper', { borderWidth: OUTLINE, paddingX: ROUND, fontSize: em(1.1), gap: em(0.5) }),
];
// #endregion

// #region activity: an in-column H2 design: the automatic number in a green disc
const DISC_H2 = 8.6; // mm: the disc and the title are this tall, so the heading reserves this
const numberDisc = { kind: 'text', id: 'num', content: '{number}', // numberingTemplate '{2}'
  fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 800, color: col('paper'),
  box: { backgroundColor: col('leaf'), borderRadius: mm(DISC_H2 / 2) }, // centred both ways
  placement: { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(DISC_H2), height: mm(DISC_H2) } } };
const activityTitle = { kind: 'text', id: 'title', content: '{titleText}',
  fontFamily: DISPLAY, fontSize: pt(17), fontWeight: 700, color: col('ink'), align: 'left',
  overflow: 'wrap', placement: { anchor: { to: '#num', edge: 'right-of' },
    offset: { x: mm(3) }, size: { width: mm(140), height: mm(DISC_H2) } } };
const activity = { enabled: true, slot: { elements: [numberDisc, activityTitle] } };
// ## How did I do? {style="review"} is not an activity: it has no number and a star for a disc.
const review = { id: 'review', numbered: false, advancedDesign: { ...activity, slot: {
  elements: [{ kind: 'image', id: 'num', resourceId: 'star', placement: numberDisc.placement },
    activityTitle] } } };
// #endregion

const [BAND, AIR, DOT] = [76, 5, 6]; // mm: the opener's band, air under the disc, dot size
const DISC = { x: 108, y: 5, d: 78 }; // mm: the drawing's disc on the page
const at = (x, y, size, edge = 'top-left') => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) }, size });
const words = (id, content, family, size, weight, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), fontWeight: weight, color: col('paper'),
  align: 'left', overflow: 'wrap', placement, ...extra });
const tag = { textTransform: 'uppercase', letterSpacing: pt(1.3) };
const dot = ([n, x, y]) => words(`dot${n}`, String(n), DISPLAY, 10.5, 800, at(DISC.x + x - DOT / 2,
  DISC.y + y - DOT / 2, { width: mm(DOT), height: mm(DOT) }), { color: col('ink'), align: 'center',
  box: { backgroundColor: col('sun'), borderRadius: mm(DOT / 2) } }); // a yellow disc per number
const field = (id, label, x, w) => [words(`${id}-label`, label, LABEL, 7.5, 600, at(x, 59), tag),
  { kind: 'box', id, style: { backgroundColor: col('paper'), borderRadius: mm(1.6) },
    placement: at(x, 63, { width: mm(w), height: mm(7.5) }) }]; // a label over a white field
// Images reserve no height in an opener (gotcha: opener-image-no-reserve), so minHeight does.
const opener = () => ({ enabled: true, minHeight: mm(DISC.y + DISC.d + AIR - TOP),
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('leaf') },
      placement: at(0, 0, { height: mm(BAND) }) },
    { kind: 'image', id: 'plant', resourceId: 'plant',
      placement: at(DISC.x, DISC.y, { width: mm(DISC.d), height: mm(DISC.d) }) },
    ...LABELS.map(dot),
    words('unit', '{attr.unit}', LABEL, 9, 600, at(SIDE, TOP - 4), { ...tag, color: col('ink'),
      box: { backgroundColor: col('sun'), borderRadius: mm(3),
        padding: { top: mm(1), right: mm(2.6), bottom: mm(1), left: mm(2.6) } } }),
    words('title', '{titleText}', DISPLAY, 40, 800, at(SIDE, 23.5, { width: mm(88) }),
      { lineHeight: 0.98 }), // two lines: the headings break with \\
    words('series', '{title} · {subtitle} · {attr.sheet}', LABEL, 9.5, 500, at(SIDE, 53)),
    ...field('name', t({ en: 'Name', es: 'Nombre' }), SIDE, 58),
    ...field('date', t({ en: 'Date', es: 'Fecha' }), SIDE + 62, 22),
  ] } });

// #region tables: a rounded panel with a picture in each row and chips to join
const tableStyles = [{ id: 'match', borderRadius: mm(3), // no header row, so no header fill
  headerBackgroundEnabled: false, bodyBackgroundEnabled: true, bodyBackground: col('paper'),
  bodyFontFamily: LABEL, bodyFontSize: pt(13), bodyColor: col('ink'), cellPadding: mm(1.4),
  // White rules on the white panel: unseen, but they hide the canvas's seams between cells.
  rules: 'grid', borderColor: col('paper'), borderWidth: pt(1) }];
const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra });
const dotCell = () => cell(`:chip[${EMPTY}]{style="dot"}`, { align: 'center' });
const matchRow = (icon, food, part) => [cell('', { image: { resourceId: icon, width: 0.76 },
  align: 'center' }), cell(food), dotCell(), cell(''), dotCell(),
cell(`:chip[${part}]{style="word"}`)];
const sheetType = { id: 'sheet', name: 'Worksheet item', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // no caption, no number
// Set 'here' in its card, after a :::space (gotcha: box-embed-no-gap); built once FOODS exist.
const foods = () => ({ id: 'foods', typeId: 'sheet', kind: 'table', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' }, table: { styleId: 'match', model: {
    columnWidths: [0.14, 0.25, 0.06, 0.31, 0.06, 0.18], // the widest gap: room to draw a line
    rows: FOODS.map((name, r) => matchRow(ICONS[r], name, PARTS[r])) } } });
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales)
  resourceTypes: [sheetType], colorPalette, chipStyles, tableStyles, header: { elements: [] },
  footer: { elements: [words('foot', '{title} · {subtitle} · {attr.unit} · {pageNumber}', LABEL,
    7.8, 600, at(SIDE, -11, undefined, 'bottom-left'), { ...tag, color: col('muted') })] },
  page: { width: mm(200), height: mm(260), dpi: 150, margins: { top: mm(TOP), bottom: mm(20),
    left: mm(SIDE), right: mm(SIDE) } }, layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  // #region grid: the headings leave the grid too, and nothing stretches the gaps
  headings: { fontFamily: DISPLAY, snapToGrid: false, // or what follows snaps back to the grid
    balancing: { enabled: false }, // no extra space above headings to fill out a page
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener() },
      { level: 2, numberingTemplate: '{2}', marginTop: mm(0), marginBottom: mm(0),
        advancedDesign: activity },
    ] },
  // #endregion
  orderedLists: { fontFamily: DISPLAY, fontWeight: 800, marginTop: mm(ASK), // a bank's AFTER wins
    numberVerticalOffset: pt(1.7) }, // centred 0.3 em over the baseline, Baloo 2 sat high
  headingStyles: [review], calloutStyles: [card, answer, bank],
  paragraphStyles: [{ id: 'lead', fontSize: pt(14), lineHeight: pt(20) }, { id: 'colophon',
    fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(10), color: col('muted') }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Muestra en Markdown · 119 líneas · content.es.mdtitle: "Semillero Ciencias" subtitle: "3.º de Primaria" --- # Las plantas \\ y sus partes {unit="Unidad 3" sheet="Ficha 3A"} :::paragraphs{style="lead"} Cada parte de una planta cumple una función. En esta ficha vas a nombrar las partes y a averiguar para qué sirve cada una y cuáles nos comemos. Necesitarás un lápiz y lápices de colores. ::: ## Observa con atención :::callout{type="card"} ¿Qué hay dentro de las vainas de la planta de guisante de arriba? :::callout{type="answer"} Escribe aquí :::space{lines=4} ::: ¿Qué parte de la planta crece bajo tierra? ¿Para qué la necesita la planta? :::callout{type="answer"} Escribe aquí :::space{lines=6} ::: ::: ## Nombra las partes :::callout{type="card"} Seis partes de la planta de guisante de la página 1 tienen un número. Elige una palabra del banco de palabras y escríbela junto al mismo número. :::callout{type="bank"} :chip[tallo]{style="word"} :chip[semillas]{style="word"} :chip[flor]{style="word"} :chip[raíces]{style="word"} :chip[hoja]{style="word"} :chip[fruto]{style="word"} ::: :::columns{count=3} 1. :chip[⁠]{style="wide"} 2. :chip[⁠]{style="wide"} 3. :chip[⁠]{style="wide"} 4. :chip[⁠]{style="wide"} 5. :chip[⁠]{style="wide"} 6. :chip[⁠]{style="wide"} ::: ::: ## ¿Para qué sirve cada parte? :::callout{type="card"} Completa cada frase con una palabra del banco de palabras. :::callout{type="bank"} :chip[alimento]{style="word"} :chip[insectos]{style="word"} :chip[agua]{style="word"} :chip[planta]{style="word"} :chip[erguida]{style="word"} :chip[semillas]{style="word"} ::: 1. Las **raíces** sujetan la planta al suelo y absorben :chip[⁠]{style="blank"}. 2. El **tallo** mantiene la planta :chip[⁠]{style="blank"} y lleva el agua hasta las hojas. 3. Las **hojas** usan la luz del sol para fabricar :chip[⁠]{style="blank"} para la planta. 4. La **flor** atrae a los :chip[⁠]{style="blank"} con sus pétalos de colores. 5. El **fruto** crece alrededor de las :chip[⁠]{style="blank"} y las protege. 6. De una **semilla** puede nacer una nueva :chip[⁠]{style="blank"}. ::: **¿Sabías que…?** La planta de guisante no se sostiene sola: trepa enroscando sus zarcillos, finos y rizados, en todo lo que toca. ## Partes que comemos :::callout{type="card"} Comemos muchas partes distintas de las plantas. Traza una línea de cada alimento a la parte de la planta de la que procede. :::space{lines=0.33} ::resource{id="foods"} ::: ## Dibuja y rotula :::callout{type="card"} Dibuja una planta que conozcas. Rotula al menos cuatro de sus partes. :::callout{type="answer"} Mi planta se llama :::space{lines=7} ::: ::: ## Pruébalo en casa :::callout{type="card"} Pon un guisante seco sobre algodón mojado en un tarro de cristal. Mantén el algodón húmedo y mira el guisante cada día durante una semana. ¿Qué crees que saldrá primero del guisante: una raíz o un brote? :::callout{type="answer"} Creo que :::space{lines=3} ::: Al cabo de una semana, dibuja o escribe lo que has visto. :::callout{type="answer"} He visto :::space{lines=6} ::: ::: ## ¿Qué he aprendido? {style="review"} :::callout{type="card"} Colorea un círculo por cada cosa que sabes hacer. - :chip[⁠]{style="tick"} Sé nombrar las seis partes de una planta. - :chip[⁠]{style="tick"} Sé explicar para qué sirve cada parte. - :chip[⁠]{style="tick"} Sé decir de qué parte de la planta viene cada alimento. ::: :::paragraphs{style="colophon"} Semillero Ciencias es una colección inventada para el Recetario de Postext. Textos y dibujos: CC BY 4.0. Compuesto en Andika, Baloo 2 y Fredoka (SIL Open Font License). :::
`; // content.<lang>.md, inlined by the Cookbook // The matching table's words. The parts are shuffled so that no food faces its own part. const FOODS = t({ en: 'carrot|asparagus|lettuce|broccoli|orange|kidney beans', es: 'zanahoria|espárrago|lechuga|brócoli|naranja|alubias' }).split('|'); const PARTS = t({ en: 'leaf|seeds|root|fruit|stem|flower', es: 'hoja|semillas|raíz|fruto|tallo|flor' }).split('|'); // #region art: the pea plant, the six foods and the star, in the palette's colours // No words in them: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts); // the plant's numbers are design elements set in Baloo 2 over the drawing (see the band). // [number, x, y] in the disc's millimetres: each numbered dot, where its leader line starts. const LABELS = [[1, 58, 8], [2, 68, 33], [3, 9, 44], [4, 13, 20], [5, 62, 52], [6, 18, 66]]; const n = (v) => +v.toFixed(2); const svgDoc = (w, h, body, scale = 10) => `<svg xmlns="http://www.w3.org/2000/svg" ` + `width="${w * scale}" height="${h * scale}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" ` + `fill="${fill}"${extra}/>`; const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const stroke = (d, color, width) => path(d, 'none', ` stroke="${color}" stroke-width="${width}" ` + 'stroke-linecap="round" stroke-linejoin="round"'); const leafShape = (x, y, len, wid, turn, fill, vein) => `<g transform="translate(${n(x)} ` + `${n(y)}) rotate(${n(turn)})">${path(`M0 0C${n(len * 0.3)} ${n(-wid)} ${n(len * 0.8)} ` + `${n(-wid)} ${n(len)} 0C${n(len * 0.8)} ${n(wid)} ${n(len * 0.3)} ${n(wid)} 0 0Z`, fill)}` + `${vein ? stroke(`M${n(len * 0.12)} 0L${n(len * 0.8)} 0`, vein, 0.35) : ''}</g>`; function plant() { // a pea plant in a white disc, its roots in a slice of soil const [c, r, ground] = [39, 39, 58]; const dx = Math.sqrt(r * r - (ground - c) ** 2); let out = circle(c, c, r, palette.paper); out += path(`M${n(c - dx)} ${ground}Q20 ${ground - 2.2} 39 ${ground}T${n(c + dx)} ${ground}` + `A${r} ${r} 0 0 1 ${n(c - dx)} ${ground}Z`, palette.soil); for (const [x, y, rr] of [[22, 70, 0.9], [55, 66, 0.7], [47, 73, 1.1], [26, 62, 0.6]]) { out += circle(x, y, rr, palette.paper, ' fill-opacity=".25"'); } out += stroke('M39 58C38 63 36 66 33 70M39 58C40 64 43 67 46 71M38 62C35 63 31 63 27 65' + 'M40 63C44 63 48 62 52 63M36 66C35 69 34 72 34 75M44 68C45 71 46 73 45 76', palette.paper, 0.7); // roots out += stroke('M39 58C37 50 42 44 39 36S37 22 41 11', palette.leaf, 1.5); // the stem for (const [y, s] of [[49, 1], [39, -1], [28, 1], [19, -1]]) { // leaflets in pairs out += leafShape(39, y, 12, 3.6, s > 0 ? -28 : -152, palette.leaf, palette.tint); out += leafShape(39, y + 2, 10, 3.2, s > 0 ? -160 : -20, palette.leaf, palette.tint); } out += stroke('M41 12c2.5-1 4.6.4 4.2 2.6s-3 2.2-3.4.2 1.8-1.8 2.4-.4M39 27c-2.6-1.4-5.4-.6-5.2 ' + '1.6s2.8 2.4 3.4.6-1.4-2-2.2-.8M40 38c2.8-.8 5.2.6 4.6 2.6', palette.leaf, 0.45); // tendrils // A closed pod (the fruit) on the right, its peas showing through; an open one (the seeds). const calyx = (x, y, turn) => `<g transform="translate(${x} ${y}) rotate(${turn})">` + path('M0 0L1.6-1.2 1.2.4 2.4 1.2.6 1.4Z', palette.leaf) + '</g>'; out += stroke('M40 31.5Q42 31 43.4 32', palette.leaf, 0.5) + path('M43 31.6C48 30.8 54 32.4 57.6 37.8C53 38.6 47.4 36.8 43 31.6Z', palette.rule, ` stroke="${palette.leaf}" stroke-width=".5"`) + [[46.4, 33.4], [49.6, 34.8], [52.8, 36.1]].map(([x, y]) => circle(x, y, 1.15, palette.leaf, ' fill-opacity=".35"')).join('') + calyx(42.6, 31.8, -10); out += stroke('M38 36Q37 36 36.2 36.4', palette.leaf, 0.5) + path('M36 36C30 37 23 40 20 45C25 47 33 43 36 36Z', palette.tint, ` stroke="${palette.leaf}" stroke-width=".5"`) + stroke('M35.4 36.6C31 39.6 26 42.6 20.6 44.8', palette.leaf, 0.3) + calyx(36.4, 36.4, 160); for (const [x, y] of [[32.6, 39.4], [29.8, 41], [27, 42.5], [24.2, 43.9]]) { out += circle(x, y, 1.25, palette.leaf); } // Pea flowers, seen from the front: white, as a garden pea's are, outlined in green. for (const [x, y, turn] of [[42, 9, -18], [48, 20.5, 24]]) { const edge = ` stroke="${palette.leaf}" stroke-width=".4"`; out += `<g transform="translate(${x} ${y}) rotate(${turn})">` + stroke('M0 2.6L0 4.6', palette.leaf, 0.5) + path('M-1.1 1.1L0 3 1.1 1.1Z', palette.leaf) + path('M0 0C-4.2-.6-5.2-6.4-1.6-7.2C-.8-7.4-.2-6.8 0-6.2C.2-6.8.8-7.4 1.6-7.2' + 'C5.2-6.4 4.2-.6 0 0Z', palette.paper, edge) // the standard petal + path('M-2.6-1.4C-3.4 1.2-1 2.6 0 1.4C1 2.6 3.4 1.2 2.6-1.4C1.4-.4-1.4-.4-2.6-1.4Z', palette.tint, edge) + stroke('M0-1.2L0-5', palette.rule, 0.3) + '</g>'; // the wings } // Where each leader ends: 2 on the pod's wall past its last pea (the fruit), 3 on a pea in // the open pod (the seeds), 4 in the middle of a leaf's blade, well clear of the stem. const targets = { 1: [43, 5.6], 2: [55.5, 36.6], 3: [27, 42.5], 4: [33.2, 15.9], 5: [39.4, 47], 6: [36.7, 64.4] }; for (const [num, x, y] of LABELS) { // leader lines from each dot to its part const [tx, ty] = targets[num]; out += stroke(`M${x} ${y}L${tx} ${ty}`, palette.ink, 0.35) + circle(tx, ty, 0.7, palette.ink); } return svgDoc(78, 78, out); } function star() { // the self-check's badge: a white star on a sun disc const pts = Array.from({ length: 10 }, (_, i) => { const a = -Math.PI / 2 + (i * Math.PI) / 5; const rr = i % 2 ? 4.4 : 9.4; return `${n(12 + Math.cos(a) * rr)} ${n(12.6 + Math.sin(a) * rr)}`; }); return svgDoc(24, 24, circle(12, 12, 12, palette.sun) + path(`M${pts.join('L')}Z`, palette.paper), 10); } const food = { carrot: () => path('M3.5 17.5C8 13 12 8.5 16.2 5.2C18 4 20.6 6.4 19.2 8.2C15.8 12.4 10 15.8 ' + '3.5 17.5Z', palette.sun) + stroke('M8 13.6l1.4 1.2M11.6 10.6l1.3 1.3M14.6 8l1.2 1.2', palette.soil, 0.4) + [-120, -80, -40].map((a) => leafShape(18.4, 6.4, 6, 1.4, a, palette.leaf)).join(''), asparagus: () => [[10, 7], [12, 12], [14, 17]].map(([x0, x1]) => path(`M${x0 - 1.3} 19` + `L${x1 - 1} 5Q${x1} 1.4 ${x1 + 1} 5L${x0 + 1.3} 19Z`, palette.leaf) + stroke(`M${x1 - 0.8} ` + `7.6l.8.8.8-.8M${(x0 + x1) / 2 - 0.9} 11.4l.9.9.9-.9`, palette.tint, 0.35)).join('') + path('M7.8 13.4h8.4v2.2H7.8Z', palette.sun), lettuce: () => { // a head seen from the side: two dark outer leaves cupping a pale heart const side = path('M13 19C6 19 2.6 15 3 9.6C3.2 7.4 4.6 6 5.8 7C6.2 5.4 7.8 4.8 8.6 6.2' + 'C9.4 5.2 10.6 5.6 10.6 6.8C9 10 9.4 15 13 19Z', palette.leaf) + stroke('M11 17.6C7.6 16 5.2 12.6 5.2 8.6', palette.tint, 0.4); // a frilled leaf, its vein return `<ellipse cx="12" cy="11.4" rx="5.8" ry="6.8" fill="${palette.rule}"/>` + stroke('M12 17.6C11.6 14 11.8 10 12.8 6', palette.tint, 0.45) + side + `<g transform="translate(24 0) scale(-1 1)">${side}</g>`; }, broccoli: () => path('M10 19l1-7h2l1 7Z', palette.rule) + [[8, 9, 3.4], [12, 7, 3.8], [16, 9, 3.4], [10, 11.5, 2.6], [14, 11.5, 2.6]].map(([x, y, rr]) => circle(x, y, rr, palette.leaf)).join(''), orange: () => circle(12, 11.5, 7.5, palette.sun) + leafShape(12, 4.2, 5, 1.6, -30, palette.leaf) + circle(9, 9, 1.2, palette.paper, ' fill-opacity=".45"'), beans: () => [[7, 13, -20], [13, 9, 15], [16, 15, -35]].map(([x, y, a]) => `<g transform=` + `"translate(${x} ${y}) rotate(${a})">${path('M-4 0C-4 -3 -1 -3 0 -1.5C1 -3 4 -3 4 0' + 'C4 3 -4 3 -4 0Z', palette.soil)}</g>`).join(''), }; const ICONS = Object.keys(food); // carrot … beans: the matching table's rows, in order const drawings = { plant, star, ...Object.fromEntries(Object.entries(food).map(([id, draw]) => [id, () => svgDoc(24, 20, draw())])) }; const ALT = { ...Object.fromEntries(ICONS.map((id, i) => [id, FOODS[i]])), plant: t({ en: 'A pea plant with its flower, pod, peas, leaf, stem and roots numbered 1 to 6', es: 'Una planta de guisante con la flor, la vaina, los guisantes, la hoja, el tallo y las ' + 'raíces numerados del 1 al 6' }), star: t({ en: 'A white star on a yellow disc', es: 'Una estrella blanca en un disco amarillo' }) }; // Each drawing is a resource that the opener, a table cell or the review heading names by // id. None is cited, so none is placed as a figure. Sizes in px, 10 to the millimetre. const pictures = Object.keys(drawings).map((id) => ({ id, typeId: 'sheet', kind: 'svg', altText: ALT[id], createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: 240, height: 200, ...{ plant: { width: 780, height: 780 }, star: { width: 240, height: 240 } }[id] } })); // #endregion const resources = [...pictures, foods()]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Andika: ['400', '700'], 'Baloo 2': ['700', '800'], // (gotcha: fonts-first) Fredoka: ['400', '500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([loadFonts(FONTS, markdown), ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Plants and their parts', es: 'Las plantas y sus partes' }) });
Kit · core, fonts, viewer, images: igual en todas las recetas · 270 líneas// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { 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', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ 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; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { 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, { 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 · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗

Variantes

#Devuelve las tarjetas a la rejilla

Con el valor por defecto, snapToGrid: true, lo que sigue a una tarjeta baja a la siguiente línea de la rejilla de 17 pt, y los 9 mm que quedaban bajo cada tarjeta pasan a ser 14,5 mm bajo la actividad 2, 13,2 mm bajo la actividad 4 y 11,2 mm bajo la actividad 6.

-  snapToGrid: false,
+  snapToGrid: true,

#Deja más sitio para una respuesta

:::space cuenta en líneas de 17 pt de la propia caja de respuesta, y cada línea que añades le da al alumno 6 mm más para escribir.

 Escribe aquí
-:::space{lines=4}
+:::space{lines=6}

Errores frecuentes

Error frecuente

:::space se descarta al principio de un recuadro o columna

:::space se descarta al principio de una columna, de un recuadro o de un grupo :::columns, aunque sea el único contenido de la caja, así que una caja de respuesta hecha solo de espacio se queda sin altura. Abre la caja con un título o una línea de enunciado y añade después el espacio. Espacio vertical explícito →

Error frecuente

Un chip solo con espacios imprime su marcado

Un chip cuyo texto son solo espacios, incluidos los de no separación, imprime :chip[ ] tal cual. Para un chip de respuesta en blanco, pon dentro un unidor de palabras (U+2060). Chips en línea →

Error frecuente

Un recuadro anidado ignora span, placement y snapToGrid

Un recuadro anidado en otro ignora su span, placement, snapToGrid y floatBarrier: siempre se compone dentro del recuadro que lo contiene, con el ancho interior de este. Recuadros anidados →

Error frecuente

Los chips más altos que el interlineado tocan la línea siguiente

Un chip cuya caja es más alta que el interlineado toca los chips de la línea siguiente (el aviso chipOverlap). Reduce su relleno vertical, su borde o su cuerpo, o abre el interlineado. Chips en línea →

Error frecuente

Una tabla o una figura dentro de un recuadro no lleva aire alrededor

En postext 1.4.1, una tabla o una figura que ::resource coloca con la posición 'here' dentro de un :::callout no recibe nada del aire que conserva en el texto corrido: toca el párrafo de encima y el de debajo. Pon un :::space antes de la línea ::resource, y otro después si sigue texto; una fracción de línea, como lines=0.33, deja un espacio pequeño. Figuras justo aquí →

Error frecuente

Las imágenes de una apertura no cuentan para la altura que reserva

En postext 1.4.1, un título con diseño avanzado mide la altura que reserva sin contar sus imágenes: sus textos, filetes y cajas cuentan, aunque estén anclados a la página, pero una imagen, como un dibujo a sangre en la cabeza de la página, no reserva nada, así que el texto puede empezar encima de ella. Fija con minHeight dónde debe empezar el texto. Aperturas diseñadas →

Error frecuente

Cualquier objeto headings desactiva el salto de página del H1

Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →

Error frecuente

El texto dentro de un SVG <img> no puede usar fuentes web

Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie. Figuras y tablas como recursos →

Error frecuente

Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones

postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer. Paleta de color semántica →

Error frecuente

Solo 8 idiomas tienen separación silábica, con el código exacto

La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano. Separación silábica e idioma del documento →

Error frecuente

Una configuración se cachea por identidad: crea un objeto nuevo

El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config(). Páginas en un canvas →

Error frecuente

Carga todas las fuentes antes de componer

La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →

Comprobación del Sandbox · chipOverlap

Los chips tocan la línea siguiente

Por qué. La caja de un estilo de chip es más alta que el interlineado, así que los chips de líneas seguidas se tocan.

Solución. Reduce el relleno vertical, el contorno o el cuerpo del chip, o aumenta el interlineado. Documentación →

  • En postext 1.4.1 un recuadro no puede pautar sus propias líneas de escritura, porque los estilos de recuadro no tienen fondo pautado. Las cajas de respuesta quedan en blanco y toman su altura de :::space, y las líneas cortas para rellenar son chips con contorno.
  • Un estilo de título con numbered: false sigue dibujando el diseño de su nivel, pero con {number} vacío. Sin un diseño propio, la autoevaluación empezaría con un disco verde sin nada dentro; por eso review copia el diseño de las actividades y pone la estrella donde iba el disco.

Créditos

Texto
Texto original, CC BY 4.0
Fuentes
Andika (SIL OFL 1.1) · Baloo 2 (SIL OFL 1.1) · Fredoka (SIL OFL 1.1)