Aller au contenu principal
Recette numéro 69

Recettes · Chapitre 8 · Tableaux

Cahier de jeux : mots croisés, mots mêlés et labyrinthe

Mots croisés et mots mêlés construits en tableaux à partir de lignes de lettres : cases noires et mots trouvés en fonds de cellule, numéros en exposant.

p. 2–3 sur 5

  • Exemple en anglais : pas encore d’édition française
  • Format 210 × 210 mm
  • 1 colonne
  • Lexend 11,5/16
  • Chivo Mono
  • Lilita One
  • 5 pages
  • Niveau
  • Postext 1.29.1
  • Mis en page en 53 ms
  • 249 lignes de code

En bref

Un cahier de vacances de jeux pour les enfants de neuf à onze ans, avec des mots croisés, des mots mêlés et un labyrinthe. On y voit comment construire les grilles à partir de simples lignes de lettres.

Ce que vous allez composer

Un cahier de vacances pour les enfants de neuf à onze ans : une couverture, trois jeux sur la mer et une page de solutions. Les pages 2 à 5 s’ouvrent sous un aplat de couleur au pied ondulé (bleu mer, menthe, corail, puis jaune pour les solutions) et un titre de 46 pt en Lilita One. Le code construit les mots croisés et les mots mêlés comme des tableaux à partir de lignes de lettres et donne à chacun son propre style de tableau : des cases à filets pour les mots croisés, des tuiles pâles aux joints blancs et aux coins arrondis pour les mots mêlés. Les cases noires des mots croisés et le mot déjà trouvé dans les mots mêlés sont des fonds de cellule, et les numéros des définitions sont des exposants dans les angles. Le labyrinthe est un SVG dessiné à partir d’une graine. La dernière page imprime les trois solutions côte à côte à partir des mêmes données, au-dessus d’un encadré sur la ponte des tortues caouannes.

Cette recette répond à

  • Comment styler plusieurs tableaux différemment (fonds, cellules zébrées, cadres arrondis) dans un même document ?
  • Comment ajouter des images et des tableaux depuis le code (ressources) plutôt qu’avec ![]() en Markdown ?
  • Comment écrire des exposants, des indices et des formules chimiques sans passer par les mathématiques ?
  • Comment faire des pastilles en ligne : touches de clavier, étiquettes, banques de mots pour des exercices ?
  • Comment mettre deux colonnes dans un encadré (une colonne de texte à côté d’une colonne de figure) ?

La réponse courte

script.js · lignes 34–60dans le code complet
// 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 });

Ingrédients

Typographie
Lexend, Lilita One, Chivo Mono (SIL OFL 1.1)
Ressources
Aucune : chaque image est dessinée en code

Préparation

#1 · Un style de tableau, trois aspects

Le code est dans la réponse courte plus haut. tableStyle fixe Chivo Mono pour toutes les grilles. Chaque style nommé de tableStyles règle son propre corps, ses marges intérieures et ses filets, et un tableau en choisit un avec table.styleId. La largeur d’une colonne est sa part de la largeur du tableau, mais une ligne est aussi haute que ses lignes de texte plus les marges intérieures : ce sont donc les marges intérieures qui fixent la hauteur de chaque case. Les mots mêlés gardent la case de 9,41 mm des mots croisés autour d’une ligne de capitales de 15 pt, et pad() calcule les 2,9 pt que cette ligne laisse au-dessus et au-dessous. Leurs filets, larges de 2,4 pt et couleur papier, découpent le fond pâle en tuiles, et borderRadius arrondit à 4 mm les angles du cadre en y rognant les fonds. Sur la page des solutions, key() fait tenir neuf ou douze cases dans 54 mm : des cases de 6 mm aux lettres de 8 pt pour les mots croisés, des cases de 4,5 mm aux lettres de 6 pt pour les mots mêlés. La solution du labyrinthe est dessinée en carré de 54 mm : les trois solutions ont donc la même hauteur et leurs légendes partagent une ligne de base à 106,4 mm du bord supérieur.

#2 · Neuf cases, quinze lignes

script.js · lignes 64–78dans le code complet
// 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}` }; // an empty second line would add no depth
  }));
  return { rows, columnWidths: rows[0].map(() => 1) };
}

Une case blanche contient deux lignes de Chivo Mono en 8,9 pt (le numéro de définition et une ligne vide) dans 1 pt de marge intérieure, soit 26,7 pt en tout : 9,41 mm, un neuvième de la largeur du tableau. Neuf cases font 84,7 mm de haut, la hauteur de quinze lignes de texte courant de 16 pt, et l’encadré des définitions commence sur la grille de ligne de base une ligne plus bas. Une case reçoit un numéro, dans l’ordre de lecture, quand un mot horizontal ou vertical y commence. Le numéro est un exposant gras de 5,2 pt (58 % de 8,9 pt) surélevé de 3 pt (un tiers de 8,9 pt), ce qui le place dans l’angle supérieur de la case. La ligne vide est un gluon de mots (U+2060). Une ligne laissée vide n’ajoute aucune hauteur à la cellule, et la grille ne ferait que 45,5 mm de haut. Une case noire est une cellule vide avec son propre background, lié à ink.

#3 · Le code trouve les mots dans la grille

script.js · lignes 82–100dans le code complet
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 grille tient en douze lignes de capitales dans son propre fichier de contenu, après la liste des mots à trouver. locate() cherche chaque mot dans huit directions et lève une erreur s’il n’y a pas exactement une occurrence : une faute de frappe dans la grille ou dans la liste arrête le rendu avant qu’il n’imprime un mot que personne ne peut trouver. setCellBackground renvoie un nouveau modèle de tableau avec une case remplie de plus. Le fond est lié à une entrée de la palette et recouvre la tuile du style : corail pour UMBRELLA en page 3, menthe pour les dix mots en page 5. La première variante raye une ligne sur deux à partir du style de tableau (bodyAlternateBackground), et le fond propre d’une cellule recouvre la rayure. La liste de mots au-dessus de la grille forme deux rangées centrées de pastilles : celle d’UMBRELLA est remplie de corail comme ses cases, et les neuf autres sont blanches avec un contour bleu marine.

#4 · Une ouverture, quatre couleurs

script.js · lignes 104–132dans le code complet
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 }), // set solid
  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) } } }),
] } };
// parity 'any': each puzzle opens the next page. span 'page' even in one column: a design kept
// in its column is cut at the column's foot, which would trim the cover's picture.
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] } }));

Les pages 2 à 5 partagent une même maquette. Un aplat de couleur descend de 40 mm depuis le bord supérieur et se termine par un pied ondulé dessiné couleur papier ; le surtitre, le titre et le thème dans sa pilule blanche viennent de la ligne de titre et de ses attributs. L’aplat et le disque du folio sont liés à l’entrée de palette band, et le style de titre de chaque page donne à band sa propre valeur : # Word Search {style="sopa"} les fait passer tous deux au menthe en page 3. Le niveau 1 fixe span: 'page' alors que le cahier n’a qu’une colonne : une maquette gardée dans sa colonne est coupée au pied de la colonne, ce qui arrêterait l’image de couverture 16 mm avant le bord inférieur. Le bloc de l’aplat compte dans la hauteur que réserve l’ouverture : le texte commence donc sur la ligne de grille suivante, à 44,2 mm du bord supérieur et 4 mm sous les vagues.

#5 · Les solutions viennent des mêmes données

script.js · lignes 499–515dans le code complet
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: gap=6mm on their :::columns fence
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 page des solutions appelle crossword() avec solved et wordSearch() avec les dix mots, sur les mêmes fichiers de contenu que les jeux : modifier une grille change aussi sa solution. Les jeux et les dessins emploient le type Plain, dont le captionPrefix vide n’imprime aucune légende pour une ressource sans texte de légende. Les solutions emploient le type Solution, numéroté de 1 à 3 dans l’ordre de lecture pour correspondre aux surtitres Puzzle 1 à Puzzle 3. Elles se trouvent dans un groupe :::columns{count=3 breaks="2,3" gap=6mm} du texte, sans encadré autour (postext 1.25) : breaks ouvre une nouvelle colonne à la deuxième et à la troisième solution, et gap espace les colonnes de 6 mm ; chaque solution fait 54 mm de large dans une colonne de 55,3 mm. L’encadré Did you know ? contient son propre groupe, avec breaks="2", pour placer le dessin du nid à côté de son paragraphe.

La recette complète

Sandbox
// ═══ 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.25.0
import {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage,
  setCellBackground,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @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
};
// Every colour is linked to its palette entry by id: a puzzle's palette recolours 'band'.
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}` }; // an empty second line would add no depth
  }));
  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 }), // set solid
  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) } } }),
] } };
// parity 'any': each puzzle opens the next page. span 'page' even in one column: a design kept
// in its column is cut at the column's foot, which would trim the cover's picture.
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 is declared with the answers below: config() runs later.
const config = () => ({
  colorPalette, resourceTypes, tableStyle, tableStyles, footer, header: { elements: [] },
  locale: t({ en: 'en-us', es: 'es' }), // the language of each edition, and of its bundle
  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'),
    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.
  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: '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`---
Exemple Markdown · 75 lignes · content.en.mdtitle: "Summer Workbook" author: "Caracola Books" --- # Summer \\ Workbook {style="portada" kicker="Caracola Books · Book 4" subtitle="Puzzles from the sea" age="Ages 9 to 11" name="Name"} # Crossword {style="crucigrama" kicker="Puzzle 1" theme="Sea creatures"} Each small number, like the **^1^** in the top corner, marks the square where an animal’s name begins. Write one letter in each white square. ::resource{id="crucigrama"} :::callout{type="pistas"} :::columns{count=2 breaks="7"} ### Across **1** See-through swimmer with a sting. **6** A flat fish shaped like a kite. **7** It sometimes hides a pearl. **9** It lays its eggs in the sand. **10** A tiny animal that builds reefs. ### Down **2** Blue in the sea, red once cooked. **3** Most have five arms. **4** Often the fish in fish and chips. **5** A long fish shaped like a snake. **8** It naps on the rocks between swims. ::: ::: # Word Search {style="sopa" kicker="Puzzle 2" theme="A day at the beach"} Find these ten beach words. They run across, down or diagonally, and some are spelt backwards. The first one is marked for you. :::paragraphs{style="banco"} :chip[UMBRELLA]{style="hallada"} :chip[SUNSCREEN]{style="palabra"} :chip[BUCKET]{style="palabra"} :chip[SPADE]{style="palabra"} :chip[KITE]{style="palabra"} :chip[SWIMSUIT]{style="palabra"} :chip[GOGGLES]{style="palabra"} :chip[SANDALS]{style="palabra"} :chip[TOWEL]{style="palabra"} :chip[BALL]{style="palabra"} ::: ::resource{id="sopa"} # Maze {style="laberinto" kicker="Puzzle 3" theme="Down to the sea"} This turtle has just hatched from a nest buried in the beach. Help it reach the water: there is only one way through the dunes. ::resource{id="laberinto"} # Answers {style="soluciones" kicker="No peeking until the end" theme="Puzzles 1 to 3" colophon="Original puzzles, text and drawings (CC BY 4.0) · Set in Lexend, Lilita One and Chivo Mono (SIL OFL)"} :::columns{count=3 breaks="2,3" gap=6mm} ::resource{id="sol-crucigrama"} ::resource{id="sol-sopa"} ::resource{id="sol-laberinto"} ::: :::callout{type="dato" title="Did you know?"} :::columns{count=2 breaks="2"} ::resource{id="nido"} A loggerhead turtle digs her nest in the sand at night and lays about a hundred eggs in it. Two months later the hatchlings come out together, also at night, and scurry towards the brightest part of the horizon, which is usually the sea. That is why the street lights of a seafront can lead them astray. On several beaches of the Spanish Mediterranean, volunteers guard the nests until the hatchlings are out. ::: :::
`; // the booklet's text, clues and word bank const crosswordText = String.raw`JELLYFISH
Exemple Markdown · 8 lignes · content.crossword.en.md##O####T# C#B#E#RAY OYSTER#R# D#T#L##F# ##E##S#I# TURTLE#S# #####A#H# #CORAL###
`; // nine lines: letters, # for a black square const searchText = String.raw`UMBRELLA SUNSCREEN BUCKET SPADE KITE SWIMSUIT GOGGLES SANDALS TOWEL BALL
Exemple Markdown · 13 lignes · content.wordsearch.en.md SGOGGLESSUER RJANIAAUNLTM ETEMHATNHEAO EBCBSSLSKWLT ENLZWRECEOTM GSETIKURUTLM AFAUMBRELLAB RCNISESELELA OEEGUSANDALS IWSXILOAIZTU WHRGTHPHIWSI NXTCZSNIROAH
`; // 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: the maze's start and finish are pictures. 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: gap=6mm on their :::columns fence 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 Lexend: ['400', '600', '700'], 'Lilita One': ['400'], 'Chivo Mono': ['400', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const allText = [markdown, crosswordText, searchText].join('\n'); await prepareFonts(allText, config(), kitFonts(FONTS)); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), { ...kitFonts(FONTS), text: allText }); showPages(doc, { title: t({ en: 'Summer Workbook · Puzzles from the sea', es: 'Cuaderno de verano · Pasatiempos del mar' }) });
Kit · core, fonts, viewer, images : identique dans toutes les recettes · 270 lignes// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes 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; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { 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 v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 ───────────────────────────────────────────────────────────────────────

Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)

Variantes

#Rayer les lignes des mots mêlés

Un jaune plus soutenu une ligne sur deux aide un jeune lecteur à suivre une ligne de lettres. Le fond propre d’une cellule recouvre la rayure, si bien qu’UMBRELLA reste corail ; la grille des solutions a son propre style et ne prend pas de rayures.

     bodyBackgroundEnabled: true, bodyBackground: col('tint'), // tiles with white joints
+    bodyAlternateBackgroundEnabled: true, // a shade deeper than the tiles on every other row
+    bodyAlternateBackground: { hex: '#ffdd80', model: 'hex' },
     borderColor: col('paper'), borderWidth: pt(2.4), borderRadius: mm(4) },

#Dessiner un labyrinthe plus difficile

Vingt et une cases sur onze tiennent dans la même largeur à 8,4 mm chacune, et la solution de la page 5 est tirée du nouveau labyrinthe.

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

Erreurs fréquentes

  • Les numéros des définitions dans l’encadré sont tapés dans le fichier de contenu, alors que ceux de la grille sont calculés. Après avoir modifié une grille, vérifiez les définitions par rapport à elle, car le code ne les compare pas.
  • La palette d’un style de titre recolore les éléments de maquette, le texte, ainsi que les filets et les fonds des tableaux, mais pas l’intérieur d’un SVG : les vagues au pied de chaque aplat sont donc dessinées couleur papier, et un seul dessin sert aux quatre aplats.

Crédits

Texte
Texte original, CC BY 4.0
Polices
Lexend (SIL OFL 1.1) · Lilita One (SIL OFL 1.1) · Chivo Mono (SIL OFL 1.1)
Sandbox