かんたんな説明
9〜11歳の子ども向けの夏休みのパズルブックで、クロスワード、単語探し、迷路が入っています。簡単な文字の並びからパズルのマス目を作る方法を示します。
できあがり
9〜11歳の子ども向けの夏休みのドリルです。表紙、海をテーマにした3つのパズル、解答のページで構成されます。2〜5ページは、波形の下端を持つ色面(海の青、ミント、サンゴ色、解答は黄色)と、Lilita Oneの46 ptの題で始まります。コードはクロスワードと単語探しを文字の行から表として組み、それぞれに独自の表スタイルを与えます。クロスワードは罫で区切ったマス、単語探しは白い目地と角丸を持つ淡いタイルです。クロスワードの黒マスと、単語探しですでに見つけた単語はセルの塗りで、カギの番号は隅の上付き文字です。迷路はシードから描くSVGです。最後のページは同じデータから3つの解答を横に並べて印刷し、その下にアカウミガメの産卵についての囲みを置きます。
このレシピが答える質問
- 1つの文書の中で、複数の表を異なるスタイル(塗り、縞模様のセル、角丸の枠)にするには?
- 画像や表を、Markdownの![]()ではなくコードから(リソースとして)追加するには?
- 数式を使わずに、上付き文字、下付き文字、化学式を書くには?
- キーボードのキー、タグ、練習問題の語群などのインラインのチップを作るには?
- 囲みの中を2段にするには(本文の段と図の段を並べる)?
手短な答え
// 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 });
材料
- 機能
- セルの塗り表スタイル上付きと下付きデータから作る表名前付きの表スタイルインラインのチップ囲みの中の段組み囲み見出しスタイル部ごとの色デザインした章扉見出しの属性ページデザインの画像表紙、扉、奥付柱とノンブル独自のリソースの種類その場に置く図キャプションのスタイルリソースとしての図と表キャンバス上のページ
- 種類
- Lexend, Lilita One, Chivo Mono (SIL OFL 1.1)
- 素材
- なし:図はすべてコードで描画
作り方
#1 · 1つの表スタイルから3つの見た目
コードは上の手短な答えにあります。tableStyleはすべてのマス目にChivo Monoを設定します。tableStylesの名前付きスタイルは、それぞれ独自の文字サイズ、パディング、罫を設定し、表はtable.styleIdでその1つを選びます。列の幅は表の幅に対する割合で決まりますが、行の深さは行数にパディングを加えた値なので、各マスの深さを決めるのはパディングです。単語探しは、15 ptの大文字1行のまわりにクロスワードと同じ9.41 mmのマスを保ちます。pad()はその1行の上下に残る2.9 ptを割り出します。紙の色をした幅2.4 ptの罫が淡い塗りをタイルに切り分け、borderRadiusが枠の角を4 mmの角丸にして、塗りもそこで切り取ります。解答ページではkey()が9マスまたは12マスを54 mmに収めます。クロスワードは6 mmのマスに8 ptの文字、単語探しは4.5 mmのマスに6 ptの文字です。迷路の解答も54 mm四方で描くので、3つの解答は深さがそろい、キャプションは上端から106.4 mmの同じベースラインに並びます。
#2 · 9マスで15行
// 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) };
}
白マスには8.9 ptのChivo Monoで2行(カギの番号と空の行)が1 ptのパディングの中に入り、合計26.7 ptになります。9.41 mm、表の幅の9分の1です。9マスで84.7 mmの深さになり、16 ptの本文15行分に相当するので、カギの囲みは1行下のベースライングリッドから始まります。横か縦の単語が始まるマスには、読む順に番号が振られます。番号は5.2 pt(8.9 ptの58 %)の太字の上付きで、3 pt(8.9 ptの3分の1)持ち上げてあり、マスの上の隅に収まります。空の行はワードジョイナー(U+2060)です。代わりにノーブレークスペースを使うと、1.4.1はその行を捨て、マス目の深さが45.5 mmになってしまいます。黒マスは、inkにリンクした独自のbackgroundを持つ空のセルです。
#3 · コードがマス目の中の単語を探す
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;
}
マス目は、探す単語の一覧のあとに、専用の原稿ファイルの中で大文字12行として書かれています。locate()は各単語を8方向に探し、一致がちょうど1つでなければ例外を投げます。そのため、マス目か一覧に打ち間違いがあれば、誰にも見つけられない単語を印刷する前にビルドが止まります。setCellBackgroundは、塗ったマスを1つ増やした新しい表モデルを返します。塗りはパレットの項目にリンクし、スタイルのタイルを覆います。3ページではUMBRELLAがサンゴ色、5ページでは10語すべてがミントです。1.4.1の名前付き表スタイルには1行おきの塗りの設定がないので、最初のアレンジでは1行おきのすべてのセルにsetCellBackgroundを呼び出します。マス目の上の単語一覧は、中央にそろえた2行のチップです。UMBRELLAのチップはそのマスと同じサンゴ色で塗られ、ほかの9つは白地に紺の枠です。
#4 · 扉は1つ、色は4つ
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] } }));
2〜5ページは1つのデザインを共有します。色面は上端から40 mm下まで伸び、紙の色で描いた波形の下端で終わります。肩書き、題、白い楕円の中のテーマは、見出しの行とその属性から取ります。色面とノンブルの円はパレット項目bandにリンクしていて、各ページの見出しスタイルがbandに独自の値を与えるので、# Word Search {style="sopa"}は3ページで両方をミントにします。この本は1段組みですが、レベル1にはspan: 'page'を設定しています。1.4.1は段の中に置いたデザインを16 mmの天の余白で切り取るので、色面の上部と肩書きが切れてしまうためです。色面の箱は扉が確保する深さに数えられるので、本文は次のグリッド線、上端から44.2 mm、波の4 mm下から始まります。
#5 · 解答は同じデータから
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' }) }),
];
解答ページは、パズルと同じ原稿ファイルに対してcrossword()をsolved付きで、wordSearch()を10語すべてで呼び出します。そのためマス目を編集すれば解答も変わります。パズルと図は種類Plainを使い、その空のcaptionPrefixのおかげで、キャプションのテキストがないリソースにはキャプションが印刷されません。解答は種類Solutionを使い、肩書きPuzzle 1〜Puzzle 3に合わせて読む順に1〜3の番号が振られます。解答は塗りも枠もない囲みに入っていて、その中の:::columns{count=3 breaks="2,3"}では、breaksが2つ目と3つ目の解答で新しい段を始めます。各解答は55.3 mmの段の中で幅54 mmです。囲みDid you know?も同じようにbreaks="2"を使い、巣の図を段落の横に置いています。
レシピの全体
// ═══ 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`---Markdownの見本 · 77行 · 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#Markdownの見本 · 8行 · 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 CREMAMarkdownの見本 · 13行 · 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' }) });キット · core, fonts, viewer, images:全レシピ共通 · 270行
// ─── 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 ───────────────────────────────────────────────────────────────────────
組み立てたscript.jsはそのまま動きます。任意のページのモジュールスクリプトに貼り付けるか、レシピをCodePenで開いてください。 GitHub上のレシピのフォルダー ↗ (新しいタブで開きます)
アレンジ
#単語探しの行を縞にする
1行おきに濃い黄色を敷くと、子どもが文字の行を目で追いやすくなります。単語は縞のあとに塗られるので、UMBRELLAはサンゴ色のまま、解答のマス目の単語もミントのままです。
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) {#もっと難しい迷路を描く
21×11マスなら、1マス8.4 mmで同じ幅に収まります。5ページの解答も新しい迷路から描かれます。
-const theMaze = maze(17, 9, 2027);
+const theMaze = maze(21, 11, 2027);よくあるつまずき
つまずき
表のセルの空行は捨てられる
表のセル内の改行は新しい段落を始めますが、postext 1.4.1は空の段落や、ノーブレークスペース(U+00A0)だけの段落を捨てます。そのため、'NAME\n\n'と書いたセルは1行の高さになります。書き込み用の行を残すには、空行ごとにワードジョイナー(U+2060)を入れてください。'NAME\n\u2060\n\u2060'なら3行の高さになります。 データから作る表 →
つまずき
'here'の表は分割されない
段やページをまたいで分割されるのはフロートの表だけで、'here'に置いた表はまとめて移動します。長い表はフロートにするか、インラインの表を短くしてください。 ページをまたぐ表 →
つまずき
:::columnsは囲みの中でしか効かず、分割されない
:::columnsは囲みの外では無視されます。また、囲みが分割されても、columnsグループの内部で切れることはありません。breaks属性は子ブロックを数え、入れ子の囲みは1つと数えます。 囲みの中の段組み →
つまずき
段内にとどまる章扉は版面の頭で切れる
postext 1.4.1では、段内にとどまる詳細デザインの見出しは段の上端でクリップされます。ページや裁ち落としに固定した箱や画像は左右の余白には描かれますが、天の余白には描かれず、警告もありません。こうした見出しには、1段組みの本でもspan: 'page'を与えてください。デザインがページの章扉の帯として、欠けずに描かれます。 デザインした章扉 →
つまずき
節や部のパレットはチップの色を変えない
postext 1.4.1では、見出しスタイルや部のパレットは、そのページのテキスト、囲み、デザイン要素の色を置き換えますが、チップはchipStylesの項目の色のままです。節が上書きする項目にリンクしたチップは、どのページでも文書の値で表示されます。チップにはどの節も変えない色(紙の上の墨など)を使うか、節ごとに専用のチップスタイルを与えてください。 インラインのチップ →
つまずき
パレットを差し替えても、デザイン要素と参照色は変わらない
postext 1.4.1はcolorPaletteをテキストのスタイル(本文、見出し、リスト、キャプション、表、囲み)には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。 セマンティックカラーパレット →
つまずき
headingsオブジェクトを渡すとH1の改ページが消える
既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →
つまずき
デザインのテキストのlineHeightは倍率で、寸法ではない
デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です(lineHeight: 1.05)。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。 ページデザインのテキスト・罫・ボックス →
つまずき
SVGの<img>内のテキストはWebフォントを使えない
SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。 リソースとしての図と表 →
つまずき
設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る
エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。 キャンバス上のページ →
つまずき
レイアウトの前にすべてのフォントを読み込む
レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →
- 囲みの中のカギの番号は原稿ファイルに手で書いたもので、マス目の中の番号は計算で求めたものです。コードは両者を照合しないので、マス目を編集したらカギと突き合わせて確かめてください。
- 見出しスタイルのパレットはデザイン要素、テキスト、表の罫の色を変えますが、SVGの中身は変えません。そのため各色面の下端の波は紙の色で描き、1つの図を4つの色面で使い回しています。1.4.1では表の塗りも対象外なので、
bandにリンクしたクロスワードのマスは、黄色の解答ページでも海の青のままになります。
クレジット
- 本文
- 書き下ろしの文章, CC BY 4.0
- フォント
- Lexend (SIL OFL 1.1) · Lilita One (SIL OFL 1.1) · Chivo Mono (SIL OFL 1.1)


