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

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

解答欄付きの試験問題

USレター判4ページの歴史の試験。マーク欄と配点はチップで描き、設問は1、a)、i)と番号付けし、解答用の罫線は表で引きます。

1ページ(全4ページ)

  • 英語の見本:日本語版はまだありません
  • 仕上がり215.9 × 279.4 mm
  • 1段
  • PT Serif 11/15
  • Inter Tight
  • 4ページ
  • レベル
  • Postext 1.4.1
  • 組版時間9 ms
  • コード180行

かんたんな説明

表紙、マーク式の解答欄、書き込み用の罫線ページが付いた4ページの歴史の試験です。設問と小問の番号の付け方と、各問の配点の示し方がわかります。

できあがり

アメリカ南北戦争をテーマにした歴史の模擬試験の第2部を、USレター判4ページの冊子に組みます。表紙は1863の年号を輪郭線で描いた深紅の帯で、その下に受験者欄、2段組みの注意事項、Section A用の文字入りマーク欄が並びます。2ページ目には多肢選択問題が10問あり、それぞれの後に4つの選択肢が続きます。どの選択肢も、マーク欄と同じ文字入りのマークで始まります。3ページ目はゲティスバーグ演説をSource Aとして組み、それについての設問を11、a)、i)と番号付けし、小問ごとの配点をチップで示します。4ページ目は解答用に、本文2行ごとに1本の罫線を引き、罫線の各組は小問の番号で始まります。表紙以降のページにはどれもノンブルと採点者用の罫線付き余白があり、その中で唯一の右ページには「Turn over」と入ります。

このレシピが答える質問

  • 記入線、解答欄、語群、チェックリストのあるワークシートのページを作るには?
  • リストをカスタマイズするには(レベルごとの記号、(a)/(i)の番号、タスクのチェックボックス、グリッドに乗る間隔)?
  • キーボードのキー、タグ、練習問題の語群などのインラインのチップを作るには?
  • 1つの文書の中で、複数の表を異なるスタイル(塗り、縞模様のセル、角丸の枠)にするには?
  • 章扉や白ページで柱を隠したり、空白の偶数ページを部の色で塗ったりするには?

手短な答え

script.js · 34–46行コード全体で見る
// A table row is one line (the table's size × the body's leading ratio) plus cellPadding above
// and below: at the body size, (ROW − LEAD) / 2 of padding makes every row ROW deep, so the
// rules keep to the text's 15 pt grid, 10.6 mm apart: room for handwriting.
const ROW = 2 * LEAD; // pt
const lines = { id: 'lines', rules: 'horizontal', // a rule on the top and foot of every row
  borderColor: col('rule'), borderWidth: pt(0.5), bodyFontFamily: LABEL, bodyFontSize: pt(BODY),
  bodyColor: col('crimson'), cellPadding: pt((ROW - LEAD) / 2) };
// Every table in this paper is set 'here': where its ::resource directive stands in the text.
const table = (id, styleId, model) => ({ id, typeId: 'form', kind: 'table', createdAt: 0,
  updatedAt: 0, placement: { position: 'here' }, table: { styleId, model } });
// One table per answer, the part's number in its first margin cell.
const answerLines = (id, part, rows) => table(id, 'lines', { columnWidths: [1, 5], rows: Array
  .from({ length: rows }, (_, i) => [{ content: i ? '' : `**${part}**` }, { content: '' }]) });

材料

種類
PT Serif, Inter Tight (SIL OFL 1.1)
素材
なし:図はすべてコードで描画

作り方

#1 · 設問に番号を付け、選択肢を吊るす

script.js · 50–62行コード全体で見る
const [NUMBER, GAP] = [12.5, 6]; // pt: the question numbers' size; number to text
const orderedLists = { fontFamily: LABEL, color: col('ink'), // bold, by default
  separatorColor: col('crimson'), gap: pt(GAP), marginTop: pt(LEAD / 2), marginBottom: pt(0),
  itemSpacing: pt(5), // after every numbered item: to a question's options, or the next part
  levels: [{ level: 1, fontSize: pt(NUMBER) }, // 'arabic', '.' (gotcha: numbering-vocabularies)
    { level: 2, numberFormat: 'lower-alpha', separator: ')' },
    { level: 3, numberFormat: 'lower-roman', separator: ')' }] };
// Options: a nested item with no bullet, indented by the widest number ('10.') plus GAP.
const width = (s) => { const ctx = new OffscreenCanvas(1, 1).getContext('2d');
  ctx.font = `700 ${NUMBER}pt "${LABEL}"`; return ctx.measureText(s).width * 0.75; }; // pt
const unorderedLists = () => ({ gap: pt(0), marginTop: pt(0), marginBottom: pt(0),
  itemSpacing: pt(LEAD), // after the options: a line before the next question
  levels: [{ level: 2, bulletChar: '', indent: pt(width('10') + width('.') + GAP) }] });

orderedListsの各レベルは、それぞれのnumberFormatとseparatorを選びます。separatorColorはピリオドと括弧をアクセント色で塗り、番号はインクの色のまま残します(番号付きリスト)。リストの番号は、同じ並びの中でいちばん幅の広い番号に合わせて右そろえになるので、どの設問の本文も23 pt字下げした位置から始まります。'10.'の17 ptにGAPを足した値です。4つの選択肢は、bulletCharを空にした入れ子の箇条書きの1項目です。その本文はレベルのindentの位置、つまり番号用の書体で測った'10.'にGAPを足した位置から始まるので、どの書体でも選択肢が設問の最初の文字の下にそろいます。間隔は直前の項目で決まり、番号付きの項目の後は5 pt、選択肢の行の後は15 ptです。

#2 · マークと配点をチップで作る

script.js · 66–75行コード全体で見る
// A chip is 0.8 em above the baseline and 0.25 em below, plus paddingY and the border on each
// side: 1.29 em and two borders. One capital is 0.63–0.72 em wide, so paddingX 0.3 em (and the
// same two borders) squares the box; a radius over half its smaller side is clamped to that half.
const chip = (id, look) => ({ id, fontFamily: LABEL, fontSize: em(0.8), bold: true,
  paddingY: em(0.12), ...look });
const bubble = (id, fill, ink, edge = 'crimson') => chip(id, { color: col(ink), paddingX: em(0.3),
  background: col(fill), borderColor: col(edge), borderWidth: pt(0.7), borderRadius: em(1) });
const chipStyles = [bubble('bubble', 'paper', 'crimson'), // first: a bare :chip[A] takes it
  bubble('filled', 'ink', 'paper', 'ink'), chip('marks', { color: col('crimson'),
    background: col('blush'), borderWidth: pt(0), paddingX: em(0.45), gap: em(0.5) })];

チップは行の中のボックスで、その内側で改行が起きることはありません(インラインのチップ)。中にキャピタルが1文字だけなら、paddingX: em(0.3)でボックスの幅が高さとほぼ同じになります。マーク欄では高さ4.7 mmに対して幅4.5〜4.8 mmです。borderRadius: em(1)はボックスの半分より大きく、チップの角丸は短い辺の半分に制限されるので、両端は完全な丸になります。styleのないチップはchipStylesの最初の項目を取るので、80個のマーク(マーク欄に40、選択肢に40)は:chip[A]のような素のチップです。スタイル名を指定するのは、注意事項にある塗りつぶしの例と配点だけです。Postext 1.4.1にはタブストップがないので、配点を右そろえにはできません。各チップは小問の最後に置き、最後の語から少なくとも0.5 em(gap)空けます。

#3 · フォームごとに専用の表スタイルを与える

script.js · 79–101行コード全体で見る
// No table has a header row; the flag keeps the default grey fill off one added later.
const form = { bodyFontFamily: LABEL, borderRadius: mm(2), headerBackgroundEnabled: false };
const tableStyles = [lines,
  { ...form, id: 'candidate', rules: 'grid', borderColor: col('rule'), borderWidth: pt(0.75),
    bodyFontSize: pt(7.5), bodyColor: col('crimson'), cellPadding: mm(1.8) },
  // Tint fills leave hairline seams between cells on a canvas: grid rules in the tint hide them.
  { ...form, id: 'grid', rules: 'grid', borderColor: col('tint'), bodyBackgroundEnabled: true,
    bodyBackground: col('tint'), bodyFontSize: pt(11.5), cellPadding: mm(1.4) }];
const cell = (content, extra) => ({ content, align: 'center', verticalAlign: 'middle', ...extra });
// The MARK cell sets the row's depth: three lines, the blank one a U+2060 word joiner, since a
// cell line that is empty or holds only a no-break space is dropped (gotcha: cell-blank-line).
const candidate = table('candidate', 'candidate', { columnWidths: [4.2, 1.2, 2, 1.3], rows: [[
  ...t({ en: ['NAME', 'CLASS', 'CANDIDATE NUMBER'], es: ['NOMBRE Y APELLIDOS', 'GRUPO',
    'N.º DE EXAMEN'] }).map((l) => cell(`**${l}**`, { align: 'left', verticalAlign: 'top' })),
  cell(`**${t({ en: 'MARK', es: 'NOTA' })}**\n\u2060\n**/ ${t({ en: '25', es: '10' })}**`,
    { align: 'right', background: col('tint') })]] });
const GROUP = ['A', 'B', 'C', 'D'].map((l) => cell(`:chip[${l}]`)), num = (n) => cell(`**${n}**`);
const grid = { ...table('grid', 'grid', { columnWidths: [0.7, 1, 1, 1, 1, 1.4, 0.7, 1, 1, 1, 1],
  rows: [1, 2, 3, 4, 5].map((n) => [num(n), ...GROUP, cell(''), num(n + 5), ...GROUP]) }),
  caption: t({ en: '**Section A answer grid**', es: '**Parte A: plantilla de respuestas**' }) };
// The grid's title is its caption: a heading would stand a body line off any 'here' table.
const captionStyle = { fontFamily: LABEL, fontSize: pt(9.4), color: col('crimson'),
  position: 'above', gap: mm(1.6) }; // the rubric's size

受験者欄、マーク欄、解答用の罫線は3つのtableStylesで、表ごとにstyleIdで選びます(名前付きの表スタイル)。手短な答えの解答用の罫線は、rules: 'horizontal'と、各行を本文2行分の深さにするパディングを使うので、罫線はどれも前の罫線の30 pt(10.6 mm)下、つまり本文の15 ptのグリッドの2行ごとに落ちます。Postext 1.4.1は、空の行やノーブレークスペースだけの行をセルから落とすので、MARKのセルの空いた中央の行にはU+2060のワードジョイナーを入れています。このセルは3行の深さになり、行全体を14.4 mmで組みます。ノーブレークスペースでは10.8 mmになります。マーク欄は罫線を塗りの色合いで引きます。canvasでは塗りつぶしたセルの間にかすかな継ぎ目が残るからです。そのタイトルは、表の1.6 mm上に組むキャプションです(キャプションスタイル)。見出しにすると、少なくとも本文1行分(5.3 mm)高くなります。'here'に置く表は、どれもその分の空きを上に確保するからです。formタイプのcaptionPrefixは空なので、タイトルの前に「Form 1.」のようなラベルは付きません。

#4 · 表紙を見出しの属性から描く

script.js · 105–120行コード全体で見る
const BAND = 116; // mm: the crimson band, bled off the top and both sides
const YEAR = { cap: 62, cut: 12, pad: 2 }; // mm: the digits' cap height; the band cuts 12 off
// span 'page': an opener page, and the band paints above the column (a design in it is clipped).
const cover = { id: 'cover', span: 'page', margins: { right: mm(LEFT) }, // forms full width
  advancedDesign: { enabled: true, slot: { elements: [ // the date line sets the height
    { kind: 'box', id: 'band', style: { backgroundColor: col('crimson') },
      placement: at(0, 0, { width: mm(PAGE.w), height: mm(BAND) }) },
    // Design text has no outline, so the year is an image, cut off by the band's foot.
    { kind: 'image', id: 'year', resourceId: 'year', placement: at(0, BAND - YEAR.cap
      + YEAR.cut - YEAR.pad, { width: mm(PAGE.w), height: mm(YEAR.cap - YEAR.cut + YEAR.pad) }) },
    text('session', '{attr.session}', LABEL, 8.5, 600, 'blush', at(LEFT, 14), tag),
    // 0.9 mm to the left: the H's side bearing at 64 pt (84 of 2048 units), so its stem aligns.
    text('title', '{titleText}', LABEL, 64, 800, 'paper', at(LEFT - 0.9, 19), { lineHeight: 1 }),
    text('paper', '{attr.paper}', LABEL, 15, 700, 'paper', at(LEFT, 44)),
    text('topic', '{attr.topic}', TEXT, 15, 400, 'blush', at(LEFT, 52), { italic: true }),
    text('date', '{attr.date}', LABEL, 9.5, 700, 'ink', at(LEFT, BAND + 6))] } } };

表紙は# History {style="cover" …}という1つの見出しで、そのスタイルが帯、年号、見出しの属性で埋める4行のテキストを描きます。span: 'page'があるとページは章扉として扱われ、帯は22 mmの天の余白より上まで塗れます。これがないとデザインは段の上端で切り取られるので、帯は余白から始まり、試験回の行が失われます。スタイルのmarginsは表紙の段をフォーム用に168 mmいっぱいまで広げ、次の見出し、スタイルなしのレベル1が、本文ページを134 mmの行長に戻します。

#5 · ノンブルと「Turn over」を表紙から外す

script.js · 124–140行コード全体で見る
const [MARGIN, FOOT] = [LEFT + MEASURE + 7, -13]; // mm: the margin rule, 7 off the text; the foot
const body = { pages: 'body', ...tag }; // body pages only: never the cover
const header = { elements: [
  text('running', '{title} · {chapterTitle}', LABEL, 7.5, 600, 'muted', at(LEFT, 12), body),
  { kind: 'rule', id: 'margin', direction: 'vertical', pages: 'body', color: col('rule'),
    thickness: pt(0.75), placement: at(MARGIN, TOP, { height: mm(PAGE.h - TOP - BOTTOM) }) },
  text('note', t({ en: 'Do not write in this margin', es: 'No escribas en este margen' }), LABEL,
    7.5, 600, 'muted', at(MARGIN + 3, TOP, { width: mm(28) }), { ...body, lineHeight: 1.3 })] };
const footer = { elements: [
  text('notice', t({ en: 'Do not turn over until you are told to do so',
    es: 'No des la vuelta a la hoja hasta que se te indique' }), LABEL, 8.5, 700, 'crimson',
  at(0, FOOT, { width: mm(PAGE.w) }, 'bottom-left'), { pages: 'opener', align: 'center', ...tag }),
  text('folio', '{pageNumber}', LABEL, 9, 700, 'ink', at(LEFT, FOOT, { width: mm(MEASURE) },
    'bottom-left'), { ...body, align: 'center' }), // centred under the text
  // Rectos only: a verso faces the page that follows it.
  text('turn', t({ en: 'Turn over ›', es: 'Pasa la página ›' }), LABEL, 9, 700, 'ink',
    at(-RIGHT, FOOT, null, 'bottom-right'), { ...body, parity: 'odd', align: 'right' })] };

本文ページの柱類の要素はすべてpages: 'body'を持つので、章扉である表紙には自分専用の注意書き(pages: 'opener')だけが出ます。そのため各セクションは:::pagebreakと、breakBeforeのない見出しで始めます。改ページする見出しはそのページを章扉にするので、2ページ目と3ページ目のノンブル、柱、余白が消えてしまいます。parity: 'odd'は「Turn over」を奇数ページにとどめます。偶数ページは、すでに次のページと向き合っているからです。

レシピの全体

Sandbox
// ═══ Postext Cookbook · Nº 049 · Exam paper with an answer sheet ═════════════════
// https://postext.dev/en/cookbook/exam-paper
// Code: MIT · Text: Lincoln (PD); questions, Spanish translation (CC BY 4.0) · Art: in code
// Fonts: PT Serif, Inter Tight (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 = 'exam-paper';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#1b1a1f', paper: '#ffffff', muted: '#6b6466', // heads, credits: 5.8:1
  crimson: '#9b1c31', // the one accent: cover, numbering, bubbles, marks (8.1:1 on white)
  blush: '#f3cdd4', tint: '#fbeff1', // type on the band, marks chips; the grid, the rubric box
  rule: '#b9aeb0' }; // answer lines and hairlines
// Hex and id: design slots and referenceColor read only the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults are linked to 'main-color': point it at the accent.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.crimson })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, LABEL, PAGE] = ['PT Serif', 'Inter Tight', { w: 215.9, h: 279.4 }]; // US Letter
const [TOP, BOTTOM, LEFT, MEASURE] = [22, 24, 24, 134]; // mm; 134 mm: about 75 characters
const RIGHT = PAGE.w - LEFT - MEASURE; // 57.9 mm: the examiner's margin, never mirrored
const [BODY, LEAD] = [11, 15]; // pt: text size and leading, the grid every line keeps to
const at = (x, y, size, edge = 'top-left') => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const text = (id, content, family, size, weight, color, placement, extra) => ({ kind: 'text',
  id, content, fontFamily: family, fontSize: pt(size), fontWeight: weight, color: col(color),
  align: 'left', overflow: 'wrap', placement, ...extra });
const tag = { textTransform: 'uppercase', letterSpacing: pt(1.4) }; // 0.16–0.19 em
const small = { fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(10), color: col('muted') };

// #region answer: ruled answer lines: a table whose rows are two body lines deep
// A table row is one line (the table's size × the body's leading ratio) plus cellPadding above
// and below: at the body size, (ROW − LEAD) / 2 of padding makes every row ROW deep, so the
// rules keep to the text's 15 pt grid, 10.6 mm apart: room for handwriting.
const ROW = 2 * LEAD; // pt
const lines = { id: 'lines', rules: 'horizontal', // a rule on the top and foot of every row
  borderColor: col('rule'), borderWidth: pt(0.5), bodyFontFamily: LABEL, bodyFontSize: pt(BODY),
  bodyColor: col('crimson'), cellPadding: pt((ROW - LEAD) / 2) };
// Every table in this paper is set 'here': where its ::resource directive stands in the text.
const table = (id, styleId, model) => ({ id, typeId: 'form', kind: 'table', createdAt: 0,
  updatedAt: 0, placement: { position: 'here' }, table: { styleId, model } });
// One table per answer, the part's number in its first margin cell.
const answerLines = (id, part, rows) => table(id, 'lines', { columnWidths: [1, 5], rows: Array
  .from({ length: rows }, (_, i) => [{ content: i ? '' : `**${part}**` }, { content: '' }]) });
// #endregion

// #region numbering: 11 → a) → i), the separators in the accent; options under a question
const [NUMBER, GAP] = [12.5, 6]; // pt: the question numbers' size; number to text
const orderedLists = { fontFamily: LABEL, color: col('ink'), // bold, by default
  separatorColor: col('crimson'), gap: pt(GAP), marginTop: pt(LEAD / 2), marginBottom: pt(0),
  itemSpacing: pt(5), // after every numbered item: to a question's options, or the next part
  levels: [{ level: 1, fontSize: pt(NUMBER) }, // 'arabic', '.' (gotcha: numbering-vocabularies)
    { level: 2, numberFormat: 'lower-alpha', separator: ')' },
    { level: 3, numberFormat: 'lower-roman', separator: ')' }] };
// Options: a nested item with no bullet, indented by the widest number ('10.') plus GAP.
const width = (s) => { const ctx = new OffscreenCanvas(1, 1).getContext('2d');
  ctx.font = `700 ${NUMBER}pt "${LABEL}"`; return ctx.measureText(s).width * 0.75; }; // pt
const unorderedLists = () => ({ gap: pt(0), marginTop: pt(0), marginBottom: pt(0),
  itemSpacing: pt(LEAD), // after the options: a line before the next question
  levels: [{ level: 2, bulletChar: '', indent: pt(width('10') + width('.') + GAP) }] });
// #endregion

// #region chips: bubbles that read as circles, and marks at the end of a part
// A chip is 0.8 em above the baseline and 0.25 em below, plus paddingY and the border on each
// side: 1.29 em and two borders. One capital is 0.63–0.72 em wide, so paddingX 0.3 em (and the
// same two borders) squares the box; a radius over half its smaller side is clamped to that half.
const chip = (id, look) => ({ id, fontFamily: LABEL, fontSize: em(0.8), bold: true,
  paddingY: em(0.12), ...look });
const bubble = (id, fill, ink, edge = 'crimson') => chip(id, { color: col(ink), paddingX: em(0.3),
  background: col(fill), borderColor: col(edge), borderWidth: pt(0.7), borderRadius: em(1) });
const chipStyles = [bubble('bubble', 'paper', 'crimson'), // first: a bare :chip[A] takes it
  bubble('filled', 'ink', 'paper', 'ink'), chip('marks', { color: col('crimson'),
    background: col('blush'), borderWidth: pt(0), paddingX: em(0.45), gap: em(0.5) })];
// #endregion

// #region forms: the candidate boxes and the bubble grid are named table styles too
// No table has a header row; the flag keeps the default grey fill off one added later.
const form = { bodyFontFamily: LABEL, borderRadius: mm(2), headerBackgroundEnabled: false };
const tableStyles = [lines,
  { ...form, id: 'candidate', rules: 'grid', borderColor: col('rule'), borderWidth: pt(0.75),
    bodyFontSize: pt(7.5), bodyColor: col('crimson'), cellPadding: mm(1.8) },
  // Tint fills leave hairline seams between cells on a canvas: grid rules in the tint hide them.
  { ...form, id: 'grid', rules: 'grid', borderColor: col('tint'), bodyBackgroundEnabled: true,
    bodyBackground: col('tint'), bodyFontSize: pt(11.5), cellPadding: mm(1.4) }];
const cell = (content, extra) => ({ content, align: 'center', verticalAlign: 'middle', ...extra });
// The MARK cell sets the row's depth: three lines, the blank one a U+2060 word joiner, since a
// cell line that is empty or holds only a no-break space is dropped (gotcha: cell-blank-line).
const candidate = table('candidate', 'candidate', { columnWidths: [4.2, 1.2, 2, 1.3], rows: [[
  ...t({ en: ['NAME', 'CLASS', 'CANDIDATE NUMBER'], es: ['NOMBRE Y APELLIDOS', 'GRUPO',
    'N.º DE EXAMEN'] }).map((l) => cell(`**${l}**`, { align: 'left', verticalAlign: 'top' })),
  cell(`**${t({ en: 'MARK', es: 'NOTA' })}**\n\u2060\n**/ ${t({ en: '25', es: '10' })}**`,
    { align: 'right', background: col('tint') })]] });
const GROUP = ['A', 'B', 'C', 'D'].map((l) => cell(`:chip[${l}]`)), num = (n) => cell(`**${n}**`);
const grid = { ...table('grid', 'grid', { columnWidths: [0.7, 1, 1, 1, 1, 1.4, 0.7, 1, 1, 1, 1],
  rows: [1, 2, 3, 4, 5].map((n) => [num(n), ...GROUP, cell(''), num(n + 5), ...GROUP]) }),
  caption: t({ en: '**Section A answer grid**', es: '**Parte A: plantilla de respuestas**' }) };
// The grid's title is its caption: a heading would stand a body line off any 'here' table.
const captionStyle = { fontFamily: LABEL, fontSize: pt(9.4), color: col('crimson'),
  position: 'above', gap: mm(1.6) }; // the rubric's size
// #endregion

// #region cover: a band with the outlined year, filled in from the heading's attributes
const BAND = 116; // mm: the crimson band, bled off the top and both sides
const YEAR = { cap: 62, cut: 12, pad: 2 }; // mm: the digits' cap height; the band cuts 12 off
// span 'page': an opener page, and the band paints above the column (a design in it is clipped).
const cover = { id: 'cover', span: 'page', margins: { right: mm(LEFT) }, // forms full width
  advancedDesign: { enabled: true, slot: { elements: [ // the date line sets the height
    { kind: 'box', id: 'band', style: { backgroundColor: col('crimson') },
      placement: at(0, 0, { width: mm(PAGE.w), height: mm(BAND) }) },
    // Design text has no outline, so the year is an image, cut off by the band's foot.
    { kind: 'image', id: 'year', resourceId: 'year', placement: at(0, BAND - YEAR.cap
      + YEAR.cut - YEAR.pad, { width: mm(PAGE.w), height: mm(YEAR.cap - YEAR.cut + YEAR.pad) }) },
    text('session', '{attr.session}', LABEL, 8.5, 600, 'blush', at(LEFT, 14), tag),
    // 0.9 mm to the left: the H's side bearing at 64 pt (84 of 2048 units), so its stem aligns.
    text('title', '{titleText}', LABEL, 64, 800, 'paper', at(LEFT - 0.9, 19), { lineHeight: 1 }),
    text('paper', '{attr.paper}', LABEL, 15, 700, 'paper', at(LEFT, 44)),
    text('topic', '{attr.topic}', TEXT, 15, 400, 'blush', at(LEFT, 52), { italic: true }),
    text('date', '{attr.date}', LABEL, 9.5, 700, 'ink', at(LEFT, BAND + 6))] } } };
// #endregion

// #region furniture: margin, folio and 'Turn over' on body pages; a notice on the cover
const [MARGIN, FOOT] = [LEFT + MEASURE + 7, -13]; // mm: the margin rule, 7 off the text; the foot
const body = { pages: 'body', ...tag }; // body pages only: never the cover
const header = { elements: [
  text('running', '{title} · {chapterTitle}', LABEL, 7.5, 600, 'muted', at(LEFT, 12), body),
  { kind: 'rule', id: 'margin', direction: 'vertical', pages: 'body', color: col('rule'),
    thickness: pt(0.75), placement: at(MARGIN, TOP, { height: mm(PAGE.h - TOP - BOTTOM) }) },
  text('note', t({ en: 'Do not write in this margin', es: 'No escribas en este margen' }), LABEL,
    7.5, 600, 'muted', at(MARGIN + 3, TOP, { width: mm(28) }), { ...body, lineHeight: 1.3 })] };
const footer = { elements: [
  text('notice', t({ en: 'Do not turn over until you are told to do so',
    es: 'No des la vuelta a la hoja hasta que se te indique' }), LABEL, 8.5, 700, 'crimson',
  at(0, FOOT, { width: mm(PAGE.w) }, 'bottom-left'), { pages: 'opener', align: 'center', ...tag }),
  text('folio', '{pageNumber}', LABEL, 9, 700, 'ink', at(LEFT, FOOT, { width: mm(MEASURE) },
    'bottom-left'), { ...body, align: 'center' }), // centred under the text
  // Rectos only: a verso faces the page that follows it.
  text('turn', t({ en: 'Turn over ›', es: 'Pasa la página ›' }), LABEL, 9, 700, 'ink',
    at(-RIGHT, FOOT, null, 'bottom-right'), { ...body, parity: 'odd', align: 'right' })] };
// #endregion

const section = { enabled: true, slot: { elements: [ // Section A, Section B: in the column
  { kind: 'rule', id: 'top', color: col('crimson'), thickness: pt(2),
    placement: { anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill' } } },
  text('kicker', '{attr.section} · {attr.marks}', LABEL, 8.5, 700, 'crimson', { anchor: {
    to: 'container', edge: 'top-left' }, offset: { y: mm(3) } }, tag),
  text('title', '{titleText}', LABEL, 20, 800, 'ink', { anchor: { to: '#kicker',
    edge: 'below' }, offset: { y: mm(1.2) }, size: { width: 'fill' } }, { lineHeight: 1.05 })] } };

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: [{ id: 'form', name: 'Form', shortLabel: '', captionPrefix: '',
    numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }], // no label
  colorPalette, chipStyles, tableStyles, captionStyle, orderedLists, header, footer,
  headingStyles: [cover], unorderedLists: unorderedLists(), // measured now that the fonts are in
  page: { width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, margins: { top: mm(TOP),
    bottom: mm(BOTTOM), left: mm(LEFT), right: mm(RIGHT) } }, layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  // No page break: a :::pagebreak opens each section, so its page stays a body page with a
  // folio. A heading that breaks the page makes an opener, which pages: 'body' leaves bare.
  headings: { fontFamily: LABEL, levels: [{ level: 1, breakBefore: { enabled: false },
    marginTop: pt(0), marginBottom: pt(0), advancedDesign: section }] },
  calloutStyles: [{ id: 'rubric', background: col('tint'), borderRadius: mm(2), columnGap: mm(8),
    padding: { top: mm(4), right: mm(5), bottom: mm(4), left: mm(5) }, marginTop: pt(0),
    marginBottom: pt(0), lists: { bulletChar: '–', color: col('crimson'), gap: mm(2),
      itemSpacing: pt(3) }, body: { fontFamily: LABEL, fontSize: pt(9.4), lineHeight: pt(13),
      boldColor: col('crimson'), paragraphSpacing: false } },
  { id: 'source', backgroundEnabled: false, marginTop: pt(LEAD), stripe: { enabled: true,
    side: 'left', width: pt(3), color: col('crimson') }, padding: { top: mm(1), right: mm(0),
    bottom: mm(1), left: mm(6) }, titleStyle: { fontFamily: LABEL, fontSize: pt(8.5),
      fontWeight: 700, color: col('crimson'), gap: mm(2), ...tag }, body: { fontSize: pt(10.5),
      lineHeight: pt(LEAD), textAlign: 'justify', paragraphSpacing: false,
      firstLineIndent: mm(4) } }],
  paragraphStyles: [{ id: 'signature', textAlign: 'right' }, { id: 'credit', ...small },
    { id: 'end', ...small, boldColor: col('crimson'), textAlign: 'center', spaceBetween: pt(8) }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdownの見本 · 100行 · content.en.mdtitle: "History Paper 2" subtitle: "The American Civil War, 1863" --- # History {style="cover" session="Mock examinations · Spring 2026" paper="Paper 2 · Sources and interpretations" topic="The American Civil War, 1863" date="Thursday 14 May 2026 · Morning · Time allowed: 1 hour 15 minutes"} ::resource{id="candidate"} :::callout{type="rubric"} :::columns{count=2 breaks="6"} **Instructions** - Use black ink. Use a pencil for Section A. - Answer every question. - Section A: shade one bubble for each question, like this: :chip[C]{style="filled"}. Rub out any mark you change. - Section B: write on the lines on page 4. **Information** - The number of marks is shown at the end of each part, like this: :chip[4 marks]{style="marks"} - Section A: 10 marks. Section B: 15 marks. - Spend about 45 minutes on Section B. ::: ::: ::resource{id="grid"} :::pagebreak # Multiple choice {section="Section A" marks="10 marks"} Shade one bubble for each question in the grid on page 1: 1. The Emancipation Proclamation took effect on 1 January 1863. Where did it declare enslaved people free? - :chip[A] every state :chip[B] border states :chip[C] rebel states :chip[D] western states 2. Who commanded the Union army at the Battle of Gettysburg? - :chip[A] U. S. Grant :chip[B] G. G. Meade :chip[C] G. B. McClellan :chip[D] W. T. Sherman 3. Which Confederate stronghold surrendered on 4 July 1863? - :chip[A] New Orleans :chip[B] Vicksburg :chip[C] Memphis :chip[D] Baton Rouge 4. How many years are “four score and seven”? - :chip[A] 47 :chip[B] 67 :chip[C] 87 :chip[D] 107 5. Who gave the two-hour main oration at the dedication of the cemetery at Gettysburg? - :chip[A] E. Everett :chip[B] F. Douglass :chip[C] W. H. Seward :chip[D] J. Hay 6. Under the Enrollment Act of March 1863, what could a drafted man pay to be excused from service? - :chip[A] \$100 :chip[B] \$300 :chip[C] \$500 :chip[D] \$1,000 7. Which regiment of Black soldiers led the assault on Fort Wagner, South Carolina, in July 1863? - :chip[A] 20th Maine :chip[B] 9th Ohio :chip[C] 2nd Iowa :chip[D] 54th Massachusetts 8. Which state joined the Union on 20 June 1863? - :chip[A] Nevada :chip[B] West Virginia :chip[C] Kansas :chip[D] Nebraska 9. At which battle in May 1863 was General Thomas “Stonewall” Jackson mortally wounded? - :chip[A] Chancellorsville :chip[B] Antietam :chip[C] Fredericksburg :chip[D] Shiloh 10. Which city saw four days of riots against the draft in July 1863? - :chip[A] Boston :chip[B] Philadelphia :chip[C] New York :chip[D] Chicago :::pagebreak # Source-based questions {section="Section B" marks="15 marks"} :::callout{type="source" title="Source A"} Four score and seven years ago our fathers brought forth, on this continent, a new nation, conceived in Liberty, and dedicated to the proposition that all men are created equal. Now we are engaged in a great civil war, testing whether that nation, or any nation so conceived and so dedicated, can long endure. We are met on a great battle-field of that war. We have come to dedicate a portion of that field, as a final resting place for those who here gave their lives that that nation might live. It is altogether fitting and proper that we should do this. But, in a larger sense, we can not dedicate—we can not consecrate—we can not hallow—this ground. The brave men, living and dead, who struggled here, have consecrated it, far above our poor power to add or detract. The world will little note, nor long remember what we say here, but it can never forget what they did here. It is for us the living, rather, to be dedicated here to the unfinished work which they who fought here have thus far so nobly advanced. It is rather for us to be here dedicated to the great task remaining before us—that from these honored dead we take increased devotion to that cause for which they gave the last full measure of devotion—that we here highly resolve that these dead shall not have died in vain—that this nation, under God, shall have a new birth of freedom—and that government of the people, by the people, for the people, shall not perish from the earth. :::paragraphs{style="signature"} *Abraham Lincoln. November 19, 1863.* ::: ::: :::paragraphs{style="credit"} Lincoln’s address at the dedication of the Soldiers’ National Cemetery, Gettysburg, 19 November 1863; the text of the Bliss copy, the last of five in his hand and the only one he signed. ::: 11. Study Source A, then answer every part on the lines on page 4. 1. Lincoln counts back “four score and seven years” in his first line. 1. Which year is he counting back to? :chip[1 mark]{style="marks"} 2. Explain why Lincoln dated the nation’s birth from that year, and not from 1787, when the Constitution was written. :chip[2 marks]{style="marks"} 2. Lincoln says the war is “testing whether that nation … can long endure”. Describe two ways in which the Union was stronger in November 1863 than in January. :chip[4 marks]{style="marks"} 3. “The address honours the dead but says little about the future of the nation.” How far do you agree? Explain your answer with Source A and your own knowledge. :chip[8 marks]{style="marks"} :::pagebreak Write your answers to Question 11 on these lines. ::resource{id="lines-ai"} ::resource{id="lines-aii"} ::resource{id="lines-b"} ::resource{id="lines-c"} :::space :::paragraphs{style="end"} **END OF QUESTIONS** Source A: the Gettysburg Address, Bliss copy, in the public domain. Questions written for this paper, CC BY 4.0. Set in PT Serif and Inter Tight, SIL Open Font License. :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the outlined year // The digits are Inter Tight 800 outlines (SIL OFL), 102.4 units to the em, cap height 74.5, // on a baseline at 0: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts). const YEAR_OUTLINE = 'M38.2-74.5V0H20.2V-57.7H19.8L3.1-47.5V-63.1L21.5-74.5ZM75.8 1Q67.1 1 60.3-1.8' + 'Q53.6-4.5 49.7-9.3Q45.8-14.1 45.8-20.1Q45.8-24.8 48-28.6Q50.2-32.5 54-35' + 'Q57.8-37.6 62.5-38.4V-38.9Q56.4-40.1 52.4-44.7Q48.5-49.2 48.5-55.4Q48.5-61.2 52-65.7' + 'Q55.6-70.2 61.8-72.9Q68-75.5 75.8-75.5Q83.7-75.5 89.9-72.9Q96.1-70.2 99.7-65.7' + 'Q103.2-61.2 103.2-55.4Q103.2-49.2 99.2-44.6Q95.2-40.1 89.2-38.9V-38.4' + 'Q93.8-37.6 97.6-35Q101.4-32.5 103.7-28.6Q105.9-24.8 105.9-20.1Q105.9-14.1 102-9.3' + 'Q98.2-4.5 91.4-1.8Q84.6 1 75.8 1ZM75.8-11.7Q79.2-11.7 81.7-13Q84.2-14.2 85.6-16.5' + 'Q87-18.8 87-21.7Q87-24.5 85.6-26.8Q84.1-29 81.6-30.3Q79.1-31.6 75.8-31.6' + 'Q72.6-31.6 70.1-30.3Q67.6-29 66.1-26.8Q64.7-24.6 64.7-21.7Q64.7-18.8 66.1-16.5' + 'Q67.5-14.3 70-13Q72.6-11.7 75.8-11.7ZM75.8-44.3Q78.7-44.3 80.9-45.5' + 'Q83.1-46.6 84.3-48.7Q85.6-50.8 85.6-53.4Q85.6-56 84.3-58Q83.1-60 80.9-61.1' + 'Q78.7-62.2 75.8-62.2Q73-62.2 70.8-61.1Q68.6-60 67.3-58Q66.1-56 66.1-53.4' + 'Q66.1-50.8 67.3-48.7Q68.6-46.7 70.8-45.5Q73-44.3 75.8-44.3ZM143.7 1Q137.6 1 132-1' + 'Q126.4-3 122.1-7.3Q117.7-11.7 115.2-18.7Q112.7-25.8 112.7-36Q112.7-45.2 114.9-52.5' + 'Q117.1-59.8 121.2-65Q125.4-70.1 131.1-72.8Q136.9-75.5 144-75.5Q151.9-75.5 157.8-72.5' + 'Q163.8-69.4 167.4-64.3Q171-59.2 171.7-53H154Q153.2-56.5 150.5-58.3Q147.8-60.2 144-60.2' + 'Q137.2-60.2 133.8-54.2Q130.5-48.4 130.5-38.4H130.9Q132.5-41.8 135.3-44.2' + 'Q138.2-46.6 141.9-47.8Q145.7-49.1 149.8-49.1Q156.5-49.1 161.6-46Q166.7-43 169.6-37.6' + 'Q172.5-32.2 172.5-25.3Q172.5-17.5 168.9-11.6Q165.2-5.7 158.7-2.3Q152.2 1 143.7 1Z' + 'M143.6-12.9Q146.9-12.9 149.5-14.4Q152.1-16 153.6-18.7Q155.1-21.4 155.1-24.7' + 'Q155.1-28.1 153.6-30.8Q152.1-33.5 149.5-35Q147-36.6 143.6-36.6Q140.4-36.6 137.7-35' + 'Q135.1-33.4 133.6-30.7Q132.1-28.1 132.1-24.7Q132.1-21.4 133.6-18.7Q135.1-16 137.7-14.4' + 'Q140.3-12.9 143.6-12.9ZM208.3 1Q199.8 1 193.2-1.9Q186.6-4.8 182.8-10' + 'Q179.1-15.2 179-21.9H197.1Q197.2-19.5 198.6-17.6Q200.1-15.7 202.7-14.7' + 'Q205.2-13.7 208.4-13.7Q211.6-13.7 214-14.8Q216.5-15.9 217.8-17.9Q219.2-19.9 219.2-22.5' + 'Q219.2-25.2 217.7-27.2Q216.2-29.2 213.4-30.4Q210.7-31.5 206.9-31.5H199.6V-44.3H206.9' + 'Q210.2-44.3 212.7-45.4Q215.2-46.5 216.6-48.5Q218-50.5 218-53Q218-55.6 216.8-57.4' + 'Q215.6-59.3 213.5-60.4Q211.3-61.5 208.4-61.5Q205.4-61.5 203-60.4Q200.6-59.3 199.2-57.4' + 'Q197.8-55.5 197.7-53H180.5Q180.5-59.6 184.2-64.7Q187.8-69.8 194.1-72.6' + 'Q200.4-75.5 208.5-75.5Q216.5-75.5 222.5-72.7Q228.6-69.9 232-65.1Q235.4-60.2 235.4-54.2' + 'Q235.4-48 231.3-43.9Q227.2-39.8 220.7-38.8V-38.2Q229.3-37.2 233.7-32.6' + 'Q238.1-28.1 238.1-21.2Q238.1-14.7 234.3-9.7Q230.5-4.7 223.7-1.8Q217 1 208.3 1Z'; function year() { // 10 units to the millimetre const [w, h] = [PAGE.w * 10, (YEAR.cap - YEAR.cut + YEAR.pad) * 10]; const s = (YEAR.cap * 10) / 74.5; // scale: cap height to YEAR.cap const x = LEFT * 10 - 3.1 * s; // the 1's flag starts 3.1 units in: line it up with the text return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ` + `${h}"><path transform="translate(${x.toFixed(1)} ${(YEAR.cap + YEAR.pad) * 10}) ` + `scale(${s.toFixed(4)})" d="${YEAR_OUTLINE}" fill="none" stroke="${palette.paper}" ` + `stroke-width="${(8.5 / s).toFixed(3)}" stroke-linejoin="round"/></svg>`; // 0.85 mm } // typeId 'form' too: the drawing is placed by the cover's design, never by ::resource, and a // type with no captionPrefix spares it a figure number. const picture = { id: 'year', typeId: 'form', kind: 'svg', altText: '1863', createdAt: 0, updatedAt: 0, svg: { fileId: 'year.svg', width: PAGE.w * 10, height: (YEAR.cap - YEAR.cut + YEAR.pad) * 10 } }; // in the drawing's own units // #endregion const resources = [picture, candidate, grid, answerLines('lines-ai', '11 a) i)', 1), answerLines('lines-aii', '11 a) ii)', 2), answerLines('lines-b', '11 b)', 4), answerLines('lines-c', '11 c)', 8)]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'PT Serif': ['400', '400i', '700', '700i'], // (gotcha: fonts-first) 'Inter Tight': ['400', '600', '700', '800'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([loadFonts(FONTS, markdown), loadSvg('year.svg', year())]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'History Paper 2', es: 'Historia, prueba 2' }) });
キット · 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上のレシピのフォルダー ↗ (新しいタブで開きます)

アレンジ

#A4判で印刷する

帯、年号、余白の罫線、注意書きはPAGEからサイズを取るので、A4判にするには1行を変えるだけです。表紙の各ブロックは位置を保ち、マーク欄の下の白い部分が、どちらの版でも17.6 mm広がります。

-const [TEXT, LABEL, PAGE] = ['PT Serif', 'Inter Tight', { w: 215.9, h: 279.4 }]; // US Letter
+const [TEXT, LABEL, PAGE] = ['PT Serif', 'Inter Tight', { w: 210, h: 297 }]; // A4

#語群やチェックリストを足す

語群、空欄、塗りつぶす丸もチップで作れます。解答欄と語群を備えたワークシートは、入れ子のボックスに黄色のチップの語群を組み、各行を空の丸で始めるチェックリストを置いています。

よくあるつまずき

つまずき

リストは'arabic'、リソースは'roman-upper'、ページは'upper-roman'

番号付けの設定は、それぞれ書式の綴りが異なります。リストのnumberFormatは'arabic'('decimal'では「undefined」と印字されます)、リソースの種類のcounterFormatは'roman-upper'、ページと:::numberingは'upper-roman'です。 番号付きリスト →

つまずき

表のセルの空行は捨てられる

表のセル内の改行は新しい段落を始めますが、postext 1.4.1は空の段落や、ノーブレークスペース(U+00A0)だけの段落を捨てます。そのため、'NAME\n\n'と書いたセルは1行の高さになります。書き込み用の行を残すには、空行ごとにワードジョイナー(U+2060)を入れてください。'NAME\n\u2060\n\u2060'なら3行の高さになります。 データから作る表 →

つまずき

'here'の表は分割されない

段やページをまたいで分割されるのはフロートの表だけで、'here'に置いた表はまとめて移動します。長い表はフロートにするか、インラインの表を短くしてください。 ページをまたぐ表 →

つまずき

行末不ぞろいのテキストはラントを検査されない

optimalLineBreaking、avoidRunts、runtPenalty、runtMinCharactersはKnuth–Plassの改行処理に働きますが、postext 1.4.1がこれを使うのは両端そろえのテキストだけです。左そろえの段落は1行ずつ改行されるため、これらの設定にかかわらず短い一語で終わることがあります。左そろえのテキストの最終行を読み、ラントで終わる段落は書き換えてください。 ウィドウ、オーファン、ラント →

つまずき

裸の$は数式を開く。\$と書く

ドル記号はインライン数式を開くため、$40のような価格は数式の始まりになります。\$40と書いてください。 エスケープとそのままの文字 →

つまずき

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

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

つまずき

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

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

つまずき

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

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

つまずき

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

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

つまずき

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

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

  • 設問の小問の間に解答用の罫線を挟むと、そのリストが分かれます。表の後のリストは新しい並びとして始まり、並びはそれぞれ自分の中でいちばん幅の広い番号に合わせて番号を右そろえにするので、ii)の本文がi)の本文より1 mm右から始まってしまいます。ここでは解答を専用のページに置いているので、どの小問も1つのリストに収まっています。

クレジット

レシピ
Ignacio Ferro
本文
フォント
PT Serif (SIL OFL 1.1) · Inter Tight (SIL OFL 1.1)
Sandbox