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

レシピ集 · 第7章 · 図と画像

裁ち落としの図版で見せる写真エッセイ

図版はリストから作る見出しスタイルで、どれもページを仕上がりまで埋めます。嵐の図版は2つに分けて、見開きののどをまたがせます。

このページの内容
ジャンル
写真集
出力
Canvas
レベル
中級
Postext
Postext 1.8.3で検証済み
1.8.0以上が必要
ライセンス
2026年9月28日更新
コードMIT · 本文CC BY 4.0

4–5ページ(全9ページ)

  • スペイン語の見本:日本語版はまだありません
  • 仕上がり280 × 210 mm
  • 1段
  • Andada Pro 10.5/15
  • Syne
  • Syne Mono
  • 9ページ
  • レベル
  • Postext 1.8.3
  • 組版時間11 ms
  • コード180行

かんたんな説明

山の一日を6枚の写真で見せる横長の写真集です。ページの端まで写真で埋める方法と、1枚の写真を向かい合う2ページにまたがせる方法を示します。

できあがり

『Sierra』は280 × 210 mmのページに組んだ横長の写真集です。花崗岩の山並みの一日を、夜明けから初雪の翌朝まで6枚の図版で追います。どの図版もページを仕上がりまで埋め、左下の隅に小さなローマ数字と時刻だけを載せます。最初の図版には題名も入り、72 ptのSyneで組んだSIERRAを夜明けの空に白抜きで置きます。嵐は4ページと5ページののどをまたいで1枚の絵になります。3つの短い文章は、のど寄りの100 mmの段に組み、それぞれのあとに続く図版と向かい合います。嵐の文章は、その下にフロートで置いた小さめの夕暮れの図版で終わり、そのローマ数字はキャプションに入ります。最後の図版は、2枚目と同じカールを同じ岩から写した雪景色です。図版は拡散モデルで生成した写真で、雪の図版は正午の図版を編集して作ったので、2つの眺めは一致します。

このレシピが答える質問

  • 章ごとに異なる写真、色、章扉のバリエーションを与えるには?
  • 意図的に白ページを入れたり、節を新しい見開きから始めたりするには?
  • 章扉や白ページで柱を隠したり、空白の偶数ページを部の色で塗ったりするには?
  • 番号のないアートワーク(オーナメント、カット、ロゴ)を組むには?

手短な答え

script.js · 35–59行コード全体で見る
// '{attr.art}' is filled in per heading, so a plain plate names its picture on its line:
// # Noon {style="lamina" art="mediodia" …}. Only the cover and the last plate draw extras.
const PLATES = [ // the style id, its picture, the ink of its caption, anything extra it draws
  { id: 'alba', art: 'alba', extra: (colour) => cover(colour) }, // an arrow: cover() is below
  { id: 'lamina', art: '{attr.art}' }, // any plate on a page of its own
  { id: 'pliego', art: '{attr.art}', half: 'verso' }, // one picture across a spread:
  { id: 'pliego-recto', art: '{attr.art}', half: 'recto' }, // the left half, then the right
  { id: 'nieve', art: 'nieve', ink: 'ink', extra: (colour) => colophon(colour) },
];
const plate = ({ id, art, half, ink = 'white', extra = () => [] }) => ({
  id, span: 'page', // an opener: a span heading always starts a page of its own
  // The left half opens on an even page, a verso, so the right half faces it across the
  // gutter (gotcha: parity-page1-recto).
  ...(half === 'verso' && { breakBefore: { enabled: true, parity: 'even' } }),
  // No margins: the plate's column is the page, and minHeight fills it, so what follows starts
  // on the next page. Images reserve no room (gotcha: opener-image-no-reserve), and a minHeight
  // taller than the column is dropped whole (gotcha: opener-taller-than-column).
  margins: { top: mm(0), bottom: mm(0), left: mm(0), right: mm(0) },
  advancedDesign: { enabled: true, minHeight: mm(PAGE.height), slot: { elements: [
    { kind: 'image', id: 'picture', resourceId: art, // the recto half is the same picture,
      placement: at('page', 'top-left', half === 'recto' ? -PAGE.width : 0, 0, // moved left
        { width: mm(half ? 2 * PAGE.width : PAGE.width), height: mm(PAGE.height) }) },
    ...(half === 'recto' ? [] : caption(col(ink))), ...extra(col(ink)),
  ] } },
});

材料

種類
Andada Pro, Syne, Syne Mono (SIL OFL 1.1)
素材
  • alba-1600.jpg
  • atardecer-2000.jpg
  • mediodia-1600.jpg
  • nieve-1600.jpg
  • noche-1600.jpg
  • tormenta-2400.jpg
  • Plate I, dawn: ridges fading into a peach haze under a dark blue sky (Generated With Diffusion Models, オリジナル)
  • Plate II, noon: a granite cirque and its lake (Generated With Diffusion Models, オリジナル)
  • Plate III, storm: rain, lightning and a break of sun across a spread (Generated With Diffusion Models, オリジナル)
  • Plate IV, dusk: the crest lit orange under a violet sky (Generated With Diffusion Models, オリジナル)
  • Plate V, night: stars over the ridges and a lit refuge (Generated With Diffusion Models, オリジナル)
  • Plate VI, snow: the cirque of plate II the morning after (Generated With Diffusion Models, オリジナル)

作り方

#1 · 図版ごとの見出しスタイルをリストから作る

コードは前の手短な答えにあります。plate()はPLATESの各行から見出しスタイルを作り、Markdownは図版ごとに1行、# Night {style="lamina" art="noche" n="V" hora="22:10"}のように書いてエッセイの順番を決めます。画像要素のresourceIdは'{attr.art}'で、テキストと同じくプレースホルダーです。そのため飾りのない図版はすべてlaminaスタイルを共有し、見出しの行で自分の絵を指定します。追加の要素を描く表紙と最後の図版だけが専用のスタイルを持ちます(画像要素)。span: 'page'で、どの図版もそれ自身のページから始まります。余白をゼロにすると図版の段はページと同じ高さになり、minHeightがその段を埋めるので、図版のあとに続くものは次のページから始まります。両方の設定が必要です。minHeightがないと、文字を描かない嵐の右半分は見出し自身の7.6 mmの1行ぶんしか確保せず、次の文章がそのページの、絵の上から始まってしまいます。文章ページの余白を使うと段の高さは158 mmで、210 mmのminHeightより短くなります。するとpostext 1.4.1は確保をまるごと捨て、3つの文章がすべて直前の図版の上に刷られます。

見開きの2つのスタイル、pliegoとpliego-rectoは、同じ560 mmの絵(ここではart="tormenta")を描きます。偶数ページの半分はページの左端に、奇数ページの半分は280 mm左にずらして置くので、各ページが自分の半分を見せ、残りはキャンバスが仕上がりで切り取ります。parity: 'even'で、2つの半分は向かい合ったままになります。この指定がないと、Markdownから正午の図版を外したとき、左半分が奇数ページの3ページに、右半分がその裏の4ページに来てしまいます。指定があれば3ページは白ページのままで、嵐は4ページと5ページを埋めます。

#2 · ローマ数字と時刻は見出しの行から

script.js · 63–74行コード全体で見る
const NUMERAL = { x: 16, y: PAGE.height - 21, size: 13 }; // mm from the top left; size in pt
// A design text's baseline sits 0.8 of its line box below its top: set a smaller label beside
// a larger one this much lower and the two share a baseline.
const dropTo = (big, small, lineHeight = 1.2) => (0.8 * lineHeight * (big - small) * 25.4) / 72;
const caption = (colour) => [
  { kind: 'text', id: 'numeral', content: '{attr.n}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(NUMERAL.size), color: colour, align: 'left',
    placement: at('page', 'top-left', NUMERAL.x, NUMERAL.y) },
  { kind: 'text', id: 'hour', content: '· {attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: colour, align: 'left', // the dot: a lone I reads as a bar
    placement: at('#numeral', 'right-of', 1.6, dropTo(NUMERAL.size, 8)) },
];

ローマ数字と時刻は見出しの属性なので、内容はMarkdownに残り、1つのデザインですべての図版に対応できます。時刻はローマ数字のright-ofに置かれ、上端がそろいます。デザインのテキストのベースラインは要素の上端から行ボックスの0.8の位置にあり、行ボックスは初期設定で文字サイズの1.2倍です。そのためdropTo(13, 8)は時刻を0.8 × 1.2 × (13 − 8) pt下げ、8 ptの時刻を13 ptのローマ数字のベースライン、ページの上端から193.4 mmの位置に置きます。図版IVのキャプションと同じく、時刻は中黒で始めます。SyneのIはただの縦棒なので、時刻の横に単独で置くと罫に見えるからです。嵐の右半分はキャプションを描きません。雪の図版は手前が白いので、キャプションを墨で組みます。

#3 · 図版そのものに載せる題名と奥付

script.js · 78–92行コード全体で見る
const cover = (colour) => [
  { kind: 'text', id: 'title', content: '{title}', fontFamily: 'Syne', fontWeight: 800,
    fontSize: pt(72), lineHeight: 1, letterSpacing: pt(6), textTransform: 'uppercase',
    color: colour, align: 'left', placement: at('page', 'top-left', 22, 26) },
  { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Syne Mono',
    fontSize: pt(10), letterSpacing: pt(2), textTransform: 'uppercase', color: colour,
    align: 'left', placement: at('#title', 'below', 1.5, 3) },
];
// The colophon: one element per line, 10.5 pt apart, so the break falls after the licence, and
// the second line on the baseline of the plate's numeral.
const COLOPHON = { y: NUMERAL.y + dropTo(NUMERAL.size, 7.5), leading: (10.5 * 25.4) / 72 };
const colophon = (colour) => ['colofon', 'tipos'].map((key, line) => ({ kind: 'text', id: key,
  content: `{attr.${key}}`, fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.2),
  color: colour, align: 'right',
  placement: at('page', 'top-right', -16, COLOPHON.y - (1 - line) * COLOPHON.leading) }));

最初の図版は表紙です。{title}と{subtitle}はフロントマターを読み、PLATESのextraがその図版のデザインに両方の要素を、キャプションと同じ色で加えます。奥付も同じ方法で最後の図版に、雪の上に墨で載せます。7.5 ptの要素を1行に1つずつ、計2つ置いてcolofon属性とtipos属性から取り、右そろえにして、2つ目をローマ数字のベースラインに置きます。1つの要素を折り返すと、幅が尽きたところで改行してしまいます。1行に1要素なら、改行はライセンスのあとに来ます。

#4 · 文章は改ページせずに図版に続く

script.js · 96–116行コード全体で見る
const textOpener = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'hours', content: '{attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: col('accent'), align: 'left',
    placement: at('container', 'top-left', 0, 0) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(28), lineHeight: 1.05, // a multiple (gotcha: design-lineheight-multiple)
    color: col('ink'), align: 'left', overflow: 'wrap',
    placement: at('#hours', 'below', 0, 2.5, { width: 'fill' }) },
] } };
// The folio and the book's title under the text block, flush with its left edge, on the
// baseline of the plates' numerals. Not on the plates: a span heading opens their pages, so
// they are opener pages, and 'body' leaves them out.
const foot = (id, content, extra) => ({ kind: 'text', id, content, pages: 'body',
  fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.8), color: col('muted'),
  align: 'left', ...extra });
const FOOT = NUMERAL.y + dropTo(NUMERAL.size, 7.5) - (PAGE.height - MARGIN.bottom); // from the foot
const footer = { elements: [
  foot('folio', '{pageNumber}', { color: col('ink'),
    placement: at('container', 'top-left', 0, FOOT) }),
  foot('book', '{title}', { textTransform: 'uppercase', placement: at('#folio', 'right-of', 5) }),
] };

文章の前の図版がそのページを埋めるので、文章は自分で改ページしなくても次のページから始まります。改ページを持つ見出しが最初のブロックになるページは章扉になり、ノンブルのpages: 'body'はそのページを除きます。改ページがなければ文章ページはノンブルを保ち、ページ幅の見出しで始まる図版にはノンブルが付きません。postext 1.4.1ではheadingsオブジェクトがあればレベル1の改ページはすでに外れるので、breakBefore: { enabled: false }を消しても今は何も変わりません。この行が効くのは、後のリリースで初期設定のalways-odd改ページが戻ったときです。文章が改ページしないので、そのページは本文ページのままノンブルを持ちます。FOOTはノンブルを図版のローマ数字のベースライン、ページの上端から193.4 mmに置くので、文章のノンブルと、向かいの図版のローマ数字が1本の線にそろいます。

#5 · 図版はすべて宣言し、引用はフロートする1枚だけ

script.js · 197–223行コード全体で見る
// Plate IV is the one plate that floats, so a counter of its type would number it 1. The type
// prints no number (no caption prefix, an empty template) and the caption carries the numeral.
// The text names the plate with :ref's text, since a bare :ref prints 'lámina' and nothing else.
const lamina = { id: 'lamina', name: t({ en: 'Plate', es: 'Lámina' }),
  shortLabel: t({ en: 'plate', es: 'lámina' }), numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal' };
// JPEGs in assets/, cut to the page's shape: 1600 × 1200 px for 280 × 210 mm (145 dpi).
const picture = (id, [w, h], alt, extra = {}) => ({ id, typeId: 'lamina', kind: 'bitmap',
  bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h },
  createdAt: 0, updatedAt: 0, altText: t(alt), ...extra });
const resources = [ // the five the heading styles draw, never cited, and plate IV
  picture('alba', [1600, 1200], { en: 'Eight ridges fading into a peach dawn haze.',
    es: 'Ocho crestas que se pierden en la bruma del alba.' }),
  picture('mediodia', [1600, 1200], { en: 'A granite cirque and its lake under a pale noon sky.',
    es: 'Un circo de granito y su laguna bajo el cielo pálido del mediodía.' }),
  picture('tormenta', [2400, 900], { en: 'A storm over the range: rain on the left, lightning '
    + 'and a break of sun on the right.', es: 'Una tormenta sobre la sierra: lluvia a la '
    + 'izquierda; un rayo y un claro de sol a la derecha.' }),
  picture('noche', [1600, 1200], { en: 'Stars over dark ridges and two lit windows at a refuge.',
    es: 'Estrellas sobre crestas oscuras y dos ventanas encendidas en un refugio.' }),
  picture('nieve', [1600, 1200], { en: 'The cirque the morning after, white with new snow.',
    es: 'El circo a la mañana siguiente, blanco de nieve nueva.' }),
  // 2000 px, 339 mm at the layout's 150 dpi, which the float fits to the 100 mm measure.
  picture('atardecer', [2000, 800], { en: 'A crest lit orange under strips of cloud, violet sky.',
    es: 'Una cresta encendida de naranja bajo franjas de nube, en un cielo violeta.' },
  { caption: t({ en: 'IV · Dusk · 19:41', es: 'IV · Atardecer · 19:41' }) }),
];

デザインの画像要素は宣言済みのリソースを描くので、6枚の絵はすべてresourcesに入っています。レシピのassets/にあるJPEGで、それぞれ埋めるページまたは見開きの形に切り、ピクセルサイズで宣言します。レイアウトが読むのはそのサイズだけです。loadImage()は組版の前に各ファイルを登録します。:refで引用するのは図版IVだけで、フロートするのもこの1枚だけです。引用のあとの最初の空き位置、つまり文章のページで、最後の段落の1行下に置かれます。番号付きのタイプなら、そのタイプでフロートするリソースはこれだけなので、キャプションはPlate 1になります。代わりにlaminaはnumberingTemplateを空にしてあり、キャプション自体がIVで始まります。文中では:refのtextが図版の名前を示します。:refだけではplateという語しか刷られないからです。

レシピの全体

Sandbox
// ═══ Postext Cookbook · Nº 068 · Photo essay with full-bleed plates ═══════════════
// https://postext.dev/en/cookbook/photo-essay-full-bleed
// Code: MIT · Text: original (CC BY 4.0) · Plates: diffusion models
// Fonts: Andada Pro, Syne, Syne Mono (SIL OFL 1.1) · Needs postext ≥ 1.8.0
// Sierra, a landscape photobook: one day in a mountain range in six plates, each on a page of
// its own and one across the gutter of a spread, with three short texts between them.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'photo-essay-full-bleed';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the paper between the plates, the ink and one rust for the labels
const palette = {
  ink: '#1c1f27', // text: the blue-black of the night plate
  paper: '#f3f0ea', // every page's ground: a pale stone, seen only between the plates
  accent: '#94462e', // the times over each text: the dusk plate's rust, dark enough for 8 pt
  muted: '#5f646d', // the running foot and the caption of the small plate
  white: '#fbfaf7', // type set on the plates
};
// Design elements read the hex, not the palette, in 1.4.1 (gotcha: palette-skips-designs).
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' } }));
// #endregion
const PAGE = { width: 280, height: 210 }; // a landscape photobook, in mm
const MARGIN = { top: 28, bottom: 24, inner: 40, outer: 140 }; // text pages: a 100 mm measure
const LEAD = 15; // body leading in pt
const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });

// #region answer: the plates' heading styles, generated from a list
// '{attr.art}' is filled in per heading, so a plain plate names its picture on its line:
// # Noon {style="lamina" art="mediodia" …}. Only the cover and the last plate draw extras.
const PLATES = [ // the style id, its picture, the ink of its caption, anything extra it draws
  { id: 'alba', art: 'alba', extra: (colour) => cover(colour) }, // an arrow: cover() is below
  { id: 'lamina', art: '{attr.art}' }, // any plate on a page of its own
  { id: 'pliego', art: '{attr.art}', half: 'verso' }, // one picture across a spread:
  { id: 'pliego-recto', art: '{attr.art}', half: 'recto' }, // the left half, then the right
  { id: 'nieve', art: 'nieve', ink: 'ink', extra: (colour) => colophon(colour) },
];
const plate = ({ id, art, half, ink = 'white', extra = () => [] }) => ({
  id, span: 'page', // an opener: a span heading always starts a page of its own
  // The left half opens on an even page, a verso, so the right half faces it across the
  // gutter (gotcha: parity-page1-recto).
  ...(half === 'verso' && { breakBefore: { enabled: true, parity: 'even' } }),
  // No margins: the plate's column is the page, and minHeight fills it, so what follows starts
  // on the next page. Images reserve no room (gotcha: opener-image-no-reserve), and a minHeight
  // taller than the column is dropped whole (gotcha: opener-taller-than-column).
  margins: { top: mm(0), bottom: mm(0), left: mm(0), right: mm(0) },
  advancedDesign: { enabled: true, minHeight: mm(PAGE.height), slot: { elements: [
    { kind: 'image', id: 'picture', resourceId: art, // the recto half is the same picture,
      placement: at('page', 'top-left', half === 'recto' ? -PAGE.width : 0, 0, // moved left
        { width: mm(half ? 2 * PAGE.width : PAGE.width), height: mm(PAGE.height) }) },
    ...(half === 'recto' ? [] : caption(col(ink))), ...extra(col(ink)),
  ] } },
});
// #endregion

// #region caption: the plate's numeral and hour, lower left, from the heading's attributes
const NUMERAL = { x: 16, y: PAGE.height - 21, size: 13 }; // mm from the top left; size in pt
// A design text's baseline sits 0.8 of its line box below its top: set a smaller label beside
// a larger one this much lower and the two share a baseline.
const dropTo = (big, small, lineHeight = 1.2) => (0.8 * lineHeight * (big - small) * 25.4) / 72;
const caption = (colour) => [
  { kind: 'text', id: 'numeral', content: '{attr.n}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(NUMERAL.size), color: colour, align: 'left',
    placement: at('page', 'top-left', NUMERAL.x, NUMERAL.y) },
  { kind: 'text', id: 'hour', content: '· {attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: colour, align: 'left', // the dot: a lone I reads as a bar
    placement: at('#numeral', 'right-of', 1.6, dropTo(NUMERAL.size, 8)) },
];
// #endregion

// #region cover: the book's title reversed out of the dawn sky of the first plate
const cover = (colour) => [
  { kind: 'text', id: 'title', content: '{title}', fontFamily: 'Syne', fontWeight: 800,
    fontSize: pt(72), lineHeight: 1, letterSpacing: pt(6), textTransform: 'uppercase',
    color: colour, align: 'left', placement: at('page', 'top-left', 22, 26) },
  { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Syne Mono',
    fontSize: pt(10), letterSpacing: pt(2), textTransform: 'uppercase', color: colour,
    align: 'left', placement: at('#title', 'below', 1.5, 3) },
];
// The colophon: one element per line, 10.5 pt apart, so the break falls after the licence, and
// the second line on the baseline of the plate's numeral.
const COLOPHON = { y: NUMERAL.y + dropTo(NUMERAL.size, 7.5), leading: (10.5 * 25.4) / 72 };
const colophon = (colour) => ['colofon', 'tipos'].map((key, line) => ({ kind: 'text', id: key,
  content: `{attr.${key}}`, fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.2),
  color: colour, align: 'right',
  placement: at('page', 'top-right', -16, COLOPHON.y - (1 - line) * COLOPHON.leading) }));
// #endregion

// #region texts: a text between plates opens on the next page with its hours above it
const textOpener = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'hours', content: '{attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: col('accent'), align: 'left',
    placement: at('container', 'top-left', 0, 0) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(28), lineHeight: 1.05, // a multiple (gotcha: design-lineheight-multiple)
    color: col('ink'), align: 'left', overflow: 'wrap',
    placement: at('#hours', 'below', 0, 2.5, { width: 'fill' }) },
] } };
// The folio and the book's title under the text block, flush with its left edge, on the
// baseline of the plates' numerals. Not on the plates: a span heading opens their pages, so
// they are opener pages, and 'body' leaves them out.
const foot = (id, content, extra) => ({ kind: 'text', id, content, pages: 'body',
  fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.8), color: col('muted'),
  align: 'left', ...extra });
const FOOT = NUMERAL.y + dropTo(NUMERAL.size, 7.5) - (PAGE.height - MARGIN.bottom); // from the foot
const footer = { elements: [
  foot('folio', '{pageNumber}', { color: col('ink'),
    placement: at('container', 'top-left', 0, FOOT) }),
  foot('book', '{title}', { textTransform: 'uppercase', placement: at('#folio', 'right-of', 5) }),
] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  // The English sample is British English, set with the US patterns: 1.4.1 ships no en-gb.
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette,
  resourceTypes: [lamina],
  page: { width: mm(PAGE.width), height: mm(PAGE.height), dpi: 150,
    backgroundColor: col('paper'), // the ground of every page; the plates cover it
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } }, // left is the inner margin
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Andada Pro', fontSize: pt(10.5), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), // both default to blue
    referenceColor: col('ink'), referenceBold: false, // 'lámina IV' reads as a word of the text
    firstLineIndent: mm(4), indentAfterHeading: false, // justified and hyphenated by default
    minWordSpacing: 0.7, maxWordSpacing: 1.6, // a narrower range than the defaults, 0.6 to 2
    maxRuntTracking: 0 }, // tracking 1.4.1 never paints (gotcha: runt-tracking-unpainted)
  // A heading's own line is measured even where its design paints the title: set it in a face
  // the page loads, or the kit fetches Open Sans for it.
  headings: { fontFamily: 'Syne', levels: [
    // A text follows its plate with no forced break (gotcha: headings-drop-h1-break): the
    // plate fills its page, so the text still starts a page, and that page stays a body page,
    // with its running foot.
    { level: 1, breakBefore: { enabled: false }, advancedDesign: textOpener },
  ] },
  headingStyles: PLATES.map(plate),
  captionStyle: { fontFamily: 'Syne Mono', fontSize: pt(7.5), color: col('muted') },
  header: { elements: [] },
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdownの見本 · 43行 · content.es.mdtitle: "Sierra" subtitle: "Ensayo en seis luces" --- # Alba {style="alba" n="I" hora="08:12"} # La subida {hora="06:40–14:06 · 1770–2180 m"} Salimos del aparcamiento a las seis y cuarenta, con frontales, por la calzada de losas que sube entre piornos. A esa hora el granito guarda todavía el frío de la noche y las botas suenan más de lo que deberían. Desde la primera curva se ven abajo las luces del pueblo, once, contadas dos veces; desde la segunda ya no. A las ocho y doce el sol toca la cresta más alta. Es un filo rosado que dura lo que se tarda en sacar la cámara; luego la luz baja por las canales, despacio, y tarda casi una hora en llegar al sendero. El cervunal está blanco de escarcha y cruje bajo las botas. Una cabra montés nos mira desde un bolo sin apartarse, y somos nosotros los que damos un rodeo. En el collado paramos a comer. Hay una fuente que no sale en el mapa, un caño de hierro clavado en la piedra, y el agua está tan fría que duelen los dientes. Andrés apunta en una libreta la hora y la altitud de cada foto; a la una, el termómetro que lleva colgado de la mochila marca once grados al sol. El mediodía solar cae aquí pasadas las dos, por el horario de verano. A esa hora las sombras se esconden debajo de las piedras y la laguna, vista desde el collado, parece una chapa que alguien ha dejado olvidada entre las paredes. # Mediodía {style="lamina" art="mediodia" n="II" hora="14:06"} # Tormenta {style="pliego" art="tormenta" n="III" hora="16:40"} # Tormenta {style="pliego-recto" art="tormenta"} # Once segundos {hora="15:00–19:50 · 2180–1950 m"} A las tres el aire se vuelve pesado. Por el oeste sube una nube que al principio parece otra montaña. Recogemos deprisa. El primer trueno llega once segundos después del relámpago: casi cuatro kilómetros, calcula Andrés, que siempre cuenta. El siguiente llega a los cuatro. Nos metemos bajo el voladizo de un bloque del tamaño de una casa. Primero cae granizo menudo y luego agua, y la pared de enfrente se borra detrás de la cortina y vuelve a salir más oscura, lavada. Nadie habla en una hora larga. Hacia las siete y media la tormenta se va valle abajo y deja el cielo roto en franjas. El sol asoma un momento por debajo de las nubes, ya pegado al horizonte, y enciende la cresta de un color que no dura ni tres minutos (:ref{id="atardecer" text="lámina IV"}). Bajamos al refugio empapados, junto al arroyo, que ahora baja lleno. # Noche {style="lamina" art="noche" n="V" hora="22:10"} # Primera nieve {hora="22:10–09:00 · 1950 m"} A las diez el cielo está limpio y tiene más estrellas de las que caben en la ventana del refugio. A medianoche alguien abre la puerta y dice que nieva. Salimos en calcetines. La nieve cae recta, sin viento, en copos grandes que tardan en deshacerse en las mangas, y la linterna solo alumbra un cono de puntos blancos que vienen hacia nosotros. Por la mañana el termómetro de la ventana marca dos bajo cero. Hay un palmo de nieve en la puerta y la laguna tiene una orilla de hielo fino. Las crestas que ayer eran grises son blancas con rayas negras, las aristas donde la nieve no agarra, y el cielo tiene el mismo color que el suelo. La última foto la hace Andrés a las ocho y treinta y uno, desde el mismo bolo que ayer a mediodía. Tarda un rato en encontrarlo: las piedras que le servían de referencia son ahora montones blancos iguales. Al final da con él comparando la cresta con la foto de ayer en la pantalla de la cámara. Es la primera nevada del otoño, dos semanas antes de lo habitual, según el guarda. Salimos a las nueve. La calzada de losas no se ve, y bajamos buscando los hitos de piedra. # Nieve {style="nieve" n="VI" hora="08:31" colofon="Sierra. Ensayo en seis luces · Láminas generadas con modelos de difusión · Textos: CC BY 4.0" tipos="Compuesto en Andada Pro, Syne y Syne Mono (SIL OFL)"}
`; // content.<lang>.md, inlined by the Cookbook // #region plates: every picture is a resource; only plate IV is cited, so only it floats // Plate IV is the one plate that floats, so a counter of its type would number it 1. The type // prints no number (no caption prefix, an empty template) and the caption carries the numeral. // The text names the plate with :ref's text, since a bare :ref prints 'lámina' and nothing else. const lamina = { id: 'lamina', name: t({ en: 'Plate', es: 'Lámina' }), shortLabel: t({ en: 'plate', es: 'lámina' }), numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }; // JPEGs in assets/, cut to the page's shape: 1600 × 1200 px for 280 × 210 mm (145 dpi). const picture = (id, [w, h], alt, extra = {}) => ({ id, typeId: 'lamina', kind: 'bitmap', bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h }, createdAt: 0, updatedAt: 0, altText: t(alt), ...extra }); const resources = [ // the five the heading styles draw, never cited, and plate IV picture('alba', [1600, 1200], { en: 'Eight ridges fading into a peach dawn haze.', es: 'Ocho crestas que se pierden en la bruma del alba.' }), picture('mediodia', [1600, 1200], { en: 'A granite cirque and its lake under a pale noon sky.', es: 'Un circo de granito y su laguna bajo el cielo pálido del mediodía.' }), picture('tormenta', [2400, 900], { en: 'A storm over the range: rain on the left, lightning ' + 'and a break of sun on the right.', es: 'Una tormenta sobre la sierra: lluvia a la ' + 'izquierda; un rayo y un claro de sol a la derecha.' }), picture('noche', [1600, 1200], { en: 'Stars over dark ridges and two lit windows at a refuge.', es: 'Estrellas sobre crestas oscuras y dos ventanas encendidas en un refugio.' }), picture('nieve', [1600, 1200], { en: 'The cirque the morning after, white with new snow.', es: 'El circo a la mañana siguiente, blanco de nieve nueva.' }), // 2000 px, 339 mm at the layout's 150 dpi, which the float fits to the 100 mm measure. picture('atardecer', [2000, 800], { en: 'A crest lit orange under strips of cloud, violet sky.', es: 'Una cresta encendida de naranja bajo franjas de nube, en un cielo violeta.' }, { caption: t({ en: 'IV · Dusk · 19:41', es: 'IV · Atardecer · 19:41' }) }), ]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) 'Andada Pro': ['400'], Syne: ['700', '800'], 'Syne Mono': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const files = resources.map((r) => r.bitmap.fileId); // a resource names a file: load each await Promise.all(files.map((f) => loadImage(f, asset(f)))); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Sierra: a photo essay in landscape', es: 'Sierra: un ensayo fotográfico apaisado' }) });
キット · 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上のレシピのフォルダー ↗ (新しいタブで開きます)

アレンジ

#図版を項目の向かいにフロートさせる

カタログの項目と向かい合う奇数ページへ図版をフロートさせ、図の幅をその縦横比から決める例は、図版と向かい合う作品解説の図録を参照してください。

#裁ち落としの写真で記事を始める

記事の頭に写真を裁ち落としで置き、見出しとリードをその上に載せる例は、雑誌の特集記事を参照してください。

よくあるつまずき

つまずき

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

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

つまずき

段より高い章扉は確保した高さをすべて失う

postext 1.4.1では、詳細デザインの見出しが確保する高さ(minHeight、または最も下のデザイン要素)が始まる段より高いと、見出しは警告なしに自分のタイトル行の高さしか確保せず、本文がデザインに重なって組まれます。全面の図版にはページと同じ高さの段が必要です。見出しスタイルの余白を0にし、minHeightをページの高さに設定してください。 デザインした章扉 →

つまずき

1ページは奇数ページ。ページは物理的な番号で計画する

1ページは右ページで、2ページが最初の偶数ページです。見開きは物理的なページ番号で計画してください。偶数ページの章扉は、その次の奇数ページと向かい合います。 改ページと改段 →

つまずき

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

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

つまずき

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

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

つまずき

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

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

つまずき

SVGの図に<marker>やフィルターを使わない(ラスターにフォールバックする)

SVGの図がPDFでベクターのまま残るのは、<marker>、フィルター、マスクを使っていない場合だけです。使っているとラスターにフォールバックし、深く入れ子にしたフィルターはChromeで図を真っ白にすることがあります。矢じりはパスで描いてください。 リソースとしての図と表 →

つまずき

ラントの修正が、描かれないトラッキングを詰めることがある

postext 1.4.1では、段落がラント(最終行に残った短い一語)で終わると、レイアウトはその段落を1行短く組みます。まず語間を詰め、次にmaxRuntTracking(1000分の1 em単位)までの負のトラッキングを使います。CanvasとPDFのレンダラーは0より大きいトラッキングしか描かないため、トラッキングを詰めた段落はトラッキングなしで印字されます。両端そろえの行はその差の分だけ語間を失って詰まって見え、最終行は行長を超えて段の端で切れることがあります。bodyText.maxRuntTracking: 0を設定すれば語間による修正は残るので、それでも出るラントは文を書き換えてください。 ウィドウ、オーファン、ラント →

つまずき

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

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

つまずき

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

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

  • デザインが描く図版はけっして引用しないでください。引用すると、図としてもフロートします。最初の文章で正午の図版を:refで引用すると、3ページにもう1枚置かれ、嵐は白ページを1つはさんで2ページ先へ移ります。
  • 夕暮れの図版はページの地ではなく、文章の1行下に置かれます。図版の前の最後のページでは、postext 1.4.1は本文より下にあるフロートを本文のところまで持ち上げ、そこではposition: 'bottom'も効きません。
  • どの文章も、両方の版で1ページに収まるよう調整してあります。最初の文章が3ページまで続くと、正午の図版は4ページへ、嵐は白い5ページのあとの6・7ページへ移ります。
  • 嵐の右半分は2つ目の# Storm見出しです。ページにその題名は刷られませんが、見出しから作る:::tocには、嵐が4ページと5ページの2回載ります。
  • 図版は仕上がり、つまりキャンバスの端で止まります。page.cutLinesをオンにするとキャンバスは仕上がりの外へ広がり、各図版のまわりの塗り足しに紙の色が見えます。印刷用には図版を'bleed'に固定し、それぞれを四辺とも塗り足しのぶんだけ大きくしてください。

クレジット

レシピ
Ignacio Ferro
本文
  • The three texts, the captions and the colophon, in Spanish and English, and the six plates, drawn in code · Postext Cookbook · CC BY 4.0
画像
  • Plate I, dawn: ridges fading into a peach haze under a dark blue sky · Generated With Diffusion Models · オリジナル
  • Plate II, noon: a granite cirque and its lake · Generated With Diffusion Models · オリジナル
  • Plate III, storm: rain, lightning and a break of sun across a spread · Generated With Diffusion Models · オリジナル
  • Plate IV, dusk: the crest lit orange under a violet sky · Generated With Diffusion Models · オリジナル
  • Plate V, night: stars over the ridges and a lit refuge · Generated With Diffusion Models · オリジナル
  • Plate VI, snow: the cirque of plate II the morning after · Generated With Diffusion Models · オリジナル
フォント
Andada Pro (SIL OFL 1.1) · Syne (SIL OFL 1.1) · Syne Mono (SIL OFL 1.1)
Sandbox