Saltar al contenido principal
Receta número 69

Recetario · Capítulo 8 · Tablas

Pasatiempos: crucigrama, sopa de letras y laberinto

Un crucigrama y una sopa de letras compuestos como tablas a partir de líneas de letras: las casillas negras son rellenos de celda; los números, superíndices.

pp. 2–3 de 5

  • Formato 210 × 210 mm
  • 1 columna
  • Lexend 11,5/16
  • Chivo Mono
  • Lilita One
  • 5 páginas
  • Nivel
  • Postext 1.4.1
  • Compuesto en 7 ms
  • 250 líneas de código

Lo que vas a componer

Un cuaderno de verano para niños de 9 a 11 años: la cubierta, tres pasatiempos sobre el mar y una página de soluciones. Las páginas 2 a 5 se abren bajo un campo de color con el pie ondulado (azul mar, menta, coral y amarillo) y un título de 46 pt en Lilita One. El código compone el crucigrama y la sopa de letras como tablas a partir de líneas de letras, cada una con su estilo: casillas con filetes en el crucigrama, teselas claras con juntas blancas y esquinas redondeadas en la sopa. Las casillas negras y la palabra ya encontrada son rellenos de celda, y los números de las definiciones, superíndices en la esquina. El laberinto es un SVG generado con una semilla. La última página reúne las tres soluciones, sacadas de los mismos datos, sobre un recuadro que cuenta cómo anida la tortuga boba.

Esta receta responde a

  • ¿Cómo doy estilos distintos a varias tablas (rellenos, filas alternas, marcos redondeados) en un mismo documento?
  • ¿Cómo añado imágenes y tablas desde el código (recursos) en lugar de ![]() de Markdown?
  • ¿Cómo escribo superíndices, subíndices y fórmulas químicas sin escribir LaTeX?
  • ¿Cómo hago chips en línea: teclas, etiquetas, bancos de palabras para ejercicios?
  • ¿Cómo pongo dos columnas dentro de un recuadro (una de texto junto a otra con una figura)?

La respuesta corta

script.js · líneas 33–59en el código completo
// Every grid shares tableStyle; a named style sets its size, padding, rules and fills.
// A row is as tall as its lines plus the padding and a column is its share of the
// table's width, so the padding sets how deep a square is.
const CELL = (15 * LEAD) / 9; // pt: nine squares are fifteen lines of text, 9.4 mm each
const PAD = 1; // pt: a clue number sits this close to the corner, clear of the 1 pt rule
const CLUE_FACE = (CELL - 2 * PAD) / (2 * LEAD / BODY); // 8.9 pt: two lines fill a square
const LETTER = 15; // pt: the word search's capitals
const pad = (cell, face) => pt((cell - face * LEAD / BODY) / 2); // one line fills the cell
const KEY = 54; // mm: the three answers are this deep, so their captions share a line
const key = (count) => { // count squares in KEY mm, letters of 4/3 pt per mm of square
  const side = (KEY / count) * PT_PER_MM, face = (KEY / count) * 4 / 3;
  return { id: `solucion-${count}`, bodyFontSize: pt(face), cellPadding: pad(side, face),
    borderWidth: pt(0.5) };
};
// No grid has a head row; the grey head is off for the Cookbook's default-skin check.
const tableStyle = { headerBackgroundEnabled: false, bodyFontFamily: LABEL };
const tableStyles = [
  { id: 'crucigrama', bodyFontSize: pt(CLUE_FACE), cellPadding: pt(PAD), borderWidth: pt(1) },
  { id: 'sopa', bodyFontSize: pt(LETTER), cellPadding: pad(CELL, LETTER), // same squares
    bodyBackgroundEnabled: true, bodyBackground: col('tint'), // tiles with white joints
    borderColor: col('paper'), borderWidth: pt(2.4), borderRadius: mm(4) },
  key(9), key(12), // 6 mm squares with 8 pt letters, 4.5 mm squares with 6 pt letters
];
// A table resource names its style; a cell's own background covers the style's fill.
const here = (width) => ({ position: 'here', width, align: 'center' }); // of the column
const grid = (id, typeId, model, styleId, width, extra) => ({ id, typeId, kind: 'table',
  createdAt: 0, updatedAt: 0, table: { model, styleId }, placement: here(width), ...extra });

Ingredientes

Tipografía
Lexend, Lilita One, Chivo Mono (SIL OFL 1.1)
Recursos
Ninguno: todas las imágenes se dibujan en código

Elaboración

#1 · Un estilo de tabla, tres aspectos

El código es la respuesta corta de arriba. tableStyle pone todas las rejillas en Chivo Mono. Cada estilo con nombre de tableStyles fija su cuerpo, su relleno interior y sus filetes, y la tabla elige el suyo con table.styleId. El ancho de una columna es su parte del ancho de la tabla, pero una fila mide lo que sus líneas más el relleno interior, así que es el relleno el que fija la altura de cada casilla. La sopa de letras conserva la casilla de 9,41 mm del crucigrama alrededor de una línea de mayúsculas de 15 pt, y pad() calcula los 2,9 pt que deja esa línea por encima y por debajo. Sus filetes, de 2,4 pt y del color del papel, cortan el fondo claro en teselas, y borderRadius redondea las esquinas del marco a 4 mm y recorta los rellenos a su forma. En la página de soluciones, key() mete nueve o doce casillas en 54 mm: casillas de 6 mm con letras de 8 pt en el crucigrama y de 4,5 mm con letras de 6 pt en la sopa de letras. La solución del laberinto se dibuja en un cuadrado de 54 mm, así que las tres soluciones tienen la misma altura y sus pies comparten la línea de base, a 106,4 mm del borde superior.

#2 · Nueve casillas, quince líneas

script.js · líneas 63–77en el código completo
// Two lines of CLUE_FACE (a number, an empty line) and the padding make a square.
function crossword(source, solved = false) {
  const lines = source.trim().split('\n');
  const white = (r, c) => (lines[r]?.[c] ?? '#') !== '#';
  let clue = 0;
  const rows = lines.map((line, r) => [...line].map((letter, c) => {
    if (!white(r, c)) return { content: '', background: col('ink') }; // a black square
    if (solved) return { content: letter, align: 'center' };
    // A square is numbered when a word starts in it, across or down, in reading order.
    const starts = (!white(r, c - 1) && white(r, c + 1)) || (!white(r - 1, c) && white(r + 1, c));
    const number = starts ? `**^${++clue}^**` : JOINER; // a bold superscript, or nothing
    return { content: `${number}\n${JOINER}` }; // gotcha: cell-blank-line
  }));
  return { rows, columnWidths: rows[0].map(() => 1) };
}

Cada casilla blanca lleva dos líneas de Chivo Mono de 8,9 pt (el número de la definición y una línea vacía) dentro de 1 pt de relleno interior, 26,7 pt en total: 9,41 mm, un noveno del ancho de la tabla. Nueve casillas miden 84,7 mm, lo mismo que quince líneas de 16 pt del texto principal, y el recuadro de las definiciones empieza en la rejilla base una línea más abajo. Una casilla lleva número, en orden de lectura, cuando en ella empieza una palabra horizontal o vertical. El número es un superíndice en negrita de 5,2 pt (el 58 % de 8,9 pt) subido 3 pt (un tercio de 8,9 pt), y eso lo coloca en la esquina superior de la casilla. La línea vacía es un carácter de unión de palabras (U+2060). Con un espacio de no separación en su lugar, la 1.4.1 descarta esa línea y la rejilla se queda en 45,5 mm de alto. Una casilla negra es una celda vacía con background propio, vinculado a ink.

#3 · El código busca las palabras en la rejilla

script.js · líneas 81–99en el código completo
const STEPS = [[0, 1], [1, 0], [1, 1], [-1, 1], [0, -1], [-1, 0], [-1, -1], [1, -1]];
function locate(letters, word) { // the squares of a word, in any of eight directions
  const hits = [];
  letters.forEach((line, r) => [...line].forEach((_, c) => STEPS.forEach(([dr, dc]) => {
    const cells = [...word].map((_, i) => ({ row: r + i * dr, col: c + i * dc }));
    if (cells.every(({ row, col: k }, i) => letters[row]?.[k] === word[i])) hits.push(cells);
  })));
  if (hits.length !== 1) throw new Error(`${word} is in the grid ${hits.length} times`);
  return hits[0];
}
function wordSearch(source, fills) { // fills: [word, palette id] pairs
  const letters = source.trim().split('\n').slice(-12);
  let model = { rows: letters.map((line) => [...line].map((content) => ({ content,
    align: 'center' }))) };
  for (const [word, fill] of fills) {
    for (const at of locate(letters, word)) model = setCellBackground(model, at, col(fill));
  }
  return model;
}

La rejilla son doce líneas de mayúsculas en su propio archivo de contenido, después de la lista de palabras que hay que buscar. locate() busca cada palabra en las ocho direcciones y lanza un error si no aparece exactamente una vez, así que una errata en la rejilla o en la lista detiene la composición antes de que se imprima una palabra que nadie podrá encontrar. setCellBackground devuelve un modelo de tabla nuevo con una casilla más rellena. El relleno va vinculado a una entrada de la paleta y tapa la tesela del estilo: coral para SOMBRILLA en la página 3, menta para las diez palabras en la página 5. En la 1.4.1 los estilos de tabla con nombre no tienen ningún ajuste para alternar el fondo de las filas, así que la primera variante llama a setCellBackground en todas las celdas de una fila sí y otra no. El banco de palabras de encima de la rejilla son dos filas centradas de chips: el de SOMBRILLA va relleno de coral, como sus casillas, y los otros nueve son blancos con borde azul marino.

#4 · Una apertura, cuatro colores

script.js · líneas 103–131en el código completo
const BAND = 40; // mm: the colour field, from the top edge
const pin = (to, edge, x, y, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), overflow: 'wrap', placement,
  align: placement.anchor.edge.endsWith('right') ? 'right' : 'left', ...extra });
const CAPS = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: em(1 / 6) };
// The field's box sets the opener's depth: the text starts on the next grid line below.
const opener = { enabled: true, slot: { elements: [
  { kind: 'box', id: 'field', style: { backgroundColor: col('band') },
    placement: pin('page', 'top-left', 0, 0, { width: 'fill', height: mm(BAND) }) },
  { kind: 'image', id: 'surf', resourceId: 'olas', // the field's foot, cut in waves
    placement: pin('page', 'top-left', 0, BAND - 3, { width: mm(PAGE), height: mm(3.2) }) },
  text('kicker', '{attr.kicker}', LABEL, 8.5, pin('page', 'top-left', MARGIN, 11), CAPS),
  text('title', '{titleText}', DISPLAY, 46, pin('#kicker', 'below', -0.6, 0.5),
    { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
  text('theme', '{attr.theme}', TEXT, 10, pin('page', 'top-right', -MARGIN, 23.5), {
    fontWeight: 700, box: { backgroundColor: col('paper'), borderRadius: mm(3.5),
      padding: { top: mm(1.6), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } } }),
] } };
// The H1 break is restated (gotcha: headings-drop-h1-break), parity 'any'. span 'page' even
// in one column, or the field is cut at the top margin (gotcha: opener-clipped-at-top).
const puzzleLevel = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: opener };
// '# Sopa de letras {style="sopa"}': the style's palette turns 'band' mint on that page,
// in the field, the folio disc and anything else linked to it.
const BANDS = { crucigrama: 'band', sopa: 'mint', laberinto: 'coral', soluciones: 'sun' };
const puzzleStyles = Object.entries(BANDS)
  .map(([id, band]) => ({ id, palette: { band: palette[band] } }));

Las páginas de la 2 a la 5 comparten el mismo diseño. Un campo de color baja 40 mm desde el borde superior y termina en un pie ondulado dibujado en el color del papel; el antetítulo, el título y el tema, en su píldora blanca, salen de la línea del título y de sus atributos. El campo y el disco del folio están vinculados a la entrada band de la paleta, y el estilo de título de cada página le da su propio valor, de modo que # Sopa de letras {style="sopa"} los vuelve menta en la página 3. El nivel 1 lleva span: 'page' aunque el cuaderno tiene una sola columna: la 1.4.1 recorta por el margen superior de 16 mm un diseño que se queda en la columna, y eso cortaría la parte de arriba del campo y el antetítulo. La caja del campo cuenta en la altura que reserva la apertura, así que el texto empieza en la siguiente línea de la rejilla base, a 44,2 mm del borde superior y 4 mm por debajo de las olas.

#5 · Las soluciones salen de los mismos datos

script.js · líneas 504–520en el código completo
const resourceTypes = [ // a plain resource prints no caption; an answer is numbered
  { id: 'plain', name: 'Plain', shortLabel: '', captionPrefix: '' },
  { id: 'solucion', ...t({ en: { name: 'Solution', captionPrefix: 'Solution' },
    es: { name: 'Solución', captionPrefix: 'Solución' } }), shortLabel: 'Sol.' },
].map((type) => ({ numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  ...type }));
const ANSWER_GAP = 6; // mm between the three answers
const ANSWER_WIDTH = KEY / ((MEASURE - 2 * ANSWER_GAP) / 3); // 54 mm of a 55.3 mm column
const answers = [
  grid('sol-crucigrama', 'solucion', crossword(crosswordText, true), 'solucion-9', ANSWER_WIDTH,
    { caption: t({ en: 'Crossword', es: 'Crucigrama' }) }),
  grid('sol-sopa', 'solucion', wordSearch(searchText, words.map((w) => [w, 'mint'])),
    'solucion-12', ANSWER_WIDTH, { caption: t({ en: 'Word search', es: 'Sopa de letras' }) }),
  picture('sol-laberinto', 'solucion', { placement: here(ANSWER_WIDTH), caption: t({
    en: 'Maze', es: 'Laberinto' }), altText: t({ en: 'The maze and its way out to the sea',
    es: 'El laberinto y su salida al mar' }) }),
];

La página de soluciones vuelve a llamar a crossword() con solved y a wordSearch() con las diez palabras, sobre los mismos archivos de contenido que los pasatiempos, así que si cambias una rejilla cambia también su solución. Los pasatiempos y los dibujos son del tipo Plain, cuyo captionPrefix vacío no imprime pie en un recurso sin texto de pie. Las soluciones son del tipo Solución, numeradas del 1 al 3 en orden de lectura para que coincidan con los antetítulos, de Pasatiempo 1 a Pasatiempo 3. Van en un recuadro sin fondo ni borde que contiene :::columns{count=3 breaks="2,3"}, donde breaks abre una columna nueva en la segunda y en la tercera solución; cada una mide 54 mm de ancho en una columna de 55,3 mm. El recuadro ¿Sabías que…? hace lo mismo con breaks="2" para poner el dibujo del nido junto a su párrafo.

La receta completa

// ═══ Postext Cookbook · Nº 069 · Puzzle book: crossword, word search and maze ═══════
// https://postext.dev/en/cookbook/puzzle-book
// Code: MIT · Text and puzzles: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Lexend, Lilita One, Chivo Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  setCellBackground } from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1f2a44', // text, grid rules and the black squares: a navy near-black
  paper: '#ffffff',
  band: '#2d9cdb', // a puzzle's colour field: sea blue until a puzzle's style replaces it
  sun: '#ffb627', coral: '#ff6f59', mint: '#5cc8a8', // the other fields; coral marks a find
  tint: '#fff1cc', // boxes and word-search tiles
  muted: '#5b6477', // running titles and the colophon
};
// Design slots read the hex in 1.4.1 and a puzzle's palette reads the id: col() writes both
// (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Lexend', 'Lilita One', 'Chivo Mono'];
const PAGE = 210, MARGIN = 16; // mm: a square booklet, the same margin on every side
const MEASURE = PAGE - 2 * MARGIN; // 178 mm
const BODY = 11.5, LEAD = 16; // pt: large, open text for young readers
const PT_PER_MM = 72 / 25.4;
const JOINER = '\u2060'; // a word joiner: a line with nothing on it that still counts

// #region answer: a named table style for each grid, picked by the table's styleId
// Every grid shares tableStyle; a named style sets its size, padding, rules and fills.
// A row is as tall as its lines plus the padding and a column is its share of the
// table's width, so the padding sets how deep a square is.
const CELL = (15 * LEAD) / 9; // pt: nine squares are fifteen lines of text, 9.4 mm each
const PAD = 1; // pt: a clue number sits this close to the corner, clear of the 1 pt rule
const CLUE_FACE = (CELL - 2 * PAD) / (2 * LEAD / BODY); // 8.9 pt: two lines fill a square
const LETTER = 15; // pt: the word search's capitals
const pad = (cell, face) => pt((cell - face * LEAD / BODY) / 2); // one line fills the cell
const KEY = 54; // mm: the three answers are this deep, so their captions share a line
const key = (count) => { // count squares in KEY mm, letters of 4/3 pt per mm of square
  const side = (KEY / count) * PT_PER_MM, face = (KEY / count) * 4 / 3;
  return { id: `solucion-${count}`, bodyFontSize: pt(face), cellPadding: pad(side, face),
    borderWidth: pt(0.5) };
};
// No grid has a head row; the grey head is off for the Cookbook's default-skin check.
const tableStyle = { headerBackgroundEnabled: false, bodyFontFamily: LABEL };
const tableStyles = [
  { id: 'crucigrama', bodyFontSize: pt(CLUE_FACE), cellPadding: pt(PAD), borderWidth: pt(1) },
  { id: 'sopa', bodyFontSize: pt(LETTER), cellPadding: pad(CELL, LETTER), // same squares
    bodyBackgroundEnabled: true, bodyBackground: col('tint'), // tiles with white joints
    borderColor: col('paper'), borderWidth: pt(2.4), borderRadius: mm(4) },
  key(9), key(12), // 6 mm squares with 8 pt letters, 4.5 mm squares with 6 pt letters
];
// A table resource names its style; a cell's own background covers the style's fill.
const here = (width) => ({ position: 'here', width, align: 'center' }); // of the column
const grid = (id, typeId, model, styleId, width, extra) => ({ id, typeId, kind: 'table',
  createdAt: 0, updatedAt: 0, table: { model, styleId }, placement: here(width), ...extra });
// #endregion

// #region crossword: nine lines of letters give the crossword, or with solved its answer
// Two lines of CLUE_FACE (a number, an empty line) and the padding make a square.
function crossword(source, solved = false) {
  const lines = source.trim().split('\n');
  const white = (r, c) => (lines[r]?.[c] ?? '#') !== '#';
  let clue = 0;
  const rows = lines.map((line, r) => [...line].map((letter, c) => {
    if (!white(r, c)) return { content: '', background: col('ink') }; // a black square
    if (solved) return { content: letter, align: 'center' };
    // A square is numbered when a word starts in it, across or down, in reading order.
    const starts = (!white(r, c - 1) && white(r, c + 1)) || (!white(r - 1, c) && white(r + 1, c));
    const number = starts ? `**^${++clue}^**` : JOINER; // a bold superscript, or nothing
    return { content: `${number}\n${JOINER}` }; // gotcha: cell-blank-line
  }));
  return { rows, columnWidths: rows[0].map(() => 1) };
}
// #endregion

// #region search: a word search that finds its words in the grid and fills their squares
const STEPS = [[0, 1], [1, 0], [1, 1], [-1, 1], [0, -1], [-1, 0], [-1, -1], [1, -1]];
function locate(letters, word) { // the squares of a word, in any of eight directions
  const hits = [];
  letters.forEach((line, r) => [...line].forEach((_, c) => STEPS.forEach(([dr, dc]) => {
    const cells = [...word].map((_, i) => ({ row: r + i * dr, col: c + i * dc }));
    if (cells.every(({ row, col: k }, i) => letters[row]?.[k] === word[i])) hits.push(cells);
  })));
  if (hits.length !== 1) throw new Error(`${word} is in the grid ${hits.length} times`);
  return hits[0];
}
function wordSearch(source, fills) { // fills: [word, palette id] pairs
  const letters = source.trim().split('\n').slice(-12);
  let model = { rows: letters.map((line) => [...line].map((content) => ({ content,
    align: 'center' }))) };
  for (const [word, fill] of fills) {
    for (const at of locate(letters, word)) model = setCellBackground(model, at, col(fill));
  }
  return model;
}
// #endregion

// #region openers: one opener for every puzzle; each puzzle's style gives it its colour
const BAND = 40; // mm: the colour field, from the top edge
const pin = (to, edge, x, y, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), overflow: 'wrap', placement,
  align: placement.anchor.edge.endsWith('right') ? 'right' : 'left', ...extra });
const CAPS = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: em(1 / 6) };
// The field's box sets the opener's depth: the text starts on the next grid line below.
const opener = { enabled: true, slot: { elements: [
  { kind: 'box', id: 'field', style: { backgroundColor: col('band') },
    placement: pin('page', 'top-left', 0, 0, { width: 'fill', height: mm(BAND) }) },
  { kind: 'image', id: 'surf', resourceId: 'olas', // the field's foot, cut in waves
    placement: pin('page', 'top-left', 0, BAND - 3, { width: mm(PAGE), height: mm(3.2) }) },
  text('kicker', '{attr.kicker}', LABEL, 8.5, pin('page', 'top-left', MARGIN, 11), CAPS),
  text('title', '{titleText}', DISPLAY, 46, pin('#kicker', 'below', -0.6, 0.5),
    { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
  text('theme', '{attr.theme}', TEXT, 10, pin('page', 'top-right', -MARGIN, 23.5), {
    fontWeight: 700, box: { backgroundColor: col('paper'), borderRadius: mm(3.5),
      padding: { top: mm(1.6), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } } }),
] } };
// The H1 break is restated (gotcha: headings-drop-h1-break), parity 'any'. span 'page' even
// in one column, or the field is cut at the top margin (gotcha: opener-clipped-at-top).
const puzzleLevel = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: opener };
// '# Sopa de letras {style="sopa"}': the style's palette turns 'band' mint on that page,
// in the field, the folio disc and anything else linked to it.
const BANDS = { crucigrama: 'band', sopa: 'mint', laberinto: 'coral', soluciones: 'sun' };
const puzzleStyles = Object.entries(BANDS)
  .map(([id, band]) => ({ id, palette: { band: palette[band] } }));
// #endregion

// The folio in a disc of the puzzle's colour; the book's title or the puzzle's beside it.
const disc = (parity, edge, x) => text(`folio-${parity}`, '{pageNumber}', DISPLAY, 12,
  pin('page', edge, x, PAGE - 14, { width: mm(8), height: mm(8) }), { parity, lineHeight: 1,
    align: 'center', verticalAlign: 'middle',
    box: { backgroundColor: col('band'), borderRadius: mm(4) } });
const running = (parity, edge, x, content) => text(`title-${parity}`, content, LABEL, 7.5,
  pin('page', edge, x, PAGE - 11.7), { ...CAPS, parity, color: col('muted') });
const footer = { elements: [
  disc('even', 'top-left', MARGIN), running('even', 'top-left', MARGIN + 11, '{title}'),
  disc('odd', 'top-right', -MARGIN), running('odd', 'top-right', -MARGIN - 11, '{chapterTitle}'),
] };
// The answer page trades its running title for the colophon of '# Answers {colophon="…"}'.
const colophon = (parity, edge, x) => text(`colophon-${parity}`, '{attr.colophon}', LABEL, 7,
  pin('page', edge, x, PAGE - 11.5), { parity, color: col('muted') });
Object.assign(puzzleStyles.find(({ id }) => id === 'soluciones'), { footer: { elements: [
  ...footer.elements.filter(({ id }) => id.startsWith('folio')),
  colophon('even', 'top-right', -MARGIN), colophon('odd', 'top-left', MARGIN)] } });

// The cover: the drawing over the whole page, the title on its sky, a name line on the sea.
const cover = { id: 'portada', header: { elements: [] },
  footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'portada',
      placement: pin('page', 'top-left', 0, 0, { width: mm(PAGE), height: mm(PAGE) }) },
    text('kicker', '{attr.kicker}', LABEL, 8.5, pin('page', 'top-left', MARGIN, 18), CAPS),
    text('title', '{titleText}', DISPLAY, 58, pin('#kicker', 'below', -0.8, 3,
      { width: mm(120) }), { lineHeight: 0.95 }),
    text('subtitle', '{attr.subtitle}', TEXT, 15, pin('#title', 'below', 0.8, 3),
      { fontWeight: 600 }),
    text('age', '{attr.age}', LABEL, 9.5, pin('#subtitle', 'below', 0, 5), { ...CAPS,
      box: { backgroundColor: col('coral'), borderRadius: mm(3.6),
        padding: { top: mm(1.8), right: mm(3.4), bottom: mm(1.8), left: mm(3.4) } } }),
    // The field fills the calm band between the wave stripes at 173 and 190 mm.
    { kind: 'box', id: 'name-field', style: { backgroundColor: col('paper'),
      borderRadius: mm(3) }, placement: pin('page', 'top-left', MARGIN, 176.9,
      { width: mm(104), height: mm(9) }) },
    text('name', '{attr.name}', LABEL, 8.5, pin('page', 'top-left', MARGIN + 5, 180.2), CAPS),
    { kind: 'rule', id: 'name-line', direction: 'horizontal', thickness: pt(0.75),
      color: col('muted'), placement: pin('page', 'top-left', MARGIN + 27, 183.2,
        { width: mm(72) }) },
  ] } } };

const chip = (id, fill) => ({ id, fontFamily: LABEL, fontSize: em(0.8), bold: true,
  background: col(fill), borderColor: col(fill === 'paper' ? 'ink' : fill),
  borderWidth: pt(0.75), borderRadius: em(1), paddingX: em(0.45) });

// resourceTypes and ANSWER_GAP are declared with the answers below: config() runs later.
const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  colorPalette, resourceTypes, tableStyle, tableStyles, footer, header: { elements: [] },
  page: { width: mm(PAGE), height: mm(PAGE), dpi: 150, margins: { top: mm(MARGIN),
    bottom: mm(MARGIN), left: mm(MARGIN), right: mm(MARGIN) } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), // in the boxes too: their bold ignores the palette in 1.4.1
    textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true },
  // The H1 line under each design is still measured: in Lilita One, not in Open Sans 700.
  headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [
    puzzleLevel,
    { level: 3, fontFamily: LABEL, fontWeight: 700, fontSize: pt(8.5), lineHeight: pt(13),
      textTransform: 'uppercase', marginBottom: pt(3) },
  ] },
  headingStyles: [cover, ...puzzleStyles],
  // The word bank: ink on paper and coral, colours no puzzle's palette changes
  // (gotcha: section-palette-skips-chips).
  chipStyles: [chip('palabra', 'paper'), chip('hallada', 'coral')],
  calloutStyles: [
    { id: 'pistas', background: col('tint'), borderRadius: mm(3), columnGap: mm(8),
      padding: { top: mm(4), right: mm(5), bottom: mm(4), left: mm(5) },
      marginTop: pt(0), // the grid above already leaves a line
      body: { fontSize: pt(10), lineHeight: pt(13), paragraphSpacing: false } },
    { id: 'soluciones', backgroundEnabled: false, columnGap: mm(ANSWER_GAP), // columns only
      padding: { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) } },
    { id: 'dato', background: col('tint'), borderRadius: mm(3), columnGap: mm(6),
      padding: { top: mm(4), right: mm(5), bottom: mm(4.5), left: mm(5) },
      titleStyle: { fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 400 },
      body: { fontSize: pt(10), lineHeight: pt(14) } },
  ],
  captionStyle: { fontFamily: LABEL, fontSize: pt(8), gap: mm(2) },
  paragraphStyles: [{ id: 'banco', textAlign: 'center' }], // the word bank's two rows
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Muestra en Markdown · 77 líneas · content.es.mdtitle: "Cuaderno de verano" author: "Ediciones Caracola" --- # Cuaderno \\ de verano {style="portada" kicker="Ediciones Caracola · Cuaderno 4" subtitle="Pasatiempos del mar" age="De 9 a 11 años" name="Nombre"} # Crucigrama {style="crucigrama" kicker="Pasatiempo 1" theme="Animales del mar"} Cada número pequeño, como el **^1^** de la esquina, marca la casilla donde empieza el nombre de un animal. Escribe una letra en cada casilla blanca. ::resource{id="crucigrama"} :::callout{type="pistas"} :::columns{count=2 breaks="7"} ### Horizontales **1** Camina de lado y tiene pinzas. **4** El mayor de todos los delfines. **5** Pone los huevos en la arena. **7** Ocho brazos, dos tentáculos y tinta. **8** La azul es el animal más grande. ### Verticales **1** Animal diminuto que forma arrecifes. **2** Ave que chilla en los puertos. **3** Pez que se vende seco y salado. **4** A veces guarda una perla. **6** Pez plano con forma de cometa. ::: ::: # Sopa de letras {style="sopa" kicker="Pasatiempo 2" theme="Un día de playa"} Busca estas diez palabras de un día de playa. Van en horizontal, en vertical o en diagonal, y algunas están escritas del revés. La primera ya está marcada. :::paragraphs{style="banco"} :chip[SOMBRILLA]{style="hallada"} :chip[CASTILLO]{style="palabra"} :chip[TOALLA]{style="palabra"} :chip[GAFAS]{style="palabra"} :chip[CUBO]{style="palabra"} :chip[CHANCLAS]{style="palabra"} :chip[BAÑADOR]{style="palabra"} :chip[PELOTA]{style="palabra"} :chip[HELADO]{style="palabra"} :chip[CREMA]{style="palabra"} ::: ::resource{id="sopa"} # Laberinto {style="laberinto" kicker="Pasatiempo 3" theme="Rumbo al mar"} Esta tortuga acaba de salir del huevo, en un nido enterrado en la playa. Ayúdala a llegar al agua: entre las dunas solo hay un camino. ::resource{id="laberinto"} # Soluciones {style="soluciones" kicker="Para mirar al terminar" theme="Pasatiempos 1 a 3" colophon="Pasatiempos, textos y dibujos originales (CC BY 4.0) · Compuesto en Lexend, Lilita One y Chivo Mono (SIL OFL)"} :::callout{type="soluciones"} :::columns{count=3 breaks="2,3"} ::resource{id="sol-crucigrama"} ::resource{id="sol-sopa"} ::resource{id="sol-laberinto"} ::: ::: :::callout{type="dato" title="¿Sabías que…?"} :::columns{count=2 breaks="2"} ::resource{id="nido"} La tortuga boba cava su nido en la arena, de noche, y deja en él unos cien huevos. Dos meses después, las crías salen todas juntas, también de noche, y corren hacia la parte más clara del horizonte, que suele ser el mar. Por eso las farolas de un paseo marítimo pueden despistarlas. En varias playas del Mediterráneo español, grupos de voluntarios vigilan los nidos hasta que nacen las crías. ::: :::
`; // the booklet's text, clues and word bank const crosswordText = String.raw`CANGREJO#
Muestra en Markdown · 8 líneas · content.crossword.es.mdO##A####B R##V#ORCA A##I#S##C L#TORTUGA #R#T#R##L CALAMAR#A #Y######O BALLENA##
`; // nine lines: letters, # for a black square const searchText = String.raw`SOMBRILLA CASTILLO TOALLA GAFAS CUBO CHANCLAS BAÑADOR PELOTA HELADO CREMA
Muestra en Markdown · 13 líneas · content.wordsearch.es.md MLZOIECDEOQO MNUOBMUIAALS RIOSOMBRILLA NETAGIOVITRM PDGPCDETRAÑE JIABALSNLTFR TAFÑCAGLSOMC TLAOCHANCLAS IBSOAODALEHA RRINTTOOEPOZ EOEYAEAENUAE TMCOGTVSUIEN
`; // the words to find, then twelve lines // #region art: the cover, the waves, the nest and the maze, drawn in the page's colours const n = (v) => +v.toFixed(2); function mulberry32(seed) { // a seeded PRNG: the same maze in every capture return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } // No words in the drawings: an SVG drawn as an image cannot see the page's fonts // (gotcha: svg-no-webfonts). Start and finish are pictures instead. const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const P = palette; const wave = (y, amp, len, x0, x1) => { // a sine line from x0 to x1, one crest per len let d = `M${x0} ${y}`; for (let x = x0; x < x1; x += len) d += ` q${len / 4} ${-amp} ${len / 2} 0 t${len / 2} 0`; return d; }; function turtle(x, y, s, turn = 0) { // a hatchling seen from above, s mm long, head up at 0° const line = `stroke="${P.ink}" stroke-width="0.45" stroke-linejoin="round"`; const skin = (d) => `<path d="${d}" fill="${P.paper}" ${line}/>`; return `<g transform="translate(${x} ${y}) rotate(${turn}) scale(${n(s / 12)})">` + skin('M-2.4 -1.6Q-6.8 -4.4 -7 -0.4Q-4.4 -0.6 -2.6 0.6Z') // front flippers + skin('M2.4 -1.6Q6.8 -4.4 7 -0.4Q4.4 -0.6 2.6 0.6Z') + skin('M-1.8 3Q-4.2 4.4 -3.6 5.9Q-2 5.2 -1 4Z') + skin('M1.8 3Q4.2 4.4 3.6 5.9Q2 5.2 1 4Z') + `<ellipse cy="-5.3" rx="1.7" ry="2" fill="${P.paper}" ${line}/>` + `<ellipse cy="0.4" rx="3.5" ry="4.5" fill="${P.mint}" ${line}/>` + `<path d="M0 -2L1.4 -1V1.4L0 2.4L-1.4 1.4V-1ZM0 -2V-4.1M0 2.4V4.9M1.4 -1L3.1 -2.2` + `M-1.4 -1L-3.1 -2.2M1.4 1.4L3.2 2.6M-1.4 1.4L-3.2 2.6" fill="none" stroke="${P.ink}" ` + 'stroke-width="0.35"/>' + `<circle cx="-0.7" cy="-5.9" r="0.32" fill="${P.ink}"/>` + `<circle cx="0.7" cy="-5.9" r="0.32" fill="${P.ink}"/></g>`; } function coverArt() { // sky, a low sun, a striped sea, a boat, two gulls and a turtle const H = PAGE, sea = 112; let g = `<rect width="${PAGE}" height="${H}" fill="${P.tint}"/>` + `<circle cx="152" cy="${sea - 8}" r="46" fill="${P.sun}"/>` + `<rect y="${sea}" width="${PAGE}" height="${H - sea}" fill="${P.band}"/>`; for (let i = 0, y = sea + 9; y < H; i++, y += 9 + i * 1.6) { // stripes widen towards us g += `<path d="${wave(y, 1.2 + i * 0.25, 18 + i * 3, -9, PAGE + 20)}" fill="none" ` + `stroke="${P.paper}" stroke-width="${n(1.1 + i * 0.35)}" stroke-linecap="round"/>`; } [[6, 34], [12, 22], [19, 12]].forEach(([dy, w]) => { // the sun's path on the water g += `<rect x="${152 - w / 2}" y="${sea + dy - 1}" width="${w}" height="2.2" rx="1.1" ` + `fill="${P.sun}"/>`; }); g += `<path d="M26 ${sea - 0.4}h15l-2.4 3.6h-10.4z" fill="${P.ink}"/>` // a boat on the line + `<path d="M33.6 ${sea - 1.6}v-15l8 13.6z" fill="${P.coral}"/>` + `<path d="M32.6 ${sea - 1.6}v-11.6l-6 11.6z" fill="${P.paper}"/>`; [[118, 34, 5], [131, 27, 3.6]].forEach(([x, y, w]) => { // two gulls g += `<path d="M${x - w} ${y}q${w / 2} -${w / 2} ${w} 0q${w / 2} -${w / 2} ${w} 0" ` + `fill="none" stroke="${P.ink}" stroke-width="0.8" stroke-linecap="round"/>`; }); return svg(PAGE, H, g + turtle(160, 170, 22, -35)); } const surf = () => svg(PAGE, 3.2, `<path d="${wave(3.2, 1.6, 12, 0, PAGE + 12)} V3.2 H0Z" ` + `fill="${P.paper}"/>`); function nestArt() { // a cutaway beach: the eggs under the sand, two hatchlings on their way const W = 80, H = 46, shore = [46, 15.5], deep = [W, 40]; // the sand slopes under the sea const slope = (y) => shore[0] + ((y - shore[1]) / (deep[1] - shore[1])) * (deep[0] - shore[0]); let g = `<rect width="${W}" height="${H}" fill="${P.tint}"/>` // the box's own tint as sky + `<path d="M0 13.6Q13 11.4 25 13.8T${shore[0]} ${shore[1]}L${deep[0]} ${deep[1]}V${H}H0Z" ` + `fill="${P.sun}"/><path d="M${shore[0]} ${shore[1]}H${W}V${deep[1]}Z" fill="${P.band}"/>`; for (let y = shore[1] + 5; y < deep[1] - 2; y += 5) { // ripples, from the slope outwards g += `<path d="${wave(y, 0.5, 4, n(slope(y) + 2), W + 4)}" fill="none" ` + `stroke="${P.paper}" stroke-width="0.55"/>`; } g += `<ellipse cx="22" cy="33" rx="12.5" ry="8" fill="${P.tint}"/>`; // the egg chamber [[36.9, 5], [33.1, 4], [29.3, 3]].forEach(([y, count]) => { // the clutch, row on row for (let i = 0; i < count; i++) { g += `<circle cx="${n(22 + (i - (count - 1) / 2) * 4.35)}" cy="${y}" r="2.05" ` + `fill="${P.paper}" stroke="${P.ink}" stroke-width="0.35"/>`; } }); return svg(W, H, g + turtle(33, 10.4, 7, 100) + turtle(44.5, 12.4, 7, 112)); } function maze(cols, rows, seed) { // a recursive backtracker: one path between any two squares const rand = mulberry32(seed); const open = new Set(); // passages, as 'r,c>r,c' const seen = new Set(['0,0']); const stack = [[0, 0]]; const around = (r, c) => [[r - 1, c], [r + 1, c], [r, c - 1], [r, c + 1]]; while (stack.length) { const [r, c] = stack.at(-1); const next = around(r, c).filter(([y, x]) => y >= 0 && y < rows && x >= 0 && x < cols && !seen.has(`${y},${x}`)); if (!next.length) { stack.pop(); continue; } const [y, x] = next[Math.floor(rand() * next.length)]; open.add(`${r},${c}>${y},${x}`).add(`${y},${x}>${r},${c}`); seen.add(`${y},${x}`); stack.push([y, x]); } const passes = (a, b) => open.has(`${a}>${b}`); const queue = [[0, 0]]; // the way out, by breadth-first search const from = new Map([['0,0', null]]); for (let i = 0; i < queue.length; i++) { const [r, c] = queue[i]; for (const [y, x] of around(r, c)) { if (from.has(`${y},${x}`) || !passes(`${r},${c}`, `${y},${x}`)) continue; from.set(`${y},${x}`, `${r},${c}`); queue.push([y, x]); } } const route = []; for (let at = `${rows - 1},${cols - 1}`; at; at = from.get(at)) { route.unshift(at.split(',').map(Number)); } return { cols, rows, passes, route }; } function mazeArt(m, unit, { solved = false } = {}) { // Sand above the maze for the nest and sea below it. The answer is square, as deep as // the answer grids beside it, so the three captions share a line: the rest of the // square goes to the sea, where the hatchling swims, with waves drawn k times larger. const side = 1.5, W = m.cols * unit + 2 * side, top = (solved ? 2.4 : 1.6) * unit; const foot = solved ? W - m.rows * unit - top : 1.6 * unit, k = solved ? 2 : 1; const H = m.rows * unit + top + foot; const X = (c) => n(side + c * unit), Y = (r) => n(top + r * unit); let walls = `M${X(1)} ${Y(0)}H${X(m.cols)}V${Y(m.rows)}M${X(m.cols - 1)} ${Y(m.rows)}` + `H${X(0)}V${Y(0)}`; // the frame, open above the first square and below the last for (let r = 0; r < m.rows; r++) { for (let c = 0; c < m.cols; c++) { const [here, right, below] = [`${r},${c}`, `${r},${c + 1}`, `${r + 1},${c}`]; if (c < m.cols - 1 && !m.passes(here, right)) walls += `M${X(c + 1)} ${Y(r)}V${Y(r + 1)}`; if (r < m.rows - 1 && !m.passes(here, below)) walls += `M${X(c)} ${Y(r + 1)}H${X(c + 1)}`; } } // The nest above the entrance, the sea below the exit. const shore = H - foot + 4; let g = `<rect width="${W}" height="${H}" fill="${P.tint}"/>` + `<path d="${wave(shore, 1.4 * k, 12 * k, 0, W + 12 * k)}V${H}H0Z" fill="${P.band}"/>`; for (let y = shore + 5 * k; y < H; y += 4.5 * k) { g += `<path d="${wave(y, 0.8 * k, 10 * k, 0, W + 10 * k)}" fill="none" ` + `stroke="${P.paper}" stroke-width="${0.7 * k}"/>`; } const [nx, ny] = [X(0.5), top - 7.5]; // a hollow in the sand and two empty shells g += `<ellipse cx="${nx}" cy="${ny}" rx="${unit * 0.6}" ry="3.6" fill="${P.sun}"/>` + [[nx + unit * 0.95, ny - 1.2], [nx + unit * 1.35, ny + 1.4]].map(([ex, ey]) => `<ellipse cx="${n(ex)}" cy="${n(ey)}" rx="1.5" ry="1.9" fill="${P.paper}" ` + `stroke="${P.ink}" stroke-width="0.4"/>`).join(''); if (solved) { // the way out in coral, from the nest into the water, and the hatchling const pts = m.route.map(([r, c]) => `${X(c + 0.5)} ${Y(r + 0.5)}`); g += `<path d="M${X(0.5)} ${Y(0) - side}L${pts.join('L')}` + `L${X(m.cols - 0.5)} ${shore + 3}" fill="none" stroke="${P.coral}" ` + `stroke-width="${n(unit * 0.3)}" stroke-linecap="round" stroke-linejoin="round"/>` + turtle(X(m.cols - 2.5), n(shore + foot / 2), unit * 1.8, 165); } else g += turtle(nx, ny + 0.5, unit * 1.05, 180); g += `<path d="${walls}" fill="none" stroke="${P.ink}" stroke-width="${n(unit * 0.12)}" ` + 'stroke-linecap="round"/>'; return { markup: svg(n(W), n(H), g), width: W, height: H }; } const theMaze = maze(17, 9, 2027); const drawings = { portada: { markup: coverArt(), width: PAGE, height: PAGE }, olas: { markup: surf(), width: PAGE, height: 3.2 }, nido: { markup: nestArt(), width: 80, height: 46 }, laberinto: mazeArt(theMaze, 10), 'sol-laberinto': mazeArt(theMaze, 10, { solved: true }) }; for (const [id, { markup }] of Object.entries(drawings)) await loadSvg(`${id}.svg`, markup); const picture = (id, typeId, extra) => ({ id, typeId, kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: drawings[id].width * 10, height: drawings[id].height * 10 }, ...extra }); // #endregion const words = searchText.trim().split('\n')[0].split(' '); // the first one comes found const share = (count, size) => (count * size) / MEASURE; // a grid's share of the measure const puzzles = [ grid('crucigrama', 'plain', crossword(crosswordText), 'crucigrama', share(9, CELL / PT_PER_MM), { altText: t({ en: 'An empty crossword, nine squares a side', es: 'Un crucigrama vacío de nueve por nueve' }) }), grid('sopa', 'plain', wordSearch(searchText, [[words[0], 'coral']]), 'sopa', share(12, CELL / PT_PER_MM), { altText: t({ en: 'A word search, twelve letters a side', es: 'Una sopa de letras de doce por doce' }) }), picture('laberinto', 'plain', { placement: here(1), altText: t({ en: 'A maze from a turtle nest to the sea', es: 'Un laberinto desde un nido hasta el mar' }) }), picture('nido', 'plain', { placement: here(1), altText: t({ en: 'Eggs under the sand, two hatchlings on their way to the sea', es: 'Huevos bajo la arena y dos crías camino del mar' }) }), picture('portada', 'plain', { altText: t({ en: 'A low sun, a sailing boat and a turtle at sea', es: 'Un sol bajo, un velero y una tortuga en el mar' }) }), picture('olas', 'plain', { altText: '' }), // the field's wavy foot: decoration ]; // #region answers: the answer page reuses the puzzles' data; a resource type numbers it const resourceTypes = [ // a plain resource prints no caption; an answer is numbered { id: 'plain', name: 'Plain', shortLabel: '', captionPrefix: '' }, { id: 'solucion', ...t({ en: { name: 'Solution', captionPrefix: 'Solution' }, es: { name: 'Solución', captionPrefix: 'Solución' } }), shortLabel: 'Sol.' }, ].map((type) => ({ numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', ...type })); const ANSWER_GAP = 6; // mm between the three answers const ANSWER_WIDTH = KEY / ((MEASURE - 2 * ANSWER_GAP) / 3); // 54 mm of a 55.3 mm column const answers = [ grid('sol-crucigrama', 'solucion', crossword(crosswordText, true), 'solucion-9', ANSWER_WIDTH, { caption: t({ en: 'Crossword', es: 'Crucigrama' }) }), grid('sol-sopa', 'solucion', wordSearch(searchText, words.map((w) => [w, 'mint'])), 'solucion-12', ANSWER_WIDTH, { caption: t({ en: 'Word search', es: 'Sopa de letras' }) }), picture('sol-laberinto', 'solucion', { placement: here(ANSWER_WIDTH), caption: t({ en: 'Maze', es: 'Laberinto' }), altText: t({ en: 'The maze and its way out to the sea', es: 'El laberinto y su salida al mar' }) }), ]; // #endregion const resources = [...puzzles, ...answers]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) Lexend: ['400', '600', '700'], 'Lilita One': ['400'], 'Chivo Mono': ['400', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const allText = [markdown, crosswordText, searchText].join('\n'); await loadFonts(FONTS, allText); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText); showPages(doc, { title: t({ en: 'Summer Workbook · Puzzles from the sea', es: 'Cuaderno de verano · Pasatiempos del mar' }) });
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

#Alterna el fondo de las filas de la sopa de letras

Un amarillo más intenso en una fila sí y otra no ayuda a un lector pequeño a seguir la fila, y como las palabras se rellenan después, SOMBRILLA sigue en coral y la solución conserva sus palabras en menta.

   let model = { rows: letters.map((line) => [...line].map((content) => ({ content,
     align: 'center' }))) };
+  const stripe = { hex: '#ffdd80', model: 'hex' }; // a shade deeper than the tiles
+  letters.forEach((line, r) => r % 2 && [...line].forEach((_, c) => {
+    model = setCellBackground(model, { row: r, col: c }, stripe);
+  }));
   for (const [word, fill] of fills) {

#Dibuja un laberinto más difícil

Con 21 por 11 casillas en el mismo ancho, cada una mide 8,4 mm, y la solución de la página 5 se dibuja a partir del nuevo laberinto.

-const theMaze = maze(17, 9, 2027);
+const theMaze = maze(21, 11, 2027);

Errores frecuentes

Error frecuente

Una línea en blanco dentro de una celda desaparece

Un salto de línea dentro de una celda empieza un párrafo nuevo, pero postext 1.4.1 descarta el párrafo vacío o que solo lleva espacios de no separación (U+00A0), así que una celda escrita 'NOMBRE\n\n' ocupa una sola línea. Para dejar líneas en las que escribir, pon un unidor de palabras (U+2060) en cada línea en blanco: 'NOMBRE\n\u2060\n\u2060' ocupa tres. Tablas a partir de datos →

Error frecuente

Una tabla 'here' nunca se parte

Solo se parten entre columnas y páginas las tablas flotantes; una tabla colocada 'here' se mueve entera. Deja flotar las tablas largas o mantén cortas las tablas en línea. Tablas que pasan de página →

Error frecuente

:::columns solo funciona dentro de un recuadro y no se parte

:::columns se ignora fuera de un recuadro, y un recuadro que se parte nunca corta dentro de un grupo de columnas. El atributo breaks cuenta bloques hijos, y un recuadro anidado cuenta como uno. Columnas dentro de un recuadro →

Error frecuente

Una apertura que se queda en su columna se corta en la cabeza de la caja de texto

En postext 1.4.1, un título con diseño avanzado que se queda en su columna se recorta por el borde superior de la columna: una caja o una imagen ancladas a la página o a la sangre se pintan en los márgenes laterales, pero no en el de cabeza, y ningún aviso lo dice. Dale span: 'page' a ese título, aunque el libro tenga una sola columna: su diseño se pinta entonces entero, como banda de apertura de la página. Aperturas diseñadas →

Error frecuente

La paleta de una sección o de una parte no recolorea los chips

En postext 1.4.1 la paleta de un estilo de título o de una parte recolorea en sus páginas el texto, los recuadros y los elementos de diseño, pero un chip conserva los colores de su entrada de chipStyles. Un chip enlazado a una entrada que la sección cambia muestra el valor del documento en todas las páginas. Pon los chips en colores que ninguna sección cambie, como tinta sobre papel, o da a cada sección su propio estilo de chip. Chips en línea →

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

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 lineHeight de un texto de diseño es un múltiplo, nunca una medida

En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título. Textos, filetes y cajas en los diseños de página →

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 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 →

  • Los números de las definiciones del recuadro se escriben a mano en el archivo de contenido, y los de la rejilla se calculan. Después de cambiar una rejilla, repasa las definiciones con ella, porque el código no las compara.
  • La paleta de un estilo de título cambia el color de los elementos de diseño, del texto y de los filetes de las tablas, pero no el del interior de un SVG, así que las olas del pie de cada campo se dibujan en el color del papel y un solo dibujo sirve para los cuatro campos. En la 1.4.1 tampoco llega a los rellenos de las tablas: una casilla del crucigrama vinculada a band seguiría en azul mar en la página amarilla de soluciones.

Créditos

Texto
Texto original, CC BY 4.0
Fuentes
Lexend (SIL OFL 1.1) · Lilita One (SIL OFL 1.1) · Chivo Mono (SIL OFL 1.1)