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

レシピ集 · 第3章 · 見出しと章扉

雑誌の特集:写真の扉からエンドマークまで

1つのadvancedDesignの扉が、裁ち落としの写真と、見出しのキッカー、見出し、リード、署名を組みます。続いて2つのフロートの囲みとチップのエンドマーク。

このページの内容
  • 英語の見本:日本語版はまだありません
  • 仕上がり225 × 297 mm
  • 2段, 段間6 mm
  • Literata 9.6/13.4
  • Instrument Sans
  • Instrument Serif
  • 4ページ
  • レベル
  • Postext 1.4.1
  • 組版時間49 ms
  • コード248行

かんたんな説明

ページ上部の大きな写真で始まる自然雑誌の記事です。題、短い導入文、著者、写真のクレジットの置き方と、そのあとの囲みや小さな終わりの印の作り方がわかります。

できあがり

自然雑誌『Boreal』冬号の特集ページで、判型は225 × 297mmです。記事は、ページ上部に裁ち落としで渡した山の湖の写真の下から始まります。写真の下では、字間を広げたキッカーが2行の見出しの上に乗り、その右にイタリックのリード、その下に署名が来ます。写真家のクレジットは写真のすぐ下に入ります。見出しはMarkdownの見出しのテキストで、キッカー、リード、署名、クレジットはその属性です。記事は見開きにわたって両端そろえの2段で続きます。偶数ページには引用符がぶら下がるプルクオートと、右の段の頭にデータ囲み。奇数ページには上部に松林の帯、地に暗色の数字のパネルがあります。小さな四角で記事が終わります。次のページでは、フィールドガイドが図版と錆色のキッカーで同じ扉を使います。

このレシピが答える質問

  • 章扉に著者名の行、リード文、ドロップキャップ付きの導入文を加えるには?
  • 章ごとに異なる写真、色、章扉のバリエーションを与えるには?
  • エピグラフ、献辞、署名、大きな引用符の付いたプルクオートを組むには?

手短な答え

script.js · 40–78行コード全体で見る
const TOP = 22; // top margin in mm: an opener's container starts here
const sans = { fontFamily: 'Instrument Sans', fontWeight: 600, textTransform: 'uppercase' };
const HEAD = 118; // mm: the headline's measure; the standfirst takes the rest of the line
// Empty padding paints nothing but counts: the story starts on the first grid line at least
// 5 mm under the lower of the headline and the byline, however many lines each one runs to.
const air = { padding: { bottom: mm(5) } };
const at = (id, edge, x, y, width) => ({ anchor: { to: id, edge },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = (resourceId, depth) => ({ // depth: how far down the page the picture bleeds
  enabled: true, // no minHeight: the story starts under the headline and byline (see air)
  slot: {
    elements: [ // the photo is an element, not a float: no float reaches the trim
      { kind: 'image', id: 'photo', resourceId, placement: { anchor: { to: 'bleed',
        edge: 'top-left' }, size: { width: 'fill', height: mm(depth) } } },
      // The photo hangs from the page's top edge, the words from the container, TOP mm lower:
      // depth − TOP is the photo's foot, so the credit sits 2 mm under it and the kicker 9 mm.
      { kind: 'text', id: 'credit', content: '{attr.credit}', ...sans, fontWeight: 500,
        fontSize: pt(6.5), letterSpacing: pt(0.6), color: col('muted'), align: 'right',
        placement: at('container', 'top-right', 0, depth - TOP + 2) },
      { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...sans, fontSize: pt(8.5),
        letterSpacing: pt(1.7), color: col('lake'), align: 'left',
        placement: at('container', 'top-left', 0, depth - TOP + 9) },
      { kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Instrument Serif',
        fontSize: pt(58), color: col('ink'), align: 'left', overflow: 'wrap', box: air,
        lineHeight: 0.94, // a multiple of the size (gotcha: design-lineheight-multiple)
        placement: at('#kicker', 'below', 0, 2.5, HEAD) },
      { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', italic: true,
        fontFamily: 'Instrument Serif', fontSize: pt(13.5), lineHeight: 1.22, // a multiple
        color: col('ink'), align: 'left',
        overflow: 'wrap', // gotcha: overflow-ellipsis-default
        placement: at('#headline', 'right-of', 7, 3.2) }, // wraps at the container's edge
      { kind: 'text', id: 'byline', content: '{attr.byline}', ...sans, fontSize: pt(7.5),
        letterSpacing: pt(1.3), color: col('ink'), align: 'left', box: air,
        placement: at('#standfirst', 'below', 0, 3.5) },
    ],
  },
}); // hook-up: headings.levels[0] = { level: 1, span: 'page', breakBefore, advancedDesign:
// opener('lake', 160) }. {titleText} prints the heading's text, {attr.<key>} its <key>="…":
// # The Lake That Keeps Time {kicker="…" standfirst="…" byline="…" credit="…"}

材料

種類
Literata, Instrument Serif, Instrument Sans (SIL OFL 1.1)
素材
  • lake-2000.jpg
  • thaw-2000.jpg
  • Clouds mirrored in a mountain lake (the opener photograph) (Ales Krivec, CC0 1.0)
  • Walk in a thawing forest (the photo band) (Hannah Donze, CC0 1.0)

作り方

#1 · 見出しの行から扉を組む

コードは上の手短な答えにあります。{attr.<key>}はそれぞれ記事の#行にある<key>="…"の値を印字し、{titleText}は見出しそのものを印字します(見出しの属性)。そのため各記事は自分の言葉を自分のMarkdownに持ち、レベル1の見出しはすべて1つのデザインを共有します。写真はページの天の縁から吊り下がり、コンテナーは22mm下から始まるので、depth - TOPはコンテナーから測った写真の下端になります。クレジットはその線の2mm下の右上に、キッカーは9mm下の左上に置きます。見出しはキッカーのbelowに、118mmのHEADの行長で組みます。リードは見出しのright-ofに置いて独自の幅を持たせないので、コンテナーの端で折り返します。署名はリードのbelowに置くので、リードが長くなれば署名は重ならずに押し下げられます。このレベルにはminHeightがありません。見出しと署名はどちらも空の5mmのパディングで終わり、それが扉の高さに数えられるので、それぞれが何行になっても、記事は2つのうち低いほうの下端から少なくとも5mm下の、最初のグリッド行から始まります。

#2 · 写真は一度だけ、実際のピクセル数で宣言する

script.js · 212–243行コード全体で見る
// The pictures are not numbered: their own type, with an empty caption prefix and template,
// keeps a figure number off the pine wood's caption.
const photoType = { id: 'photo', name: t({ en: 'Photograph', es: 'Fotografía' }),
  shortLabel: t({ en: 'photo', es: 'foto' }), captionPrefix: '', numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal' };
const PX = 10; // the drawing's pixels per mm
const resources = [
  { id: 'lake', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0, // never cited:
    // the opener fits it inside its box, so the JPEG is cropped to the box, 225 × 160 mm
    bitmap: { fileId: 'lake-2000.jpg', format: 'jpeg', width: 2000, height: 1422 },
    altText: t({ en: 'A still mountain lake mirroring clouds between autumn slopes.',
      es: 'Un lago de montaña en calma que refleja las nubes entre laderas otoñales.' }) },
  { id: 'thaw', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    // Pixels at the page's 150 dpi (gotcha: bitmap-print-size): 2000 px make 339 mm, so the
    // band shrinks to the 195 mm measure. At 300 dpi it would print 169 mm wide.
    bitmap: { fileId: 'thaw-2000.jpg', format: 'jpeg', width: 2000, height: 944 },
    // A top float opens the page after its ::resource line (gotcha: top-float-next-page):
    // the line sits on the verso, so the band heads the recto.
    placement: { position: 'top', span: 'page' },
    caption: t({ en: 'Early March in the pine wood above the shore: the snow goes first where '
      + 'the sun reaches the ground, weeks before the ice lets go of the lake.',
    es: 'Principios de marzo en el pinar sobre la orilla: la nieve se retira primero donde el '
      + 'sol llega al suelo, semanas antes de que el hielo suelte el lago.' }),
    note: t({ en: 'Photograph: Hannah Donze, CC0, via Wikimedia Commons',
      es: 'Fotografía: Hannah Donze, CC0, vía Wikimedia Commons' }),
    altText: t({ en: 'A walker on a snowy path between tall pines.',
      es: 'Un caminante en un sendero nevado entre pinos altos.' }) },
  { id: 'ice-art', typeId: 'photo', kind: 'svg', createdAt: 0, updatedAt: 0, // drawn below
    svg: { fileId: 'ice-art.svg', width: TRIM * PX, height: ART * PX },
    altText: t({ en: 'A lake in section: snow, white ice, black ice and water under a low sun.',
      es: 'Un lago en sección: nieve, hielo blanco, hielo negro y agua bajo un sol bajo.' }) },
];

デザイン要素は写真をリソースidで指すので、扉の写真はほかの図と同じように宣言しますが、引用はしません。描くのは扉だけで、どのフロートも届かない仕上がり線まで広げます。ビットマップはページの150dpiで測るので、松林の2000pxは339mmになり、195mmの行長に縮んで、印刷では約260dpiになります。写真には専用の種類を設け、キャプションの接頭辞と番号のテンプレートを空にしているので、松林のキャプションには図番号が付きません。帯はページ幅のtopフロートで、トップフロートはそれを引用したページの次のページに置かれます。その::resource行は偶数ページの1944年の段落のあとにあるので、帯は奇数ページの頭に来ます。

#3 · 引用符を余白にぶら下げる

script.js · 113–131行コード全体で見る
// The glyph is centred in an icon square that the box keeps as a column, size + gap wide,
// left of the text. A negative gap pulls the text back over the square's empty right side,
// and a left padding of −(size + gap) moves that column out into the margin, so the text
// starts on the column's edge and the mark hangs outside it. The frame stays on the column
// (a background would stop short of the mark). « sits lower and runs wider than “, so the
// Spanish mark is set smaller.
const MARK = t({ en: { glyph: '“', size: 50, column: 34 }, // pt; the column is 12 mm
  es: { glyph: '«', size: 28, column: 22.5 } });
// The paddings are optical: once the next paragraph snaps to the grid, the quote has
// the same air above and below it, in both languages.
const quote = { id: 'pullquote', backgroundEnabled: false, marginTop: pt(LEAD),
  marginBottom: pt(0), padding: { top: pt(8), right: pt(0), bottom: pt(7),
    left: pt(-MARK.column) }, // negative: the icon column starts out in the margin
  icon: { kind: 'glyph', glyph: MARK.glyph, fontFamily: 'Instrument Serif',
    size: pt(MARK.size), color: col('lake') },
  titleStyle: { gap: pt(MARK.column - MARK.size) }, // negative too: size + gap = column
  body: { fontFamily: 'Instrument Serif', fontSize: pt(19), lineHeight: pt(1.5 * LEAD),
    textAlign: 'left', hyphenation: false, color: col('lake'), // display type: no hyphens
    italicColor: col('lake'), firstLineIndent: pt(0) } };

グリフのアイコンは本文の横に専用の列を取るので、そのままでは引用が字下げされます。負のgapで、グリフの正方形の右側の空いた部分に本文を戻します。続いて、その列の残りの幅(サイズ + gap、34pt)と同じ幅の負の左パディングで列を余白へ押し出すと、本文は段の端から始まります。ボックスの枠は段の上にとどまるので、背景を付けると引用符の手前で途切れます。正方形は12mm外から始まり、その中央に置いた引用符は、偶数ページの14mmの小口の余白に約6mmぶら下がります。スペイン語版では«を小さく組みます。ギユメは引用符より低い位置に座り、幅も広いからです。

#4 · 囲みを段の頭とページの地にフロートさせる

script.js · 135–159行コード全体で見る
// Floated boxes keep one body line from the text, so they need no margins of their own.
const glance = { id: 'glance', placement: 'top', // floats to the next column head: no hole
  backgroundEnabled: false, // one device, the stripe; the text keeps the column's edges
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('lake') },
  padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
    color: col('lake'), gap: mm(2) },
  body: { fontFamily: 'Instrument Sans', fontSize: pt(8.6), lineHeight: pt(12.2),
    textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } }; // ink from bodyText
const numbers = { id: 'numbers', span: 'page', placement: 'bottom', // floats to a page foot
  background: col('ink'), columnGap: mm(8),
  padding: { top: mm(5), right: mm(6), bottom: mm(5.5), left: mm(6) },
  titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
    color: col('ice'), gap: mm(1) },
  body: { fontFamily: 'Instrument Sans', fontSize: pt(9), lineHeight: pt(12.5),
    textAlign: 'left', hyphenation: false, color: col('ice'), firstLineIndent: pt(0) } };
// The panel's figures are level-4 headings (#### 31), a level the story never uses.
const figures = { level: 4, fontSize: pt(40), lineHeight: pt(40), color: col('ember'),
  marginBottom: pt(4) };
// The end mark is a chip with no visible text: a U+2060 inside, because a chip of spaces
// prints its markup (gotcha: empty-chip). Its lengths are in its own ems: paddingX makes
// the width, and the height is its font size's band (0.8 ascent + 0.25 descent). It is ink,
// not lake: the guide's palette would leave a lake chip teal on its rust page.
const endMark = { id: 'end', background: col('ink'), borderWidth: pt(0), borderRadius: pt(0),
  fontSize: em(0.62), paddingX: em(0.525), paddingY: em(0), gap: em(0.8) }; // 1.05 em square

placement: 'top'にするとデータ囲みはフロートになります。流れから外れて次の空いた段の頭を取るので、囲みが入りきらなかった場所にすき間を残さず、本文は段の地まで続きます。数字のパネルは逆にページの地へフロートし、両段にまたがります。3つの数字は記事が使わないレベル4の見出しで、breaks="3,5"で配置します。これは行ではなく子ブロックを数えます。エンドマークは見える文字のないチップです。paddingXが四角の幅を、チップのフォントサイズが高さを決め、どちらもチップ自身のサイズで1.05emです。

#5 · 号と記事を示す柱

script.js · 82–109行コード全体で見る
const FOLIO_PT = 8.5; // the folio's size in pt
const LABEL_PT = 7.5; // the label's size in pt
const SQUARE = 2.1; // mm: the lake square's side, the folio's cap height
const label = { ...sans, fontSize: pt(LABEL_PT), letterSpacing: pt(1.3), color: col('muted') };
const folio = { fontFamily: 'Instrument Sans', fontWeight: 700, fontSize: pt(FOLIO_PT),
  color: col('ink') };
// A design text's baseline sits 0.8 down its line box, 1.2 × its size (the default lineHeight).
const baseline = (size) => size * 1.2 * 0.8 * 25.4 / 72; // mm from its box's top, size in pt
const HEAD_Y = 12; // mm from the top edge to the folio's box, inside the 22 mm top margin
const LINE = HEAD_Y + baseline(FOLIO_PT); // the heads' one baseline, from the top edge
const pin = (edge, x, y = HEAD_Y) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) } }); // in the margin (gotcha: header-paints-over-text)
const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left
const header = { elements: sides.flatMap(([parity, edge, s]) => [
  { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
    placement: pin(`top-${edge}`, s * OUTER) },
  { kind: 'box', id: `square-${parity}`, style: { backgroundColor: col('lake') },
    placement: { ...pin(`top-${edge}`, s * (OUTER + 7.5), LINE - SQUARE), // on the line
      size: { width: mm(SQUARE), height: mm(SQUARE) } } },
  // The smaller label's baseline sits higher in its box (0.34 mm at 7.5 and 8.5 pt), so its
  // box goes that much lower: folio, square and label share one baseline at any size.
  { kind: 'text', id: `head-${parity}`, ...label,
    content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}',
    placement: pin(`top-${edge}`, s * (OUTER + 11.5), LINE - baseline(LABEL_PT)) },
].map((element) => ({ ...element, parity, pages: 'body' }))) }; // no running heads on openers
const footer = { elements: sides.map(([parity, edge, s]) => ({ kind: 'text', parity,
  id: `drop-folio-${parity}`, ...folio, content: '{pageNumber}', pages: 'opener', align: edge,
  placement: pin(`bottom-${edge}`, s * OUTER, -11) })) }; // an opener's only folio, at the foot

ヘッダーの要素はページの上に描かれ、スペースを確保しないので、どの部品も天の余白の中でページに固定し、ミリメートル単位でずらします。偶数ページはフロントマターから誌名と号を、奇数ページは特集の題を示し、pages: 'body'でどちらも扉には出しません。扉には地のノンブルだけが入ります。デザインのテキストのベースラインは、サイズの1.2倍の行ボックスの上から0.8の位置にあるので、baseline()は7.5ptのラベルを8.5ptのノンブルより0.34mm下げ、四角を同じ行に立たせます。どちらかのサイズを変えても、3つの部品は1本のベースラインを共有したままです。

#6 · 次の記事にも扉を使い回す

script.js · 163–166行コード全体で見る
const ART = 126; // mm: the drawing bleeds less far down the page than the photograph
// On its pages, 'lake' turns rust in the opener, the headings and the boxes, but not in chips.
const guide = { id: 'guide', advancedDesign: opener('ice-art', ART),
  palette: { lake: palette.rust } };

扉は関数なので、フィールドガイドは見出しスタイル# Five Kinds of Ice {style="guide" kicker="Field guide" …}を通じて、自分の図版と深さで同じデザインを得ます。図版は160mmではなく126mmまで裁ち落としで下がります。クレジットとキッカーはdepth - TOPから測り、ほかの文字はキッカーから吊り下がるので、すべて34mm上がり、ガイドの本文はより高い位置から始まります。スタイルのパレットはそれらのページでlakeを錆色に対応させるので、キッカーと、lakeにリンクした小見出しや囲みは、opener()に色の引数を渡さなくても錆色になります。チップはパレットに従わないので、エンドマークはインクの色にしています。

レシピの全体

Sandbox
// ═══ Postext Cookbook · Nº 004 · Magazine feature: photo opener to end mark ═══════
// https://postext.dev/en/cookbook/magazine-feature-opener
// Code: MIT · Text: original (CC BY 4.0) · Photos: Ales Krivec, Hannah Donze (CC0)
// Fonts: Literata, Instrument Serif, Instrument Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// A nature feature from a winter issue. The level-1 heading carries its kicker, standfirst,
// byline and photo credit as attributes, and one opener design lays them out under a bleed
// photograph; the story runs on with a pull quote, a fact box, a photo band, a numbers panel
// and an end mark, and the next item reuses the opener with a drawing in place of the photo.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'magazine-feature-opener';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Every colour below is linked to this palette by id.
const palette = {
  ink: '#15191c', // text: a blue-black
  lake: '#2d6a7d', // the accent: kickers, crossheads, the quote, the fact box's stripe
  ember: '#c8773d', // the panel's figures: 5.2:1 on ink (only 3.4:1 on paper)
  rust: '#9a5a2e', // the field guide's accent, swapped in for 'lake' by its heading style
  ice: '#dbe8ec', // the type on the dark panel and the drawing's sky
  rule: '#c7cdd1', // the drawing's far ridge and air bubbles
  muted: '#66707a', // running heads, credits, the colophon
  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' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'lake (defaults)', value: { hex: palette.lake, model: 'hex' } },
];
const TRIM = 225; // page width in mm, shared with the drawing
const INNER = 16; // inner margin in mm
const OUTER = 14; // outer margin in mm: folios and running heads align to it
const LEAD = 13.4; // body leading in pt: the baseline grid

// #region answer: a bleed photo, then the heading's kicker, headline, standfirst, byline and credit
const TOP = 22; // top margin in mm: an opener's container starts here
const sans = { fontFamily: 'Instrument Sans', fontWeight: 600, textTransform: 'uppercase' };
const HEAD = 118; // mm: the headline's measure; the standfirst takes the rest of the line
// Empty padding paints nothing but counts: the story starts on the first grid line at least
// 5 mm under the lower of the headline and the byline, however many lines each one runs to.
const air = { padding: { bottom: mm(5) } };
const at = (id, edge, x, y, width) => ({ anchor: { to: id, edge },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = (resourceId, depth) => ({ // depth: how far down the page the picture bleeds
  enabled: true, // no minHeight: the story starts under the headline and byline (see air)
  slot: {
    elements: [ // the photo is an element, not a float: no float reaches the trim
      { kind: 'image', id: 'photo', resourceId, placement: { anchor: { to: 'bleed',
        edge: 'top-left' }, size: { width: 'fill', height: mm(depth) } } },
      // The photo hangs from the page's top edge, the words from the container, TOP mm lower:
      // depth − TOP is the photo's foot, so the credit sits 2 mm under it and the kicker 9 mm.
      { kind: 'text', id: 'credit', content: '{attr.credit}', ...sans, fontWeight: 500,
        fontSize: pt(6.5), letterSpacing: pt(0.6), color: col('muted'), align: 'right',
        placement: at('container', 'top-right', 0, depth - TOP + 2) },
      { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...sans, fontSize: pt(8.5),
        letterSpacing: pt(1.7), color: col('lake'), align: 'left',
        placement: at('container', 'top-left', 0, depth - TOP + 9) },
      { kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Instrument Serif',
        fontSize: pt(58), color: col('ink'), align: 'left', overflow: 'wrap', box: air,
        lineHeight: 0.94, // a multiple of the size (gotcha: design-lineheight-multiple)
        placement: at('#kicker', 'below', 0, 2.5, HEAD) },
      { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', italic: true,
        fontFamily: 'Instrument Serif', fontSize: pt(13.5), lineHeight: 1.22, // a multiple
        color: col('ink'), align: 'left',
        overflow: 'wrap', // gotcha: overflow-ellipsis-default
        placement: at('#headline', 'right-of', 7, 3.2) }, // wraps at the container's edge
      { kind: 'text', id: 'byline', content: '{attr.byline}', ...sans, fontSize: pt(7.5),
        letterSpacing: pt(1.3), color: col('ink'), align: 'left', box: air,
        placement: at('#standfirst', 'below', 0, 3.5) },
    ],
  },
}); // hook-up: headings.levels[0] = { level: 1, span: 'page', breakBefore, advancedDesign:
// opener('lake', 160) }. {titleText} prints the heading's text, {attr.<key>} its <key>="…":
// # The Lake That Keeps Time {kicker="…" standfirst="…" byline="…" credit="…"}
// #endregion

// #region heads: magazine and issue on the verso, the story on the recto, a lake square
const FOLIO_PT = 8.5; // the folio's size in pt
const LABEL_PT = 7.5; // the label's size in pt
const SQUARE = 2.1; // mm: the lake square's side, the folio's cap height
const label = { ...sans, fontSize: pt(LABEL_PT), letterSpacing: pt(1.3), color: col('muted') };
const folio = { fontFamily: 'Instrument Sans', fontWeight: 700, fontSize: pt(FOLIO_PT),
  color: col('ink') };
// A design text's baseline sits 0.8 down its line box, 1.2 × its size (the default lineHeight).
const baseline = (size) => size * 1.2 * 0.8 * 25.4 / 72; // mm from its box's top, size in pt
const HEAD_Y = 12; // mm from the top edge to the folio's box, inside the 22 mm top margin
const LINE = HEAD_Y + baseline(FOLIO_PT); // the heads' one baseline, from the top edge
const pin = (edge, x, y = HEAD_Y) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) } }); // in the margin (gotcha: header-paints-over-text)
const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left
const header = { elements: sides.flatMap(([parity, edge, s]) => [
  { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
    placement: pin(`top-${edge}`, s * OUTER) },
  { kind: 'box', id: `square-${parity}`, style: { backgroundColor: col('lake') },
    placement: { ...pin(`top-${edge}`, s * (OUTER + 7.5), LINE - SQUARE), // on the line
      size: { width: mm(SQUARE), height: mm(SQUARE) } } },
  // The smaller label's baseline sits higher in its box (0.34 mm at 7.5 and 8.5 pt), so its
  // box goes that much lower: folio, square and label share one baseline at any size.
  { kind: 'text', id: `head-${parity}`, ...label,
    content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}',
    placement: pin(`top-${edge}`, s * (OUTER + 11.5), LINE - baseline(LABEL_PT)) },
].map((element) => ({ ...element, parity, pages: 'body' }))) }; // no running heads on openers
const footer = { elements: sides.map(([parity, edge, s]) => ({ kind: 'text', parity,
  id: `drop-folio-${parity}`, ...folio, content: '{pageNumber}', pages: 'opener', align: edge,
  placement: pin(`bottom-${edge}`, s * OUTER, -11) })) }; // an opener's only folio, at the foot
// #endregion

// #region quote: a pull quote whose mark hangs in the margin, outside the text's edge
// The glyph is centred in an icon square that the box keeps as a column, size + gap wide,
// left of the text. A negative gap pulls the text back over the square's empty right side,
// and a left padding of −(size + gap) moves that column out into the margin, so the text
// starts on the column's edge and the mark hangs outside it. The frame stays on the column
// (a background would stop short of the mark). « sits lower and runs wider than “, so the
// Spanish mark is set smaller.
const MARK = t({ en: { glyph: '“', size: 50, column: 34 }, // pt; the column is 12 mm
  es: { glyph: '«', size: 28, column: 22.5 } });
// The paddings are optical: once the next paragraph snaps to the grid, the quote has
// the same air above and below it, in both languages.
const quote = { id: 'pullquote', backgroundEnabled: false, marginTop: pt(LEAD),
  marginBottom: pt(0), padding: { top: pt(8), right: pt(0), bottom: pt(7),
    left: pt(-MARK.column) }, // negative: the icon column starts out in the margin
  icon: { kind: 'glyph', glyph: MARK.glyph, fontFamily: 'Instrument Serif',
    size: pt(MARK.size), color: col('lake') },
  titleStyle: { gap: pt(MARK.column - MARK.size) }, // negative too: size + gap = column
  body: { fontFamily: 'Instrument Serif', fontSize: pt(19), lineHeight: pt(1.5 * LEAD),
    textAlign: 'left', hyphenation: false, color: col('lake'), // display type: no hyphens
    italicColor: col('lake'), firstLineIndent: pt(0) } };
// #endregion

// #region boxes: a fact box at a column head, a dark panel at the page foot, the end mark
// Floated boxes keep one body line from the text, so they need no margins of their own.
const glance = { id: 'glance', placement: 'top', // floats to the next column head: no hole
  backgroundEnabled: false, // one device, the stripe; the text keeps the column's edges
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('lake') },
  padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
    color: col('lake'), gap: mm(2) },
  body: { fontFamily: 'Instrument Sans', fontSize: pt(8.6), lineHeight: pt(12.2),
    textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } }; // ink from bodyText
const numbers = { id: 'numbers', span: 'page', placement: 'bottom', // floats to a page foot
  background: col('ink'), columnGap: mm(8),
  padding: { top: mm(5), right: mm(6), bottom: mm(5.5), left: mm(6) },
  titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
    color: col('ice'), gap: mm(1) },
  body: { fontFamily: 'Instrument Sans', fontSize: pt(9), lineHeight: pt(12.5),
    textAlign: 'left', hyphenation: false, color: col('ice'), firstLineIndent: pt(0) } };
// The panel's figures are level-4 headings (#### 31), a level the story never uses.
const figures = { level: 4, fontSize: pt(40), lineHeight: pt(40), color: col('ember'),
  marginBottom: pt(4) };
// The end mark is a chip with no visible text: a U+2060 inside, because a chip of spaces
// prints its markup (gotcha: empty-chip). Its lengths are in its own ems: paddingX makes
// the width, and the height is its font size's band (0.8 ascent + 0.25 descent). It is ink,
// not lake: the guide's palette would leave a lake chip teal on its rust page.
const endMark = { id: 'end', background: col('ink'), borderWidth: pt(0), borderRadius: pt(0),
  fontSize: em(0.62), paddingX: em(0.525), paddingY: em(0), gap: em(0.8) }; // 1.05 em square
// #endregion

// #region guide: the next item reuses the opener with its own picture, depth and accent
const ART = 126; // mm: the drawing bleeds less far down the page than the photograph
// On its pages, 'lake' turns rust in the opener, the headings and the boxes, but not in chips.
const guide = { id: 'guide', advancedDesign: opener('ice-art', ART),
  palette: { lake: palette.rust } };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  resourceTypes: [photoType], // one unnumbered type for every picture (see the resources)
  colorPalette,
  page: { width: mm(TRIM), height: mm(297), margins: { top: mm(TOP), bottom: mm(20),
    left: mm(INNER), right: mm(OUTER), mirror: true }, // a magazine trim; left is the inner side
    dpi: 150 }, // the layout's pixels per inch, which bitmaps are measured in (see 'thaw')
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { fontFamily: 'Literata', fontSize: pt(9.6), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(3.5), indentAfterHeading: false,
    minWordSpacing: 0.65, // a space never shrinks below 65 % (the default allows 60 %)
    runtMinCharacters: 40 }, // 40 spaces' width, about 20 letters: no one-word last lines
  // Hyphenation, optimal line breaking and widow control are on by default.
  headings: {
    fontFamily: 'Instrument Serif', fontWeight: 400, color: col('ink'),
    // Under a top photo band on a closing page, this lever can drop the shorter column a line,
    // out of line with the other (gotcha: float-stretch-closing-page). The switch covers every
    // page, not only closing ones; the shipped copy does not trip it, edited copy might.
    balancing: { stretchAfterFloats: false },
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break);
      // 'any' lets the next item open on the following page, recto or verso.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginBottom: pt(0), advancedDesign: opener('lake', 160) },
      { level: 2, fontSize: pt(15), lineHeight: pt(LEAD), italic: true, color: col('lake'),
        marginTop: pt(LEAD), marginBottom: pt(0) }, // crossheads, one grid line above
      figures,
    ],
  },
  headingStyles: [guide],
  calloutStyles: [quote, glance, numbers],
  chipStyles: [endMark],
  captionStyle: { fontFamily: 'Instrument Sans', fontSize: pt(7.6), gap: mm(2), // ink: bodyText's
    note: { fontSize: pt(6.5), color: col('muted'), gap: mm(0.6) } },
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Instrument Sans', fontSize: pt(6.6),
    lineHeight: pt(9.4), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0),
    marginTop: pt(2 * LEAD) }],
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// #region resources: two photographs and a drawing, declared once, by their real pixels
// The pictures are not numbered: their own type, with an empty caption prefix and template,
// keeps a figure number off the pine wood's caption.
const photoType = { id: 'photo', name: t({ en: 'Photograph', es: 'Fotografía' }),
  shortLabel: t({ en: 'photo', es: 'foto' }), captionPrefix: '', numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal' };
const PX = 10; // the drawing's pixels per mm
const resources = [
  { id: 'lake', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0, // never cited:
    // the opener fits it inside its box, so the JPEG is cropped to the box, 225 × 160 mm
    bitmap: { fileId: 'lake-2000.jpg', format: 'jpeg', width: 2000, height: 1422 },
    altText: t({ en: 'A still mountain lake mirroring clouds between autumn slopes.',
      es: 'Un lago de montaña en calma que refleja las nubes entre laderas otoñales.' }) },
  { id: 'thaw', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    // Pixels at the page's 150 dpi (gotcha: bitmap-print-size): 2000 px make 339 mm, so the
    // band shrinks to the 195 mm measure. At 300 dpi it would print 169 mm wide.
    bitmap: { fileId: 'thaw-2000.jpg', format: 'jpeg', width: 2000, height: 944 },
    // A top float opens the page after its ::resource line (gotcha: top-float-next-page):
    // the line sits on the verso, so the band heads the recto.
    placement: { position: 'top', span: 'page' },
    caption: t({ en: 'Early March in the pine wood above the shore: the snow goes first where '
      + 'the sun reaches the ground, weeks before the ice lets go of the lake.',
    es: 'Principios de marzo en el pinar sobre la orilla: la nieve se retira primero donde el '
      + 'sol llega al suelo, semanas antes de que el hielo suelte el lago.' }),
    note: t({ en: 'Photograph: Hannah Donze, CC0, via Wikimedia Commons',
      es: 'Fotografía: Hannah Donze, CC0, vía Wikimedia Commons' }),
    altText: t({ en: 'A walker on a snowy path between tall pines.',
      es: 'Un caminante en un sendero nevado entre pinos altos.' }) },
  { id: 'ice-art', typeId: 'photo', kind: 'svg', createdAt: 0, updatedAt: 0, // drawn below
    svg: { fileId: 'ice-art.svg', width: TRIM * PX, height: ART * PX },
    altText: t({ en: 'A lake in section: snow, white ice, black ice and water under a low sun.',
      es: 'Un lago en sección: nieve, hielo blanco, hielo negro y agua bajo un sol bajo.' }) },
];
// #endregion

const markdown = String.raw`---
Markdownの見本 · 107行 · content.en.mdtitle: "Boreal" subtitle: "Winter 2026" --- # The Lake That Keeps Time {kicker="Climate · Field report" standfirst="For a hundred and fifteen winters, one family has written down the day their lake froze and the day it let go. Their notebooks are now one of the longest climate records in the mountains." byline="Words by Ingrid Solberg" credit="Late October, before the freeze · Photograph: Ales Krivec, CC0"} The notebook lives in a biscuit tin on the kitchen dresser, between the matches and the parish calendar. It is the fourth of its kind. The first three are in the regional archive now, wrapped in acid-free paper, but Hanna Brenner still prefers the tin. “The archive asked for this one as well,” she says, lifting the lid. “I told them we are still using it.” Her great-grandfather ran the ferry across the lake from 1906, and he began the record for a practical reason: a man who rows passengers over water needs to know when the water will carry a sledge instead. On the ninth of December 1911 he wrote, in pencil, *Frozen. All of it.* On the fourth of April he noted that the ice had gone out overnight, with a noise “like a door slammed somewhere in the mountains”. Every winter since, someone in the family has kept up the record, one line in pencil for every year. One hundred and fifteen lines in pencil do not look like much. Printed out, they fit on two sheets of paper. But the nearest weather station, in the next valley, opened only in 1931, and few lakes anywhere have an ice record as long as this one. The oldest, on Lake Suwa in Japan, was begun by Shinto priests in 1443 and notes the day a ridge of ice forms across the lake. ## A thermometer with a lid “A lake is a thermometer that you read once a year,” says Vera Lind, a limnologist who has spent six winters on the ice here. “The air changes from hour to hour. The lake averages all of that. When it freezes and when it thaws tells you what the whole season was like.” The reason lies in an odd habit of water. Most liquids grow denser as they cool. Fresh water does too, but only down to about 4 °C; below that it becomes lighter again, which is why a lake freezes from the top down; ice, lighter still, floats on it. In autumn, as the surface chills, the cold water sinks and warmer water rises to take its place. The lake turns over, again and again, for weeks. Only when the whole column has reached 4 °C can the surface cool further without sinking, and only then, on a still, clear night, can it skin over. :::callout{type="pullquote"} *A person stood on the same jetty and looked at the same water.* ::: That is why the freeze comes late and all at once. Hanna remembers standing on the jetty as a girl and watching the last open water close in the middle of the lake “like a pupil shrinking in the light”. By morning her father was walking out with an axe to measure the thickness. Ten centimetres will hold a person, the family rule went; twenty will hold the sledge; thirty, a horse. ## What the ice is made of The first ice to form is black ice, grown straight down from the water beneath it and so clear that you can see stones on the bottom through a hand’s width of it. It is the strongest ice a lake makes. Snow brings a second kind. A heavy fall presses the sheet down until water seeps up through the cracks and soaks the snow into slush, which freezes into white ice, cloudy with trapped air and barely half as strong. Lind drills through both every week of the winter. The cores come up banded like a tree trunk, and she reads them the same way: a thick black layer means a cold, dry December; a stack of white bands means storm after storm. Under the ice the lake goes on living. The water at the bottom stays close to 4 °C all winter. The trout slow down but keep feeding. Light still passes through clear ice, and algae grow in the green gloom beneath it, which surprised the first scientists who went looking. :::callout{type="glance" title="At a glance"} **Altitude** 1,540 m above sea level **Area** 3.2 km², deepest point 63 m **Record kept since** the winter of 1911–12 **Ice cover, 1911–1960** 118 days a year on average **Ice cover, 1991–2025** 87 days a year on average **Winters with no ice** 2007 and 2020 ::: ## Counting the days For the first fifty years of the record, the lake stayed frozen for an average of 118 days a winter. Since 1991 the average has been 87. The freeze now comes about a fortnight later than it did in the ferryman’s time, and the ice goes out more than two weeks earlier. The change has not been smooth. Some winters of the 1960s, and again the winter of 2010, were as long as any in the notebooks. But the open winters are new. In 2007 and again in 2020 the lake never froze across at all, and the family wrote a single word for the year: *open*. The winter of 1944 nearly went unrecorded. The ferryman’s son was away at the war, and the dates for that year are in another hand, small and upright, with a note in the margin: *kept by his mother*. She had written them on the back of a ration card and copied them in when he came home. Lind has checked them against the weather station in the next valley. They are, she says, as good as any in the notebooks. ::resource{id="thaw"} Lind is careful about what one lake can say. A single record is a local story: the valley has its own winds, and the lake its own depth and shape. But the notebooks agree with hundreds of other lakes across the northern hemisphere, from Finland to Japan, where the ice seasons have shortened by weeks over the past century. “I trust it because the method never changed,” she says. “A person stood on the same jetty and looked at the same water.” She has added instruments of her own. A chain of temperature loggers hangs from a buoy over the deepest part of the lake, reading every fifteen minutes from the surface to the floor, and a camera on the church tower photographs the ice at noon each day. But when she sets them beside the notebooks, the dates hold up: the family has never been more than a day or two from what the loggers record. ## Ice-out The ice rarely leaves quietly. Through March the sun works on it from above and warmer streams from below, and the black ice rots into long vertical crystals, candle ice, that chime against each other when the wind moves them. Then a warm rain or a south wind breaks the sheet, and within a day or two the surface is open. The old fishermen claimed they could hear it happen from the village. The date matters to more than the ferry. The spring bloom of algae, which feeds everything else in the lake, starts when the ice breaks up and sunlight pours into the water. Perch spawn in the shallows and midges hatch on a timetable that the ice sets. When ice-out moves earlier, those timetables can drift apart, and a young fish may hatch into water whose food has already come and gone. :::callout{type="numbers" title="In numbers"} :::columns{count=3 breaks="3,5"} #### 31 fewer days of ice each winter than in the first fifty years of the notebooks #### 115 winters recorded, in pencil, by five generations of one family #### 4 °C the temperature of the lake floor all winter, where water is densest ::: ::: There are human timetables too. The winter road across the lake, which once carried hay, timber and the doctor’s sleigh, has not been opened officially since 2014. The skating club moved its races to an artificial rink in the town. The ice fishermen still go out, but later in the season, and they carry ropes. Hanna’s grandfather remembered the other extreme. In the hard winter of 1963 the ice grew to sixty centimetres, and a baker from the next village drove his van straight across the lake to save the long road round. The entry for that year has a small drawing of the van in the margin, the only picture in all four notebooks. Hanna is not sentimental about any of this. She teaches mathematics at the valley school, and she has turned the notebooks into a lesson: every class plots the two dates of each winter and draws a line through the dots. “The children always find the trend by themselves,” she says. “I hand out graph paper and a ruler and say nothing about climate.” Some of her pupils have gone further. Two years ago a class set the notebooks beside the school’s own record of when the cherry trees in the yard came into flower, and found that both dates had moved by about the same number of days. Lind now shows their chart at conferences, with the children’s names in the corner. The record will go on. Hanna’s son, who is fourteen, has taken over the November walks to the jetty to watch for the morning when the last dark patch of water disappears. He keeps a spreadsheet now, and every night a copy goes to a server at the university, where Lind stores her logger readings. But on the day the lake freezes he does what his great-great-grandfather did in 1911 and writes the date in pencil, in the notebook in the tin. Last winter the lake froze on the twenty-first of December and opened again on the eighteenth of March: eighty-seven days, almost exactly the modern average. Hanna wrote *ordinary* beside the dates, then crossed the word out. “Ordinary for now,” she says. :chip[⁠]{style="end"} # Five Kinds of Ice {style="guide" kicker="Field guide" standfirst="How to read a frozen lake before you trust it with your weight, from the clear black sheet of early winter to the rotten candles of March." byline="Text by the editors" credit="Illustration generated in code for Boreal"} **Black ice.** The first ice of the season grows straight down from calm water in long crystals that let the light through. It looks dark because you are seeing the lake beneath it. Ten centimetres of new black ice will bear a walker, and on a calm morning you can watch fish pass under your boots. **White ice.** When snow loads the sheet, water seeps up through the cracks, soaks the snow and freezes into a milky layer full of air. It is roughly half as strong as black ice, so count it at half its thickness when you judge a crossing. On a sunny afternoon it softens further. **Slush.** A heavy snowfall can push the ice below the waterline and leave a layer of wet snow on top, kept liquid under an insulating crust. It is heavy going on foot and treacherous on skis, and it is where most white ice begins. Grey patches on fresh snow are its warning sign. **Shore ice.** The ice along the edge is the first to form and the first to go. Springs, reeds and the warmth of the ground weaken it, and by March a strip of open water, the moat, often separates the sheet from the land. You can walk out on good ice and find that you cannot walk back. **Candle ice.** In spring the sun rots the black ice along the boundaries of its crystals. The sheet can still look solid while it has become a bundle of loose vertical rods that give way under a boot. When the surface turns grey and granular, and the rods chime in the wind, stay on the shore. Never judge ice by its colour alone. Measure it every few steps, carry a pair of ice picks round your neck, and ask the people who live by the shore. :chip[⁠]{style="end"} :::paragraphs{style="colophon"} Set in Literata, Instrument Serif and Instrument Sans (SIL Open Font License) · Text: Postext Cookbook, CC BY 4.0 · Photographs: Ales Krivec and Hannah Donze, CC0, via Wikimedia Commons · The lake, the family, the scientists and the writer are fictional. :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the guide's picture, a lake in section, drawn in code with a seeded PRNG function mulberry32(seed) { return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } function iceArt() { // in mm, TRIM × ART: sky, shore, snow, white ice, black ice, water const rand = mulberry32(14); const f = (id, a = 1) => `fill="${palette[id]}"${a < 1 ? ` fill-opacity="${a}"` : ''}`; const rect = (x, y, w, h, paint) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" ${paint}/>`; const ridge = (base, amp, step, paint) => { // a mountain line, closed down to the shore let d = `M0 ${base}`; for (let x = 0; x <= TRIM; x += step) d += `L${x} ${(base - rand() * amp).toFixed(1)}`; return `<path d="${d}L${TRIM} 60L0 60Z" ${paint}/>`; }; const pines = Array.from({ length: 46 }, (_, i) => { // the far shore, a row of spruces const x = i * 5 + rand() * 3; const h = 4 + rand() * 5; return `<path d="M${x.toFixed(1)} ${60 - h}l${h * 0.28} ${h}h${-h * 0.56}Z" ` + `${f('ink', 0.85)}/>`; }).join(''); const bubbles = Array.from({ length: 70 }, () => { // air trapped in the white ice const r = 0.25 + rand() * 0.6; const [cx, cy] = [(rand() * TRIM).toFixed(1), (72 + rand() * 6).toFixed(1)]; return `<circle cx="${cx}" cy="${cy}" r="${r}" ${f('paper')} stroke="${palette.rule}" ` + 'stroke-width="0.15"/>'; }).join(''); // Black ice: a solid sheet on the winter side (left) that rots into candles towards spring, // ten rods evenly spaced, each shorter and thinner than the last. const candles = Array.from({ length: 10 }, (_, i) => rect(150 + i * 7.5, 79, (2.6 - i * 0.1).toFixed(2), (12.5 - i * 0.55 - rand() * 1.5).toFixed(1), f('ink'))).join(''); const clouds = [[18, 15, 52], [98, 8, 38], [146, 21, 30]].map(([x, y, w]) => [[x, y, w], [x + w * 0.22, y - 2.4, w * 0.42]].map(([cx, cy, cw]) => `<rect x="${cx}" y="${cy}" ` + `width="${cw}" height="4.4" rx="2.2" ${f('paper', 0.7)}/>`).join('')).join(''); const fish = (x, y, s) => `<path d="M${x} ${y}c${3 * s} ${-2 * s} ${7 * s} ${-2 * s} ${9 * s} 0` + `c${-2 * s} ${2 * s} ${-6 * s} ${2 * s} ${-9 * s} 0Z` // the body, then the tail + `m0 0l${-2.5 * s} ${-1.6 * s}v${3.2 * s}Z" ${f('ink', 0.55)}/>`; return `<svg xmlns="http://www.w3.org/2000/svg" width="${TRIM * PX}" height="${ART * PX}" ` + `viewBox="0 0 ${TRIM} ${ART}">` + rect(0, 0, TRIM, ART, f('ice')) + clouds // winter sky + `<g transform="translate(0 ${ART - 112})">` // the lake keeps to the foot of the picture + `<circle cx="188" cy="24" r="9" ${f('ember', 0.9)}/>` // a low March sun + ridge(38, 16, 9, f('rule')) + ridge(48, 12, 6, f('muted', 0.55)) + pines + rect(0, 60, TRIM, 11, f('paper')) + rect(0, 60, TRIM, 11, f('ice', 0.3)) // snow + rect(0, 71, TRIM, 8, f('ice', 0.75)) + bubbles // white ice, cloudy with air + rect(0, 79, TRIM, 33, f('lake')) // the water, 4 °C at the floor + rect(0, 79, 146, 13, f('ink')) + candles + fish(60, 101, 1) + fish(128, 106, 0.8) + '</g></svg>'; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) Literata: ['400', '400i', '700'], 'Instrument Serif': ['400', '400i'], 'Instrument Sans': ['400', '500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await Promise.all([loadImage('lake-2000.jpg', asset('lake-2000.jpg')), loadImage('thaw-2000.jpg', asset('thaw-2000.jpg')), loadSvg('ice-art.svg', iceArt())]); const continuation = { pageNumbering: { startAt: 57 } }; // pages 57–60 of the issue const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'Magazine feature: photo opener to end mark', es: 'Reportaje de revista: de la foto de apertura al signo final' }) });
キット · 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上のレシピのフォルダー ↗ (新しいタブで開きます)

アレンジ

#どの記事も奇数ページから始める

'odd'にすると、フィールドガイドは61ページに移り、60ページは白紙のまま残ります。

- { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
+ { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },

#題を色の帯に載せる

写真の代わりに色の帯と大きな番号を使う教科書の章については、裁ち落としの帯を敷いた章扉を参照してください。

よくあるつまずき

つまずき

属性値には{も}も書けない。"を含む値は一重引用符で囲む

属性値は閉じ波かっこで終わるため、{や}を含められません。二重引用符を含む値は一重引用符で囲みます。ドル記号は問題ありません。 見出しの属性 →

つまずき

ビットマップは文書のdpiでのpxで配置される。印刷サイズを宣言する

ビットマップのリソースの大きさは、ファイルからではなく、宣言した幅と高さ(文書のdpiでのピクセル数)から決まります。印刷サイズのピクセル数(刷り上がりの幅で約300 dpi)を宣言すれば、図は正しい大きさに収まり、鮮明さも保たれます。 リソースとしての図と表 →

つまずき

'top'のフロートは参照したページには入らない

フロートが自分の参照より上に置かれることはありません。そのため、Nページで参照したページ幅の'top'フロートはN+1ページの頭に置かれます。もっと前で参照するか、参照したページの下端に入れる位置'auto'または'bottom'を使ってください。 図の配置 →

つまずき

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

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

つまずき

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

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

つまずき

ヘッダーとフッターの要素は本文の上に描かれる

ヘッダーとフッターの要素はページの上に重ねて描かれ、本文の領域はそのための場所を空けません。要素は余白の中に収めてください。その場所を確保するのは余白です。 柱とノンブル →

つまずき

デザインのテキストのoverflowの既定値は'ellipsis-end'

幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。 ページデザインのテキスト・罫・ボックス →

つまずき

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

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

つまずき

最終ページでは、topフロートが最後の段を押し下げることがある

postext 1.4.1では、章や話がページ幅のtopフロートで始まるページで終わり、その行が段の間で不均等に分かれると、stretchAfterFloatsは短いほうの段を短いまま終わらせず、フロートの下に空行を1行加えます。そのため、2つの段の始まりの行がそろわなくなります。headings.balancing.stretchAfterFloatsをfalseにするか、原稿を偶数行に収めてください。 段末そろえ →

つまずき

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

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

つまずき

デザインのテキストのlineHeightは倍率で、寸法ではない

デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です(lineHeight: 1.05)。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。 ページデザインのテキスト・罫・ボックス →

つまずき

:::columnsは囲みの中でしか効かず、分割されない

:::columnsは囲みの外では無視されます。また、囲みが分割されても、columnsグループの内部で切れることはありません。breaks属性は子ブロックを数え、入れ子の囲みは1つと数えます。 囲みの中の段組み →

Sandboxの検査 · bitmapTooSmall

低解像度の画像

原因. ビットマップがピクセル数の1.5倍を超える幅で描画されるため、印刷するとぼやけて見えます。

対処. 印刷サイズで約300dpiの画像を用意し、ビットマップの実際の幅と高さを宣言してください。 ドキュメント →

  • 画像要素は写真をボックスの中に収め、切り抜くことはありません。JPEGをボックスの縦横比、ここでは225 × 160mmに切っておきます。そうしないと写真が縮み、ページの縁に白い帯が残ります。
  • ぶら下がる引用符には余白が必要です。編集でプルクオートが右の段に移ると、引用符は6mmの段間を埋め、左の段の本文に触れます。引用を1段落前か後ろに移してください。
  • 記事は59ページで、数字のパネルの上で終わるようにコピーフィットしています。数行増えると記事の末尾が独立したページに送られるので、編集したあとは両方の言語でもう一度コピーフィットしてください。
  • runtMinCharacters: 40は1語だけの最終行を防ぎます。行分割で短い最終行を避けられないとき、エンジンは段落を1行短く組み、語間を詰め、それでも足りなければ字間を最大0.01em詰めます。編集したあとは、そこまで詰めて組まれた段落を探し、代わりに数語を削るか足してください。収録した原稿は、どちらの言語でもこの修正を必要としません。

クレジット

レシピ
Ignacio Ferro
本文
書き下ろしの文章, CC BY 4.0
画像
フォント
Literata (SIL OFL 1.1) · Instrument Serif (SIL OFL 1.1) · Instrument Sans (SIL OFL 1.1)
Sandbox