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

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

図版と向かい合う作品解説の図録

各解説は偶数ページで始まり、本文の最初の文で図版を引用します。図版は向かいの奇数ページへフロートし、スキャンの縦横比のまま高さ221 mmで入ります。

このページの内容

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

  • 英語の見本:日本語版はまだありません
  • 仕上がり230 × 280 mm
  • 1段半組み, 段間7 mm
  • Ibarra Real Nova 11.5/16
  • Libre Bodoni
  • Sofia Sans Condensed
  • 8ページ
  • レベル
  • Postext 1.4.1
  • 組版時間12 ms
  • コード250行

かんたんな説明

『ドン・キホーテ』の古い挿絵3点を扱う小さな展覧会図録です。作品の解説は左ページに入り、その絵が向かいの右ページいっぱいに載ります。

できあがり

ギュスターヴ・ドレの『ドン・キホーテ』(パリ、1863年)から木口木版3点を選んだ小企画展の図録を、230 × 280 mmのページで作ります。表紙は温かみのある黒地に、枠で囲んだ風車の版画の部分図を置き、その下にDORÉを88 ptのLibre Bodoniで組みます。各解説は左ページで始まり、イタリックの題の横にセピアのカタログ番号、外側の段に作品データ(トゥームストーン)、3行のドロップキャップで始まるリード、そして解説本文が続きます。本文の最初の文が図版を引用し、図版は向かいの右ページの先頭に中央そろえ、高さ221 mmで入るので、図版とキャプションとクレジット行で版面が埋まります。最後のページは出品リストで、各行の最初のセルにサムネイルがあり、各図版が印刷されるページを示します。

このレシピが答える質問

  • 図録を作るには(左ページに解説、向かいの右ページに図版)?
  • 図の位置を制御するには(ページの上端、2段をまたぐ、まさにこの位置、余白)?
  • 表のセルに画像やアイコンを入れるには?
  • 章扉に著者名の行、リード文、ドロップキャップ付きの導入文を加えるには?

手短な答え

script.js · 38–62行コード全体で見る
// Each entry breaks to a verso and cites its plate in the commentary's first sentence; a 'top'
// float never lands on its citing page (gotcha: top-float-next-page), so it opens the recto.
const entryLevel = () => ({
  level: 1, span: 'page', numberingTemplate: '{1}', // {number} in the opener: Cat. 1, 2, 3
  // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
  breakBefore: { enabled: true, parity: 'even' },
  marginBottom: pt(LEAD), advancedDesign: entryOpener(),
});
const plateType = {
  id: 'plate', name: 'Cat.', shortLabel: 'Cat.', captionPrefix: 'Cat.', // 'Cat. 1. …'
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'top', span: 'page', align: 'center' },
};
// Under the plate (pt): a caption and a credit line at the body's leading ratio, and their gaps.
const CAPTION = { size: 8.5, gap: 6, note: 7.2, noteGap: 1.5 };
const UNDER = (CAPTION.size + CAPTION.note) * (LEAD / SIZE) + CAPTION.gap + CAPTION.noteGap;
const PLATE_H = (LINES * LEAD - UNDER) * PT; // 221.1 mm: the rest of the text block
// A float is not shrunk to fit the room left on its page (gap: float-shrink); fitFiguresToPage
// sets the smaller picture flush left. A width fraction narrows the float, and 'center' centres it.
const plate = ({ id, file, caption, altText }, [pxW, pxH]) => ({
  id, typeId: 'plate', kind: 'bitmap', caption, note: CREDIT, altText,
  // The print master's pixels, about 275 dpi at this size (gotcha: bitmap-print-size).
  bitmap: { fileId: file, format: 'jpeg', width: pxW, height: pxH },
  placement: { width: Math.min(1, ((pxW / pxH) * PLATE_H) / BLOCK_W) }, createdAt: 0, updatedAt: 0,
});

材料

種類
Ibarra Real Nova, Libre Bodoni, Sofia Sans Condensed (SIL OFL 1.1)
素材
  • library-835.jpg
  • vigil-835.jpg
  • windmills-835.jpg
  • windmills-detail-540.jpg
  • Cat. 1, Don Quixote in His Library (Part I, ch. I), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide (Gustave Doré · Héliodore Pisan, パブリックドメイン)
  • Cat. 2, The Vigil of Arms (Part I, ch. III), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide (Gustave Doré · Héliodore Pisan, パブリックドメイン)
  • Cat. 3, The Adventure of the Windmills (Part I, ch. VIII), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide (Gustave Doré · Héliodore Pisan, パブリックドメイン)
  • The cover’s detail of Cat. 3: a 1,100-px square cut from the same scan and reduced to 540 px (Gustave Doré · Héliodore Pisan, パブリックドメイン)

作り方

#1 · 図版は向かいのページを待つ

コードは上の手短な答えにあります。どの解説も偶数ページへ改ページし、topフロートは自分を引用したページには置かれません。そのため解説の最初の文で引用された図版は偶数ページを飛ばし、向かいの奇数ページの先頭に入ります。Postext 1.4.1はフロートをそのページに残った余地に合わせて縮めないので、plate()がスキャンの縦横比と、キャプションとクレジット行を除いた版面の残りの高さからフロートの幅を割り出します。layout.fitFiguresToPageもページに収まらない図版を縮めますが、1.4.1では縮んだ絵がページ幅いっぱいのままのフロートの中で左そろえになります。幅の比率を指定すればフロートそのものが狭まり、align: 'center'で図版とキャプションがいっしょに中央へそろいます。

#2 · 解説の頭部は見出しから作る

script.js · 83–121行コード全体で見る
const [KICKER, NUMERAL] = [8.5, { size: 80, y: 7 }]; // pt: labels; the numeral, y in mm
const TITLE = { size: 30, lineHeight: 1.08 }; // pt (gotcha: design-lineheight-multiple)
const HEAD_RULE = NUMERAL.y + NUMERAL.size * PT + 3; // mm: the hairline under the numeral
const LEAD_Y = HEAD_RULE + 5; // mm: the lead paragraph, with the tombstone beside it
const [LEADIN, TOMB] = [{ size: 13.5, lead: 19 }, { size: 8.5, lead: 12.5 }]; // pt
const LEAD_LINES = 5; // the longest lead: shorter ones keep the commentary on the same line
const BASE = 0.8; // 1.4.1 sets a design text's first baseline 0.8 down its line box
// em: cap heights, and Libre Bodoni's figures, which stop short of its capitals ('1': 0.716)
const [CAP_HEIGHT, FIGURE_HEIGHT] = [{ [TEXT]: 0.673, [DISPLAY]: 0.754 }, 0.716];
const inkTop = (size, lineHeight, height) => (BASE * lineHeight - height) * size; // pt to the ink
// The title's capitals level with the numeral's figures: 1.2 mm below the numeral's top.
const TITLE_Y = NUMERAL.y + (inkTop(NUMERAL.size, 1, FIGURE_HEIGHT)
  - inkTop(TITLE.size, TITLE.lineHeight, CAP_HEIGHT[DISPLAY])) * PT;
// The initial's top on the first line's capitals, its foot on the third baseline.
const dropSize = (lines) => pt(((lines - 1) * LEADIN.lead + CAP_HEIGHT[TEXT] * LEADIN.size)
  / CAP_HEIGHT[DISPLAY]);
// Entries open on versos, whose outer column is on the left: the side column is at x = 0.
const head = (label, numeral) => [
  text('label', label, LABEL, KICKER, 'sepia', at('container', 'top-left', 0, 0), caps(KICKER)),
  text('numeral', numeral, DISPLAY, NUMERAL.size, 'sepia',
    at('container', 'top-left', 0, NUMERAL.y, SIDE), { lineHeight: 1 }),
  text('title', '{titleText}', DISPLAY, TITLE.size, 'ink', at('container', 'top-left', MAIN_X,
    TITLE_Y, MAIN), { italic: true, lineHeight: TITLE.lineHeight }),
  { kind: 'rule', id: 'head-rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: at('container', 'top-left', 0, HEAD_RULE, 'fill') },
];
const entryOpener = () => ({ enabled: true, minHeight: mm(LEAD_Y + LEAD_LINES * LEADIN.lead * PT),
  slot: { elements: [...head('Cat.', '{number}'),
  text('chapter', '{attr.chapter}', LABEL, KICKER, 'sepia',
    at('container', 'top-left', MAIN_X, 0, MAIN), caps(KICKER)),
  // Baseline on the lead's; \n breaks only with a paragraphIndent (gotcha: design-text-newline).
  text('tombstone', '{attr.tombstone}', LABEL, TOMB.size, 'muted',
    at('container', 'top-left', 0, LEAD_Y + BASE * (LEADIN.lead - TOMB.lead) * PT, SIDE - 6),
    { lineHeight: TOMB.lead / TOMB.size, paragraphIndent: pt(0.01) }),
  // Drop caps exist only in design text, so the lead is an attribute (gotcha: design-text-ragged).
  text('lead', '{attr.lead}', TEXT, LEADIN.size, 'ink', at('container', 'top-left', MAIN_X,
    LEAD_Y, MAIN), { lineHeight: LEADIN.lead / LEADIN.size, dropCap: { lines: 3,
    fontFamily: DISPLAY, fontSize: dropSize(3), color: col('sepia'), gap: mm(1.6) } }),
] } });

各#見出しは章、作品データ、リードを属性として持ち、扉のデザインスロットがそれらを描きます。大きな番号は、numberingTemplate: '{1}'による見出し自身の{number}です。1つの解説に図版は1点なので、図版のCat.カウンターと歩調をそろえて1、2、3と進みます。1.4.1ではドロップキャップはデザインテキストにしか使えず、デザインテキストは両端そろえにできません。そこでリードを属性にして、両端そろえの解説本文の上に13.5 ptで左そろえ(行末不ぞろい)に組みます。dropSize(3)は頭文字の大きさを、上端が1行目の大文字の高さにそろい、下端が3本目のベースラインに乗るように決めます。minHeightは5行のリードが入る余地を確保するので、リードが4行のCat. 3でも、解説本文はほかの解説と同じ行から始まります。TITLE_Yは題を数字より1.2 mm下げて組み、題の大文字の高さを数字にそろえます。Libre Bodoniでは数字の高さが0.716 em、大文字が0.754 emです。

#3 · 原版を宣言し、小さな複製を描く

script.js · 303–320行コード全体で見る
// plate() sizes each plate from its master, the Commons scan at 2,400 px tall (MASTER_PX);
// the canvas draws the registered 835-px copy (the block's width on a 1,000-px page) in that box.
async function scan(file) {
  const res = await fetch(asset(file));
  if (!res.ok) throw new Error(`Scan not found (${res.status}): ${file}`);
  return createImageBitmap(await res.blob());
}
async function loadPlates() {
  const [detail, ...scans] = await Promise.all([DETAIL, ...PLATES.map((p) => p.file)].map(scan));
  const resources = [checklist(PLATES), picture('cover-detail', onPaper(detail),
    'Detail of Cat. 3: the knight and his horse caught on the sail.')];
  for (const [i, p] of PLATES.entries()) {
    registerResourceImage(p.file, onPaper(scans[i]));
    resources.push(plate(p, MASTER_PX[p.id]), // and a thumbnail: its own small file and size
      picture(`${p.id}-thumb`, onPaper(await resized(scans[i], THUMB_PX)), p.altText));
  }
  return resources;
}

Postextはビットマップの大きさを、宣言されたピクセル数と文書のdpiから決め、そのピクセル数が許す幅を超えて印刷することはありません。plate()は各図版の原版として、Commonsのスキャンを高さ2,400 pxに縮めたものを宣言します。Cat. 1は1,900 × 2,400 pxで、page.dpi: 150なら322 mmまで広げられます。したがって大きさを決めるのは幅の比率で、高さは221 mm、その高さでは2,400 pxが約275 dpiに相当します。既定の300 dpiだと図版は幅161 mmで止まってしまいます。Canvasはその枠に登録されている絵をそのまま描くので、CodePenのサンプルはassets/にある835 pxの複製3点を取得します。原版の5.8 MBに対して0.97 MBです。2,400 pxの原版から描くと空にモアレの縞が出て、キャプチャー画像が約0.3 MB増え、レシピ集の上限1.4 MBを超えます。

#4 · 見出しのスタイルで作る表紙

script.js · 125–149行コード全体で見る
const DETAIL = 'windmills-detail-540.jpg'; // a square cut from Cat. 3, at its size on screen
const FRAME = { w: 124, y: 40, pad: 3.5 }; // mm: the picture, its top, the hairline's inset
const FRAME_X = (TRIM.w - FRAME.w) / 2;
const NAME = { size: 88, track: 8, y: 176 }; // pt, pt, mm
const centred = (id, content, family, size, color, y, more, x = 0) => text(id, content, family,
  size, color, at('page', 'top', x, y, 'fill'), { align: 'center', ...more });
const coverStyle = () => ({
  id: 'cover', numbered: false, header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [ // painted in this order
    { kind: 'box', id: 'night', style: { backgroundColor: col('night') },
      placement: { ...at('bleed', 'top-left', 0, 0), size: { width: 'fill', height: 'fill' } } },
    { kind: 'box', id: 'frame', style: { borderColor: col('gilt'), borderWidth: pt(0.5) },
      placement: { ...at('page', 'top-left', FRAME_X - FRAME.pad, FRAME.y - FRAME.pad),
        size: { width: mm(FRAME.w + 2 * FRAME.pad), height: mm(FRAME.w + 2 * FRAME.pad) } } },
    { kind: 'image', id: 'detail', resourceId: 'cover-detail',
      placement: at('page', 'top-left', FRAME_X, FRAME.y, FRAME.w) },
    centred('kicker', '{attr.kicker}', LABEL, 8.5, 'gilt', 18, caps(8.5)),
    // 1.4.1 counts the tracking after the last letter as well, so the word moves right by half.
    centred('name', '{titleText}', DISPLAY, NAME.size, 'paper', NAME.y, { lineHeight: 1,
      textTransform: 'uppercase', letterSpacing: pt(NAME.track) }, (NAME.track / 2) * PT),
    centred('subtitle', '{attr.subtitle}', DISPLAY, 18, 'paper', NAME.y + NAME.size * PT + 4,
      { italic: true }),
    centred('foot', '{attr.foot}', LABEL, 8.5, 'gilt', 250, caps(8.5)),
  ] } },
});

表紙は最初の見出し# Doré {style="cover" …}で、ノンブルのないスタイルが描きます。その画像要素が描くwindmills-detail-540.jpgは、どの段落からも引用されないリソースとして登録されているので、番号もキャプションも付きません。このファイルは風車のスキャンから1,100 px四方を切り出して540 pxに縮めたもので、540 pxは1,000 px幅のページ上での幅です。Postext 1.4.1はデザインテキストの計測に最後の文字のあとのトラッキングも含めるため、中央そろえの名前がページの中心線より4 pt左に寄ってしまいます。名前を8 ptのトラッキングの半分だけ右へ動かして中心線に乗せています。

#5 · 出品リストのセルにサムネイル

script.js · 153–175行コード全体で見る
const THUMB_PX = 300; // px wide: 25 mm at 300 dpi, about the width the cell prints it
// The entries' head, label and numeral from attributes; the level gives span, break and margin.
const checklistStyle = () => ({ id: 'checklist', numbered: false,
  advancedDesign: { enabled: true, slot: { elements: head('{attr.kicker}', '{attr.range}') } } });
const cell = (content, more) => ({ content, verticalAlign: 'middle', ...more });
const checklist = (plates) => ({
  id: 'checklist', typeId: 'list', kind: 'table', placement: { position: 'here' },
  altText: 'Checklist of the three works with thumbnails, titles, chapters and pages.',
  table: { model: { headerRowCount: 1, columnWidths: [26, 9, 70, 11], rows: [
    ['', 'Cat.', 'Work and chapter', 'Page'].map((label, i) =>
      cell(label, { isHeader: true, align: i === 3 ? 'right' : 'left' })),
    ...plates.map((p, i) => [
      { content: '', image: { resourceId: `${p.id}-thumb` } },
      cell(String(i + 1)), cell(`*${p.caption}*. ${p.illustrates}`),
      cell(String(platePage(i + 1)), { align: 'right' }),
    ]),
  ] } },
  createdAt: 0, updatedAt: 0,
});
// The cover is page 1 and entry n opens page 2n, so its plate prints on page 2n + 1.
const platePage = (n) => 2 * n + 1;
const listType = { id: 'list', name: 'List', shortLabel: 'List', captionPrefix: '', // no caption
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' };

セルのimageはビットマップのリソースをセルの内側の幅、ここでは23 mmに合わせます。サムネイルは835 pxの図版を描画時に縮めたものではなく、それぞれ300 pxの専用の複製で、その大きさで宣言しています。表にはキャプションのテキストがなく、その種類のキャプション接頭辞も空なので、キャプションは描かれません。出品リストのスタイルが設定するのはnumbered: falseとそのデザインだけで、ページ幅いっぱいに渡ること、偶数ページへの改ページ、地の余白はレベル1から受け継ぎます。

#6 · 解説がページからあふれたら止める

script.js · 335–340行コード全体で見る
const astray = doc.pages.find((pg) => pg.role !== 'opener' && !pg.floats?.length);
if (astray) { // text run past its verso, or the blank page that follows it
  const error = new Error(`Page ${astray.pageLabel} holds no plate: shorten the entry before it.`);
  kitFail(error); // the viewer's bar says why
  throw error;
}

偶数ページからあふれた解説も、図版は次のページへ送ります。すると解説の残りは図版のあとに続き、次の解説を偶数ページに置くために白ページが1枚入るので、それ以降の図版はどれも出品リストが示すページより2ページあとに印刷されます。この検査は、扉でも図版のページでもない最初のページでビルドを止め、そのページ番号をビューアーのバーに表示します。

レシピの全体

Sandbox
// ═══ Postext Cookbook · Nº 034 · Catalogue entries facing their plates ══════════════
// https://postext.dev/en/cookbook/catalogue-facing-plates
// Code: MIT · Text: original (CC BY 4.0), Ormsby 1885 (PD) · Plates: Doré and Pisan, 1863 (PD)
// Fonts: Ibarra Real Nova, Libre Bodoni, Sofia Sans Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en')
const RECIPE = 'catalogue-facing-plates';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1b1918', paper: '#faf7f1', night: '#2a2724', // text; the page and plates; the cover
  sepia: '#8a6a45', gilt: '#c9ad86', // the accent on paper (4.6:1) and on the night (6.9:1)
  rule: '#cfc6b8', muted: '#6c665e', // hairlines; tombstones, credit lines, folios
};
// A linked colour carries its hex too: postext 1.4.1 reads the hex, not the palette, in design
// slots and referenceColor (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' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'sepia (defaults)', value: { hex: palette.sepia, model: 'hex' } },
];
const [TEXT, DISPLAY, LABEL] = ['Ibarra Real Nova', 'Libre Bodoni', 'Sofia Sans Condensed'];
const PT = 25.4 / 72; // mm per point
const [SIZE, LEAD, LINES] = [11.5, 16, 41]; // body pt, leading pt; 41 lines make the text block
const TRIM = { w: 230, h: 280 }; // mm: a catalogue trim
const [TOP, INNER, OUTER] = [24, 20, 18]; // margins, mm
const BLOCK_W = TRIM.w - INNER - OUTER; // 192 mm: the text block, and the widest plate
const BLOCK_H = LINES * LEAD * PT; // 231.4 mm
const [SIDE_PC, GUTTER] = [34, 7]; // the outer column: 65.3 mm for numeral and tombstone; mm
const SIDE = (BLOCK_W * SIDE_PC) / 100;
const MAIN_X = SIDE + GUTTER; // mm from the block's outer edge to the text column
const MAIN = BLOCK_W - MAIN_X; // 119.7 mm: the measure, about 71 characters

// #region answer: a Plate type that floats to the head of the facing recto, sized to fill it
// Each entry breaks to a verso and cites its plate in the commentary's first sentence; a 'top'
// float never lands on its citing page (gotcha: top-float-next-page), so it opens the recto.
const entryLevel = () => ({
  level: 1, span: 'page', numberingTemplate: '{1}', // {number} in the opener: Cat. 1, 2, 3
  // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
  breakBefore: { enabled: true, parity: 'even' },
  marginBottom: pt(LEAD), advancedDesign: entryOpener(),
});
const plateType = {
  id: 'plate', name: 'Cat.', shortLabel: 'Cat.', captionPrefix: 'Cat.', // 'Cat. 1. …'
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'top', span: 'page', align: 'center' },
};
// Under the plate (pt): a caption and a credit line at the body's leading ratio, and their gaps.
const CAPTION = { size: 8.5, gap: 6, note: 7.2, noteGap: 1.5 };
const UNDER = (CAPTION.size + CAPTION.note) * (LEAD / SIZE) + CAPTION.gap + CAPTION.noteGap;
const PLATE_H = (LINES * LEAD - UNDER) * PT; // 221.1 mm: the rest of the text block
// A float is not shrunk to fit the room left on its page (gap: float-shrink); fitFiguresToPage
// sets the smaller picture flush left. A width fraction narrows the float, and 'center' centres it.
const plate = ({ id, file, caption, altText }, [pxW, pxH]) => ({
  id, typeId: 'plate', kind: 'bitmap', caption, note: CREDIT, altText,
  // The print master's pixels, about 275 dpi at this size (gotcha: bitmap-print-size).
  bitmap: { fileId: file, format: 'jpeg', width: pxW, height: pxH },
  placement: { width: Math.min(1, ((pxW / pxH) * PLATE_H) / BLOCK_W) }, createdAt: 0, updatedAt: 0,
});
// #endregion
const MASTER_PX = { library: [1900, 2400], vigil: [1921, 2400], windmills: [1923, 2400] };
const CREDIT = 'Public domain; scan from Wikimedia Commons.'; // the artists are in the tombstone
// A picture drawn only in a design slot or a table cell: registered, never cited or numbered.
function picture(id, image, altText) {
  registerResourceImage(`${id}.jpg`, image);
  return { id, typeId: 'plate', kind: 'bitmap', altText, createdAt: 0, updatedAt: 0,
    bitmap: { fileId: `${id}.jpg`, format: 'jpeg', width: image.width, height: image.height } };
}

// Design-slot shorthands; text wraps instead of ending in '…' (gotcha: overflow-ellipsis-default).
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width !== undefined && { size: { width: width === 'fill' ? 'fill' : mm(width) } }) });
const text = (id, content, fontFamily, size, color, placement, more = {}) => ({ kind: 'text',
  id, content, fontFamily, fontSize: pt(size), color: col(color), align: 'left',
  overflow: 'wrap', placement, ...more });
const caps = (size) => ({ fontWeight: 600, textTransform: 'uppercase',
  letterSpacing: pt(size * 0.2) });

// #region opener: the entry's head, a numeral and tombstone beside the title and the lead
const [KICKER, NUMERAL] = [8.5, { size: 80, y: 7 }]; // pt: labels; the numeral, y in mm
const TITLE = { size: 30, lineHeight: 1.08 }; // pt (gotcha: design-lineheight-multiple)
const HEAD_RULE = NUMERAL.y + NUMERAL.size * PT + 3; // mm: the hairline under the numeral
const LEAD_Y = HEAD_RULE + 5; // mm: the lead paragraph, with the tombstone beside it
const [LEADIN, TOMB] = [{ size: 13.5, lead: 19 }, { size: 8.5, lead: 12.5 }]; // pt
const LEAD_LINES = 5; // the longest lead: shorter ones keep the commentary on the same line
const BASE = 0.8; // 1.4.1 sets a design text's first baseline 0.8 down its line box
// em: cap heights, and Libre Bodoni's figures, which stop short of its capitals ('1': 0.716)
const [CAP_HEIGHT, FIGURE_HEIGHT] = [{ [TEXT]: 0.673, [DISPLAY]: 0.754 }, 0.716];
const inkTop = (size, lineHeight, height) => (BASE * lineHeight - height) * size; // pt to the ink
// The title's capitals level with the numeral's figures: 1.2 mm below the numeral's top.
const TITLE_Y = NUMERAL.y + (inkTop(NUMERAL.size, 1, FIGURE_HEIGHT)
  - inkTop(TITLE.size, TITLE.lineHeight, CAP_HEIGHT[DISPLAY])) * PT;
// The initial's top on the first line's capitals, its foot on the third baseline.
const dropSize = (lines) => pt(((lines - 1) * LEADIN.lead + CAP_HEIGHT[TEXT] * LEADIN.size)
  / CAP_HEIGHT[DISPLAY]);
// Entries open on versos, whose outer column is on the left: the side column is at x = 0.
const head = (label, numeral) => [
  text('label', label, LABEL, KICKER, 'sepia', at('container', 'top-left', 0, 0), caps(KICKER)),
  text('numeral', numeral, DISPLAY, NUMERAL.size, 'sepia',
    at('container', 'top-left', 0, NUMERAL.y, SIDE), { lineHeight: 1 }),
  text('title', '{titleText}', DISPLAY, TITLE.size, 'ink', at('container', 'top-left', MAIN_X,
    TITLE_Y, MAIN), { italic: true, lineHeight: TITLE.lineHeight }),
  { kind: 'rule', id: 'head-rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: at('container', 'top-left', 0, HEAD_RULE, 'fill') },
];
const entryOpener = () => ({ enabled: true, minHeight: mm(LEAD_Y + LEAD_LINES * LEADIN.lead * PT),
  slot: { elements: [...head('Cat.', '{number}'),
  text('chapter', '{attr.chapter}', LABEL, KICKER, 'sepia',
    at('container', 'top-left', MAIN_X, 0, MAIN), caps(KICKER)),
  // Baseline on the lead's; \n breaks only with a paragraphIndent (gotcha: design-text-newline).
  text('tombstone', '{attr.tombstone}', LABEL, TOMB.size, 'muted',
    at('container', 'top-left', 0, LEAD_Y + BASE * (LEADIN.lead - TOMB.lead) * PT, SIDE - 6),
    { lineHeight: TOMB.lead / TOMB.size, paragraphIndent: pt(0.01) }),
  // Drop caps exist only in design text, so the lead is an attribute (gotcha: design-text-ragged).
  text('lead', '{attr.lead}', TEXT, LEADIN.size, 'ink', at('container', 'top-left', MAIN_X,
    LEAD_Y, MAIN), { lineHeight: LEADIN.lead / LEADIN.size, dropCap: { lines: 3,
    fontFamily: DISPLAY, fontSize: dropSize(3), color: col('sepia'), gap: mm(1.6) } }),
] } });
// #endregion

// #region cover: a heading style on warm black, a framed detail of Cat. 3 and the name
const DETAIL = 'windmills-detail-540.jpg'; // a square cut from Cat. 3, at its size on screen
const FRAME = { w: 124, y: 40, pad: 3.5 }; // mm: the picture, its top, the hairline's inset
const FRAME_X = (TRIM.w - FRAME.w) / 2;
const NAME = { size: 88, track: 8, y: 176 }; // pt, pt, mm
const centred = (id, content, family, size, color, y, more, x = 0) => text(id, content, family,
  size, color, at('page', 'top', x, y, 'fill'), { align: 'center', ...more });
const coverStyle = () => ({
  id: 'cover', numbered: false, header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [ // painted in this order
    { kind: 'box', id: 'night', style: { backgroundColor: col('night') },
      placement: { ...at('bleed', 'top-left', 0, 0), size: { width: 'fill', height: 'fill' } } },
    { kind: 'box', id: 'frame', style: { borderColor: col('gilt'), borderWidth: pt(0.5) },
      placement: { ...at('page', 'top-left', FRAME_X - FRAME.pad, FRAME.y - FRAME.pad),
        size: { width: mm(FRAME.w + 2 * FRAME.pad), height: mm(FRAME.w + 2 * FRAME.pad) } } },
    { kind: 'image', id: 'detail', resourceId: 'cover-detail',
      placement: at('page', 'top-left', FRAME_X, FRAME.y, FRAME.w) },
    centred('kicker', '{attr.kicker}', LABEL, 8.5, 'gilt', 18, caps(8.5)),
    // 1.4.1 counts the tracking after the last letter as well, so the word moves right by half.
    centred('name', '{titleText}', DISPLAY, NAME.size, 'paper', NAME.y, { lineHeight: 1,
      textTransform: 'uppercase', letterSpacing: pt(NAME.track) }, (NAME.track / 2) * PT),
    centred('subtitle', '{attr.subtitle}', DISPLAY, 18, 'paper', NAME.y + NAME.size * PT + 4,
      { italic: true }),
    centred('foot', '{attr.foot}', LABEL, 8.5, 'gilt', 250, caps(8.5)),
  ] } },
});
// #endregion

// #region checklist: a table of the works with a thumbnail in each first cell
const THUMB_PX = 300; // px wide: 25 mm at 300 dpi, about the width the cell prints it
// The entries' head, label and numeral from attributes; the level gives span, break and margin.
const checklistStyle = () => ({ id: 'checklist', numbered: false,
  advancedDesign: { enabled: true, slot: { elements: head('{attr.kicker}', '{attr.range}') } } });
const cell = (content, more) => ({ content, verticalAlign: 'middle', ...more });
const checklist = (plates) => ({
  id: 'checklist', typeId: 'list', kind: 'table', placement: { position: 'here' },
  altText: 'Checklist of the three works with thumbnails, titles, chapters and pages.',
  table: { model: { headerRowCount: 1, columnWidths: [26, 9, 70, 11], rows: [
    ['', 'Cat.', 'Work and chapter', 'Page'].map((label, i) =>
      cell(label, { isHeader: true, align: i === 3 ? 'right' : 'left' })),
    ...plates.map((p, i) => [
      { content: '', image: { resourceId: `${p.id}-thumb` } },
      cell(String(i + 1)), cell(`*${p.caption}*. ${p.illustrates}`),
      cell(String(platePage(i + 1)), { align: 'right' }),
    ]),
  ] } },
  createdAt: 0, updatedAt: 0,
});
// The cover is page 1 and entry n opens page 2n, so its plate prints on page 2n + 1.
const platePage = (n) => 2 * n + 1;
const listType = { id: 'list', name: 'List', shortLabel: 'List', captionPrefix: '', // no caption
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' };
// #endregion

const foot = (id, content, parity, edge, x, more) => text(id, content, LABEL, 7.5, 'muted',
  at('page', edge, x, TRIM.h - 15), { ...caps(7.5), parity, ...more });
const footer = { elements: [ // folios at the outer foot; entries (versos) add the title
  foot('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, { color: col('ink') }),
  foot('verso-title', '{title}', 'even', 'top-left', OUTER + 9),
  foot('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER,
    { color: col('ink'), align: 'right' }),
] };

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette, resourceTypes: [plateType, listType],
  // A bitmap is never set wider than its declared pixels at this dpi: at 150 dpi a 1,900-px
  // scan may reach 322 mm, so the width fraction decides (at 300 dpi it stops at 161 mm).
  page: { width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(TOP), bottom: mm(TRIM.h - TOP - BLOCK_H), left: mm(INNER),
      right: mm(OUTER), mirror: true } },
  // Body text never enters the outer column: the openers draw in it and the plates span it.
  layout: { layoutType: 'oneAndHalf', sideColumnPercent: SIDE_PC, sideColumnRole: 'floats',
    sideColumnSide: 'outer', gutterWidth: mm(GUTTER) },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(SIZE), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('sepia'), referenceBold: false, // 'Cat. 1' in the accent, regular weight
    firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.5,
    maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
  },
  headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), levels: [entryLevel()] },
  headingStyles: [coverStyle(), checklistStyle()],
  captionStyle: { fontFamily: LABEL, fontSize: pt(CAPTION.size), gap: pt(CAPTION.gap),
    labelColor: col('sepia'), // the label is bold by default
    note: { fontSize: pt(CAPTION.note), gap: pt(CAPTION.noteGap), color: col('muted') } },
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    cellPadding: mm(1.8), headerBackground: col('ink'), headerColor: col('paper'),
    headerFontFamily: LABEL, headerFontSize: pt(8), bodyFontFamily: TEXT, bodyFontSize: pt(9.5) },
  paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(11),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  header: { elements: [] }, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdownの見本 · 43行 · content.en.mdtitle: "Doré: Three Plates for Don Quixote" author: "The Print Room" --- # Doré {style="cover" kicker="The Print Room · A cabinet exhibition" subtitle="Three plates for Don Quixote, 1863" foot="Wood engravings by Héliodore Pisan after Gustave Doré"} # Don Quixote \\ in His Library {chapter="Part I · Chapter I" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I" lead="Doré opens the book with its hero wide awake. Seated upright in a carved chair, the hidalgo holds up a sword in his right hand and a book in his left, while everything he has read comes loose from the page and fills the room around him."} The plate (:ref{id="library"}, opposite) sets out the sentence in which the hidalgo loses his wits. In John Ormsby’s translation his fancy grew full of ‘enchantments, quarrels, battles, challenges, wounds, wooings, loves, agonies, and all sorts of impossible nonsense’, and Doré draws the list almost item by item. Knights ride out from behind the chair, a dragon uncoils above a shield of arms, a knight no taller than a folio stands on a book beside a giant’s severed head, and a lady in chains kneels at the fore-edge of a volume lettered AMADIS. Cervantes says the hidalgo ‘sold many an acre of tillageland to buy books of chivalry to read’, and Doré scatters them about the room: open on the table under the window, fallen open at his feet, and one propped on its edge at the lower left, beside the giant’s head. Daylight comes in at the window on the left and falls on his face and on the open page. The figures crowding the rest of the room have come out of the books, down to the two knights no taller than a hand who joust on the backs of mice among the flagstones. Doré gives these phantoms the same firm outline as the man who imagines them. In wood engraving whatever the tool cuts away prints white, so Pisan had to turn each of Doré’s greys into a pattern of strokes: close parallel cuts for the heavy curtain, finer and more open ones for the light on the wall, broken curls for the smoke of figures behind the chair. Both men signed the block, Doré in script at the lower left and Pisan in capitals at the lower right. # The Vigil \\ of Arms {chapter="Part I · Chapter III" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I" lead="The innkeeper has no chapel, so Don Quixote keeps his vigil of arms in the yard. Doré makes the scene a nocturne: the armour stands on the well-head, and the bareheaded knight lifts his lance towards a full moon breaking through cloud."} The plate (:ref{id="vigil"}, opposite) follows the text closely. The innkeeper has told his guest that armour may be watched anywhere, so Don Quixote lays his on a trough beside the well and paces up and down in front of it. In Ormsby’s translation the night closes in ‘with a light from the moon so brilliant that it might vie with his that lent it’. Doré stands the armour upright on the well-head, helmet and breastplate and gauntlets, so that it reads as a second knight facing the thin man in his shirt who will wear it. On the right the inn is a dark mass with an outside stair, and a figure watches from its landing: in the novel the guests ‘flocked to see it from a distance’. Water lies in the ruts of the yard and catches the light, and the knight’s shadow runs across it towards the viewer. Cervantes plays the vigil as farce. Two carriers come in turn to water their mules, each goes to move the armour off the trough, and each is laid out with the lance; the landlord then hurries the dubbing through before anyone else is hurt. Doré leaves all of that out and keeps the hour before it, when the knight is alone with his arms and the moon. In this sky Pisan widens his level cuts as they near the moon, until the black left between them thins to hairlines around the halo. Short flicks pick out the edges of the clouds. A dark bar of cloud crosses the disc on the left, and a few faint strokes shade the moon’s face. # The Adventure \\ of the Windmills {chapter="Part I · Chapter VIII" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I" lead="Doré passes over the charge and draws the moment after it. The turning sail has caught the lance and lifted horse and rider clear of the plain, and Rocinante’s legs thrash at the empty air below them."} The plate (:ref{id="windmills"}, opposite) illustrates the sentence in which the charge ends. As the knight drove his lance into the sail, in Ormsby’s words, ‘the wind whirled it round with such force that it shivered the lance to pieces, sweeping with it horse and rider, who went rolling over on the plain’. Doré stops before the fall. The broken lance is still caught in the canvas, and the knight hangs tangled against the sail with his shield flung out beside him. The composition is built on one diagonal. The sail runs from the top left corner towards the centre, the horse hangs from it, and the line carries on down the slope to the thistles in the foreground. Against it Doré sets the level plain of La Mancha, where Cervantes counts ‘thirty or forty windmills’ and the plate lines up a row of them along the horizon, turning in the same wind. Sancho, hatless, throws up one arm and clutches his head with the other; his hat lies on the ground and his ass waits behind him. He had warned his master, in Ormsby’s version, that ‘what we see there are not giants but windmills, and what seem to be their arms are the sails that turned by the wind make the millstone go’. Pisan cuts the sky in long even lines and the patched canvas of the sail in torn, crossing strokes. The mills on the horizon are drawn with a few lines each. # Works in \\ the Exhibition {style="checklist" kicker="Checklist" range="1–3"} All three plates come from the first volume of *L’Ingénieux Hidalgo Don Quichotte de la Manche*, in the French translation by Louis Viardot, published in two folio volumes by L. Hachette et Cie, Paris, in 1863 with 377 compositions by Gustave Doré, 120 of them full-page. Héliodore Pisan engraved the three plates on wood, and both men signed them in the block. Quotations are from John Ormsby’s English translation of 1885. ::resource{id="checklist"} :::paragraphs{style="colophon"} Set in Ibarra Real Nova, Libre Bodoni and Sofia Sans Condensed (SIL Open Font License). Commentary written for this catalogue under a CC BY 4.0 licence. The plates are in the public domain and reproduced from scans on Wikimedia Commons; the cover shows a detail of Cat. 3. Ormsby’s translation is Project Gutenberg eBook #996. :::
`; // content.<lang>.md, inlined by the Cookbook // id, scan file, caption, the chapter it illustrates and alt text: one block per plate. const plateTexts = String.raw`library
Markdownの見本 · 16行 · content.plates.en.mdlibrary-835.jpg Don Quixote in His Library Part I, ch. I: ‘which treats of the character and pursuits of the famous gentleman Don Quixote of La Mancha’ Don Quixote sits upright in a carved chair, a book in his left hand and a sword raised in his right, surrounded by the figures of his reading: knights on horseback, a dragon, a giant’s severed head, a lady in chains and two tiny knights jousting on mice. vigil vigil-835.jpg The Vigil of Arms Part I, ch. III: ‘wherein is related the droll way in which Don Quixote had himself dubbed a knight’ A moonlit inn yard. Don Quixote, bareheaded and holding a lance and a buckler, looks up at the moon; his armour stands propped on the well-head facing him, and a figure watches from the inn’s outside stair. windmills windmills-835.jpg The Adventure of the Windmills Part I, ch. VIII: ‘the terrible and undreamt-of adventure of the windmills’ A windmill’s sail sweeps Don Quixote and Rocinante off the ground, the knight tangled against the canvas and his buckler flung out; a row of mills stands on the horizon and Sancho, hatless, throws up one arm and clutches his head beside his ass.
`; const FIELDS = ['id', 'file', 'caption', 'illustrates', 'altText']; const PLATES = plateTexts.trim().split(/\n\s*\n/).map((block) => Object.fromEntries(block.split('\n').map((line, i) => [FIELDS[i], line.trim()]))); // #region art: the engravings printed on the page's paper // Multiplying by the paper colour turns the scan's white into the page's cream. function onPaper(source) { const canvas = new OffscreenCanvas(source.width, source.height); const ctx = canvas.getContext('2d'); ctx.drawImage(source, 0, 0); ctx.globalCompositeOperation = 'multiply'; ctx.fillStyle = palette.paper; ctx.fillRect(0, 0, source.width, source.height); return canvas.transferToImageBitmap(); } const resized = (scan, width) => createImageBitmap(scan, { resizeWidth: width, resizeQuality: 'high' }); // #endregion // #region scans: the plates, their thumbnails and the cover's detail, fetched from assets/ // plate() sizes each plate from its master, the Commons scan at 2,400 px tall (MASTER_PX); // the canvas draws the registered 835-px copy (the block's width on a 1,000-px page) in that box. async function scan(file) { const res = await fetch(asset(file)); if (!res.ok) throw new Error(`Scan not found (${res.status}): ${file}`); return createImageBitmap(await res.blob()); } async function loadPlates() { const [detail, ...scans] = await Promise.all([DETAIL, ...PLATES.map((p) => p.file)].map(scan)); const resources = [checklist(PLATES), picture('cover-detail', onPaper(detail), 'Detail of Cat. 3: the knight and his horse caught on the sail.')]; for (const [i, p] of PLATES.entries()) { registerResourceImage(p.file, onPaper(scans[i])); resources.push(plate(p, MASTER_PX[p.id]), // and a thumbnail: its own small file and size picture(`${p.id}-thumb`, onPaper(await resized(scans[i], THUMB_PX)), p.altText)); } return resources; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Ibarra Real Nova': ['400', '400i'], // text, the lead, the checklist 'Libre Bodoni': ['400', '400i'], // numerals, titles, drop caps, the cover 'Sofia Sans Condensed': ['400', '600', '700'], // labels, tombstones, captions, folios }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const words = `${markdown}\n${plateTexts}\n${CREDIT}`; // their letters decide the font subsets const [, resources] = await Promise.all([loadFonts(FONTS, words), loadPlates()]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), words); // #region check: every page past an opener holds a plate, so each plate faces its entry const astray = doc.pages.find((pg) => pg.role !== 'opener' && !pg.floats?.length); if (astray) { // text run past its verso, or the blank page that follows it const error = new Error(`Page ${astray.pageLabel} holds no plate: shorten the entry before it.`); kitFail(error); // the viewer's bar says why throw error; } // #endregion showPages(doc, { title: t({ en: 'Catalogue entries facing their plates' }) });
キット · core, fonts, viewer:全レシピ共通 · 235行// ─── 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 ───────────────────────────────────────────────────────────────────────

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

アレンジ

#図版の下に余白を残す

高さを固定すると、どの図版も190 mmで中央そろえになり、クレジット行の下に約31 mmの版面の空きが残ります。

-const PLATE_H = (LINES * LEAD - UNDER) * PT; // 221.1 mm: the rest of the text block
+const PLATE_H = 190; // mm: the same height for every plate, with air below

#図を外側の段に置く

図や注釈を、それを引用する段落の横にある、フロート専用の外側の段に積み重ねるには、余白に段を設けた教科書を参照してください。

よくあるつまずき

つまずき

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

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

つまずき

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

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

つまずき

デザインのテキストは両端そろえにならず、ドロップキャップのリードは行末がそろわない

postext 1.4.1のデザインのテキスト要素は左そろえ、中央、右そろえのいずれかで、語単位で折り返します。両端そろえはなく、hyphenate: trueは1行に収まらない長い語を分割するだけです。そのため、dropCapの横に組むリードの行は、両端そろえの本文の横で行末がそろいません。リードは頭文字の横の行だけにして、手で収めてください。lines: 1(上がり頭文字)ならリードは1行で、dropCapの間隔で行末をぴったり合わせられます。段落の続きはMarkdownに書きます。 章扉のドロップキャップ →

つまずき

属性内の\nが改行になるのはparagraphIndent > 0のときだけ

デザインのテキスト要素で、属性値に書いた\nが改行になるのは、paragraphIndentが0より大きいか、ドロップキャップを設定したときだけです。それ以外では1行のままになります。paragraphIndentをごくわずかな値(0.01 pt)にするか、1行ごとに属性を分けてください。 ページデザインのテキスト・罫・ボックス →

つまずき

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

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

Sandboxの検査 · bitmapTooSmall

低解像度の画像

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

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

クレジット

レシピ
Ignacio Ferro
本文
画像
フォント
Ibarra Real Nova (SIL OFL 1.1) · Libre Bodoni (SIL OFL 1.1) · Sofia Sans Condensed (SIL OFL 1.1)
Sandbox