本文へスキップ
レシピ番号22

レシピ集 · 第6章 · 囲みと注

解答欄と語群を備えたワークシート

4ページの理科のワークシート。淡い緑のカードに白い解答欄を入れ、各問いの2 mm下にグリッドから外して置き、語群と空欄はチップで作ります。

1ページ(全4ページ)

  • 英語の見本:日本語版はまだありません
  • 仕上がり200 × 260 mm
  • 1段
  • Andika 12/17
  • Baloo 2
  • Fredoka
  • 4ページ
  • レベル
  • Postext 1.9.2
  • 組版時間9 ms
  • コード183行

かんたんな説明

8歳の子ども向けの、植物のつくりを学ぶ理科のワークシートです。問いごとに下に答えを書く欄があり、一部の文には、語のリストから選んで埋める空欄があります。

できあがり

8歳の子ども向けの架空のシリーズ『Seedlings Science』のワークシート3Aを、200 × 260 mmの4ページに片面刷りで組みます。若葉色の帯には、黄色い単元のピル、タイトル、名前と日付を書く白い欄が載ります。その横では、白い円に入った水彩のエンドウの6つの部位に黄色で番号が振られています。この水彩画は拡散モデルで生成したもので、最初から円に収めた状態で、帯の端で接する2枚のJPEGとして用意しています。上の1枚は四隅が帯の緑、下の1枚は白です。番号と引出線は画像の上にコードで描きます。どの活動も、淡い緑のカードの上に番号付きの見出しを立てます。カードには、小さな灰色の促しの言葉と数行分の余白を持つ白い解答欄、黄色いチップの語群、文の中の枠線の空欄、食べ物と植物の部位を結ぶ表、色を塗る丸のチェックリストが入ります。各解答欄は問いの2 mm下、次の要素の5 mm上に置き、どのカードもそのページで次に来るものの9 mm上で終わります。本文は、SILが文字を学ぶ子どものために設計したAndikaで組みます。

このレシピが答える質問

  • 解答欄を含むワークシートのカードのように、囲みを正確な間隔で入れ子にするには?
  • 記入線、解答欄、語群、チェックリストのあるワークシートのページを作るには?
  • キーボードのキー、タグ、練習問題の語群などのインラインのチップを作るには?
  • 表のセルに画像やアイコンを入れるには?

手短な答え

script.js · 24–56行コード全体で見る
// In the Markdown a box inside a box is a fence inside a fence (each ::: closes one):
//   :::callout{type="card"}
//   What is inside the pea pods?
//   :::callout{type="answer"}
//   Write here               ← a prompt first: a box of :::space alone collapses
//   :::space{lines=3}           (gotcha: space-dropped-box-top)
//   :::
//   :::
const [ASK, AFTER, HEAD, NEXT] = [2, 5, 3, 9]; // mm: question→box, box→next, heading→card
const card = {
  id: 'card', background: col('tint'), borderRadius: mm(4),
  padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
  marginTop: mm(HEAD), marginBottom: mm(NEXT), // NEXT: under a card, to whatever follows
  // Off the grid, or what follows a card drops to the next grid line. Only the outer card can
  // set it: a nested box ignores snapToGrid, span and placement (gotcha: nested-callout-limits).
  snapToGrid: false,
  body: { paragraphSpacing: false }, // the boxes' margins do all the spacing
  lists: { bulletChar: '', // no bullet: in the checklist a circle chip opens each item
    gap: mm(2), itemSpacing: pt(7) }, columnGap: mm(6),
};
// A nested box's margins collapse with its neighbours', the larger winning (ASK, AFTER); the
// last box's marginBottom falls inside the card's padding instead of adding to it.
const nested = (id, look) => ({ id, background: col('paper'), borderRadius: mm(2.5),
  marginTop: mm(ASK), marginBottom: mm(AFTER), ...look });
const answer = nested('answer', { border: { enabled: true, color: col('rule'), width: pt(0.75) },
  padding: { top: mm(1.6), right: mm(3), bottom: mm(2), left: mm(3) },
  // A small grey prompt on a full 17 pt line; :::space counts in these body lines.
  body: { fontFamily: LABEL, fontSize: pt(8), color: col('muted') } });
const bank = nested('bank', { title: t({ en: 'Word bank', es: 'Banco de palabras' }),
  padding: { top: mm(2.2), right: mm(3), bottom: mm(2.4), left: mm(3) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('leaf'),
    textTransform: 'uppercase', letterSpacing: pt(1.3), gap: mm(1.6) } });
// config() plugs in all three: calloutStyles: [card, answer, bank].

材料

種類
Andika, Baloo 2, Fredoka (SIL OFL 1.1)
素材
  • plant-1092.jpg
  • plant-foot-1092.jpg
  • The pea plant on the opener, a watercolour (top of the disc) (Generated With Diffusion Models, オリジナル)
  • The pea plant’s roots, the foot of the same watercolour below the band (Generated With Diffusion Models, オリジナル)
  • The plant's leader lines, the six food icons and the star, drawn in code in the page’s palette (Ignacio Ferro, MIT)

作り方

#1 · 見出しもグリッドから外す

script.js · 160–168行コード全体で見る
  headings: { fontFamily: DISPLAY, snapToGrid: false, // or what follows snaps back to the grid
    balancing: { enabled: false }, // no extra space above headings to fill out a page
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener() },
      { level: 2, numberingTemplate: '{2}', marginTop: mm(0), marginBottom: mm(0),
        advancedDesign: activity },
    ] },

手短な答えでは、各囲みのマージンが隣のマージンと相殺され、大きいほうが採用されます。語群の5 mmはその下のリストの上の2 mmに勝ち、最後の解答欄の5 mmは、カードの下パディング5 mmに足されずにその中に収まります。グリッドに乗せたままだと、レベル2の見出しは円の8.6 mmではなく10.8~14.1 mmを確保します。見出しの後に続くものが次の17 ptの行に吸着し直すからです。headings.snapToGrid: falseで8.6 mmに保ちます。入れ子のスタイルにsnapToGridを設定しても何も変わりません。グリッドから外せるのは外側のカードだけです(入れ子の囲み)。段末そろえはオフにしています。段末そろえはページを埋めるために見出しの上にスペースを足すので、2ページ目では活動3の上が9 mmではなく33 mmになってしまいます。

#2 · 空欄はすべてチップで作る

script.js · 60–74行コード全体で見る
const chip = (id, fill, look) => ({ id, fontFamily: LABEL, background: col(fill),
  borderWidth: pt(0), borderColor: col('leaf'), borderRadius: em(1), ...look });
// An empty chip holds a U+2060 word joiner, as one of spaces prints its markup (gotcha:
// empty-chip). Its box is a band 1.25 em tall, so paddingX ROUND makes it a circle.
const [EMPTY, ROUND, OUTLINE] = ['\u2060', em(0.625), pt(0.9)];
// A blank is all padding, 2 × paddingX wide and 19.6 pt tall: taller than the 17 pt line, so
// the card's itemSpacing sets its items 24 pt apart (gotcha: chip-overlap).
const blank = (id, width) => chip(id, 'paper', { borderWidth: OUTLINE, borderRadius: mm(1.4),
  paddingX: mm(width / 2), paddingY: em(0.12), fontSize: em(1.15) });
const chipStyles = [
  chip('word', 'sun', { bold: true, paddingX: em(0.6), paddingY: em(0.08), gap: em(0.45) }),
  blank('blank', 34), blank('wide', 38), // mm: in a sentence; alone in a column
  chip('dot', 'leaf', { paddingX: ROUND, fontSize: em(0.6) }),
  chip('tick', 'paper', { borderWidth: OUTLINE, paddingX: ROUND, fontSize: em(1.1), gap: em(0.5) }),
];

チップは行の中の分割されない箱で、そのパディングは行を高くせずに行の外まで塗られます。語群は、入れ子にしたbankの囲みに並べたチップの列です。空欄、線で結ぶ点、色を塗る丸もチップで、中身はそれぞれU+2060のワードジョイナーだけです。postext 1.4.1では、スペースだけのチップがマークアップをそのまま刷ってしまうためです(インラインのチップ)。幅はpaddingXで決めます。空欄は文の中では34 mm、段に単独で置くときは38 mmで、丸には高さ1.25 emの半分を左右それぞれに与えます。チェックリストでは、箇条書きの記号の位置に丸が立ちます(bulletChar: '')。空欄は19.6 ptで17 ptの行より高いので、1つずつ専用のリスト項目に入れ、次の項目と24 pt離しています。1.4.1のSandboxは、blank、wide、tickについて今も「チップが隣の行に接触」の警告を表示します。各チップを17 ptの行送りと比べるだけで、リスト項目の間の7 ptを数えに入れないためです。

#3 · 見出しのデザインで活動に番号を振る

script.js · 78–92行コード全体で見る
const DISC_H2 = 8.6; // mm: the disc and the title are this tall, so the heading reserves this
const numberDisc = { kind: 'text', id: 'num', content: '{number}', // numberingTemplate '{2}'
  fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 800, color: col('paper'),
  box: { backgroundColor: col('leaf'), borderRadius: mm(DISC_H2 / 2) }, // centred both ways
  placement: { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(DISC_H2), height: mm(DISC_H2) } } };
const activityTitle = { kind: 'text', id: 'title', content: '{titleText}',
  fontFamily: DISPLAY, fontSize: pt(17), fontWeight: 700, color: col('ink'), align: 'left',
  overflow: 'wrap', placement: { anchor: { to: '#num', edge: 'right-of' },
    offset: { x: mm(3) }, size: { width: mm(140), height: mm(DISC_H2) } } };
const activity = { enabled: true, slot: { elements: [numberDisc, activityTitle] } };
// ## How did I do? {style="review"} is not an activity: it has no number and a star for a disc.
const review = { id: 'review', numbered: false, advancedDesign: { ...activity, slot: {
  elements: [{ kind: 'image', id: 'num', resourceId: 'star', placement: numberDisc.placement },
    activityTitle] } } };

advancedDesignを持つレベル2の見出しは段の中の位置を保ち、テキストの代わりにデザインが描かれます。レベルのnumberingTemplateを刷る{number}を入れた円と、その右の{titleText}です(幅と詳細デザイン)。円とタイトルはどちらも高さ8.6 mmで、グリッドから外した見出しはちょうどその高さを確保するので、デザインにminHeightは要りません。番号はレベル2のカウンターから取るので、Markdownに活動を挿入すると、その後の活動の番号が振り直されます。自己評価の見出しはnumbered: falseのスタイルを使って番号の対象から外し、円の位置にSVGの星を置きます。

#4 · 表のセルに絵を入れる

script.js · 130–146行コード全体で見る
const tableStyles = [{ id: 'match', borderRadius: mm(3), // no header row, so no header fill
  headerBackgroundEnabled: false, bodyBackgroundEnabled: true, bodyBackground: col('paper'),
  bodyFontFamily: LABEL, bodyFontSize: pt(13), bodyColor: col('ink'), cellPadding: mm(1.4),
  // White rules on the white panel: unseen, but they hide the canvas's seams between cells.
  rules: 'grid', borderColor: col('paper'), borderWidth: pt(1) }];
const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra });
const dotCell = () => cell(`:chip[${EMPTY}]{style="dot"}`, { align: 'center' });
const matchRow = (icon, food, part) => [cell('', { image: { resourceId: icon, width: 0.76 },
  align: 'center' }), cell(food), dotCell(), cell(''), dotCell(),
cell(`:chip[${part}]{style="word"}`)];
const sheetType = { id: 'sheet', name: 'Worksheet item', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // no caption, no number
// Set 'here' in its card, after a :::space (gotcha: box-embed-no-gap); built once FOODS exist.
const foods = () => ({ id: 'foods', typeId: 'sheet', kind: 'table', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' }, table: { styleId: 'match', model: {
    columnWidths: [0.14, 0.25, 0.06, 0.31, 0.06, 0.18], // the widest gap: room to draw a line
    rows: FOODS.map((name, r) => matchRow(ICONS[r], name, PARTS[r])) } } });

TableCell.imageはリソースをセルの中に描きます。ここではセル幅の76 %で描き、行はそれに合わせて高くなります。verticalAlign: 'middle'で、食べ物の名前、点、チップを絵に対して中央にそろえます。表の31 %を占めるいちばん広い列は、児童が線を引くために空けておきます。名前付きのスタイルは表の枠の角を丸め、白い本文の塗りをその形で切り抜きます。グリッドの罫も白です。Canvasは塗ったセルの間にかすかな細線を残しますが、白いパネルの上の白い1 ptの罫がそれを覆います(名前付きの表スタイル)。リソースの種類のcaptionPrefixは空で、表にはキャプションのテキストもないので、表の下には何も刷られません。placement: { position: 'here' }は、表を::resourceの行がある位置、つまりカードの中に置きます。

レシピの全体

Sandbox
// ═══ Postext Cookbook · Nº 022 · Worksheet with answer boxes and a word bank ══════
// https://postext.dev/en/cookbook/worksheet-answer-boxes
// Code: MIT · Text: original (CC BY 4.0) · Plant: diffusion models · Icons: drawn in code
// Fonts: Andika, Baloo 2, Fredoka (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'worksheet-answer-boxes';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#243040', muted: '#5d6975', paper: '#ffffff', // type; prompts; boxes
  leaf: '#2f7d4a', sun: '#f4b43a', soil: '#8a5a36', // the accent; words to pick; drawings
  tint: '#e5f1e7', rule: '#a9c9b1' }; // the activity cards; answer-box outlines
// Design elements read the hex and ignore the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the leaf, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.leaf })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Andika', 'Baloo 2', 'Fredoka']; // Andika: for early readers
const [TOP, SIDE] = [18, 16]; // mm; a sheet printed one-sided, so the margins do not mirror

// #region answer: cards that hold white answer boxes, stacked exactly, off the grid
// In the Markdown a box inside a box is a fence inside a fence (each ::: closes one):
//   :::callout{type="card"}
//   What is inside the pea pods?
//   :::callout{type="answer"}
//   Write here               ← a prompt first: a box of :::space alone collapses
//   :::space{lines=3}           (gotcha: space-dropped-box-top)
//   :::
//   :::
const [ASK, AFTER, HEAD, NEXT] = [2, 5, 3, 9]; // mm: question→box, box→next, heading→card
const card = {
  id: 'card', background: col('tint'), borderRadius: mm(4),
  padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
  marginTop: mm(HEAD), marginBottom: mm(NEXT), // NEXT: under a card, to whatever follows
  // Off the grid, or what follows a card drops to the next grid line. Only the outer card can
  // set it: a nested box ignores snapToGrid, span and placement (gotcha: nested-callout-limits).
  snapToGrid: false,
  body: { paragraphSpacing: false }, // the boxes' margins do all the spacing
  lists: { bulletChar: '', // no bullet: in the checklist a circle chip opens each item
    gap: mm(2), itemSpacing: pt(7) }, columnGap: mm(6),
};
// A nested box's margins collapse with its neighbours', the larger winning (ASK, AFTER); the
// last box's marginBottom falls inside the card's padding instead of adding to it.
const nested = (id, look) => ({ id, background: col('paper'), borderRadius: mm(2.5),
  marginTop: mm(ASK), marginBottom: mm(AFTER), ...look });
const answer = nested('answer', { border: { enabled: true, color: col('rule'), width: pt(0.75) },
  padding: { top: mm(1.6), right: mm(3), bottom: mm(2), left: mm(3) },
  // A small grey prompt on a full 17 pt line; :::space counts in these body lines.
  body: { fontFamily: LABEL, fontSize: pt(8), color: col('muted') } });
const bank = nested('bank', { title: t({ en: 'Word bank', es: 'Banco de palabras' }),
  padding: { top: mm(2.2), right: mm(3), bottom: mm(2.4), left: mm(3) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('leaf'),
    textTransform: 'uppercase', letterSpacing: pt(1.3), gap: mm(1.6) } });
// config() plugs in all three: calloutStyles: [card, answer, bank].
// #endregion

// #region chips: words to pick, blanks to fill, dots to join, circles to colour
const chip = (id, fill, look) => ({ id, fontFamily: LABEL, background: col(fill),
  borderWidth: pt(0), borderColor: col('leaf'), borderRadius: em(1), ...look });
// An empty chip holds a U+2060 word joiner, as one of spaces prints its markup (gotcha:
// empty-chip). Its box is a band 1.25 em tall, so paddingX ROUND makes it a circle.
const [EMPTY, ROUND, OUTLINE] = ['\u2060', em(0.625), pt(0.9)];
// A blank is all padding, 2 × paddingX wide and 19.6 pt tall: taller than the 17 pt line, so
// the card's itemSpacing sets its items 24 pt apart (gotcha: chip-overlap).
const blank = (id, width) => chip(id, 'paper', { borderWidth: OUTLINE, borderRadius: mm(1.4),
  paddingX: mm(width / 2), paddingY: em(0.12), fontSize: em(1.15) });
const chipStyles = [
  chip('word', 'sun', { bold: true, paddingX: em(0.6), paddingY: em(0.08), gap: em(0.45) }),
  blank('blank', 34), blank('wide', 38), // mm: in a sentence; alone in a column
  chip('dot', 'leaf', { paddingX: ROUND, fontSize: em(0.6) }),
  chip('tick', 'paper', { borderWidth: OUTLINE, paddingX: ROUND, fontSize: em(1.1), gap: em(0.5) }),
];
// #endregion

// #region activity: an in-column H2 design: the automatic number in a green disc
const DISC_H2 = 8.6; // mm: the disc and the title are this tall, so the heading reserves this
const numberDisc = { kind: 'text', id: 'num', content: '{number}', // numberingTemplate '{2}'
  fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 800, color: col('paper'),
  box: { backgroundColor: col('leaf'), borderRadius: mm(DISC_H2 / 2) }, // centred both ways
  placement: { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(DISC_H2), height: mm(DISC_H2) } } };
const activityTitle = { kind: 'text', id: 'title', content: '{titleText}',
  fontFamily: DISPLAY, fontSize: pt(17), fontWeight: 700, color: col('ink'), align: 'left',
  overflow: 'wrap', placement: { anchor: { to: '#num', edge: 'right-of' },
    offset: { x: mm(3) }, size: { width: mm(140), height: mm(DISC_H2) } } };
const activity = { enabled: true, slot: { elements: [numberDisc, activityTitle] } };
// ## How did I do? {style="review"} is not an activity: it has no number and a star for a disc.
const review = { id: 'review', numbered: false, advancedDesign: { ...activity, slot: {
  elements: [{ kind: 'image', id: 'num', resourceId: 'star', placement: numberDisc.placement },
    activityTitle] } } };
// #endregion

const [BAND, AIR, DOT] = [76, 5, 6]; // mm: the opener's band, air under the disc, dot size
const DISC = { x: 108, y: 5, d: 78 }; // mm: the plant's disc on the page
const EDGE = BAND - DISC.y; // mm down the disc: the band's edge, where its two pictures meet
const at = (x, y, size, edge = 'top-left') => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) }, size });
const words = (id, content, family, size, weight, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), fontWeight: weight, color: col('paper'),
  align: 'left', overflow: 'wrap', placement, ...extra });
const tag = { textTransform: 'uppercase', letterSpacing: pt(1.3) };
const dot = ([n, x, y]) => words(`dot${n}`, String(n), DISPLAY, 10.5, 800, at(DISC.x + x - DOT / 2,
  DISC.y + y - DOT / 2, { width: mm(DOT), height: mm(DOT) }), { color: col('ink'), align: 'center',
  box: { backgroundColor: col('sun'), borderRadius: mm(DOT / 2) } }); // a yellow disc per number
const field = (id, label, x, w) => [words(`${id}-label`, label, LABEL, 7.5, 600, at(x, 59), tag),
  { kind: 'box', id, style: { backgroundColor: col('paper'), borderRadius: mm(1.6) },
    placement: at(x, 63, { width: mm(w), height: mm(7.5) }) }]; // a label over a white field
// Images reserve no height in an opener (gotcha: opener-image-no-reserve), so minHeight does.
const opener = () => ({ enabled: true, minHeight: mm(DISC.y + DISC.d + AIR - TOP),
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('leaf') },
      placement: at(0, 0, { height: mm(BAND) }) },
    ...[['plant', 0, EDGE], ['plant-foot', EDGE, DISC.d - EDGE], ['leaders', 0, DISC.d]]
      .map(([id, y, h]) => ({ kind: 'image', id, resourceId: id, // the leaders last, on top
        placement: at(DISC.x, DISC.y + y, { width: mm(DISC.d), height: mm(h) }) })),
    ...LABELS.map(dot),
    words('unit', '{attr.unit}', LABEL, 9, 600, at(SIDE, TOP - 4), { ...tag, color: col('ink'),
      box: { backgroundColor: col('sun'), borderRadius: mm(3),
        padding: { top: mm(1), right: mm(2.6), bottom: mm(1), left: mm(2.6) } } }),
    words('title', '{titleText}', DISPLAY, 40, 800, at(SIDE, 23.5, { width: mm(88) }),
      { lineHeight: 0.98 }), // two lines: the headings break with \\
    words('series', '{title} · {subtitle} · {attr.sheet}', LABEL, 9.5, 500, at(SIDE, 53)),
    ...field('name', t({ en: 'Name', es: 'Nombre' }), SIDE, 58),
    ...field('date', t({ en: 'Date', es: 'Fecha' }), SIDE + 62, 22),
  ] } });

// #region tables: a rounded panel with a picture in each row and chips to join
const tableStyles = [{ id: 'match', borderRadius: mm(3), // no header row, so no header fill
  headerBackgroundEnabled: false, bodyBackgroundEnabled: true, bodyBackground: col('paper'),
  bodyFontFamily: LABEL, bodyFontSize: pt(13), bodyColor: col('ink'), cellPadding: mm(1.4),
  // White rules on the white panel: unseen, but they hide the canvas's seams between cells.
  rules: 'grid', borderColor: col('paper'), borderWidth: pt(1) }];
const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra });
const dotCell = () => cell(`:chip[${EMPTY}]{style="dot"}`, { align: 'center' });
const matchRow = (icon, food, part) => [cell('', { image: { resourceId: icon, width: 0.76 },
  align: 'center' }), cell(food), dotCell(), cell(''), dotCell(),
cell(`:chip[${part}]{style="word"}`)];
const sheetType = { id: 'sheet', name: 'Worksheet item', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // no caption, no number
// Set 'here' in its card, after a :::space (gotcha: box-embed-no-gap); built once FOODS exist.
const foods = () => ({ id: 'foods', typeId: 'sheet', kind: 'table', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' }, table: { styleId: 'match', model: {
    columnWidths: [0.14, 0.25, 0.06, 0.31, 0.06, 0.18], // the widest gap: room to draw a line
    rows: FOODS.map((name, r) => matchRow(ICONS[r], name, PARTS[r])) } } });
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales)
  resourceTypes: [sheetType], colorPalette, chipStyles, tableStyles, header: { elements: [] },
  footer: { elements: [words('foot', '{title} · {subtitle} · {attr.unit} · {pageNumber}', LABEL,
    7.8, 600, at(SIDE, -11, undefined, 'bottom-left'), { ...tag, color: col('muted') })] },
  page: { width: mm(200), height: mm(260), dpi: 150, margins: { top: mm(TOP), bottom: mm(20),
    left: mm(SIDE), right: mm(SIDE) } }, layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  // #region grid: the headings leave the grid too, and nothing stretches the gaps
  headings: { fontFamily: DISPLAY, snapToGrid: false, // or what follows snaps back to the grid
    balancing: { enabled: false }, // no extra space above headings to fill out a page
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener() },
      { level: 2, numberingTemplate: '{2}', marginTop: mm(0), marginBottom: mm(0),
        advancedDesign: activity },
    ] },
  // #endregion
  orderedLists: { fontFamily: DISPLAY, fontWeight: 800, marginTop: mm(ASK), // a bank's AFTER wins
    numberVerticalOffset: pt(1.7) }, // centred 0.3 em over the baseline, Baloo 2 sat high
  headingStyles: [review], calloutStyles: [card, answer, bank],
  paragraphStyles: [{ id: 'lead', fontSize: pt(14), lineHeight: pt(20) }, { id: 'colophon',
    fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(10), color: col('muted') }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdownの見本 · 119行 · content.en.mdtitle: "Seedlings Science" subtitle: "Year 4" --- # Plants and \\ their parts {unit="Unit 3" sheet="Worksheet 3A"} :::paragraphs{style="lead"} Every part of a plant has a job to do. In this worksheet you will name the parts of a pea plant and find out what each one does and which ones we eat. You will need a pencil and some coloured pencils. ::: ## Look closely :::callout{type="card"} What is inside the pea pods on the plant at the top of the page? :::callout{type="answer"} Write here :::space{lines=4} ::: Which part of the plant grows under the ground? Why does the plant need it? :::callout{type="answer"} Write here :::space{lines=6} ::: ::: ## Name the parts :::callout{type="card"} Six parts of the pea plant on page 1 have a number. Choose a word from the word bank and write it next to the same number. :::callout{type="bank"} :chip[stem]{style="word"} :chip[seeds]{style="word"} :chip[flower]{style="word"} :chip[roots]{style="word"} :chip[leaf]{style="word"} :chip[fruit]{style="word"} ::: :::columns{count=3} 1. :chip[⁠]{style="wide"} 2. :chip[⁠]{style="wide"} 3. :chip[⁠]{style="wide"} 4. :chip[⁠]{style="wide"} 5. :chip[⁠]{style="wide"} 6. :chip[⁠]{style="wide"} ::: ::: ## What does each part do? :::callout{type="card"} Finish each sentence with a word from the word bank. :::callout{type="bank"} :chip[food]{style="word"} :chip[insects]{style="word"} :chip[water]{style="word"} :chip[plant]{style="word"} :chip[upright]{style="word"} :chip[seeds]{style="word"} ::: 1. The **roots** hold the plant in the soil and take in :chip[⁠]{style="blank"}. 2. The **stem** keeps the plant :chip[⁠]{style="blank"} and carries water to the leaves. 3. The **leaves** use sunlight to make :chip[⁠]{style="blank"} for the plant. 4. The **flower** has bright petals that attract :chip[⁠]{style="blank"} such as bees. 5. The **fruit** grows around the :chip[⁠]{style="blank"} and keeps them safe. 6. A **seed** can grow into a new :chip[⁠]{style="blank"}. ::: **Did you know?** A pea plant cannot stand up on its own. It climbs by wrapping thin, curly tendrils around anything it touches. ## Parts we eat :::callout{type="card"} We eat many different plant parts. Draw a line from each food to the part of the plant it comes from. :::space{lines=0.33} ::resource{id="foods"} ::: ## Draw and label :::callout{type="card"} Draw a plant you have seen at home or at school. Label at least four of its parts. :::callout{type="answer"} My plant is called :::space{lines=7} ::: ::: ## Try it at home :::callout{type="card"} Put a dried pea on some wet cotton wool in a clear jar. Keep the cotton wool damp and look at the pea every day for a week. What do you think will come out of the pea first: a root or a shoot? :::callout{type="answer"} I think :::space{lines=3} ::: After a week, draw or write what you saw. :::callout{type="answer"} I saw :::space{lines=6} ::: ::: ## How did I do? {style="review"} :::callout{type="card"} Colour a circle for each thing you can do. - :chip[⁠]{style="tick"} I can name the six parts of a plant. - :chip[⁠]{style="tick"} I can say what each part does. - :chip[⁠]{style="tick"} I can tell which part of a plant a food comes from. ::: :::paragraphs{style="colophon"} Seedlings Science is an invented series made for the Postext Cookbook. Text: CC BY 4.0. Plant: diffusion models. Set in Andika, Baloo 2 and Fredoka (SIL Open Font License). :::
`; // content.<lang>.md, inlined by the Cookbook // The matching table's words. The parts are shuffled so that no food faces its own part. const FOODS = t({ en: 'carrot|asparagus|lettuce|broccoli|orange|kidney beans', es: 'zanahoria|espárrago|lechuga|brócoli|naranja|alubias' }).split('|'); const PARTS = t({ en: 'leaf|seeds|root|fruit|stem|flower', es: 'hoja|semillas|raíz|fruto|tallo|flor' }).split('|'); // #region art: the plant's leader lines, the six foods and the star, in the palette's colours // The pea plant is a watercolour already set in its disc, in two JPEGs that meet at the band's // edge: the top one's corners carry the band's green, the foot's are white. One picture would // resample its green-to-white step and print a pale line along the band's edge. No words in // the drawings: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts); the // plant's numbers are design elements set in Baloo 2 over the picture (see the band). // [number, x, y] in the disc's millimetres: each numbered dot, where its leader line starts. const LABELS = [[1, 58, 8], [2, 68, 33], [3, 9, 44], [4, 13, 20], [5, 62, 52], [6, 18, 66]]; const n = (v) => +v.toFixed(2); const svgDoc = (w, h, body, scale = 10) => `<svg xmlns="http://www.w3.org/2000/svg" ` + `width="${w * scale}" height="${h * scale}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" ` + `fill="${fill}"${extra}/>`; const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const stroke = (d, color, width) => path(d, 'none', ` stroke="${color}" stroke-width="${width}" ` + 'stroke-linecap="round" stroke-linejoin="round"'); const leafShape = (x, y, len, wid, turn, fill) => `<g transform="translate(${n(x)} ${n(y)}) ` + `rotate(${n(turn)})">${path(`M0 0C${n(len * 0.3)} ${n(-wid)} ${n(len * 0.8)} ${n(-wid)} ` + `${n(len)} 0C${n(len * 0.8)} ${n(wid)} ${n(len * 0.3)} ${n(wid)} 0 0Z`, fill)}</g>`; function leaders() { // from each dot to its part, in ink on a white halo that clears the soil // 1 a flower, 2 the closed pod's wall (the fruit), 3 a pea in the open pod (the seeds), // 4 a leaf's blade, 5 the stem between two leaves, 6 the taproot. const targets = { 1: [45.5, 11.5], 2: [50, 34], 3: [28.4, 33.2], 4: [34.6, 16.4], 5: [38.8, 52], 6: [38.4, 64] }; const lines = LABELS.map(([num, x, y]) => `M${x} ${y}L${targets[num].join(' ')}`).join(''); return svgDoc(78, 78, stroke(lines, palette.paper, 0.9) + stroke(lines, palette.ink, 0.35) + Object.values(targets).map(([x, y]) => circle(x, y, 0.75, palette.ink, ` stroke="${palette.paper}" stroke-width=".3"`)).join('')); } function star() { // the self-check's badge: a white star on a sun disc const pts = Array.from({ length: 10 }, (_, i) => { const a = -Math.PI / 2 + (i * Math.PI) / 5; const rr = i % 2 ? 4.4 : 9.4; return `${n(12 + Math.cos(a) * rr)} ${n(12.6 + Math.sin(a) * rr)}`; }); return svgDoc(24, 24, circle(12, 12, 12, palette.sun) + path(`M${pts.join('L')}Z`, palette.paper), 10); } const food = { carrot: () => path('M3.5 17.5C8 13 12 8.5 16.2 5.2C18 4 20.6 6.4 19.2 8.2C15.8 12.4 10 15.8 ' + '3.5 17.5Z', palette.sun) + stroke('M8 13.6l1.4 1.2M11.6 10.6l1.3 1.3M14.6 8l1.2 1.2', palette.soil, 0.4) + [-120, -80, -40].map((a) => leafShape(18.4, 6.4, 6, 1.4, a, palette.leaf)).join(''), asparagus: () => [[10, 7], [12, 12], [14, 17]].map(([x0, x1]) => path(`M${x0 - 1.3} 19` + `L${x1 - 1} 5Q${x1} 1.4 ${x1 + 1} 5L${x0 + 1.3} 19Z`, palette.leaf) + stroke(`M${x1 - 0.8} ` + `7.6l.8.8.8-.8M${(x0 + x1) / 2 - 0.9} 11.4l.9.9.9-.9`, palette.tint, 0.35)).join('') + path('M7.8 13.4h8.4v2.2H7.8Z', palette.sun), lettuce: () => { // a head seen from the side: two dark outer leaves cupping a pale heart const side = path('M13 19C6 19 2.6 15 3 9.6C3.2 7.4 4.6 6 5.8 7C6.2 5.4 7.8 4.8 8.6 6.2' + 'C9.4 5.2 10.6 5.6 10.6 6.8C9 10 9.4 15 13 19Z', palette.leaf) + stroke('M11 17.6C7.6 16 5.2 12.6 5.2 8.6', palette.tint, 0.4); // a frilled leaf, its vein return `<ellipse cx="12" cy="11.4" rx="5.8" ry="6.8" fill="${palette.rule}"/>` + stroke('M12 17.6C11.6 14 11.8 10 12.8 6', palette.tint, 0.45) + side + `<g transform="translate(24 0) scale(-1 1)">${side}</g>`; }, broccoli: () => path('M10 19l1-7h2l1 7Z', palette.rule) + [[8, 9, 3.4], [12, 7, 3.8], [16, 9, 3.4], [10, 11.5, 2.6], [14, 11.5, 2.6]].map(([x, y, rr]) => circle(x, y, rr, palette.leaf)).join(''), orange: () => circle(12, 11.5, 7.5, palette.sun) + leafShape(12, 4.2, 5, 1.6, -30, palette.leaf) + circle(9, 9, 1.2, palette.paper, ' fill-opacity=".45"'), beans: () => [[7, 13, -20], [13, 9, 15], [16, 15, -35]].map(([x, y, a]) => `<g transform=` + `"translate(${x} ${y}) rotate(${a})">${path('M-4 0C-4 -3 -1 -3 0 -1.5C1 -3 4 -3 4 0' + 'C4 3 -4 3 -4 0Z', palette.soil)}</g>`).join(''), }; const ICONS = Object.keys(food); // carrot … beans: the matching table's rows, in order const drawings = { leaders, star, ...Object.fromEntries(Object.entries(food).map(([id, draw]) => [id, () => svgDoc(24, 20, draw())])) }; const ALT = { ...Object.fromEntries(ICONS.map((id, i) => [id, FOODS[i]])), plant: t({ en: 'A pea plant with its flower, pod, peas, leaf, stem and roots numbered 1 to 6', es: 'Una planta de guisante con la flor, la vaina, los guisantes, la hoja, el tallo y las ' + 'raíces numerados del 1 al 6' }), leaders: '', star: t({ en: 'A white star on a yellow disc', es: 'Una estrella blanca en un disco amarillo' }) }; // Each picture is a resource that the opener, a table cell or the review heading names by // id. None is cited, so none is placed as a figure. Drawings in px, 10 to the millimetre. const pictures = Object.keys(drawings).map((id) => ({ id, typeId: 'sheet', kind: 'svg', altText: ALT[id], createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: 240, height: 200, ...{ leaders: { width: 780, height: 780 }, star: { width: 240, height: 240 } }[id] } })); // The plant's two slices, 14 px to the millimetre (356 dpi): 78 × 71 mm and 78 × 7 mm. pictures.push(...[['plant', 994], ['plant-foot', 98]].map(([id, h]) => ({ id, typeId: 'sheet', kind: 'bitmap', altText: id === 'plant' ? ALT.plant : '', createdAt: 0, updatedAt: 0, bitmap: { fileId: `${id}-1092.jpg`, format: 'jpeg', width: 1092, height: h } }))); // #endregion const resources = [...pictures, foods()]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Andika: ['400', '700'], 'Baloo 2': ['700', '800'], // (gotcha: fonts-first) Fredoka: ['400', '500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([loadFonts(FONTS, markdown), ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw())), ...['plant-1092.jpg', 'plant-foot-1092.jpg'].map((file) => loadImage(file, asset(file)))]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Plants and their parts', es: 'Las plantas y sus partes' }) });
キット · 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上のレシピのフォルダー ↗ (新しいタブで開きます)

アレンジ

#カードをグリッドに戻す

既定のsnapToGrid: trueでは、カードの後に続くものが次の17 ptのグリッド行まで下がり、各カードの下の9 mmは、活動2の下で14.5 mm、活動4の下で13.2 mm、活動6の下で11.2 mmになります。

-  snapToGrid: false,
+  snapToGrid: true,

#解答の場所を広げる

:::spaceは解答欄自身の17 ptの行で数えます。1行足すごとに、児童が書ける場所が6 mm増えます。

 Write here
-:::space{lines=4}
+:::space{lines=6}

よくあるつまずき

つまずき

:::spaceは囲みや段の頭では捨てられる

:::spaceは段、囲み、:::columnsグループの頭では、それが囲みの唯一の中身であっても捨てられます。そのため、空きだけで作った解答欄の囲みはつぶれてしまいます。囲みはタイトルか設問の行で始め、そのあとに空きを入れてください。 明示的な縦のアキ →

つまずき

空白だけのチップはマークアップがそのまま出る

テキストが空白(ノーブレークスペースを含む)だけのチップは、:chip[ ]がそのまま印字されます。空の解答用チップにはワードジョイナー(U+2060)を入れてください。 インラインのチップ →

つまずき

入れ子の囲みはspan、placement、snapToGridを無視する

ほかの囲みの中に入れ子にした囲みは、自分のspan、placement、snapToGrid、floatBarrierを無視し、常に親の内側の幅で親の中を流れます。 入れ子の囲み →

つまずき

行送りより高いチップは次の行に触れる

箱が行送りより高いチップは、次の行のチップに触れます(chipOverlap警告)。上下のパディング、枠線、サイズを小さくするか、行送りを広げてください。 インラインのチップ →

つまずき

囲みの中の表や図には前後の空きがない

postext 1.4.1では、::resourceで位置'here'に置いた表や図は、:::calloutの中では本文中で取る空きをまったく取らず、上下の段落に接します。::resourceの行の前に:::spaceを置き、あとにテキストが続くときはもう1つ置いてください。lines=0.33のように1行の端数を指定すれば、小さな空きになります。 その場に置く図 →

つまずき

章扉の画像は確保する高さに数えられない

postext 1.4.1では、詳細デザインの見出しは、確保する高さを画像抜きで計測します。テキスト、罫、箱はページに固定していても数えられますが、ページの頭に裁ち落としで置いた写真のような画像は何も確保しないため、本文がその上から始まることがあります。minHeightを本文が始まるべき位置に設定してください。 デザインした章扉 →

つまずき

headingsオブジェクトを渡すとH1の改ページが消える

既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →

つまずき

SVGの<img>内のテキストはWebフォントを使えない

SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。 リソースとしての図と表 →

つまずき

パレットを差し替えても、デザイン要素と参照色は変わらない

postext 1.4.1はcolorPaletteをテキストのスタイル(本文、見出し、リスト、キャプション、表、囲み)には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。 セマンティックカラーパレット →

つまずき

ハイフネーションできるのは8つのロケールだけで、コードは完全一致

ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。 ハイフネーションと文書の言語 →

つまずき

設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る

エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。 キャンバス上のページ →

つまずき

レイアウトの前にすべてのフォントを読み込む

レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →

Sandboxの検査 · chipOverlap

チップが隣の行に接触

原因. チップスタイルのボックスが行送りより高いため、連続する行のチップどうしが接します。

対処. チップの上下のパディング、輪郭線、サイズを小さくするか、行送りを大きくしてください。 ドキュメント →

  • postext 1.4.1では、囲みのスタイルに罫線入りの背景がないため、囲みが自分の中に記入用の罫線を引けません。解答欄は無地のまま:::spaceで高さを取り、短い記入欄は枠線のチップで作ります。
  • numbered: falseの見出しスタイルも、そのレベルのデザインを描き、{number}が空のまま残ります。専用のデザインがなければ、自己評価は空の緑の円で始まってしまいます。そこでreviewは活動のデザインを複製し、円があった位置に星を置きます。

クレジット

レシピ
Ignacio Ferro
本文
書き下ろしの文章, CC BY 4.0
画像
  • The pea plant on the opener, a watercolour (top of the disc) · Generated With Diffusion Models · オリジナル
  • The pea plant’s roots, the foot of the same watercolour below the band · Generated With Diffusion Models · オリジナル
  • The plant's leader lines, the six food icons and the star, drawn in code in the page’s palette · Ignacio Ferro · MIT
フォント
Andika (SIL OFL 1.1) · Baloo 2 (SIL OFL 1.1) · Fredoka (SIL OFL 1.1)
Sandbox