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

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

文章に合わせて囲みが伸びるワークブック

ページテンプレートで組む理科のワークブック。スタックの囲みは文章に合わせて伸び、解答罫は空のフレームに引き、収まらない工作は追加ページに続きます。

このページの内容

1ページ(全5ページ)

  • 英語の見本:日本語版はまだありません
  • 仕上がり210 × 280 mm
  • 1段
  • Andika 11.5/16
  • Baloo 2
  • Lexend
  • 5ページ
  • レベル
  • Postext 1.38.0
  • 組版時間95 ms
  • コード217行

かんたんな説明

水をテーマにした子ども向け理科ワークブックの5ページ。問題はそれぞれ色の付いた囲みに入り、下に答えを書く罫線があります。問題が長いと囲みが伸び、下のものを押し下げます。長い工作は次のページに続きます。

できあがり

架空の小学4年生向け理科ワークブック『Water on the move』の単元3から5ページを、210 × 280 mm で組みます。表紙は水の循環を描いたベタ塗りの絵が裁ち落としまで広がり、その下に 54 pt の書名と名前を書く罫線があります。プリントはどれも同じページテンプレートから組みます。単元名の行、青い帯の見出し、絵を添えた短い本文、番号のタブが付いた水色の活動の囲みが三つ。一つめと三つめの囲みの下には答えを書く罫線があります。囲みの高さは中の文章で決まるので、あちこちで1行ずつ長くなるフランス語版では、テンプレートの位置を何も変えなくても英語版より罫線が下に来ます。工作はペットボトルで雨量計を作るもので、手順が1ページに収まりません。手順の囲みはページの下端で Continued を付けて切れ、追加されたページの上で続き、そのあとに観察を書く6本の罫線が続きます。タブや観察欄の見出しを含め、文字はすべて Markdown に書きます。

このレシピが答える質問

  • ページの囲みを文章に合わせて伸ばし、下の囲みを押し下げるには?
  • 収まらない活動を「次ページへ続く」の印を付けて次のページに続けるには?
  • 記入線、解答欄、語群、チェックリストのあるワークシートのページを作るには?

手短な答え

script.js · 50–76行コード全体で見る
// An empty frame is an answer space: the Markdown sends it nothing (optional: no slotEmpty).
// Its `rules` draw a line at the foot of every LINE mm of its height, so the lines are part
// of the frame and move with it when a box above grows. minHeight too: a box that runs out of
// room may not take the lines away from the child.
const answer = (name, lines) => ({ name, optional: true, width: mm(W), height: mm(LINE * lines),
  minHeight: mm(LINE * lines),
  rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A box is as tall as its text, never less than drawn: a longer text makes it taller and
// pushes everything after it in the group down the page.
const box = (name, style, minHeight) => ({ name, style, width: mm(W), minHeight: mm(minHeight) });
const sheet = {
  id: 'ficha',
  decoration: { elements: [kicker] },
  frames: [
    ...opening,
    // The group starts where its first box is placed: under the text, and runs down from there.
    { ...box('act-1', 'actividad', 22), placement: at('#texto', 'below', 0, 6) },
    answer('lineas-1', 3),
    box('act-2', 'actividad', 22),
    // The last box goes on overleaf, with the answer space after it, when its text outgrows
    // the page; the other two push it down instead.
    { ...box('act-3', 'actividad', 22), overflow: { continueIn: 'next-page' } },
    answer('lineas-3', 2),
  ],
  groups: [{ name: 'actividades', kind: 'stack', gap: mm(4.5),
    frames: ['act-1', 'lineas-1', 'act-2', 'act-3', 'lineas-3'] }],
};

材料

種類
Andika, Baloo 2, Lexend (SIL OFL 1.1)
素材
なし:図はすべてコードで描画

作り方

#1 · 活動を名前で自分の囲みに流し込む

テンプレートは上の手短な答えです。Markdown では、プリント1枚が一つの ::::page フェンスです。最初の見出しは takes: 'h1' のフレームへ、段落はフローのフレームへ、:::frame{slot="…"} はそれぞれ同じ名前のフレームへ入り、タブの文字は label に書きます。解答のフレームには何も入らないので optional: true を付けます。付けないと空のフレームが slotEmpty の警告を出します。絵を描く活動は :::space{lines=3} で自分の囲みの中に白い場所を取ります。どれだけの場所が要るかは、問題の一部だからです。

:::frame{slot="act-1" label="1 · Read and answer"}
Tom lives in Leeds. The water from his tap comes from a reservoir in the hills…
:::

#2 · 罫線を囲みと一緒にスタックで動かす

囲みにはどれも描いたときの高さ 22 mm を minHeight に指定し、それより上は auto の高さにします。グループは、本文の下に置いた act-1 の位置から5つのフレームを 4.5 mm 間隔で縦に並べるので、ある囲みが伸びるとその後ろがすべて下がります。罫線は空のフレームそのものに属します。その rules が、フレームの高さ 8 mm ごとの下端、文字の下に1本ずつ線を引くので、罫線はフレームと一緒に動き、解答欄を長くすればフレームが高くなります。解答のフレームには高さと同じ minHeight を指定してください。グループの場所が足りなくなると、リフローの段階はグループのフレームから最小値まで場所を取り戻しますが、空のフレームの最小値はゼロなので、罫線が次の囲みに重なってしまいます。ワークシートの最後の囲み act-3 は、文章がページに収まらないと次のページへ続き(overflow.continueIn: 'next-page')、その下の解答の罫線も一緒に移ります。

#3 · 工作を次のページへ続ける

script.js · 80–107行コード全体で見る
const project = {
  id: 'proyecto',
  overflowTemplate: 'proyecto-sigue',
  decoration: { elements: [kicker] },
  frames: [
    ...opening,
    { ...box('material', 'material', 20), placement: at('#texto', 'below', 0, 9) },
    // As tall as its text, never past the foot of the page, at full size: the rest ends
    // with the language's mark (Continúa, Continued, À suivre) and goes on on an added page.
    { name: 'pasos', style: 'proyecto', width: mm(W), overflow: { continueIn: 'next-page' } },
  ],
  groups: [{ name: 'trabajo', kind: 'stack', gap: mm(9), frames: ['material', 'pasos'] }],
};
// The added page: the rest of the steps at the head, then lines for the child's notes,
// under a heading the page fence names (notas="…").
const projectGoesOn = {
  id: 'proyecto-sigue',
  decoration: { elements: [kicker,
    { kind: 'text', id: 'notas-titulo', content: '{attr.notas}', fontFamily: DISPLAY,
      fontSize: pt(15), fontWeight: 700, color: col('sea'),
      placement: { ...at('#notas', 'top-left', 0, -3), size: { width: mm(W) } } }] },
  frames: [
    { name: 'pasos', style: 'proyecto', width: mm(W),
      placement: at('container', 'top-left', 0, 9) },
    answer('notas', 6),
  ],
  groups: [{ name: 'trabajo', kind: 'stack', gap: mm(16), frames: ['pasos', 'notas'] }],
};

手順の囲みには決まった高さがなく、文章に合わせて伸び、版面の下端で止まります。別のページへ続けられるフレームは、文字を縮める前に続きへ送ります。そのため囲みは本来の文字サイズのまま行と行のあいだで切れ、言語ごとの続きの印(locale に応じて Continúa、Continued、À suivre)で終わり、proyecto-sigue のページの同じ名前のフレームで続きます(文章が追加ページに続くとき)。追加されたページは囲みの続きの下に観察の罫線を引き、見出しにはフェンスの notas="What I noticed" の文字を使います。文字は Markdown に残り、版ごとに変わります。

#4 · 表紙を裁ち落としまで描く

script.js · 111–128行コード全体で見る
const cover = {
  id: 'portada', header: false, footer: false,
  decoration: { elements: [
    { kind: 'image', id: 'cielo', resourceId: 'portada', decorative: true,
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } },
    { kind: 'rule', id: 'nombre-linea', direction: 'horizontal', thickness: pt(1),
      color: col('sea'), placement: { ...at('#nombre', 'bottom-left', 0, 1),
        size: { width: mm(W) } } },
  ] },
  frames: [
    { name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left', 0, 162) },
    { name: 'subtitulo', flow: true, width: mm(W) },
    { name: 'nombre', style: 'campo', width: mm(W), minHeight: mm(12) },
    { name: 'colofon', style: 'colofon', width: mm(W), placement: at('container', 'bottom-left') },
  ],
  groups: [{ name: 'portada', kind: 'stack', gap: mm(4),
    frames: ['titulo', 'subtitulo', 'nombre'] }],
};

表紙もテンプレートページです。絵は裁ち落としに固定した装飾で、書名、シリーズ名、名前欄が版面の上から 162 mm の位置で始まるスタックになります。名前の罫はフレームの下端に固定してあるので、別の言語でラベルが2行になっても罫は文字の下にあります。header: false と footer: false で表紙にはノンブルを付けません。

レシピの全体

Sandbox
// ═══ Postext Cookbook · Nº 159 · Activity book with boxes that grow ══════════════
// https://postext.dev/en/cookbook/activity-book-growing-boxes
// Code: MIT · Text: original (CC BY 4.0) · Pictures: drawn in code
// Fonts: Andika, Baloo 2, Lexend (SIL OFL 1.1) · Needs postext ≥ 1.38.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es' | 'fr')
const RECIPE = 'activity-book-growing-boxes';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one ink, the water blue, the sun, and the pale fields of the boxes
const palette = {
  ink: '#1f2d38', // text
  sea: '#1d659c', // titles, tabs, list numbers
  sky: '#e2f0f9', // activity boxes, the cover's sky
  sun: '#f0a531', // the project's box, the sun
  leaf: '#4c8f55', // the cover's hills
  sand: '#fcf0d8', // the materials box
  rule: '#93bcd8', // answer lines, blanks
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'sea (defaults)', value: { hex: palette.sea, model: 'hex' } },
];
// #endregion

const [TEXT, DISPLAY, LABEL] = ['Andika', 'Baloo 2', 'Lexend']; // Andika: for early readers
const W = 174; // mm: the width of the type area, and of every box
const LINE = 8; // mm between two answer lines: room for a child's handwriting
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// The head every sheet shares: the unit over the title band, the text with its drawing.
const kicker = { kind: 'text', id: 'tema', content: '{attr.tema}', fontFamily: LABEL,
  fontSize: pt(8.5), fontWeight: 600, color: col('sea'), textTransform: 'uppercase',
  letterSpacing: pt(1.4), placement: at('container', 'top-left') };
const opening = [
  { name: 'titulo', takes: 'h1', style: 'banda', width: mm(W),
    placement: at('container', 'top-left', 0, 6) },
  { name: 'texto', flow: true, width: mm(W - 52), minHeight: mm(36),
    placement: at('#titulo', 'below', 0, 4) },
  { name: 'dibujo', width: mm(44), placement: at('#titulo', 'below', W - 44, 4) },
];

// #region answer: boxes in a stack grow with their text; empty ruled frames hold the answers
// An empty frame is an answer space: the Markdown sends it nothing (optional: no slotEmpty).
// Its `rules` draw a line at the foot of every LINE mm of its height, so the lines are part
// of the frame and move with it when a box above grows. minHeight too: a box that runs out of
// room may not take the lines away from the child.
const answer = (name, lines) => ({ name, optional: true, width: mm(W), height: mm(LINE * lines),
  minHeight: mm(LINE * lines),
  rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A box is as tall as its text, never less than drawn: a longer text makes it taller and
// pushes everything after it in the group down the page.
const box = (name, style, minHeight) => ({ name, style, width: mm(W), minHeight: mm(minHeight) });
const sheet = {
  id: 'ficha',
  decoration: { elements: [kicker] },
  frames: [
    ...opening,
    // The group starts where its first box is placed: under the text, and runs down from there.
    { ...box('act-1', 'actividad', 22), placement: at('#texto', 'below', 0, 6) },
    answer('lineas-1', 3),
    box('act-2', 'actividad', 22),
    // The last box goes on overleaf, with the answer space after it, when its text outgrows
    // the page; the other two push it down instead.
    { ...box('act-3', 'actividad', 22), overflow: { continueIn: 'next-page' } },
    answer('lineas-3', 2),
  ],
  groups: [{ name: 'actividades', kind: 'stack', gap: mm(4.5),
    frames: ['act-1', 'lineas-1', 'act-2', 'act-3', 'lineas-3'] }],
};
// #endregion

// #region continue: the project's steps go on on a page of their own, after their mark
const project = {
  id: 'proyecto',
  overflowTemplate: 'proyecto-sigue',
  decoration: { elements: [kicker] },
  frames: [
    ...opening,
    { ...box('material', 'material', 20), placement: at('#texto', 'below', 0, 9) },
    // As tall as its text, never past the foot of the page, at full size: the rest ends
    // with the language's mark (Continúa, Continued, À suivre) and goes on on an added page.
    { name: 'pasos', style: 'proyecto', width: mm(W), overflow: { continueIn: 'next-page' } },
  ],
  groups: [{ name: 'trabajo', kind: 'stack', gap: mm(9), frames: ['material', 'pasos'] }],
};
// The added page: the rest of the steps at the head, then lines for the child's notes,
// under a heading the page fence names (notas="…").
const projectGoesOn = {
  id: 'proyecto-sigue',
  decoration: { elements: [kicker,
    { kind: 'text', id: 'notas-titulo', content: '{attr.notas}', fontFamily: DISPLAY,
      fontSize: pt(15), fontWeight: 700, color: col('sea'),
      placement: { ...at('#notas', 'top-left', 0, -3), size: { width: mm(W) } } }] },
  frames: [
    { name: 'pasos', style: 'proyecto', width: mm(W),
      placement: at('container', 'top-left', 0, 9) },
    answer('notas', 6),
  ],
  groups: [{ name: 'trabajo', kind: 'stack', gap: mm(16), frames: ['pasos', 'notas'] }],
};
// #endregion

// #region cover: a sky drawn to the bleed, the title, and a line for the child's name
const cover = {
  id: 'portada', header: false, footer: false,
  decoration: { elements: [
    { kind: 'image', id: 'cielo', resourceId: 'portada', decorative: true,
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } },
    { kind: 'rule', id: 'nombre-linea', direction: 'horizontal', thickness: pt(1),
      color: col('sea'), placement: { ...at('#nombre', 'bottom-left', 0, 1),
        size: { width: mm(W) } } },
  ] },
  frames: [
    { name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left', 0, 162) },
    { name: 'subtitulo', flow: true, width: mm(W) },
    { name: 'nombre', style: 'campo', width: mm(W), minHeight: mm(12) },
    { name: 'colofon', style: 'colofon', width: mm(W), placement: at('container', 'bottom-left') },
  ],
  groups: [{ name: 'portada', kind: 'stack', gap: mm(4),
    frames: ['titulo', 'subtitulo', 'nombre'] }],
};
// #endregion

// A box with a tab: the tab's words come from the Markdown (label="…").
const tabbed = (id, fill, tab, tabInk, extra = {}) => ({
  id, backgroundEnabled: true, background: col(fill), borderRadius: mm(3),
  border: { enabled: false },
  padding: { top: mm(3), right: mm(5), bottom: mm(3.5), left: mm(5) },
  label: { position: 'top-left', background: col(tab), color: col(tabInk), fontFamily: LABEL,
    fontSize: pt(9), fontWeight: 600, height: mm(6), paddingX: mm(3), offset: mm(6),
    inset: mm(4), rule: { enabled: false, color: col(tab) } },
  body: { fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(16), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0) },
  ...extra,
});
const plain = (id, size, lead, color, extra = {}) => ({ id, backgroundEnabled: false,
  border: { enabled: false }, padding: { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) },
  body: { fontFamily: LABEL, fontSize: pt(size), lineHeight: pt(lead), color: col(color),
    boldColor: col(color), italicColor: col(color), firstLineIndent: pt(0) }, ...extra });

const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity,
  fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 800, color: col('paper'),
  align: 'center', box: { backgroundColor: col('sea'), borderRadius: mm(4) },
  placement: { ...at('page', edge, x, -9), size: { width: mm(8), height: mm(8) } } });
const bookTitle = (id, parity, edge, x) => ({ kind: 'text', id, content: '{title} · {subtitle}',
  parity, fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 500, color: col('sea'),
  textTransform: 'uppercase', letterSpacing: pt(1.1), placement: at('page', edge, x, -11) });

const config = {
  locale: t({ es: 'es', en: 'en-gb', fr: 'fr' }), // hyphenation and the continuation mark
  resourceTypes: [{ id: 'dibujo', name: t({ es: 'Dibujo', en: 'Drawing', fr: 'Dessin' }),
    shortLabel: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal',
    captionPrefix: '', defaultPlacement: { position: 'here' } }],
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(210), height: mm(280), dpi: 150,
    margins: { top: mm(20), bottom: mm(22), left: mm(20), right: mm(16), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(16), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: {
    fontFamily: DISPLAY, color: col('sea'), fontWeight: 700,
    balancing: { enabled: false },
    levels: [
      // Every sheet is a composed page, which starts a page of its own.
      { level: 1, fontSize: pt(25), lineHeight: pt(30), fontWeight: 800, color: col('paper'),
        breakBefore: { enabled: false }, marginTop: pt(0), marginBottom: pt(0) },
      { level: 3, fontSize: pt(13.5), lineHeight: pt(17), marginTop: pt(6), marginBottom: pt(2) },
    ],
  },
  headingStyles: [{ id: 'portada', fontSize: pt(54), lineHeight: pt(54), fontWeight: 800,
    color: col('sea'), marginTop: pt(0), marginBottom: pt(0) }],
  unorderedLists: { bulletChar: '•', color: col('sea'), fontWeight: 700, itemSpacing: pt(3) },
  orderedLists: { numberFormat: 'arabic', fontFamily: DISPLAY, fontWeight: 800, color: col('sea'),
    itemSpacing: pt(4) },
  chipStyles: [
    // An empty chip, :chip[ ]{style="hueco"}, is a blank to write in: padding and an outline.
    { id: 'hueco', fontFamily: LABEL, background: col('paper'), borderColor: col('rule'),
      borderWidth: pt(0.9), borderRadius: mm(1.4), paddingX: mm(13), paddingY: em(0.1) },
    { id: 'casilla', fontFamily: LABEL, background: col('paper'), borderColor: col('sea'),
      borderWidth: pt(0.9), borderRadius: mm(1), paddingX: em(0.55), paddingY: em(0.05),
      gap: em(0.5) },
  ],
  calloutStyles: [
    tabbed('actividad', 'sky', 'sea', 'paper'),
    tabbed('material', 'sand', 'sun', 'ink'),
    tabbed('proyecto', 'paper', 'sun', 'ink',
      { border: { enabled: true, color: col('sun'), width: pt(1.5) } }),
    { ...plain('banda', 25, 30, 'paper'), backgroundEnabled: true, background: col('sea'),
      borderRadius: mm(3), padding: { top: mm(2.5), right: mm(5), bottom: mm(3), left: mm(5) } },
    plain('campo', 10, 14, 'sea', { padding: { top: mm(5), right: mm(0), bottom: mm(0),
      left: mm(0) } }),
    plain('colofon', 7.5, 10, 'ink'),
  ],
  header: { elements: [] },
  footer: { elements: [
    folio('folio-par', 'even', 'bottom-left', 20),
    bookTitle('titulo-par', 'even', 'bottom-left', 32),
    folio('folio-impar', 'odd', 'bottom-right', -20),
    bookTitle('titulo-impar', 'odd', 'bottom-right', -32),
  ] },
  pageTemplates: [cover, sheet, project, projectGoesOn],
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdownの見本 · 100行 · content.en.mdtitle: "Water on the move" subtitle: "Activity book" --- ::::page{template="portada"} # Water on the move {style="portada"} Science activity book · Year 4 · Unit 3 :::frame{slot="nombre"} Name ::: :::frame{slot="colofon"} Set in Andika, Baloo 2 and Lexend (SIL OFL) · Text and drawings: original, CC BY 4.0 ::: :::: ::::page{template="ficha" tema="Unit 3 · Sheet 1"} # Where is the water? Almost three quarters of the Earth is covered by water. Most of it is **salt water**, in the seas and oceans. **Fresh water**, the kind we drink, is only a small part: it runs in rivers, rests in lakes, hides under the ground or lies frozen at the poles. ::resource{id="planeta" slot="dibujo"} :::frame{slot="act-1" label="1 · Read and answer"} Tom lives in Leeds. The water from his tap comes from a reservoir in the hills, goes through a treatment works and travels through pipes to his house. Where does the water from your tap come from? Why do you think it has to be cleaned before we drink it? ::: :::frame{slot="act-2" label="2 · Fill in the gaps"} - The water in the seas and oceans is :chip[ ]{style="hueco"} and we cannot drink it. - Rivers, lakes and the ground below us hold :chip[ ]{style="hueco"} water. - At the poles, nearly all the water is frozen into :chip[ ]{style="hueco"}. ::: :::frame{slot="act-3" label="3 · Draw and write"} Draw a place near your home where there is water: a fountain, a river, a reservoir or the beach. Then write its name on the lines and what people use it for. :::space{lines=3} ::: :::: ::::page{template="ficha" tema="Unit 3 · Sheet 2"} # Solid, liquid and gas Water changes state when it gets warmer or colder. Below zero degrees it turns into **ice**, which is a solid. When ice warms up it melts and becomes a **liquid** again. When water boils it turns into **steam**, a gas we cannot see. ::resource{id="estados" slot="dibujo"} :::frame{slot="act-1" label="1 · Observe"} Put an ice cube on a plate and leave it on the table. Look at it every ten minutes for half an hour. What happens to the ice cube? What is on the plate at the end? Write down why you think this happens. ::: :::frame{slot="act-2" label="2 · Tick the true sentences"} - :chip[ ]{style="casilla"} Ice is water in its solid state. - :chip[ ]{style="casilla"} You can pick up steam in your hand. - :chip[ ]{style="casilla"} A puddle dries up because the sun makes the water evaporate. - :chip[ ]{style="casilla"} Water in the freezer turns into steam. ::: :::frame{slot="act-3" label="3 · Explain"} When you cook pasta, steam rises from the pan. If you hold a cold lid over it, the lid fills with tiny drops. Where do those drops come from? ::: :::: ::::page{template="proyecto" tema="Unit 3 · Project" notas="What I noticed"} # Make a rain gauge A rain gauge measures how much rain falls in one place. Weather stations use very precise instruments, but you can make one from a bottle and keep a record of the rain for a whole week. ::resource{id="pluviometro" slot="dibujo"} :::frame{slot="material" label="You will need"} - A clean, empty two-litre plastic bottle. - Scissors, a 20-centimetre ruler and sticky tape. - A permanent marker and a handful of small stones. ::: :::frame{slot="pasos" label="Step by step"} 1. Ask an adult to cut the bottle where it starts to get narrower. Keep the top part: it will be your funnel. 2. Put the stones in the bottom. They stop the bottle blowing over in the wind. 3. Pour in water until the stones are covered and mark that level with the marker. That line is the zero of your rain gauge. 4. Tape the ruler to the outside of the bottle, so that the zero on the ruler sits exactly on the line you marked. 5. Turn the top part upside down and push it into the bottle, neck first. The funnel collects the rain and slows down evaporation. 6. Find an open spot in the playground or the garden, away from trees and roofs, which keep the rain off. 7. Every day at the same time, read how many millimetres the water has risen above the zero and write it in your record. Then pour the water out down to the zero line. ### My record for the week - Monday: :chip[ ]{style="hueco"} millimetres - Tuesday: :chip[ ]{style="hueco"} millimetres - Wednesday: :chip[ ]{style="hueco"} millimetres - Thursday: :chip[ ]{style="hueco"} millimetres - Friday: :chip[ ]{style="hueco"} millimetres ### Think Which day had the most rain? Add up the five days: how many millimetres fell in total? Compare your result with a classmate who put their rain gauge somewhere else. ::: ::::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the water cycle on the cover, the planet, the three states, the rain gauge const P = palette; const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" ` + `width="${w * 10}" height="${h * 10}">${body}</svg>`; const cloud = (x, y, s, fill = P.paper) => `<g transform="translate(${x} ${y}) scale(${s})" ` + `fill="${fill}">` + '<circle cx="0" cy="0" r="9"/><circle cx="11" cy="-5" r="11"/><circle cx="24" cy="0" r="9"/>' + '<rect x="-9" y="0" width="42" height="9" rx="4.5"/></g>'; const arrow = (x1, y1, x2, y2, color) => { const a = Math.atan2(y2 - y1, x2 - x1); const tip = (d, o) => `${(x2 - d * Math.cos(a + o)).toFixed(1)},` + `${(y2 - d * Math.sin(a + o)).toFixed(1)}`; return `<path d="M${x1} ${y1} L${x2} ${y2}" stroke="${color}" stroke-width="1.6" ` + `stroke-linecap="round" stroke-dasharray="3 3" fill="none"/>` + `<path d="M${x2} ${y2} L${tip(5, 0.45)} L${tip(5, -0.45)} Z" fill="${color}"/>`; }; const rain = (x, y) => [0, 6, 12, 18].map((dx, i) => `<path d="M${x + dx} ${y + (i % 2) * 3} ` + `l-2 7" stroke="${P.sea}" stroke-width="1.6" stroke-linecap="round"/>`).join(''); const ART = { portada: [210, 178, () => `<rect width="210" height="178" fill="${P.sky}"/>` + `<circle cx="168" cy="38" r="17" fill="${P.sun}"/>` + Array.from({ length: 12 }, (_, i) => { const a = (i * Math.PI) / 6; const p = (r) => `${(168 + r * Math.cos(a)).toFixed(1)} ${(38 + r * Math.sin(a)).toFixed(1)}`; return `<path d="M${p(22)} L${p(29)}" stroke="${P.sun}" stroke-width="2.4" ` + 'stroke-linecap="round"/>'; }).join('') + cloud(28, 40, 1.2) + cloud(86, 26, 0.9) + rain(30, 58) + `<path d="M0 132 L34 84 L52 104 L74 70 L112 132 Z" fill="${P.leaf}"/>` + `<path d="M74 70 L66 82 L72 80 L76 85 L81 80 L74 70 Z" fill="${P.paper}"/>` + `<path d="M58 132 C70 120 64 112 76 104" stroke="${P.sea}" stroke-width="3" fill="none"/>` + `<path d="M0 132 L210 132 L210 178 L0 178 Z" fill="${P.sea}"/>` + [140, 152, 164].map((y) => `<path d="M${y - 120} ${y} q8 -4 16 0 t16 0 t16 0" ` + `stroke="${P.sky}" stroke-width="1.4" fill="none"/>`).join('') + arrow(150, 126, 128, 66, P.paper) + arrow(176, 124, 160, 70, P.paper) + arrow(120, 30, 70, 34, P.paper)], planeta: [56, 46, () => `<circle cx="26" cy="23" r="20" fill="${P.sea}"/>` + `<path d="M14 10 q8 -2 10 6 q-2 7 -9 6 q-5 -4 -1 -12 Z" fill="${P.leaf}"/>` + `<path d="M30 26 q9 -3 11 4 q-1 8 -8 9 q-6 -5 -3 -13 Z" fill="${P.leaf}"/>` + `<path d="M20 5 q6 -3 12 0 q-6 3 -12 0 Z" fill="${P.sky}"/>` + `<path d="M48 18 q-5 8 0 12 q5 -4 0 -12 Z" fill="${P.sea}" stroke="${P.paper}" ` + 'stroke-width="1"/>'], estados: [56, 46, () => `<rect x="3" y="24" width="14" height="14" rx="2.5" fill="${P.sky}" ` + `stroke="${P.sea}" stroke-width="1.4"/>` + `<path d="M22 18 L24 42 L36 42 L38 18 Z" fill="${P.sky}" stroke="${P.sea}" ` + 'stroke-width="1.4"/>' + `<path d="M23 27 L37 27" stroke="${P.sea}" stroke-width="1"/>` + `<path d="M23.3 27 L24 42 L36 42 L36.7 27 Z" fill="${P.sea}"/>` + [44, 49, 54].map((x) => `<path d="M${x - 4} 40 q-3 -6 0 -12 t0 -12" stroke="${P.rule}" ` + `stroke-width="1.6" fill="none" stroke-linecap="round"/>`).join('')], pluviometro: [56, 46, () => `<path d="M18 10 L40 10 L40 42 Q40 44 38 44 L20 44 Q18 44 18 42 Z" ` + `fill="${P.sky}" stroke="${P.sea}" stroke-width="1.4"/>` + `<path d="M16 10 L42 10 L33 19 L25 19 Z" fill="${P.paper}" stroke="${P.sea}" ` + 'stroke-width="1.4"/>' + '<path d="M18.7 30 L39.3 30 L39.3 42 Q39.3 43.3 38 43.3 L20 43.3 Q18.7 43.3 18.7 42 Z" ' + `fill="${P.sea}"/>` + [22, 26, 30, 34, 38].map((y) => `<path d="M40 ${y} l5 0" stroke="${P.ink}" ` + 'stroke-width="1"/>').join('') + cloud(4, 4, 0.35, P.rule) + rain(6, 8).replaceAll('l-2 7', 'l-1 4')], }; const ALT = { portada: t({ es: 'El ciclo del agua: el sol calienta el mar, el vapor sube a las nubes y la lluvia cae ' + 'en la montaña y vuelve por el río', en: 'The water cycle: the sun warms the sea, vapour rises to the clouds, and rain falls on ' + 'the hills and runs back down the river', fr: 'Le cycle de l’eau : le soleil chauffe la mer, la vapeur monte aux nuages, la pluie ' + 'tombe sur la montagne et revient par la rivière' }), planeta: t({ es: 'La Tierra, casi toda azul, y una gota de agua', en: 'The Earth, mostly blue, and a drop of water', fr: 'La Terre, presque toute bleue, et une goutte d’eau' }), estados: t({ es: 'Un cubito de hielo, un vaso de agua y vapor', en: 'An ice cube, a glass of water and steam', fr: 'Un glaçon, un verre d’eau et de la vapeur' }), pluviometro: t({ es: 'Una botella cortada con una regla: el pluviómetro', en: 'A cut bottle with a ruler: the rain gauge', fr: 'Une bouteille coupée avec une règle : le pluviomètre' }), }; const resources = Object.entries(ART).map(([id, [w, h]]) => ({ id, typeId: 'dibujo', kind: 'svg', caption: '', altText: ALT[id], createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, })); for (const [id, [w, h, draw]] of Object.entries(ART)) { await loadSvg(`${id}.svg`, svgDoc(w, h, draw())); } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build Andika: ['400', '400i', '700'], 'Baloo 2': ['700', '800'], Lexend: ['500', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS)); showPages(doc, { title: t({ es: 'El agua, de viaje', en: 'Water on the move', fr: 'L’eau en voyage' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);
キット · core, fonts, viewer, pdf, images:全レシピ共通 · 315行// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

組み立てたscript.jsはそのまま動きます。任意のページのモジュールスクリプトに貼り付けるか、レシピをCodePenで開いてください。 GitHub上のレシピのフォルダー ↗ (新しいタブで開きます)

アレンジ

#活動の罫線を増やす

罫線の本数は解答のフレームの高さで決まります。rules が入るだけの線を引くので、テンプレートでこの値を変えれば、それを使うすべてのワークシートにその本数の罫線が付きます。

-    answer('lineas-1', 3),
+    answer('lineas-1', 5),

#続ける前に手順を縮めてみる

別のページへ続けられるフレームは、文字を縮める前に続きへ送ります。fit.preferContinue: false を指定すると、段階はまず手順を fit.minScale(90 %)まで小さく組み、それでも足りないときだけ別のページへ送ります。子どもの本では、1ページ減らすことより文字の大きさのほうが大事なので、このレシピは既定のままにしています。

-      overflow: { continueIn: 'next-page' } },
+      fit: { preferContinue: false }, overflow: { continueIn: 'next-page' } },

よくあるつまずき

  • 何も入らないフレームは、optional: true を付けないと slotEmpty を出します。ここでは解答のフレームすべてに付けています。
  • スタックでは囲みを置くのはグループで、囲み自身の placement は使われません。ただし最初の一つは例外です。グループの最初の囲みの位置を決めておかないと、スタックは版面の上端から始まり、見出しに重なります。

クレジット

レシピ
Ignacio Ferro
本文
書き下ろしの文章, CC BY 4.0
フォント
Andika (SIL OFL 1.1) · Baloo 2 (SIL OFL 1.1) · Lexend (SIL OFL 1.1)
SandboxPDF