En poques paraules
Un quadern de passatemps d'estiu per a nens de 9 a 11 anys, amb mots encreuats, sopa de lletres i laberint. Ensenya a construir les quadrícules a partir de simples línies de lletres.
Què compondràs
Un quadern d'estiu per a nens de 9 a 11 anys: la coberta, tres passatemps sobre el mar i una pàgina de solucions. Les pàgines 2 a 5 s'obren sota un camp de color amb el peu ondulat (blau mar, menta, corall i groc) i un títol de 46 pt en Lilita One. El codi compon els mots encreuats i la sopa de lletres com a taules a partir de línies de lletres, cadascuna amb el seu estil: caselles amb filets als mots encreuats, tessel·les clares amb juntes blanques i cantonades arrodonides a la sopa. Les caselles negres i la paraula ja trobada són emplenaments de cel·la, i els números de les definicions, superíndexs a la cantonada. El laberint és un SVG generat amb una llavor. L'última pàgina reuneix les tres solucions, tretes de les mateixes dades, sobre un requadre que explica com nia la tortuga babaua.
Aquesta recepta respon a
- Com dono estils diferents a diverses taules (emplenaments, files alternes, marcs arrodonits) en un mateix document?
- Com afegeixo imatges i taules des del codi (recursos) en lloc de ![]() de Markdown?
- Com escric superíndexs, subíndexs i fórmules químiques sense escriure LaTeX?
- Com faig xips en línia: tecles, etiquetes, bancs de paraules per a exercicis?
- Com poso dues columnes dins d'un requadre (una de text al costat d'una altra amb una figura)?
La resposta curta
// 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 });
Ingredients
- Funcions
- Emplenaments de cel·laEstil de taulesSuperíndexs i subíndexsTaules a partir de dadesEstils de taula amb nomChips en líniaColumnes dins d'un requadreRequadresEstils de títolColors per partObertures dissenyadesAtributs de títolImatges als dissenys de pàginaCobertes, portades i colofonsCapçaleres i folisTipus de recurs propisFigures just aquíEstil dels peusFigures i taules com a recursosPàgines en un canvas
- També fa servir
- Banda de capítol d'amplada completaEstils de paràgrafCapçaleres per seccióSalts de línia als títols
- Tipografia
- Lexend, Lilita One, Chivo Mono (SIL OFL 1.1)
- Recursos
- Cap: totes les imatges es dibuixen en codi
Elaboració
#1 · Un estil de taula, tres aspectes
El codi és la resposta curta de dalt. tableStyle posa totes les graelles en Chivo Mono. Cada estil amb nom de tableStyles fixa el seu cos, el seu farciment interior i els seus filets, i la taula tria el seu amb table.styleId. L'amplada d'una columna és la seva part de l'amplada de la taula, però una fila mesura el que fan les seves línies més el farciment interior, de manera que és el farciment el que fixa l'alçada de cada casella. La sopa de lletres conserva la casella de 9,41 mm dels mots encreuats al voltant d'una línia de majúscules de 15 pt, i pad() calcula els 2,9 pt que deixa aquesta línia per sobre i per sota. Els seus filets, de 2,4 pt i del color del paper, tallen el fons clar en tessel·les, i borderRadius arrodoneix les cantonades del marc a 4 mm i retalla els emplenaments a la seva forma. A la pàgina de solucions, key() fa cabre nou o dotze caselles en 54 mm: caselles de 6 mm amb lletres de 8 pt als mots encreuats i de 4,5 mm amb lletres de 6 pt a la sopa de lletres. La solució del laberint es dibuixa en un quadrat de 54 mm, així que les tres solucions tenen la mateixa alçada i els seus peus comparteixen la línia de base, a 106,4 mm de la vora superior.
#2 · Nou caselles, quinze línies
// Two lines of CLUE_FACE (a number, an empty line) and the padding make a square.
function crossword(source, solved = false) {
const lines = source.trim().split('\n');
const white = (r, c) => (lines[r]?.[c] ?? '#') !== '#';
let clue = 0;
const rows = lines.map((line, r) => [...line].map((letter, c) => {
if (!white(r, c)) return { content: '', background: col('ink') }; // a black square
if (solved) return { content: letter, align: 'center' };
// A square is numbered when a word starts in it, across or down, in reading order.
const starts = (!white(r, c - 1) && white(r, c + 1)) || (!white(r - 1, c) && white(r + 1, c));
const number = starts ? `**^${++clue}^**` : JOINER; // a bold superscript, or nothing
return { content: `${number}\n${JOINER}` }; // gotcha: cell-blank-line
}));
return { rows, columnWidths: rows[0].map(() => 1) };
}
Cada casella blanca porta dues línies de Chivo Mono de 8,9 pt (el número de la definició i una línia buida) dins d'1 pt de farciment interior, 26,7 pt en total: 9,41 mm, un novè de l'amplada de la taula. Nou caselles mesuren 84,7 mm, el mateix que quinze línies de 16 pt del text principal, i el requadre de les definicions comença a la retícula de base una línia més avall. Una casella porta número, en ordre de lectura, quan hi comença una paraula horitzontal o vertical. El número és un superíndex en negreta de 5,2 pt (el 58 % de 8,9 pt) pujat 3 pt (un terç de 8,9 pt), i això el col·loca a la cantonada superior de la casella. La línia buida és un caràcter d'unió de paraules (U+2060). Amb un espai de no separació al seu lloc, la 1.4.1 descarta aquesta línia i la graella es queda en 45,5 mm d'alçada. Una casella negra és una cel·la buida amb background propi, vinculat a ink.
#3 · El codi cerca les paraules a la graella
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 graella són dotze línies de majúscules en el seu propi fitxer de contingut, després de la llista de paraules que cal cercar. locate() cerca cada paraula en les vuit direccions i llança un error si no hi apareix exactament una vegada, de manera que una errata a la graella o a la llista atura la composició abans que s'imprimeixi una paraula que ningú no podrà trobar. setCellBackground retorna un model de taula nou amb una casella més emplenada. L'emplenament va vinculat a una entrada de la paleta i tapa la tessel·la de l'estil: corall per a SOMBRILLA a la pàgina 3, menta per a les deu paraules a la pàgina 5. A la 1.4.1 els estils de taula amb nom no tenen cap opció per alternar el fons de les files, així que la primera variant crida setCellBackground a totes les cel·les d'una fila sí i una altra no. El banc de paraules damunt de la graella són dues files centrades de xips: el de SOMBRILLA va emplenat de corall, com les seves caselles, i els altres nou són blancs amb vora blau marí.
#4 · Una obertura, quatre colors
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] } }));
Les pàgines de la 2 a la 5 comparteixen el mateix disseny. Un camp de color baixa 40 mm des de la vora superior i acaba en un peu ondulat dibuixat amb el color del paper; l'avanttítol, el títol i el tema, a la seva píndola blanca, surten de la línia del títol i dels seus atributs. El camp i el disc del foli estan vinculats a l'entrada band de la paleta, i l'estil de títol de cada pàgina li dona el seu propi valor, de manera que # Sopa de letras {style="sopa"} els torna menta a la pàgina 3. El nivell 1 porta span: 'page' tot i que el quadern té una sola columna: la 1.4.1 retalla pel marge superior de 16 mm un disseny que es queda a la columna, i això tallaria la part de dalt del camp i l'avanttítol. La caixa del camp compta en l'alçada que reserva l'obertura, de manera que el text comença a la línia següent de la retícula de base, a 44,2 mm de la vora superior i 4 mm per sota de les onades.
#5 · Les solucions surten de les mateixes dades
const resourceTypes = [ // a plain resource prints no caption; an answer is numbered
{ id: 'plain', name: 'Plain', shortLabel: '', captionPrefix: '' },
{ id: 'solucion', ...t({ en: { name: 'Solution', captionPrefix: 'Solution' },
es: { name: 'Solución', captionPrefix: 'Solución' } }), shortLabel: 'Sol.' },
].map((type) => ({ numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
...type }));
const ANSWER_GAP = 6; // mm between the three answers
const ANSWER_WIDTH = KEY / ((MEASURE - 2 * ANSWER_GAP) / 3); // 54 mm of a 55.3 mm column
const answers = [
grid('sol-crucigrama', 'solucion', crossword(crosswordText, true), 'solucion-9', ANSWER_WIDTH,
{ caption: t({ en: 'Crossword', es: 'Crucigrama' }) }),
grid('sol-sopa', 'solucion', wordSearch(searchText, words.map((w) => [w, 'mint'])),
'solucion-12', ANSWER_WIDTH, { caption: t({ en: 'Word search', es: 'Sopa de letras' }) }),
picture('sol-laberinto', 'solucion', { placement: here(ANSWER_WIDTH), caption: t({
en: 'Maze', es: 'Laberinto' }), altText: t({ en: 'The maze and its way out to the sea',
es: 'El laberinto y su salida al mar' }) }),
];
La pàgina de solucions torna a cridar crossword() amb solved i wordSearch() amb les deu paraules, sobre els mateixos fitxers de contingut que els passatemps, de manera que si canvies una graella també en canvia la solució. Els passatemps i els dibuixos són del tipus Plain, el captionPrefix buit del qual no imprimeix peu en un recurs sense text de peu. Les solucions són del tipus Solució, numerades de l'1 al 3 en ordre de lectura perquè coincideixin amb els avanttítols, de Passatemps 1 a Passatemps 3. Van en un requadre sense fons ni vora que conté :::columns{count=3 breaks="2,3"}, on breaks obre una columna nova a la segona i a la tercera solució; cadascuna mesura 54 mm d'amplada en una columna de 55,3 mm. El requadre Sabies que…? fa el mateix amb breaks="2" per posar el dibuix del niu al costat del seu paràgraf.
La recepta completa
// ═══ Postext Cookbook · Nº 069 · Puzzle book: crossword, word search and maze ═══════ // https://postext.dev/en/cookbook/puzzle-book // Code: MIT · Text and puzzles: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Lexend, Lilita One, Chivo Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, setCellBackground } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'puzzle-book'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#1f2a44', // text, grid rules and the black squares: a navy near-black paper: '#ffffff', band: '#2d9cdb', // a puzzle's colour field: sea blue until a puzzle's style replaces it sun: '#ffb627', coral: '#ff6f59', mint: '#5cc8a8', // the other fields; coral marks a find tint: '#fff1cc', // boxes and word-search tiles muted: '#5b6477', // running titles and the colophon }; // Design slots read the hex in 1.4.1 and a puzzle's palette reads the id: col() writes both // (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [TEXT, DISPLAY, LABEL] = ['Lexend', 'Lilita One', 'Chivo Mono']; const PAGE = 210, MARGIN = 16; // mm: a square booklet, the same margin on every side const MEASURE = PAGE - 2 * MARGIN; // 178 mm const BODY = 11.5, LEAD = 16; // pt: large, open text for young readers const PT_PER_MM = 72 / 25.4; const JOINER = '\u2060'; // a word joiner: a line with nothing on it that still counts // #region answer: a named table style for each grid, picked by the table's styleId // Every grid shares tableStyle; a named style sets its size, padding, rules and fills. // A row is as tall as its lines plus the padding and a column is its share of the // table's width, so the padding sets how deep a square is. const CELL = (15 * LEAD) / 9; // pt: nine squares are fifteen lines of text, 9.4 mm each const PAD = 1; // pt: a clue number sits this close to the corner, clear of the 1 pt rule const CLUE_FACE = (CELL - 2 * PAD) / (2 * LEAD / BODY); // 8.9 pt: two lines fill a square const LETTER = 15; // pt: the word search's capitals const pad = (cell, face) => pt((cell - face * LEAD / BODY) / 2); // one line fills the cell const KEY = 54; // mm: the three answers are this deep, so their captions share a line const key = (count) => { // count squares in KEY mm, letters of 4/3 pt per mm of square const side = (KEY / count) * PT_PER_MM, face = (KEY / count) * 4 / 3; return { id: `solucion-${count}`, bodyFontSize: pt(face), cellPadding: pad(side, face), borderWidth: pt(0.5) }; }; // No grid has a head row; the grey head is off for the Cookbook's default-skin check. const tableStyle = { headerBackgroundEnabled: false, bodyFontFamily: LABEL }; const tableStyles = [ { id: 'crucigrama', bodyFontSize: pt(CLUE_FACE), cellPadding: pt(PAD), borderWidth: pt(1) }, { id: 'sopa', bodyFontSize: pt(LETTER), cellPadding: pad(CELL, LETTER), // same squares bodyBackgroundEnabled: true, bodyBackground: col('tint'), // tiles with white joints borderColor: col('paper'), borderWidth: pt(2.4), borderRadius: mm(4) }, key(9), key(12), // 6 mm squares with 8 pt letters, 4.5 mm squares with 6 pt letters ]; // A table resource names its style; a cell's own background covers the style's fill. const here = (width) => ({ position: 'here', width, align: 'center' }); // of the column const grid = (id, typeId, model, styleId, width, extra) => ({ id, typeId, kind: 'table', createdAt: 0, updatedAt: 0, table: { model, styleId }, placement: here(width), ...extra }); // #endregion // #region crossword: nine lines of letters give the crossword, or with solved its answer // Two lines of CLUE_FACE (a number, an empty line) and the padding make a square. function crossword(source, solved = false) { const lines = source.trim().split('\n'); const white = (r, c) => (lines[r]?.[c] ?? '#') !== '#'; let clue = 0; const rows = lines.map((line, r) => [...line].map((letter, c) => { if (!white(r, c)) return { content: '', background: col('ink') }; // a black square if (solved) return { content: letter, align: 'center' }; // A square is numbered when a word starts in it, across or down, in reading order. const starts = (!white(r, c - 1) && white(r, c + 1)) || (!white(r - 1, c) && white(r + 1, c)); const number = starts ? `**^${++clue}^**` : JOINER; // a bold superscript, or nothing return { content: `${number}\n${JOINER}` }; // gotcha: cell-blank-line })); return { rows, columnWidths: rows[0].map(() => 1) }; } // #endregion // #region search: a word search that finds its words in the grid and fills their squares const STEPS = [[0, 1], [1, 0], [1, 1], [-1, 1], [0, -1], [-1, 0], [-1, -1], [1, -1]]; function locate(letters, word) { // the squares of a word, in any of eight directions const hits = []; letters.forEach((line, r) => [...line].forEach((_, c) => STEPS.forEach(([dr, dc]) => { const cells = [...word].map((_, i) => ({ row: r + i * dr, col: c + i * dc })); if (cells.every(({ row, col: k }, i) => letters[row]?.[k] === word[i])) hits.push(cells); }))); if (hits.length !== 1) throw new Error(`${word} is in the grid ${hits.length} times`); return hits[0]; } function wordSearch(source, fills) { // fills: [word, palette id] pairs const letters = source.trim().split('\n').slice(-12); let model = { rows: letters.map((line) => [...line].map((content) => ({ content, align: 'center' }))) }; for (const [word, fill] of fills) { for (const at of locate(letters, word)) model = setCellBackground(model, at, col(fill)); } return model; } // #endregion // #region openers: one opener for every puzzle; each puzzle's style gives it its colour const BAND = 40; // mm: the colour field, from the top edge const pin = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col('ink'), overflow: 'wrap', placement, align: placement.anchor.edge.endsWith('right') ? 'right' : 'left', ...extra }); const CAPS = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: em(1 / 6) }; // The field's box sets the opener's depth: the text starts on the next grid line below. const opener = { enabled: true, slot: { elements: [ { kind: 'box', id: 'field', style: { backgroundColor: col('band') }, placement: pin('page', 'top-left', 0, 0, { width: 'fill', height: mm(BAND) }) }, { kind: 'image', id: 'surf', resourceId: 'olas', // the field's foot, cut in waves placement: pin('page', 'top-left', 0, BAND - 3, { width: mm(PAGE), height: mm(3.2) }) }, text('kicker', '{attr.kicker}', LABEL, 8.5, pin('page', 'top-left', MARGIN, 11), CAPS), text('title', '{titleText}', DISPLAY, 46, pin('#kicker', 'below', -0.6, 0.5), { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple) text('theme', '{attr.theme}', TEXT, 10, pin('page', 'top-right', -MARGIN, 23.5), { fontWeight: 700, box: { backgroundColor: col('paper'), borderRadius: mm(3.5), padding: { top: mm(1.6), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } } }), ] } }; // The H1 break is restated (gotcha: headings-drop-h1-break), parity 'any'. span 'page' even // in one column, or the field is cut at the top margin (gotcha: opener-clipped-at-top). const puzzleLevel = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener }; // '# Sopa de letras {style="sopa"}': the style's palette turns 'band' mint on that page, // in the field, the folio disc and anything else linked to it. const BANDS = { crucigrama: 'band', sopa: 'mint', laberinto: 'coral', soluciones: 'sun' }; const puzzleStyles = Object.entries(BANDS) .map(([id, band]) => ({ id, palette: { band: palette[band] } })); // #endregion // The folio in a disc of the puzzle's colour; the book's title or the puzzle's beside it. const disc = (parity, edge, x) => text(`folio-${parity}`, '{pageNumber}', DISPLAY, 12, pin('page', edge, x, PAGE - 14, { width: mm(8), height: mm(8) }), { parity, lineHeight: 1, align: 'center', verticalAlign: 'middle', box: { backgroundColor: col('band'), borderRadius: mm(4) } }); const running = (parity, edge, x, content) => text(`title-${parity}`, content, LABEL, 7.5, pin('page', edge, x, PAGE - 11.7), { ...CAPS, parity, color: col('muted') }); const footer = { elements: [ disc('even', 'top-left', MARGIN), running('even', 'top-left', MARGIN + 11, '{title}'), disc('odd', 'top-right', -MARGIN), running('odd', 'top-right', -MARGIN - 11, '{chapterTitle}'), ] }; // The answer page trades its running title for the colophon of '# Answers {colophon="…"}'. const colophon = (parity, edge, x) => text(`colophon-${parity}`, '{attr.colophon}', LABEL, 7, pin('page', edge, x, PAGE - 11.5), { parity, color: col('muted') }); Object.assign(puzzleStyles.find(({ id }) => id === 'soluciones'), { footer: { elements: [ ...footer.elements.filter(({ id }) => id.startsWith('folio')), colophon('even', 'top-right', -MARGIN), colophon('odd', 'top-left', MARGIN)] } }); // The cover: the drawing over the whole page, the title on its sky, a name line on the sea. const cover = { id: 'portada', header: { elements: [] }, footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'art', resourceId: 'portada', placement: pin('page', 'top-left', 0, 0, { width: mm(PAGE), height: mm(PAGE) }) }, text('kicker', '{attr.kicker}', LABEL, 8.5, pin('page', 'top-left', MARGIN, 18), CAPS), text('title', '{titleText}', DISPLAY, 58, pin('#kicker', 'below', -0.8, 3, { width: mm(120) }), { lineHeight: 0.95 }), text('subtitle', '{attr.subtitle}', TEXT, 15, pin('#title', 'below', 0.8, 3), { fontWeight: 600 }), text('age', '{attr.age}', LABEL, 9.5, pin('#subtitle', 'below', 0, 5), { ...CAPS, box: { backgroundColor: col('coral'), borderRadius: mm(3.6), padding: { top: mm(1.8), right: mm(3.4), bottom: mm(1.8), left: mm(3.4) } } }), // The field fills the calm band between the wave stripes at 173 and 190 mm. { kind: 'box', id: 'name-field', style: { backgroundColor: col('paper'), borderRadius: mm(3) }, placement: pin('page', 'top-left', MARGIN, 176.9, { width: mm(104), height: mm(9) }) }, text('name', '{attr.name}', LABEL, 8.5, pin('page', 'top-left', MARGIN + 5, 180.2), CAPS), { kind: 'rule', id: 'name-line', direction: 'horizontal', thickness: pt(0.75), color: col('muted'), placement: pin('page', 'top-left', MARGIN + 27, 183.2, { width: mm(72) }) }, ] } } }; const chip = (id, fill) => ({ id, fontFamily: LABEL, fontSize: em(0.8), bold: true, background: col(fill), borderColor: col(fill === 'paper' ? 'ink' : fill), borderWidth: pt(0.75), borderRadius: em(1), paddingX: em(0.45) }); // resourceTypes and ANSWER_GAP are declared with the answers below: config() runs later. const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity) colorPalette, resourceTypes, tableStyle, tableStyles, footer, header: { elements: [] }, page: { width: mm(PAGE), height: mm(PAGE), dpi: 150, margins: { top: mm(MARGIN), bottom: mm(MARGIN), left: mm(MARGIN), right: mm(MARGIN) } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), // in the boxes too: their bold ignores the palette in 1.4.1 textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true }, // The H1 line under each design is still measured: in Lilita One, not in Open Sans 700. headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [ puzzleLevel, { level: 3, fontFamily: LABEL, fontWeight: 700, fontSize: pt(8.5), lineHeight: pt(13), textTransform: 'uppercase', marginBottom: pt(3) }, ] }, headingStyles: [cover, ...puzzleStyles], // The word bank: ink on paper and coral, colours no puzzle's palette changes // (gotcha: section-palette-skips-chips). chipStyles: [chip('palabra', 'paper'), chip('hallada', 'coral')], calloutStyles: [ { id: 'pistas', background: col('tint'), borderRadius: mm(3), columnGap: mm(8), padding: { top: mm(4), right: mm(5), bottom: mm(4), left: mm(5) }, marginTop: pt(0), // the grid above already leaves a line body: { fontSize: pt(10), lineHeight: pt(13), paragraphSpacing: false } }, { id: 'soluciones', backgroundEnabled: false, columnGap: mm(ANSWER_GAP), // columns only padding: { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) } }, { id: 'dato', background: col('tint'), borderRadius: mm(3), columnGap: mm(6), padding: { top: mm(4), right: mm(5), bottom: mm(4.5), left: mm(5) }, titleStyle: { fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 400 }, body: { fontSize: pt(10), lineHeight: pt(14) } }, ], captionStyle: { fontFamily: LABEL, fontSize: pt(8), gap: mm(2) }, paragraphStyles: [{ id: 'banco', textAlign: 'center' }], // the word bank's two rows }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Mostra en Markdown · 77 línies · content.es.md
title: "Cuaderno de verano" author: "Ediciones Caracola" --- # Cuaderno \\ de verano {style="portada" kicker="Ediciones Caracola · Cuaderno 4" subtitle="Pasatiempos del mar" age="De 9 a 11 años" name="Nombre"} # Crucigrama {style="crucigrama" kicker="Pasatiempo 1" theme="Animales del mar"} Cada número pequeño, como el **^1^** de la esquina, marca la casilla donde empieza el nombre de un animal. Escribe una letra en cada casilla blanca. ::resource{id="crucigrama"} :::callout{type="pistas"} :::columns{count=2 breaks="7"} ### Horizontales **1** Camina de lado y tiene pinzas. **4** El mayor de todos los delfines. **5** Pone los huevos en la arena. **7** Ocho brazos, dos tentáculos y tinta. **8** La azul es el animal más grande. ### Verticales **1** Animal diminuto que forma arrecifes. **2** Ave que chilla en los puertos. **3** Pez que se vende seco y salado. **4** A veces guarda una perla. **6** Pez plano con forma de cometa. ::: ::: # Sopa de letras {style="sopa" kicker="Pasatiempo 2" theme="Un día de playa"} Busca estas diez palabras de un día de playa. Van en horizontal, en vertical o en diagonal, y algunas están escritas del revés. La primera ya está marcada. :::paragraphs{style="banco"} :chip[SOMBRILLA]{style="hallada"} :chip[CASTILLO]{style="palabra"} :chip[TOALLA]{style="palabra"} :chip[GAFAS]{style="palabra"} :chip[CUBO]{style="palabra"} :chip[CHANCLAS]{style="palabra"} :chip[BAÑADOR]{style="palabra"} :chip[PELOTA]{style="palabra"} :chip[HELADO]{style="palabra"} :chip[CREMA]{style="palabra"} ::: ::resource{id="sopa"} # Laberinto {style="laberinto" kicker="Pasatiempo 3" theme="Rumbo al mar"} Esta tortuga acaba de salir del huevo, en un nido enterrado en la playa. Ayúdala a llegar al agua: entre las dunas solo hay un camino. ::resource{id="laberinto"} # Soluciones {style="soluciones" kicker="Para mirar al terminar" theme="Pasatiempos 1 a 3" colophon="Pasatiempos, textos y dibujos originales (CC BY 4.0) · Compuesto en Lexend, Lilita One y Chivo Mono (SIL OFL)"} :::callout{type="soluciones"} :::columns{count=3 breaks="2,3"} ::resource{id="sol-crucigrama"} ::resource{id="sol-sopa"} ::resource{id="sol-laberinto"} ::: ::: :::callout{type="dato" title="¿Sabías que…?"} :::columns{count=2 breaks="2"} ::resource{id="nido"} La tortuga boba cava su nido en la arena, de noche, y deja en él unos cien huevos. Dos meses después, las crías salen todas juntas, también de noche, y corren hacia la parte más clara del horizonte, que suele ser el mar. Por eso las farolas de un paseo marítimo pueden despistarlas. En varias playas del Mediterráneo español, grupos de voluntarios vigilan los nidos hasta que nacen las crías. ::: :::`; // the booklet's text, clues and word bank const crosswordText = String.raw`CANGREJO#Mostra en Markdown · 8 línies · content.crossword.es.md
O##A####B R##V#ORCA A##I#S##C L#TORTUGA #R#T#R##L CALAMAR#A #Y######O BALLENA##`; // nine lines: letters, # for a black square const searchText = String.raw`SOMBRILLA CASTILLO TOALLA GAFAS CUBO CHANCLAS BAÑADOR PELOTA HELADO CREMAMostra en Markdown · 13 línies · content.wordsearch.es.md
MLZOIECDEOQO MNUOBMUIAALS RIOSOMBRILLA NETAGIOVITRM PDGPCDETRAÑE JIABALSNLTFR TAFÑCAGLSOMC TLAOCHANCLAS IBSOAODALEHA RRINTTOOEPOZ EOEYAEAENUAE TMCOGTVSUIEN`; // the words to find, then twelve lines // #region art: the cover, the waves, the nest and the maze, drawn in the page's colours const n = (v) => +v.toFixed(2); function mulberry32(seed) { // a seeded PRNG: the same maze in every capture return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } // No words in the drawings: an SVG drawn as an image cannot see the page's fonts // (gotcha: svg-no-webfonts). Start and finish are pictures instead. const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const P = palette; const wave = (y, amp, len, x0, x1) => { // a sine line from x0 to x1, one crest per len let d = `M${x0} ${y}`; for (let x = x0; x < x1; x += len) d += ` q${len / 4} ${-amp} ${len / 2} 0 t${len / 2} 0`; return d; }; function turtle(x, y, s, turn = 0) { // a hatchling seen from above, s mm long, head up at 0° const line = `stroke="${P.ink}" stroke-width="0.45" stroke-linejoin="round"`; const skin = (d) => `<path d="${d}" fill="${P.paper}" ${line}/>`; return `<g transform="translate(${x} ${y}) rotate(${turn}) scale(${n(s / 12)})">` + skin('M-2.4 -1.6Q-6.8 -4.4 -7 -0.4Q-4.4 -0.6 -2.6 0.6Z') // front flippers + skin('M2.4 -1.6Q6.8 -4.4 7 -0.4Q4.4 -0.6 2.6 0.6Z') + skin('M-1.8 3Q-4.2 4.4 -3.6 5.9Q-2 5.2 -1 4Z') + skin('M1.8 3Q4.2 4.4 3.6 5.9Q2 5.2 1 4Z') + `<ellipse cy="-5.3" rx="1.7" ry="2" fill="${P.paper}" ${line}/>` + `<ellipse cy="0.4" rx="3.5" ry="4.5" fill="${P.mint}" ${line}/>` + `<path d="M0 -2L1.4 -1V1.4L0 2.4L-1.4 1.4V-1ZM0 -2V-4.1M0 2.4V4.9M1.4 -1L3.1 -2.2` + `M-1.4 -1L-3.1 -2.2M1.4 1.4L3.2 2.6M-1.4 1.4L-3.2 2.6" fill="none" stroke="${P.ink}" ` + 'stroke-width="0.35"/>' + `<circle cx="-0.7" cy="-5.9" r="0.32" fill="${P.ink}"/>` + `<circle cx="0.7" cy="-5.9" r="0.32" fill="${P.ink}"/></g>`; } function coverArt() { // sky, a low sun, a striped sea, a boat, two gulls and a turtle const H = PAGE, sea = 112; let g = `<rect width="${PAGE}" height="${H}" fill="${P.tint}"/>` + `<circle cx="152" cy="${sea - 8}" r="46" fill="${P.sun}"/>` + `<rect y="${sea}" width="${PAGE}" height="${H - sea}" fill="${P.band}"/>`; for (let i = 0, y = sea + 9; y < H; i++, y += 9 + i * 1.6) { // stripes widen towards us g += `<path d="${wave(y, 1.2 + i * 0.25, 18 + i * 3, -9, PAGE + 20)}" fill="none" ` + `stroke="${P.paper}" stroke-width="${n(1.1 + i * 0.35)}" stroke-linecap="round"/>`; } [[6, 34], [12, 22], [19, 12]].forEach(([dy, w]) => { // the sun's path on the water g += `<rect x="${152 - w / 2}" y="${sea + dy - 1}" width="${w}" height="2.2" rx="1.1" ` + `fill="${P.sun}"/>`; }); g += `<path d="M26 ${sea - 0.4}h15l-2.4 3.6h-10.4z" fill="${P.ink}"/>` // a boat on the line + `<path d="M33.6 ${sea - 1.6}v-15l8 13.6z" fill="${P.coral}"/>` + `<path d="M32.6 ${sea - 1.6}v-11.6l-6 11.6z" fill="${P.paper}"/>`; [[118, 34, 5], [131, 27, 3.6]].forEach(([x, y, w]) => { // two gulls g += `<path d="M${x - w} ${y}q${w / 2} -${w / 2} ${w} 0q${w / 2} -${w / 2} ${w} 0" ` + `fill="none" stroke="${P.ink}" stroke-width="0.8" stroke-linecap="round"/>`; }); return svg(PAGE, H, g + turtle(160, 170, 22, -35)); } const surf = () => svg(PAGE, 3.2, `<path d="${wave(3.2, 1.6, 12, 0, PAGE + 12)} V3.2 H0Z" ` + `fill="${P.paper}"/>`); function nestArt() { // a cutaway beach: the eggs under the sand, two hatchlings on their way const W = 80, H = 46, shore = [46, 15.5], deep = [W, 40]; // the sand slopes under the sea const slope = (y) => shore[0] + ((y - shore[1]) / (deep[1] - shore[1])) * (deep[0] - shore[0]); let g = `<rect width="${W}" height="${H}" fill="${P.tint}"/>` // the box's own tint as sky + `<path d="M0 13.6Q13 11.4 25 13.8T${shore[0]} ${shore[1]}L${deep[0]} ${deep[1]}V${H}H0Z" ` + `fill="${P.sun}"/><path d="M${shore[0]} ${shore[1]}H${W}V${deep[1]}Z" fill="${P.band}"/>`; for (let y = shore[1] + 5; y < deep[1] - 2; y += 5) { // ripples, from the slope outwards g += `<path d="${wave(y, 0.5, 4, n(slope(y) + 2), W + 4)}" fill="none" ` + `stroke="${P.paper}" stroke-width="0.55"/>`; } g += `<ellipse cx="22" cy="33" rx="12.5" ry="8" fill="${P.tint}"/>`; // the egg chamber [[36.9, 5], [33.1, 4], [29.3, 3]].forEach(([y, count]) => { // the clutch, row on row for (let i = 0; i < count; i++) { g += `<circle cx="${n(22 + (i - (count - 1) / 2) * 4.35)}" cy="${y}" r="2.05" ` + `fill="${P.paper}" stroke="${P.ink}" stroke-width="0.35"/>`; } }); return svg(W, H, g + turtle(33, 10.4, 7, 100) + turtle(44.5, 12.4, 7, 112)); } function maze(cols, rows, seed) { // a recursive backtracker: one path between any two squares const rand = mulberry32(seed); const open = new Set(); // passages, as 'r,c>r,c' const seen = new Set(['0,0']); const stack = [[0, 0]]; const around = (r, c) => [[r - 1, c], [r + 1, c], [r, c - 1], [r, c + 1]]; while (stack.length) { const [r, c] = stack.at(-1); const next = around(r, c).filter(([y, x]) => y >= 0 && y < rows && x >= 0 && x < cols && !seen.has(`${y},${x}`)); if (!next.length) { stack.pop(); continue; } const [y, x] = next[Math.floor(rand() * next.length)]; open.add(`${r},${c}>${y},${x}`).add(`${y},${x}>${r},${c}`); seen.add(`${y},${x}`); stack.push([y, x]); } const passes = (a, b) => open.has(`${a}>${b}`); const queue = [[0, 0]]; // the way out, by breadth-first search const from = new Map([['0,0', null]]); for (let i = 0; i < queue.length; i++) { const [r, c] = queue[i]; for (const [y, x] of around(r, c)) { if (from.has(`${y},${x}`) || !passes(`${r},${c}`, `${y},${x}`)) continue; from.set(`${y},${x}`, `${r},${c}`); queue.push([y, x]); } } const route = []; for (let at = `${rows - 1},${cols - 1}`; at; at = from.get(at)) { route.unshift(at.split(',').map(Number)); } return { cols, rows, passes, route }; } function mazeArt(m, unit, { solved = false } = {}) { // Sand above the maze for the nest and sea below it. The answer is square, as deep as // the answer grids beside it, so the three captions share a line: the rest of the // square goes to the sea, where the hatchling swims, with waves drawn k times larger. const side = 1.5, W = m.cols * unit + 2 * side, top = (solved ? 2.4 : 1.6) * unit; const foot = solved ? W - m.rows * unit - top : 1.6 * unit, k = solved ? 2 : 1; const H = m.rows * unit + top + foot; const X = (c) => n(side + c * unit), Y = (r) => n(top + r * unit); let walls = `M${X(1)} ${Y(0)}H${X(m.cols)}V${Y(m.rows)}M${X(m.cols - 1)} ${Y(m.rows)}` + `H${X(0)}V${Y(0)}`; // the frame, open above the first square and below the last for (let r = 0; r < m.rows; r++) { for (let c = 0; c < m.cols; c++) { const [here, right, below] = [`${r},${c}`, `${r},${c + 1}`, `${r + 1},${c}`]; if (c < m.cols - 1 && !m.passes(here, right)) walls += `M${X(c + 1)} ${Y(r)}V${Y(r + 1)}`; if (r < m.rows - 1 && !m.passes(here, below)) walls += `M${X(c)} ${Y(r + 1)}H${X(c + 1)}`; } } // The nest above the entrance, the sea below the exit. const shore = H - foot + 4; let g = `<rect width="${W}" height="${H}" fill="${P.tint}"/>` + `<path d="${wave(shore, 1.4 * k, 12 * k, 0, W + 12 * k)}V${H}H0Z" fill="${P.band}"/>`; for (let y = shore + 5 * k; y < H; y += 4.5 * k) { g += `<path d="${wave(y, 0.8 * k, 10 * k, 0, W + 10 * k)}" fill="none" ` + `stroke="${P.paper}" stroke-width="${0.7 * k}"/>`; } const [nx, ny] = [X(0.5), top - 7.5]; // a hollow in the sand and two empty shells g += `<ellipse cx="${nx}" cy="${ny}" rx="${unit * 0.6}" ry="3.6" fill="${P.sun}"/>` + [[nx + unit * 0.95, ny - 1.2], [nx + unit * 1.35, ny + 1.4]].map(([ex, ey]) => `<ellipse cx="${n(ex)}" cy="${n(ey)}" rx="1.5" ry="1.9" fill="${P.paper}" ` + `stroke="${P.ink}" stroke-width="0.4"/>`).join(''); if (solved) { // the way out in coral, from the nest into the water, and the hatchling const pts = m.route.map(([r, c]) => `${X(c + 0.5)} ${Y(r + 0.5)}`); g += `<path d="M${X(0.5)} ${Y(0) - side}L${pts.join('L')}` + `L${X(m.cols - 0.5)} ${shore + 3}" fill="none" stroke="${P.coral}" ` + `stroke-width="${n(unit * 0.3)}" stroke-linecap="round" stroke-linejoin="round"/>` + turtle(X(m.cols - 2.5), n(shore + foot / 2), unit * 1.8, 165); } else g += turtle(nx, ny + 0.5, unit * 1.05, 180); g += `<path d="${walls}" fill="none" stroke="${P.ink}" stroke-width="${n(unit * 0.12)}" ` + 'stroke-linecap="round"/>'; return { markup: svg(n(W), n(H), g), width: W, height: H }; } const theMaze = maze(17, 9, 2027); const drawings = { portada: { markup: coverArt(), width: PAGE, height: PAGE }, olas: { markup: surf(), width: PAGE, height: 3.2 }, nido: { markup: nestArt(), width: 80, height: 46 }, laberinto: mazeArt(theMaze, 10), 'sol-laberinto': mazeArt(theMaze, 10, { solved: true }) }; for (const [id, { markup }] of Object.entries(drawings)) await loadSvg(`${id}.svg`, markup); const picture = (id, typeId, extra) => ({ id, typeId, kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: drawings[id].width * 10, height: drawings[id].height * 10 }, ...extra }); // #endregion const words = searchText.trim().split('\n')[0].split(' '); // the first one comes found const share = (count, size) => (count * size) / MEASURE; // a grid's share of the measure const puzzles = [ grid('crucigrama', 'plain', crossword(crosswordText), 'crucigrama', share(9, CELL / PT_PER_MM), { altText: t({ en: 'An empty crossword, nine squares a side', es: 'Un crucigrama vacío de nueve por nueve' }) }), grid('sopa', 'plain', wordSearch(searchText, [[words[0], 'coral']]), 'sopa', share(12, CELL / PT_PER_MM), { altText: t({ en: 'A word search, twelve letters a side', es: 'Una sopa de letras de doce por doce' }) }), picture('laberinto', 'plain', { placement: here(1), altText: t({ en: 'A maze from a turtle nest to the sea', es: 'Un laberinto desde un nido hasta el mar' }) }), picture('nido', 'plain', { placement: here(1), altText: t({ en: 'Eggs under the sand, two hatchlings on their way to the sea', es: 'Huevos bajo la arena y dos crías camino del mar' }) }), picture('portada', 'plain', { altText: t({ en: 'A low sun, a sailing boat and a turtle at sea', es: 'Un sol bajo, un velero y una tortuga en el mar' }) }), picture('olas', 'plain', { altText: '' }), // the field's wavy foot: decoration ]; // #region answers: the answer page reuses the puzzles' data; a resource type numbers it const resourceTypes = [ // a plain resource prints no caption; an answer is numbered { id: 'plain', name: 'Plain', shortLabel: '', captionPrefix: '' }, { id: 'solucion', ...t({ en: { name: 'Solution', captionPrefix: 'Solution' }, es: { name: 'Solución', captionPrefix: 'Solución' } }), shortLabel: 'Sol.' }, ].map((type) => ({ numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', ...type })); const ANSWER_GAP = 6; // mm between the three answers const ANSWER_WIDTH = KEY / ((MEASURE - 2 * ANSWER_GAP) / 3); // 54 mm of a 55.3 mm column const answers = [ grid('sol-crucigrama', 'solucion', crossword(crosswordText, true), 'solucion-9', ANSWER_WIDTH, { caption: t({ en: 'Crossword', es: 'Crucigrama' }) }), grid('sol-sopa', 'solucion', wordSearch(searchText, words.map((w) => [w, 'mint'])), 'solucion-12', ANSWER_WIDTH, { caption: t({ en: 'Word search', es: 'Sopa de letras' }) }), picture('sol-laberinto', 'solucion', { placement: here(ANSWER_WIDTH), caption: t({ en: 'Maze', es: 'Laberinto' }), altText: t({ en: 'The maze and its way out to the sea', es: 'El laberinto y su salida al mar' }) }), ]; // #endregion const resources = [...puzzles, ...answers]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) Lexend: ['400', '600', '700'], 'Lilita One': ['400'], 'Chivo Mono': ['400', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const allText = [markdown, crosswordText, searchText].join('\n'); await loadFonts(FONTS, allText); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText); showPages(doc, { title: t({ en: 'Summer Workbook · Puzzles from the sea', es: 'Cuaderno de verano · Pasatiempos del mar' }) });Kit · core, fonts, viewer, images: igual a totes les receptes · 270 línies
// ─── 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 ───────────────────────────────────────────────────────────────────────
L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)
Variants
#Alterna el fons de les files de la sopa de lletres
Un groc més intens en una fila sí i una altra no ajuda un lector petit a seguir la fila, i com que les paraules s'emplenen després, SOMBRILLA continua en corall i la solució conserva les seves paraules en menta.
let model = { rows: letters.map((line) => [...line].map((content) => ({ content,
align: 'center' }))) };
+ const stripe = { hex: '#ffdd80', model: 'hex' }; // a shade deeper than the tiles
+ letters.forEach((line, r) => r % 2 && [...line].forEach((_, c) => {
+ model = setCellBackground(model, { row: r, col: c }, stripe);
+ }));
for (const [word, fill] of fills) {#Dibuixa un laberint més difícil
Amb 21 per 11 caselles en la mateixa amplada, cadascuna mesura 8,4 mm, i la solució de la pàgina 5 es dibuixa a partir del nou laberint.
-const theMaze = maze(17, 9, 2027);
+const theMaze = maze(21, 11, 2027);Errors freqüents
Error freqüent
Una línia en blanc dins d'una cel·la desapareix
Un salt de línia dins d'una cel·la comença un paràgraf nou, però postext 1.4.1 descarta el paràgraf buit o que només porta espais de no separació (U+00A0), així que una cel·la escrita 'NOM\n\n' ocupa una sola línia. Per deixar línies on escriure, posa un unidor de paraules (U+2060) a cada línia en blanc: 'NOM\n\u2060\n\u2060' n'ocupa tres. Taules a partir de dades →
Error freqüent
Una taula 'here' no es parteix mai
Només es parteixen entre columnes i pàgines les taules flotants; una taula col·locada 'here' es mou sencera. Deixa flotar les taules llargues o mantén curtes les taules en línia. Taules que passen de pàgina →
Error freqüent
:::columns només funciona dins d'un requadre i no es parteix
:::columns s'ignora fora d'un requadre, i un requadre que es parteix mai no talla dins d'un grup de columnes. L'atribut breaks compta blocs fills, i un requadre imbricat compta com un. Columnes dins d'un requadre →
Error freqüent
Una obertura que es queda a la seva columna es talla al capdamunt de la caixa de text
A postext 1.4.1, un títol amb disseny avançat que es queda a la seva columna es retalla per la vora superior de la columna: una caixa o una imatge ancorades a la pàgina o a la sang es pinten als marges laterals, però no al superior, i cap avís no ho diu. Dona span: 'page' a aquest títol, encara que el llibre tingui una sola columna: llavors el seu disseny es pinta sencer, com a banda d'obertura de la pàgina. Obertures dissenyades →
Error freqüent
La paleta d'una secció o d'una part no torna a acolorir els xips
A postext 1.4.1 la paleta d'un estil de títol o d'una part torna a acolorir a les seves pàgines el text, els requadres i els elements de disseny, però un xip conserva els colors de la seva entrada de chipStyles. Un xip enllaçat a una entrada que la secció canvia mostra el valor del document a totes les pàgines. Posa els xips en colors que cap secció no canviï, com tinta sobre paper, o dona a cada secció el seu propi estil de xip. Chips en línia →
Error freqüent
Una paleta canviada no arriba als elements de disseny ni al color de les remissions
postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →
Error freqüent
Qualsevol objecte headings desactiva el salt de pàgina de l'H1
Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →
Error freqüent
El lineHeight d'un text de disseny és un múltiple, mai una mesura
En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol. Textos, filets i caixes als dissenys de pàgina →
Error freqüent
El text dins d'un SVG <img> no pot fer servir fonts web
Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu. Figures i taules com a recursos →
Error freqüent
Una configuració es desa a la memòria cau per identitat: crea un objecte nou
El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config(). Pàgines en un canvas →
Error freqüent
Carrega totes les fonts abans de compondre
La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →
- Els números de les definicions del requadre s'escriuen a mà al fitxer de contingut, i els de la graella es calculen. Després de canviar una graella, repassa-hi les definicions, perquè el codi no les compara.
- La paleta d'un estil de títol canvia el color dels elements de disseny, del text i dels filets de les taules, però no el de l'interior d'un SVG, així que les onades del peu de cada camp es dibuixen amb el color del paper i un sol dibuix serveix per als quatre camps. A la 1.4.1 tampoc no arriba als emplenaments de les taules: una casella dels mots encreuats vinculada a
bandcontinuaria en blau mar a la pàgina groga de solucions.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Text original, CC BY 4.0
- Fonts
- Lexend (SIL OFL 1.1) · Lilita One (SIL OFL 1.1) · Chivo Mono (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


