Kurz gesagt
Ein Sommerrätselheft für Kinder von neun bis elf Jahren, mit Kreuzworträtsel, Buchstabensalat und Labyrinth. Es zeigt, wie man die Rätselgitter aus einfachen Buchstabenzeilen baut.
Was Sie setzen
Ein Sommerheft für Kinder von neun bis elf Jahren: ein Umschlag, drei Rätsel rund ums Meer und eine Seite mit Lösungen. Die Seiten 2 bis 5 beginnen unter einem Farbfeld mit welligem Fuß (Meerblau, Mintgrün, Korallenrot, dann Gelb für die Lösungen) und einem Titel in Lilita One zu 46 pt. Der Code baut Kreuzworträtsel und Buchstabensalat als Tabellen aus Buchstabenzeilen und gibt jedem sein eigenes Tabellenformat: Felder mit Linien für das Kreuzworträtsel, blasse Kacheln mit weißen Fugen und abgerundeten Ecken für den Buchstabensalat. Die schwarzen Felder des Kreuzworträtsels und das schon gefundene Wort im Buchstabensalat sind Zellfüllungen, und die Fragenummern sind hochgestellte Ziffern in den Ecken. Das Labyrinth ist ein SVG, aus einem Seed gezeichnet. Die letzte Seite druckt die drei Lösungen nebeneinander aus denselben Daten, über einem Kasten dazu, wie Unechte Karettschildkröten nisten.
Dieses Rezept beantwortet
- Wie gestalte ich mehrere Tabellen in einem Dokument unterschiedlich (Füllungen, Zebrastreifen, abgerundete Rahmen)?
- Wie füge ich Bilder und Tabellen im Code (Ressourcen) statt mit Markdown ![]() hinzu?
- Wie schreibe ich Hoch- und Tiefstellungen und chemische Formeln ohne vollen Formelsatz?
- Wie erstelle ich Inline-Chips: Tastaturtasten, Schlagwörter, Wortspeicher für Übungen?
- Wie setze ich zwei Spalten in einen Kasten (eine Textspalte neben einer Abbildungsspalte)?
Die kurze Antwort
// 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 });
Zutaten
- Funktionen
- ZellfüllungenTabellenformatHoch- und TiefstellungTabellen aus DatenBenannte TabellenformateInline-ChipsSpalten in einem Kasten oder im TextKästenÜberschriftenformateFarben je TeilGestaltete AuftakteÜberschriftenattributeBilder in SeitengestaltungenUmschläge, Titelseiten und ImpressumKolumnentitel und SeitenzahlenEigene RessourcentypenAbbildungen genau hierFormat der BildunterschriftAbbildungen und Tabellen als RessourcenSeiten auf einem Canvas
- Nutzt außerdem
- Kapitelband über die ganze BreiteAbsatzformateKolumnentitel je AbschnittZeilenumbrüche in Titeln
- Schrift
- Lexend, Lilita One, Chivo Mono (SIL OFL 1.1)
- Dateien
- Keine: Jedes Bild wird im Code gezeichnet
Zubereitung
#1 · Ein Tabellenformat, drei Erscheinungsbilder
Der Code steht oben in der kurzen Antwort. tableStyle setzt Chivo Mono für jedes Gitter. Jedes benannte Format in tableStyles legt seine eigene Schriftgröße, seinen Innenabstand und seine Linien fest, und eine Tabelle wählt eines mit table.styleId. Die Breite einer Spalte ist ihr Anteil an der Tabellenbreite, eine Tabellenzeile aber ist so tief wie ihre Textzeilen plus der Innenabstand, deshalb bestimmt der Innenabstand die Tiefe jedes Feldes. Der Buchstabensalat behält das Feld von 9,41 mm des Kreuzworträtsels um eine Zeile aus Versalien zu 15 pt, und pad() berechnet die 2,9 pt, die diese Zeile darüber und darunter lässt. Seine Linien, 2,4 pt breit und in der Papierfarbe, schneiden die blasse Füllung in Kacheln, und borderRadius rundet die Ecken des Rahmens auf 4 mm und beschneidet die Füllungen darauf. Auf der Lösungsseite bringt key() neun oder zwölf Felder in 54 mm unter: Felder von 6 mm mit Buchstaben zu 8 pt für das Kreuzworträtsel, Felder von 4,5 mm mit Buchstaben zu 6 pt für den Buchstabensalat. Die Lösung des Labyrinths ist als Quadrat von 54 mm gezeichnet, sodass die drei Lösungen gleich tief sind und ihre Legenden eine Grundlinie 106,4 mm unter der Oberkante teilen.
#2 · Neun Felder, fünfzehn Zeilen
// Two lines of CLUE_FACE (a number, an empty line) and the padding make a square.
function crossword(source, solved = false) {
const lines = source.trim().split('\n');
const white = (r, c) => (lines[r]?.[c] ?? '#') !== '#';
let clue = 0;
const rows = lines.map((line, r) => [...line].map((letter, c) => {
if (!white(r, c)) return { content: '', background: col('ink') }; // a black square
if (solved) return { content: letter, align: 'center' };
// A square is numbered when a word starts in it, across or down, in reading order.
const starts = (!white(r, c - 1) && white(r, c + 1)) || (!white(r - 1, c) && white(r + 1, c));
const number = starts ? `**^${++clue}^**` : JOINER; // a bold superscript, or nothing
return { content: `${number}\n${JOINER}` }; // an empty second line would add no depth
}));
return { rows, columnWidths: rows[0].map(() => 1) };
}
Ein weißes Feld enthält zwei Zeilen Chivo Mono zu 8,9 pt (die Fragenummer und eine leere Zeile) in 1 pt Innenabstand, zusammen 26,7 pt: 9,41 mm, ein Neuntel der Tabellenbreite. Neun Felder sind 84,7 mm tief, die Tiefe von fünfzehn Zeilen Grundtext zu 16 pt, und der Fragenkasten beginnt eine Zeile tiefer auf dem Grundlinienraster. Ein Feld bekommt eine Nummer in Lesereihenfolge, wenn darin ein waagerechtes oder ein senkrechtes Wort beginnt. Die Nummer ist eine fette hochgestellte Ziffer zu 5,2 pt (58 % von 8,9 pt), um 3 pt angehoben (ein Drittel von 8,9 pt), was sie in die obere Ecke des Feldes setzt. Die leere Zeile ist ein Wortverbinder (U+2060). Eine leer gelassene Zeile gibt der Zelle keine Tiefe, und das Gitter wäre 45,5 mm tief. Ein schwarzes Feld ist eine leere Zelle mit einem eigenen background, verknüpft mit ink.
#3 · Der Code findet die Wörter im Gitter
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;
}
Das Gitter besteht aus zwölf Zeilen Versalien in einer eigenen Inhaltsdatei, nach der Liste der zu findenden Wörter. locate() sucht jedes Wort in acht Richtungen und wirft einen Fehler, wenn es nicht genau einen Treffer gibt, sodass ein Tippfehler im Gitter oder in der Liste den Build anhält, bevor er ein Wort druckt, das niemand finden kann. setCellBackground gibt ein neues Tabellenmodell mit einem weiteren gefüllten Feld zurück. Die Füllung ist mit einem Paletteneintrag verknüpft und deckt die Kachel des Formats ab: Korallenrot für UMBRELLA auf Seite 3, Mintgrün für alle zehn Wörter auf Seite 5. Die erste Variante streift jede zweite Zeile über das Tabellenformat (bodyAlternateBackground), und die eigene Füllung einer Zelle deckt den Streifen ab. Die Wortliste über dem Gitter besteht aus zwei zentrierten Reihen von Chips: Der von UMBRELLA ist korallenrot gefüllt wie seine Felder, die anderen neun sind weiß mit marineblauem Rahmen.
#4 · Ein Auftakt, vier Farben
const BAND = 40; // mm: the colour field, from the top edge
const pin = (to, edge, x, y, size) => ({ anchor: { to, edge },
offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
fontFamily: family, fontSize: pt(size), color: col('ink'), overflow: 'wrap', placement,
align: placement.anchor.edge.endsWith('right') ? 'right' : 'left', ...extra });
const CAPS = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: em(1 / 6) };
// The field's box sets the opener's depth: the text starts on the next grid line below.
const opener = { enabled: true, slot: { elements: [
{ kind: 'box', id: 'field', style: { backgroundColor: col('band') },
placement: pin('page', 'top-left', 0, 0, { width: 'fill', height: mm(BAND) }) },
{ kind: 'image', id: 'surf', resourceId: 'olas', // the field's foot, cut in waves
placement: pin('page', 'top-left', 0, BAND - 3, { width: mm(PAGE), height: mm(3.2) }) },
text('kicker', '{attr.kicker}', LABEL, 8.5, pin('page', 'top-left', MARGIN, 11), CAPS),
text('title', '{titleText}', DISPLAY, 46, pin('#kicker', 'below', -0.6, 0.5),
{ lineHeight: 1 }), // set solid
text('theme', '{attr.theme}', TEXT, 10, pin('page', 'top-right', -MARGIN, 23.5), {
fontWeight: 700, box: { backgroundColor: col('paper'), borderRadius: mm(3.5),
padding: { top: mm(1.6), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } } }),
] } };
// parity 'any': each puzzle opens the next page. span 'page' even in one column: a design kept
// in its column is cut at the column's foot, which would trim the cover's picture.
const puzzleLevel = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
advancedDesign: opener };
// '# Sopa de letras {style="sopa"}': the style's palette turns 'band' mint on that page,
// in the field, the folio disc and anything else linked to it.
const BANDS = { crucigrama: 'band', sopa: 'mint', laberinto: 'coral', soluciones: 'sun' };
const puzzleStyles = Object.entries(BANDS)
.map(([id, band]) => ({ id, palette: { band: palette[band] } }));
Die Seiten 2 bis 5 teilen eine Gestaltung. Ein Farbfeld reicht 40 mm von der Oberkante hinunter und endet in einem welligen Fuß, der in der Papierfarbe gezeichnet ist; Dachzeile, Titel und das Thema in seiner weißen Pille kommen aus der Überschriftenzeile und ihren Attributen. Das Feld und die Paginascheibe sind mit dem Paletteneintrag band verknüpft, und das Überschriftenformat jeder Seite gibt band einen eigenen Wert, sodass # Word Search {style="sopa"} beide auf Seite 3 mintgrün färbt. Die Ebene 1 setzt span: 'page', obwohl das Heft nur eine Spalte hat: Eine Gestaltung, die in ihrer Spalte bleibt, wird am Fuß der Spalte abgeschnitten, was das Umschlagbild 16 mm vor der Unterkante enden ließe. Der Kasten des Feldes zählt zur Tiefe, die der Auftakt reserviert, sodass der Text auf der nächsten Rasterzeile beginnt, 44,2 mm unter der Oberkante und 4 mm unter den Wellen.
#5 · Die Lösungen kommen aus denselben Daten
const resourceTypes = [ // a plain resource prints no caption; an answer is numbered
{ id: 'plain', name: 'Plain', shortLabel: '', captionPrefix: '' },
{ id: 'solucion', ...t({ en: { name: 'Solution', captionPrefix: 'Solution' },
es: { name: 'Solución', captionPrefix: 'Solución' } }), shortLabel: 'Sol.' },
].map((type) => ({ numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
...type }));
const ANSWER_GAP = 6; // mm between the three answers: gap=6mm on their :::columns fence
const ANSWER_WIDTH = KEY / ((MEASURE - 2 * ANSWER_GAP) / 3); // 54 mm of a 55.3 mm column
const answers = [
grid('sol-crucigrama', 'solucion', crossword(crosswordText, true), 'solucion-9', ANSWER_WIDTH,
{ caption: t({ en: 'Crossword', es: 'Crucigrama' }) }),
grid('sol-sopa', 'solucion', wordSearch(searchText, words.map((w) => [w, 'mint'])),
'solucion-12', ANSWER_WIDTH, { caption: t({ en: 'Word search', es: 'Sopa de letras' }) }),
picture('sol-laberinto', 'solucion', { placement: here(ANSWER_WIDTH), caption: t({
en: 'Maze', es: 'Laberinto' }), altText: t({ en: 'The maze and its way out to the sea',
es: 'El laberinto y su salida al mar' }) }),
];
Die Lösungsseite ruft crossword() mit solved und wordSearch() mit allen zehn Wörtern auf, auf denselben Inhaltsdateien wie die Rätsel, sodass eine Änderung an einem Gitter auch seine Lösung ändert. Die Rätsel und die Zeichnungen verwenden den Typ Plain, dessen leeres captionPrefix für eine Ressource ohne Legendentext keine Legende druckt. Die Lösungen verwenden den Typ Solution, in Lesereihenfolge von 1 bis 3 nummeriert, passend zu den Dachzeilen „Puzzle 1“ bis „Puzzle 3“. Sie stehen im Text in einer Gruppe :::columns{count=3 breaks="2,3" gap=6mm}, ohne Kasten darum (postext 1.25): breaks eröffnet bei der zweiten und dritten Lösung eine neue Spalte, und gap setzt die Spalten 6 mm auseinander; jede Lösung ist 54 mm breit in einer Spalte von 55,3 mm. Der Kasten „Did you know?“ enthält eine eigene Gruppe mit breaks="2", um die Nestzeichnung neben ihren Absatz zu setzen.
Das ganze Rezept
// ═══ Postext Cookbook · Nº 069 · Puzzle book: crossword, word search and maze ═══════ // https://postext.dev/en/cookbook/puzzle-book // Code: MIT · Text and puzzles: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Lexend, Lilita One, Chivo Mono (SIL OFL 1.1) · Needs postext ≥ 1.25.0 import { buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage, setCellBackground, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'puzzle-book'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#1f2a44', // text, grid rules and the black squares: a navy near-black paper: '#ffffff', band: '#2d9cdb', // a puzzle's colour field: sea blue until a puzzle's style replaces it sun: '#ffb627', coral: '#ff6f59', mint: '#5cc8a8', // the other fields; coral marks a find tint: '#fff1cc', // boxes and word-search tiles muted: '#5b6477', // running titles and the colophon }; // Every colour is linked to its palette entry by id: a puzzle's palette recolours 'band'. const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [TEXT, DISPLAY, LABEL] = ['Lexend', 'Lilita One', 'Chivo Mono']; const PAGE = 210, MARGIN = 16; // mm: a square booklet, the same margin on every side const MEASURE = PAGE - 2 * MARGIN; // 178 mm const BODY = 11.5, LEAD = 16; // pt: large, open text for young readers const PT_PER_MM = 72 / 25.4; const JOINER = '\u2060'; // a word joiner: a line with nothing on it that still counts // #region answer: a named table style for each grid, picked by the table's styleId // Every grid shares tableStyle; a named style sets its size, padding, rules and fills. // A row is as tall as its lines plus the padding and a column is its share of the // table's width, so the padding sets how deep a square is. const CELL = (15 * LEAD) / 9; // pt: nine squares are fifteen lines of text, 9.4 mm each const PAD = 1; // pt: a clue number sits this close to the corner, clear of the 1 pt rule const CLUE_FACE = (CELL - 2 * PAD) / (2 * LEAD / BODY); // 8.9 pt: two lines fill a square const LETTER = 15; // pt: the word search's capitals const pad = (cell, face) => pt((cell - face * LEAD / BODY) / 2); // one line fills the cell const KEY = 54; // mm: the three answers are this deep, so their captions share a line const key = (count) => { // count squares in KEY mm, letters of 4/3 pt per mm of square const side = (KEY / count) * PT_PER_MM, face = (KEY / count) * 4 / 3; return { id: `solucion-${count}`, bodyFontSize: pt(face), cellPadding: pad(side, face), borderWidth: pt(0.5) }; }; // No grid has a head row; the grey head is off for the Cookbook's default-skin check. const tableStyle = { headerBackgroundEnabled: false, bodyFontFamily: LABEL }; const tableStyles = [ { id: 'crucigrama', bodyFontSize: pt(CLUE_FACE), cellPadding: pt(PAD), borderWidth: pt(1) }, { id: 'sopa', bodyFontSize: pt(LETTER), cellPadding: pad(CELL, LETTER), // same squares bodyBackgroundEnabled: true, bodyBackground: col('tint'), // tiles with white joints borderColor: col('paper'), borderWidth: pt(2.4), borderRadius: mm(4) }, key(9), key(12), // 6 mm squares with 8 pt letters, 4.5 mm squares with 6 pt letters ]; // A table resource names its style; a cell's own background covers the style's fill. const here = (width) => ({ position: 'here', width, align: 'center' }); // of the column const grid = (id, typeId, model, styleId, width, extra) => ({ id, typeId, kind: 'table', createdAt: 0, updatedAt: 0, table: { model, styleId }, placement: here(width), ...extra }); // #endregion // #region crossword: nine lines of letters give the crossword, or with solved its answer // Two lines of CLUE_FACE (a number, an empty line) and the padding make a square. function crossword(source, solved = false) { const lines = source.trim().split('\n'); const white = (r, c) => (lines[r]?.[c] ?? '#') !== '#'; let clue = 0; const rows = lines.map((line, r) => [...line].map((letter, c) => { if (!white(r, c)) return { content: '', background: col('ink') }; // a black square if (solved) return { content: letter, align: 'center' }; // A square is numbered when a word starts in it, across or down, in reading order. const starts = (!white(r, c - 1) && white(r, c + 1)) || (!white(r - 1, c) && white(r + 1, c)); const number = starts ? `**^${++clue}^**` : JOINER; // a bold superscript, or nothing return { content: `${number}\n${JOINER}` }; // an empty second line would add no depth })); return { rows, columnWidths: rows[0].map(() => 1) }; } // #endregion // #region search: a word search that finds its words in the grid and fills their squares const STEPS = [[0, 1], [1, 0], [1, 1], [-1, 1], [0, -1], [-1, 0], [-1, -1], [1, -1]]; function locate(letters, word) { // the squares of a word, in any of eight directions const hits = []; letters.forEach((line, r) => [...line].forEach((_, c) => STEPS.forEach(([dr, dc]) => { const cells = [...word].map((_, i) => ({ row: r + i * dr, col: c + i * dc })); if (cells.every(({ row, col: k }, i) => letters[row]?.[k] === word[i])) hits.push(cells); }))); if (hits.length !== 1) throw new Error(`${word} is in the grid ${hits.length} times`); return hits[0]; } function wordSearch(source, fills) { // fills: [word, palette id] pairs const letters = source.trim().split('\n').slice(-12); let model = { rows: letters.map((line) => [...line].map((content) => ({ content, align: 'center' }))) }; for (const [word, fill] of fills) { for (const at of locate(letters, word)) model = setCellBackground(model, at, col(fill)); } return model; } // #endregion // #region openers: one opener for every puzzle; each puzzle's style gives it its colour const BAND = 40; // mm: the colour field, from the top edge const pin = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col('ink'), overflow: 'wrap', placement, align: placement.anchor.edge.endsWith('right') ? 'right' : 'left', ...extra }); const CAPS = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: em(1 / 6) }; // The field's box sets the opener's depth: the text starts on the next grid line below. const opener = { enabled: true, slot: { elements: [ { kind: 'box', id: 'field', style: { backgroundColor: col('band') }, placement: pin('page', 'top-left', 0, 0, { width: 'fill', height: mm(BAND) }) }, { kind: 'image', id: 'surf', resourceId: 'olas', // the field's foot, cut in waves placement: pin('page', 'top-left', 0, BAND - 3, { width: mm(PAGE), height: mm(3.2) }) }, text('kicker', '{attr.kicker}', LABEL, 8.5, pin('page', 'top-left', MARGIN, 11), CAPS), text('title', '{titleText}', DISPLAY, 46, pin('#kicker', 'below', -0.6, 0.5), { lineHeight: 1 }), // set solid text('theme', '{attr.theme}', TEXT, 10, pin('page', 'top-right', -MARGIN, 23.5), { fontWeight: 700, box: { backgroundColor: col('paper'), borderRadius: mm(3.5), padding: { top: mm(1.6), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } } }), ] } }; // parity 'any': each puzzle opens the next page. span 'page' even in one column: a design kept // in its column is cut at the column's foot, which would trim the cover's picture. const puzzleLevel = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener }; // '# Sopa de letras {style="sopa"}': the style's palette turns 'band' mint on that page, // in the field, the folio disc and anything else linked to it. const BANDS = { crucigrama: 'band', sopa: 'mint', laberinto: 'coral', soluciones: 'sun' }; const puzzleStyles = Object.entries(BANDS) .map(([id, band]) => ({ id, palette: { band: palette[band] } })); // #endregion // The folio in a disc of the puzzle's colour; the book's title or the puzzle's beside it. const disc = (parity, edge, x) => text(`folio-${parity}`, '{pageNumber}', DISPLAY, 12, pin('page', edge, x, PAGE - 14, { width: mm(8), height: mm(8) }), { parity, lineHeight: 1, align: 'center', verticalAlign: 'middle', box: { backgroundColor: col('band'), borderRadius: mm(4) } }); const running = (parity, edge, x, content) => text(`title-${parity}`, content, LABEL, 7.5, pin('page', edge, x, PAGE - 11.7), { ...CAPS, parity, color: col('muted') }); const footer = { elements: [ disc('even', 'top-left', MARGIN), running('even', 'top-left', MARGIN + 11, '{title}'), disc('odd', 'top-right', -MARGIN), running('odd', 'top-right', -MARGIN - 11, '{chapterTitle}'), ] }; // The answer page trades its running title for the colophon of '# Answers {colophon="…"}'. const colophon = (parity, edge, x) => text(`colophon-${parity}`, '{attr.colophon}', LABEL, 7, pin('page', edge, x, PAGE - 11.5), { parity, color: col('muted') }); Object.assign(puzzleStyles.find(({ id }) => id === 'soluciones'), { footer: { elements: [ ...footer.elements.filter(({ id }) => id.startsWith('folio')), colophon('even', 'top-right', -MARGIN), colophon('odd', 'top-left', MARGIN)] } }); // The cover: the drawing over the whole page, the title on its sky, a name line on the sea. const cover = { id: 'portada', header: { elements: [] }, footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'art', resourceId: 'portada', placement: pin('page', 'top-left', 0, 0, { width: mm(PAGE), height: mm(PAGE) }) }, text('kicker', '{attr.kicker}', LABEL, 8.5, pin('page', 'top-left', MARGIN, 18), CAPS), text('title', '{titleText}', DISPLAY, 58, pin('#kicker', 'below', -0.8, 3, { width: mm(120) }), { lineHeight: 0.95 }), text('subtitle', '{attr.subtitle}', TEXT, 15, pin('#title', 'below', 0.8, 3), { fontWeight: 600 }), text('age', '{attr.age}', LABEL, 9.5, pin('#subtitle', 'below', 0, 5), { ...CAPS, box: { backgroundColor: col('coral'), borderRadius: mm(3.6), padding: { top: mm(1.8), right: mm(3.4), bottom: mm(1.8), left: mm(3.4) } } }), // The field fills the calm band between the wave stripes at 173 and 190 mm. { kind: 'box', id: 'name-field', style: { backgroundColor: col('paper'), borderRadius: mm(3) }, placement: pin('page', 'top-left', MARGIN, 176.9, { width: mm(104), height: mm(9) }) }, text('name', '{attr.name}', LABEL, 8.5, pin('page', 'top-left', MARGIN + 5, 180.2), CAPS), { kind: 'rule', id: 'name-line', direction: 'horizontal', thickness: pt(0.75), color: col('muted'), placement: pin('page', 'top-left', MARGIN + 27, 183.2, { width: mm(72) }) }, ] } } }; const chip = (id, fill) => ({ id, fontFamily: LABEL, fontSize: em(0.8), bold: true, background: col(fill), borderColor: col(fill === 'paper' ? 'ink' : fill), borderWidth: pt(0.75), borderRadius: em(1), paddingX: em(0.45) }); // resourceTypes is declared with the answers below: config() runs later. const config = () => ({ colorPalette, resourceTypes, tableStyle, tableStyles, footer, header: { elements: [] }, locale: t({ en: 'en-us', es: 'es' }), // the language of each edition, and of its bundle page: { width: mm(PAGE), height: mm(PAGE), dpi: 150, margins: { top: mm(MARGIN), bottom: mm(MARGIN), left: mm(MARGIN), right: mm(MARGIN) } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true }, // The H1 line under each design is still measured: in Lilita One, not in Open Sans 700. headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [ puzzleLevel, { level: 3, fontFamily: LABEL, fontWeight: 700, fontSize: pt(8.5), lineHeight: pt(13), textTransform: 'uppercase', marginBottom: pt(3) }, ] }, headingStyles: [cover, ...puzzleStyles], // The word bank: ink on paper and coral, colours no puzzle's palette changes. chipStyles: [chip('palabra', 'paper'), chip('hallada', 'coral')], calloutStyles: [ { id: 'pistas', background: col('tint'), borderRadius: mm(3), columnGap: mm(8), padding: { top: mm(4), right: mm(5), bottom: mm(4), left: mm(5) }, marginTop: pt(0), // the grid above already leaves a line body: { fontSize: pt(10), lineHeight: pt(13), paragraphSpacing: false } }, { id: 'dato', background: col('tint'), borderRadius: mm(3), columnGap: mm(6), padding: { top: mm(4), right: mm(5), bottom: mm(4.5), left: mm(5) }, titleStyle: { fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 400 }, body: { fontSize: pt(10), lineHeight: pt(14) } }, ], captionStyle: { fontFamily: LABEL, fontSize: pt(8), gap: mm(2) }, paragraphStyles: [{ id: 'banco', textAlign: 'center' }], // the word bank's two rows }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown-Beispiel · 75 Zeilen · content.en.md
title: "Summer Workbook" author: "Caracola Books" --- # Summer \\ Workbook {style="portada" kicker="Caracola Books · Book 4" subtitle="Puzzles from the sea" age="Ages 9 to 11" name="Name"} # Crossword {style="crucigrama" kicker="Puzzle 1" theme="Sea creatures"} Each small number, like the **^1^** in the top corner, marks the square where an animal’s name begins. Write one letter in each white square. ::resource{id="crucigrama"} :::callout{type="pistas"} :::columns{count=2 breaks="7"} ### Across **1** See-through swimmer with a sting. **6** A flat fish shaped like a kite. **7** It sometimes hides a pearl. **9** It lays its eggs in the sand. **10** A tiny animal that builds reefs. ### Down **2** Blue in the sea, red once cooked. **3** Most have five arms. **4** Often the fish in fish and chips. **5** A long fish shaped like a snake. **8** It naps on the rocks between swims. ::: ::: # Word Search {style="sopa" kicker="Puzzle 2" theme="A day at the beach"} Find these ten beach words. They run across, down or diagonally, and some are spelt backwards. The first one is marked for you. :::paragraphs{style="banco"} :chip[UMBRELLA]{style="hallada"} :chip[SUNSCREEN]{style="palabra"} :chip[BUCKET]{style="palabra"} :chip[SPADE]{style="palabra"} :chip[KITE]{style="palabra"} :chip[SWIMSUIT]{style="palabra"} :chip[GOGGLES]{style="palabra"} :chip[SANDALS]{style="palabra"} :chip[TOWEL]{style="palabra"} :chip[BALL]{style="palabra"} ::: ::resource{id="sopa"} # Maze {style="laberinto" kicker="Puzzle 3" theme="Down to the sea"} This turtle has just hatched from a nest buried in the beach. Help it reach the water: there is only one way through the dunes. ::resource{id="laberinto"} # Answers {style="soluciones" kicker="No peeking until the end" theme="Puzzles 1 to 3" colophon="Original puzzles, text and drawings (CC BY 4.0) · Set in Lexend, Lilita One and Chivo Mono (SIL OFL)"} :::columns{count=3 breaks="2,3" gap=6mm} ::resource{id="sol-crucigrama"} ::resource{id="sol-sopa"} ::resource{id="sol-laberinto"} ::: :::callout{type="dato" title="Did you know?"} :::columns{count=2 breaks="2"} ::resource{id="nido"} A loggerhead turtle digs her nest in the sand at night and lays about a hundred eggs in it. Two months later the hatchlings come out together, also at night, and scurry towards the brightest part of the horizon, which is usually the sea. That is why the street lights of a seafront can lead them astray. On several beaches of the Spanish Mediterranean, volunteers guard the nests until the hatchlings are out. ::: :::`; // the booklet's text, clues and word bank const crosswordText = String.raw`JELLYFISHMarkdown-Beispiel · 8 Zeilen · 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 BALLMarkdown-Beispiel · 13 Zeilen · content.wordsearch.en.md
SGOGGLESSUER RJANIAAUNLTM ETEMHATNHEAO EBCBSSLSKWLT ENLZWRECEOTM GSETIKURUTLM AFAUMBRELLAB RCNISESELELA OEEGUSANDALS IWSXILOAIZTU WHRGTHPHIWSI NXTCZSNIROAH`; // the words to find, then twelve lines // #region art: the cover, the waves, the nest and the maze, drawn in the page's colours const n = (v) => +v.toFixed(2); function mulberry32(seed) { // a seeded PRNG: the same maze in every capture return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } // No words in the drawings: the maze's start and finish are pictures. const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const P = palette; const wave = (y, amp, len, x0, x1) => { // a sine line from x0 to x1, one crest per len let d = `M${x0} ${y}`; for (let x = x0; x < x1; x += len) d += ` q${len / 4} ${-amp} ${len / 2} 0 t${len / 2} 0`; return d; }; function turtle(x, y, s, turn = 0) { // a hatchling seen from above, s mm long, head up at 0° const line = `stroke="${P.ink}" stroke-width="0.45" stroke-linejoin="round"`; const skin = (d) => `<path d="${d}" fill="${P.paper}" ${line}/>`; return `<g transform="translate(${x} ${y}) rotate(${turn}) scale(${n(s / 12)})">` + skin('M-2.4 -1.6Q-6.8 -4.4 -7 -0.4Q-4.4 -0.6 -2.6 0.6Z') // front flippers + skin('M2.4 -1.6Q6.8 -4.4 7 -0.4Q4.4 -0.6 2.6 0.6Z') + skin('M-1.8 3Q-4.2 4.4 -3.6 5.9Q-2 5.2 -1 4Z') + skin('M1.8 3Q4.2 4.4 3.6 5.9Q2 5.2 1 4Z') + `<ellipse cy="-5.3" rx="1.7" ry="2" fill="${P.paper}" ${line}/>` + `<ellipse cy="0.4" rx="3.5" ry="4.5" fill="${P.mint}" ${line}/>` + `<path d="M0 -2L1.4 -1V1.4L0 2.4L-1.4 1.4V-1ZM0 -2V-4.1M0 2.4V4.9M1.4 -1L3.1 -2.2` + `M-1.4 -1L-3.1 -2.2M1.4 1.4L3.2 2.6M-1.4 1.4L-3.2 2.6" fill="none" stroke="${P.ink}" ` + 'stroke-width="0.35"/>' + `<circle cx="-0.7" cy="-5.9" r="0.32" fill="${P.ink}"/>` + `<circle cx="0.7" cy="-5.9" r="0.32" fill="${P.ink}"/></g>`; } function coverArt() { // sky, a low sun, a striped sea, a boat, two gulls and a turtle const H = PAGE, sea = 112; let g = `<rect width="${PAGE}" height="${H}" fill="${P.tint}"/>` + `<circle cx="152" cy="${sea - 8}" r="46" fill="${P.sun}"/>` + `<rect y="${sea}" width="${PAGE}" height="${H - sea}" fill="${P.band}"/>`; for (let i = 0, y = sea + 9; y < H; i++, y += 9 + i * 1.6) { // stripes widen towards us g += `<path d="${wave(y, 1.2 + i * 0.25, 18 + i * 3, -9, PAGE + 20)}" fill="none" ` + `stroke="${P.paper}" stroke-width="${n(1.1 + i * 0.35)}" stroke-linecap="round"/>`; } [[6, 34], [12, 22], [19, 12]].forEach(([dy, w]) => { // the sun's path on the water g += `<rect x="${152 - w / 2}" y="${sea + dy - 1}" width="${w}" height="2.2" rx="1.1" ` + `fill="${P.sun}"/>`; }); g += `<path d="M26 ${sea - 0.4}h15l-2.4 3.6h-10.4z" fill="${P.ink}"/>` // a boat on the line + `<path d="M33.6 ${sea - 1.6}v-15l8 13.6z" fill="${P.coral}"/>` + `<path d="M32.6 ${sea - 1.6}v-11.6l-6 11.6z" fill="${P.paper}"/>`; [[118, 34, 5], [131, 27, 3.6]].forEach(([x, y, w]) => { // two gulls g += `<path d="M${x - w} ${y}q${w / 2} -${w / 2} ${w} 0q${w / 2} -${w / 2} ${w} 0" ` + `fill="none" stroke="${P.ink}" stroke-width="0.8" stroke-linecap="round"/>`; }); return svg(PAGE, H, g + turtle(160, 170, 22, -35)); } const surf = () => svg(PAGE, 3.2, `<path d="${wave(3.2, 1.6, 12, 0, PAGE + 12)} V3.2 H0Z" ` + `fill="${P.paper}"/>`); function nestArt() { // a cutaway beach: the eggs under the sand, two hatchlings on their way const W = 80, H = 46, shore = [46, 15.5], deep = [W, 40]; // the sand slopes under the sea const slope = (y) => shore[0] + ((y - shore[1]) / (deep[1] - shore[1])) * (deep[0] - shore[0]); let g = `<rect width="${W}" height="${H}" fill="${P.tint}"/>` // the box's own tint as sky + `<path d="M0 13.6Q13 11.4 25 13.8T${shore[0]} ${shore[1]}L${deep[0]} ${deep[1]}V${H}H0Z" ` + `fill="${P.sun}"/><path d="M${shore[0]} ${shore[1]}H${W}V${deep[1]}Z" fill="${P.band}"/>`; for (let y = shore[1] + 5; y < deep[1] - 2; y += 5) { // ripples, from the slope outwards g += `<path d="${wave(y, 0.5, 4, n(slope(y) + 2), W + 4)}" fill="none" ` + `stroke="${P.paper}" stroke-width="0.55"/>`; } g += `<ellipse cx="22" cy="33" rx="12.5" ry="8" fill="${P.tint}"/>`; // the egg chamber [[36.9, 5], [33.1, 4], [29.3, 3]].forEach(([y, count]) => { // the clutch, row on row for (let i = 0; i < count; i++) { g += `<circle cx="${n(22 + (i - (count - 1) / 2) * 4.35)}" cy="${y}" r="2.05" ` + `fill="${P.paper}" stroke="${P.ink}" stroke-width="0.35"/>`; } }); return svg(W, H, g + turtle(33, 10.4, 7, 100) + turtle(44.5, 12.4, 7, 112)); } function maze(cols, rows, seed) { // a recursive backtracker: one path between any two squares const rand = mulberry32(seed); const open = new Set(); // passages, as 'r,c>r,c' const seen = new Set(['0,0']); const stack = [[0, 0]]; const around = (r, c) => [[r - 1, c], [r + 1, c], [r, c - 1], [r, c + 1]]; while (stack.length) { const [r, c] = stack.at(-1); const next = around(r, c).filter(([y, x]) => y >= 0 && y < rows && x >= 0 && x < cols && !seen.has(`${y},${x}`)); if (!next.length) { stack.pop(); continue; } const [y, x] = next[Math.floor(rand() * next.length)]; open.add(`${r},${c}>${y},${x}`).add(`${y},${x}>${r},${c}`); seen.add(`${y},${x}`); stack.push([y, x]); } const passes = (a, b) => open.has(`${a}>${b}`); const queue = [[0, 0]]; // the way out, by breadth-first search const from = new Map([['0,0', null]]); for (let i = 0; i < queue.length; i++) { const [r, c] = queue[i]; for (const [y, x] of around(r, c)) { if (from.has(`${y},${x}`) || !passes(`${r},${c}`, `${y},${x}`)) continue; from.set(`${y},${x}`, `${r},${c}`); queue.push([y, x]); } } const route = []; for (let at = `${rows - 1},${cols - 1}`; at; at = from.get(at)) { route.unshift(at.split(',').map(Number)); } return { cols, rows, passes, route }; } function mazeArt(m, unit, { solved = false } = {}) { // Sand above the maze for the nest and sea below it. The answer is square, as deep as // the answer grids beside it, so the three captions share a line: the rest of the // square goes to the sea, where the hatchling swims, with waves drawn k times larger. const side = 1.5, W = m.cols * unit + 2 * side, top = (solved ? 2.4 : 1.6) * unit; const foot = solved ? W - m.rows * unit - top : 1.6 * unit, k = solved ? 2 : 1; const H = m.rows * unit + top + foot; const X = (c) => n(side + c * unit), Y = (r) => n(top + r * unit); let walls = `M${X(1)} ${Y(0)}H${X(m.cols)}V${Y(m.rows)}M${X(m.cols - 1)} ${Y(m.rows)}` + `H${X(0)}V${Y(0)}`; // the frame, open above the first square and below the last for (let r = 0; r < m.rows; r++) { for (let c = 0; c < m.cols; c++) { const [here, right, below] = [`${r},${c}`, `${r},${c + 1}`, `${r + 1},${c}`]; if (c < m.cols - 1 && !m.passes(here, right)) walls += `M${X(c + 1)} ${Y(r)}V${Y(r + 1)}`; if (r < m.rows - 1 && !m.passes(here, below)) walls += `M${X(c)} ${Y(r + 1)}H${X(c + 1)}`; } } // The nest above the entrance, the sea below the exit. const shore = H - foot + 4; let g = `<rect width="${W}" height="${H}" fill="${P.tint}"/>` + `<path d="${wave(shore, 1.4 * k, 12 * k, 0, W + 12 * k)}V${H}H0Z" fill="${P.band}"/>`; for (let y = shore + 5 * k; y < H; y += 4.5 * k) { g += `<path d="${wave(y, 0.8 * k, 10 * k, 0, W + 10 * k)}" fill="none" ` + `stroke="${P.paper}" stroke-width="${0.7 * k}"/>`; } const [nx, ny] = [X(0.5), top - 7.5]; // a hollow in the sand and two empty shells g += `<ellipse cx="${nx}" cy="${ny}" rx="${unit * 0.6}" ry="3.6" fill="${P.sun}"/>` + [[nx + unit * 0.95, ny - 1.2], [nx + unit * 1.35, ny + 1.4]].map(([ex, ey]) => `<ellipse cx="${n(ex)}" cy="${n(ey)}" rx="1.5" ry="1.9" fill="${P.paper}" ` + `stroke="${P.ink}" stroke-width="0.4"/>`).join(''); if (solved) { // the way out in coral, from the nest into the water, and the hatchling const pts = m.route.map(([r, c]) => `${X(c + 0.5)} ${Y(r + 0.5)}`); g += `<path d="M${X(0.5)} ${Y(0) - side}L${pts.join('L')}` + `L${X(m.cols - 0.5)} ${shore + 3}" fill="none" stroke="${P.coral}" ` + `stroke-width="${n(unit * 0.3)}" stroke-linecap="round" stroke-linejoin="round"/>` + turtle(X(m.cols - 2.5), n(shore + foot / 2), unit * 1.8, 165); } else g += turtle(nx, ny + 0.5, unit * 1.05, 180); g += `<path d="${walls}" fill="none" stroke="${P.ink}" stroke-width="${n(unit * 0.12)}" ` + 'stroke-linecap="round"/>'; return { markup: svg(n(W), n(H), g), width: W, height: H }; } const theMaze = maze(17, 9, 2027); const drawings = { portada: { markup: coverArt(), width: PAGE, height: PAGE }, olas: { markup: surf(), width: PAGE, height: 3.2 }, nido: { markup: nestArt(), width: 80, height: 46 }, laberinto: mazeArt(theMaze, 10), 'sol-laberinto': mazeArt(theMaze, 10, { solved: true }) }; for (const [id, { markup }] of Object.entries(drawings)) await loadSvg(`${id}.svg`, markup); const picture = (id, typeId, extra) => ({ id, typeId, kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: drawings[id].width * 10, height: drawings[id].height * 10 }, ...extra }); // #endregion const words = searchText.trim().split('\n')[0].split(' '); // the first one comes found const share = (count, size) => (count * size) / MEASURE; // a grid's share of the measure const puzzles = [ grid('crucigrama', 'plain', crossword(crosswordText), 'crucigrama', share(9, CELL / PT_PER_MM), { altText: t({ en: 'An empty crossword, nine squares a side', es: 'Un crucigrama vacío de nueve por nueve' }) }), grid('sopa', 'plain', wordSearch(searchText, [[words[0], 'coral']]), 'sopa', share(12, CELL / PT_PER_MM), { altText: t({ en: 'A word search, twelve letters a side', es: 'Una sopa de letras de doce por doce' }) }), picture('laberinto', 'plain', { placement: here(1), altText: t({ en: 'A maze from a turtle nest to the sea', es: 'Un laberinto desde un nido hasta el mar' }) }), picture('nido', 'plain', { placement: here(1), altText: t({ en: 'Eggs under the sand, two hatchlings on their way to the sea', es: 'Huevos bajo la arena y dos crías camino del mar' }) }), picture('portada', 'plain', { altText: t({ en: 'A low sun, a sailing boat and a turtle at sea', es: 'Un sol bajo, un velero y una tortuga en el mar' }) }), picture('olas', 'plain', { altText: '' }), // the field's wavy foot: decoration ]; // #region answers: the answer page reuses the puzzles' data; a resource type numbers it const resourceTypes = [ // a plain resource prints no caption; an answer is numbered { id: 'plain', name: 'Plain', shortLabel: '', captionPrefix: '' }, { id: 'solucion', ...t({ en: { name: 'Solution', captionPrefix: 'Solution' }, es: { name: 'Solución', captionPrefix: 'Solución' } }), shortLabel: 'Sol.' }, ].map((type) => ({ numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', ...type })); const ANSWER_GAP = 6; // mm between the three answers: gap=6mm on their :::columns fence const ANSWER_WIDTH = KEY / ((MEASURE - 2 * ANSWER_GAP) / 3); // 54 mm of a 55.3 mm column const answers = [ grid('sol-crucigrama', 'solucion', crossword(crosswordText, true), 'solucion-9', ANSWER_WIDTH, { caption: t({ en: 'Crossword', es: 'Crucigrama' }) }), grid('sol-sopa', 'solucion', wordSearch(searchText, words.map((w) => [w, 'mint'])), 'solucion-12', ANSWER_WIDTH, { caption: t({ en: 'Word search', es: 'Sopa de letras' }) }), picture('sol-laberinto', 'solucion', { placement: here(ANSWER_WIDTH), caption: t({ en: 'Maze', es: 'Laberinto' }), altText: t({ en: 'The maze and its way out to the sea', es: 'El laberinto y su salida al mar' }) }), ]; // #endregion const resources = [...puzzles, ...answers]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build Lexend: ['400', '600', '700'], 'Lilita One': ['400'], 'Chivo Mono': ['400', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const allText = [markdown, crosswordText, searchText].join('\n'); await prepareFonts(allText, config(), kitFonts(FONTS)); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), { ...kitFonts(FONTS), text: allText }); showPages(doc, { title: t({ en: 'Summer Workbook · Puzzles from the sea', es: 'Cuaderno de verano · Pasatiempos del mar' }) });Kit · core, fonts, viewer, images: in jedem Rezept gleich · 270 Zeilen
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)
Varianten
#Die Zeilen des Buchstabensalats streifen
Ein kräftigeres Gelb auf jeder zweiten Zeile hilft jungen Lesern, einer Buchstabenzeile zu folgen. Die eigene Füllung einer Zelle deckt den Streifen ab, sodass UMBRELLA korallenrot bleibt; das Lösungsgitter hat ein eigenes Format und bekommt keine Streifen.
bodyBackgroundEnabled: true, bodyBackground: col('tint'), // tiles with white joints
+ bodyAlternateBackgroundEnabled: true, // a shade deeper than the tiles on every other row
+ bodyAlternateBackground: { hex: '#ffdd80', model: 'hex' },
borderColor: col('paper'), borderWidth: pt(2.4), borderRadius: mm(4) },#Ein schwereres Labyrinth zeichnen
Einundzwanzig mal elf Felder passen mit je 8,4 mm in dieselbe Breite, und die Lösung auf Seite 5 wird aus dem neuen Labyrinth gezeichnet.
-const theMaze = maze(17, 9, 2027);
+const theMaze = maze(21, 11, 2027);Häufige Fehler
- Die Fragenummern im Kasten sind in der Inhaltsdatei getippt, die im Gitter dagegen berechnet. Prüfen Sie nach einer Änderung am Gitter die Fragen daran, denn der Code vergleicht sie nicht.
- Die Palette eines Überschriftenformats färbt Gestaltungselemente, Text sowie Linien und Füllungen von Tabellen um, aber nicht das Innere eines SVG, deshalb sind die Wellen am Fuß jedes Feldes in der Papierfarbe gezeichnet, und eine Zeichnung dient allen vier Feldern.
Nachweise
- Rezept
- Ignacio Ferro
- Text
- Eigener Text, CC BY 4.0
- Schriften
- Lexend (SIL OFL 1.1) · Lilita One (SIL OFL 1.1) · Chivo Mono (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


