Skip to main content
Recipe number 69

Cookbook · Chapter 8 · Tables

Puzzle book: crossword, word search and maze

A crossword and a word search built as tables from lines of letters: black squares and found words are cell fills, clue numbers are superscripts.

pp. 2–3 of 5

  • Trim 210 × 210 mm
  • 1 column
  • Lexend 11.5/16
  • Chivo Mono
  • Lilita One
  • 5 pages
  • Level
  • Postext 1.4.1
  • Laid out in 7 ms
  • 250 lines of code

What you'll build

A summer workbook for children of nine to eleven: a cover, three puzzles about the sea and a page of answers. Pages 2 to 5 open under a colour field with a wavy foot (sea blue, mint, coral, then yellow for the answers) and a 46 pt title in Lilita One. The code builds the crossword and the word search as tables from lines of letters and gives each its own table style: ruled squares for the crossword, pale tiles with white joints and rounded corners for the word search. The crossword's black squares and the word already found in the word search are cell fills, and the clue numbers are superscripts in the corners. The maze is an SVG drawn from a seed. The last page prints the three answers side by side from the same data, above a box on how loggerhead turtles nest.

This recipe answers

  • How do I style several tables differently (fills, zebra cells, rounded frames) in one document?
  • How do I add images and tables from code (resources) instead of Markdown ![]()?
  • How do I write superscripts, subscripts and chemical formulas without full maths?
  • How do I make inline chips: keyboard keys, tags, word banks for exercises?
  • How do I put two columns inside a box (a text column beside a figure column)?

The short answer

script.js · lines 33–59in full code
// 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 });

A named table style for each grid, picked by the table's styleId

Ingredients

Type
Lexend, Lilita One, Chivo Mono (SIL OFL 1.1)
Assets
None: every picture is drawn in code

Method

#1 · One table style, three looks

The code is the short answer above. tableStyle sets Chivo Mono for every grid. Each named style in tableStyles sets its own type size, padding and rules, and a table picks one with table.styleId. A column's width is its share of the table's width, but a row is as deep as its lines plus the padding, so the padding sets the depth of each square. The word search keeps the crossword's 9.41 mm square around one line of 15 pt capitals, and pad() works out the 2.9 pt that line leaves above and below it. Its rules, 2.4 pt wide and in the paper's colour, cut the pale fill into tiles, and borderRadius rounds the frame's corners to 4 mm and clips the fills to them. On the answer page key() fits nine or twelve squares into 54 mm: 6 mm squares with 8 pt letters for the crossword, 4.5 mm squares with 6 pt letters for the word search. The maze answer is drawn 54 mm square, so the three answers are equally deep and their captions share a baseline 106.4 mm from the top edge.

#2 · Nine squares, fifteen lines

script.js · lines 63–77in full code
// 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) };
}

A white square holds two lines of 8.9 pt Chivo Mono (the clue number and an empty line) inside 1 pt of padding, 26.7 pt in all: 9.41 mm, a ninth of the table's width. Nine squares are 84.7 mm deep, the depth of fifteen 16 pt lines of body text, and the clue box starts on the baseline grid one line below. A square gets a number, in reading order, when an across or a down word starts in it. The number is a bold superscript of 5.2 pt (58 % of 8.9 pt) raised 3 pt (a third of 8.9 pt), which puts it in the square's top corner. The empty line is a word joiner (U+2060). With a no-break space in its place, 1.4.1 drops the line and the grid comes out 45.5 mm deep. A black square is an empty cell with a background of its own, linked to ink.

#3 · The code finds the words in the grid

script.js · lines 81–99in full code
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;
}

The grid is twelve lines of capitals in its own content file, after the list of words to find. locate() looks for each word in eight directions and throws unless there is exactly one match, so a typo in the grid or in the list stops the build before it prints a word nobody can find. setCellBackground returns a new table model with one more square filled. The fill is linked to a palette entry and covers the style's tile: coral for UMBRELLA on page 3, mint for all ten words on page 5. Named table styles have no setting for alternate rows in 1.4.1, so the first Variation calls setCellBackground on every cell of every other row. The word bank above the grid is two centred rows of chips: UMBRELLA's is filled coral like its squares, and the other nine are white with a navy border.

#4 · One opener, four colours

script.js · lines 103–131in full code
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] } }));

Pages 2 to 5 share one design. A colour field runs 40 mm down from the top edge and ends in a wavy foot drawn in the paper's colour; the kicker, the title and the theme in its white pill come from the heading line and its attributes. The field and the folio disc are linked to the palette entry band, and each page's heading style gives band its own value, so # Word Search {style="sopa"} turns both mint on page 3. Level 1 sets span: 'page' although the book has a single column: 1.4.1 clips a design kept in the column at the 16 mm top margin, which would cut off the top of the field and the kicker. The field's box counts towards the depth the opener reserves, so the text starts on the next grid line, 44.2 mm from the top edge and 4 mm under the waves.

#5 · The answers come from the same data

script.js · lines 504–520in full code
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' }) }),
];

The answer page calls crossword() with solved and wordSearch() with all ten words, on the same content files as the puzzles, so an edit to a grid changes its answer too. The puzzles and the drawings use the type Plain, whose empty captionPrefix prints no caption for a resource with no caption text. The answers use the type Solution, numbered 1 to 3 in reading order to match the kickers Puzzle 1 to Puzzle 3. They sit in a box with no fill or border that holds :::columns{count=3 breaks="2,3"}, where breaks opens a new column at the second and third answers; each answer is 54 mm wide in a 55.3 mm column. The Did you know? box does the same with breaks="2" to set the nest drawing beside its paragraph.

The whole recipe

// ═══ 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 = '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
};
// 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`---
Markdown sample · 77 lines · 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)"} :::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="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
Markdown sample · 8 lines · 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
Markdown sample · 13 lines · 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: 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: the same in every recipe · 270 lines// ─── 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 ───────────────────────────────────────────────────────────────────────

The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗

Variations

#Stripe the word search's rows

A deeper yellow on every other row helps a young reader follow a line of letters, and since the words are filled after the stripes, UMBRELLA stays coral and the answer grid keeps its mint words.

   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) {

#Draw a harder maze

Twenty-one by eleven squares fit the same width at 8.4 mm each, and the answer on page 5 is drawn from the new maze.

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

Pitfalls

Pitfall

A blank line in a table cell is dropped

A line break in a table cell starts a new paragraph, but postext 1.4.1 drops a paragraph that is empty or holds only no-break spaces (U+00A0), so a cell written 'NAME\n\n' is one line tall. To leave lines to write on, put a word joiner (U+2060) on each blank line: 'NAME\n\u2060\n\u2060' is three lines tall. Tables from data →

Pitfall

A 'here' table never splits

Only floated tables split across columns and pages; a table placed 'here' moves whole. Let a long table float, or keep inline tables short. Tables across pages →

Pitfall

:::columns works only inside a box and never splits

:::columns is ignored outside a callout, and a box that splits never cuts inside a columns group. A breaks attribute counts child blocks, with a nested box as one. Columns inside a box →

Pitfall

An opener kept in its column is cut off at the top of the text block

In postext 1.4.1 an advanced-design heading that stays in its column is clipped at the column's top edge: a box or a picture anchored to the page or the bleed paints into the side margins but not into the top margin, and no warning says so. Give such a heading span: 'page', even in a single-column book: its design is then painted as the page's opener band, whole. Designed openers →

Pitfall

A section or part palette does not recolour chips

In postext 1.4.1 the palette of a heading style or a part recolours the text, the boxes and the design elements of its pages, but a chip keeps the colours of its chipStyles entry. A chip linked to an entry the section overrides shows the document's value on every page. Set chips in colours no section changes, such as ink on paper, or give each section a chip style of its own. Inline chips →

Pitfall

A swapped palette misses design elements and the reference colour

postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build. Semantic colour palette →

Pitfall

Any headings object switches off the H1 page break

By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →

Pitfall

A design text's lineHeight is a multiple, never a dimension

In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title. Text, rules and boxes in page designs →

Pitfall

Text inside an SVG <img> cannot use web fonts

An SVG is drawn as an image, and an image has no access to the page's web fonts, so its labels fall back to a system face. Outline the text, embed an @font-face subset in the SVG, or move the labels to the caption. Figures and tables as resources →

Pitfall

A config is cached by identity: build a fresh object

The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config(). Pages on a canvas →

Pitfall

Load every face before layout

Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →

  • The clue numbers in the box are typed in the content file, while the ones in the grid are computed. After you edit a grid, check the clues against it, since the code does not compare them.
  • A heading style's palette recolours design elements, text and table rules, but not the inside of an SVG, so the waves at the foot of each field are drawn in the paper's colour and one drawing serves all four fields. In 1.4.1 it skips table fills too: a crossword square linked to band would stay sea blue on the yellow answer page.

Credits

Text
Original prose, CC BY 4.0
Fonts
Lexend (SIL OFL 1.1) · Lilita One (SIL OFL 1.1) · Chivo Mono (SIL OFL 1.1)