かんたんな説明
昔の本のページの割り付けを論じたエッセイの3ページです。2つの段と向かい合う2ページのどこでも、文字の行を同じ高さにそろえる方法を示します。
できあがり
製本をテーマにした作品集『The Ruled Page』の9〜11ページには、中世の写字生がページに与えた比率を論じるエッセイ「The Secret Canon」が載っています。余白はエッセイが説くカノンのとおり2:3:4:6で、見開きで左右対称にとり、210 × 280mmの仕上がりの中にVollkornの2段組みを収めます。ページの上には淡い赤でベースライングリッドを描き、両段と向かい合う両ページの本文がその上に並びます。Playfair Displayの題は章扉の第1段を埋め、カノンの図は第2段の頭に立ちます。節見出しはグリッドから半行ずれ、その下の本文はグリッドに戻ります。小さな文字の工房ノートは、独自の10ptの行送りで組みます。11ページでは、ページ幅の図の下でエッセイが同じ長さにそろえた2段で終わり、第2段を奥付が締めくくります。
このレシピが答える質問
- 本のページを設定するには(仕上がりサイズ、左右対称ののどと小口の余白、2段組みと段間)?
- テキストをベースライングリッドに固定し、段や見開きをまたいで行をそろえるには?
- 段の下端をそろえ、最終ページの段の長さを均等にするには(垂直方向の両端そろえ)?
- ウィドウ、オーファン、最終行の一語(ラント)を避け、見出しを本文と離さないようにするには?
手短な答え
const LEAD = 13.4; // pt: the body leading is the pitch of the baseline grid
const UNIT = 8; // mm: the margins step 2:3:4:6 in units of 8 mm, like the canon's
const LINES = 44; // lines per column: the text block is a whole number of leads tall
const TRIM = { width: 210, height: 280 }; // mm
const MM_PER_PT = 25.4 / 72;
const page = {
sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
backgroundColor: col('paper'),
margins: { mirror: true, // left is the inner margin; mirror swaps it on every verso
left: mm(2 * UNIT), top: mm(3 * UNIT), right: mm(4 * UNIT), // 16, 24 and 32 mm
bottom: mm(TRIM.height - 3 * UNIT - LINES * LEAD * MM_PER_PT) }, // 48 mm: six units
// The layout keeps to the grid whether or not the overlay draws it.
baselineGrid: { enabled: true, color: col('grid') },
};
const GUTTER = 6; // mm: 162 mm of text block make two columns of 78 mm
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER),
columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } };
const body = { fontFamily: 'Vollkorn', fontSize: pt(9.8), lineHeight: pt(LEAD) };
// Lists add no space of their own (any whole number of leads would do). The grid never
// absorbs a list's top margin: the default 1.5 em (14.7 pt here) would set the items
// 1.3 pt off the lines of the column beside them, until the list reaches a new column.
const onGrid = { marginTop: pt(0), marginBottom: pt(0) };
材料
- 機能
- 見開きの余白ベースライングリッド段末そろえ意図してグリッドを外す図の配置ウィドウ、オーファン、ラント段落スタイル仕上がりサイズ1段組みと2段組み段間罫単位と寸法本文の書体箇条書きとチェックリスト番号付きリスト見出しの階層柱とノンブルページの役割ごとの柱リソースとしての図と表キャンバス上のページ
- 種類
- Vollkorn, Playfair Display, Vollkorn SC (SIL OFL 1.1)
- 素材
- The two drawings, made in code in the page's palette (Postext Cookbook, CC BY 4.0)
作り方
#1 · 見出しはグリッドを外れてよく、リストは外さない
headings: { fontFamily: 'Playfair Display', color: col('ink'), balancing,
levels: [
// Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
// Two lines of 46 pt, each four leads tall, then one lead: the text starts on line 10.
{ level: 1, breakBefore: { enabled: true, parity: 'odd' },
fontSize: pt(46), lineHeight: pt(4 * LEAD), italic: true, color: col('rubric'),
marginBottom: pt(LEAD) },
// A lead and a half above puts the head between two grid lines. The margin below
// needs no fitting: the engine snaps the text under a head to the next grid line.
{ level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(1.5 * LEAD) },
] },
// Lists add no space of their own (onGrid, in the short answer): items stay on the grid.
unorderedLists: { ...onGrid, bulletChar: '–', color: col('rubric'), fontWeight: 400 },
orderedLists: { ...onGrid, color: col('rubric') }, // numbers in the rubric, text in ink
見出しの上のアキは設定したとおりに保たれるので、行送り1.5行分にすると見出しは2本のグリッド線のあいだに入り、その下の本文は次の線にスナップされます(ベースライングリッド)。リストは最初の項目の前ではスナップされないため、手短な答えではリストのマージンを0にしています(行送りの整数倍でもかまいません)。既定の1.5emのままだと、10ページの3つの項目が隣の段の行より1.3pt下に並びます。題は46ptの2行がそれぞれ行送り4行分を取り、下マージンがさらに1行分を取るので、エッセイは10行目から始まります。

#2 · 意図してグリッドを離れ、そして戻る
paragraphStyles: [
{ id: 'note', fontSize: pt(7.8), lineHeight: pt(10), color: col('muted'),
boldColor: col('rubric'), firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
{ id: 'colophon', fontFamily: 'Vollkorn SC', fontSize: pt(7.5), lineHeight: pt(10),
color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
],
ノートは文字サイズ7.8pt、行送り10ptで組んだ:::paragraphsコンテナーで、グリッドから外れています。最後の段落のあと、位置は次のグリッド線まで切り上げられるので、続く見出しは本文のあとと同じ位置に立ちます(:::paragraphsコンテナー)。奥付も同じコンテナーを7.5pt、行送り10ptで使います。4行で40ptとなり、行送り3行分に0.2pt足りないだけなので、エッセイの最後の段は隣の段と同じ高さで終わります。
#3 · 図はグリッドが吸収できる位置に置く
// mm: a column wide and just short enough for a 17-lead band with its caption; a block wide
const CANON = [COLUMN_W, 61.8], PAGES = [BLOCK_W, 64];
const figure = (id, [width, height], placement, [caption, altText]) => ({
id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height },
placement, caption, altText, createdAt: 0, updatedAt: 0,
});
const resources = [
// A float never lands above the paragraph that cites it. A column 'top' float cited in
// the first column can still take the head of the second, on the same page.
figure('canon', CANON, { position: 'top' }, t(CAPTIONS.canon)),
// A page-wide 'top' float cannot, so it waits for the next page (gotcha: top-float-next-page).
// Each band is rounded up to whole leads, so the text under it stays on the grid.
figure('pages', PAGES, { position: 'top', span: 'page' }, t(CAPTIONS.pages)),
];
フロートは、それを参照する段落より上には置かれません(配置)。カノンの図は第1段で参照されますが、9ページの第2段の頭には入ることができます。10ページで参照されるページ幅の比較図は、11ページまで待ちます。図の帯はそれぞれ行送りの整数倍に切り上げられるので、図の下の本文はグリッド線から始まり、隣の段とも向かいのページともそろいます。図の大きさはこの切り上げに合わせて決めてください。61.8mmならカノンの帯はキャプションを含めて行送り17行分ですが、62.5mmにすると18行分になり、キャプションの下にほぼ1行分の白が残ります。

#4 · 段末そろえで段の長さをそろえる
// On by default: it fills a column a keep rule leaves short, and cuts the last page level.
// Off: the lever that starts a short closing column a line low under a page-wide figure;
// this copy never trips it, other copy can (gotcha: float-stretch-closing-page).
const balancing = { stretchAfterFloats: false };
9ページと10ページの満杯の段は、どれも44行目で終わります。版面がちょうど行送り44行分で、原稿をそれに合わせて調整したからです。次の行と離さない設定(keep-with-next)と、ウィドウ、オーファン、ラントの規則は既定で有効です。どれかが段を1行短く終わらせると、段末そろえは足りない分のアキをそれより前の見出しの上に入れ(段末そろえ)、そこが隙間として見えてしまいます。段末そろえが埋めるものがなくなるまで原稿を調整してください。最終ページでは、段末そろえが残りの行を割り振り、両方の段が同じ行で終わります。
#5 · 余白を左右対称にし、9ページから数える
// Design slots skip the palette: col() writes each hex too (gotcha: palette-skips-designs).
const OUTER = 4 * UNIT; // mm: the heads end where the text block does
const RISE = 1.5 * UNIT; // mm: heads 12 mm into the head margin; the drop folio 12 mm below
const TAB = 7; // mm from a folio to the title beside it
const onPage = (edge, x) => ({ anchor: { to: 'page', edge },
offset: { x: mm(x), y: mm(RISE) } });
const head = (id, content, parity, placement, extra = {}) => ({
kind: 'text', id, content, parity, placement, pages: 'body', // never on the opener
fontFamily: 'Vollkorn SC', fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(0.8),
color: col('muted'), ...extra,
});
const folio = { fontFamily: 'Vollkorn', fontWeight: 700, letterSpacing: pt(0),
color: col('rubric') };
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', onPage('top-left', OUTER), folio),
head('verso-title', '{title}', 'even', onPage('top-left', OUTER + TAB)),
head('recto-title', '{chapterTitle}', 'odd', onPage('top-right', -(OUTER + TAB))),
head('recto-folio', '{pageNumber}', 'odd', onPage('top-right', -OUTER), folio),
] };
// The opener drops its folio into the foot margin, centred under the text block.
const underBlock = { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(RISE) } };
const footer = { elements: [
head('drop-folio', '{pageNumber}', 'all', underBlock, { ...folio, pages: 'opener' }),
] };
// startAt prints the folios from 9; pageIndexOffset counts the 8 pages before them, so
// mirroring, parity and breakBefore follow the book (7 would add a blank page).
const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } };
手短な答えでは地の余白を計算で求めます。280mmの仕上がりから天の余白24mmと、ちょうどLINES行分の版面(44 × 13.4pt = 208mm)を引くと48mmが残り、2:3:4:6の比が求める6単位になります。mirror: trueにすると、leftとして設定した16mmはのど側の余白になり、奇数ページでは左、偶数ページでは右に来ます(見開きの余白)。そのため、ページの端からOUTERmm離して置いた柱は、小口側で版面と同じ位置で終わります。pages: 'body'は柱を章扉から外します。startAt: 9でノンブルは9〜11となり、pageIndexOffset: 8がその前の8ページを数えるので、左右対称の配置、柱の奇数・偶数、breakBefore: 'odd'が製本された本と一致します。オフセットを7にすると最初のページが偶数ページとして数えられ、奇数ページへの改ページでエッセイの前に白ページが1枚入ります。
レシピの全体
// ═══ Postext Cookbook · Nº 013 · A book page on a baseline grid ═════════════════════ // https://postext.dev/en/cookbook/baseline-grid-book-page // Code: MIT · Text: original (CC BY 4.0) · Pictures: drawn in code (CC BY 4.0) // Fonts: Vollkorn, Playfair Display, Vollkorn SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // An essay on the canon of page proportions, on a page that shows its geometry and grid. import { buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'baseline-grid-book-page'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // A scribe's colours: ink, one rubric red and the pale red of a ruled page. const palette = { ink: '#211d1a', // text: a warm near-black rubric: '#b3261e', // the one accent: title, numbers, folios, the drawings' lines grid: '#efc6bd', // the baseline grid, as pale as a manuscript's ruling rule: '#d5cbbb', // the column rule and the drawings' hairlines muted: '#716860', // running heads, the workshop note, the colophon (5.1:1 on paper) tint: '#ede4d3', // the ground of the drawings paper: '#fbf8f1', }; 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' } })), // Defaults this config does not restate link to main-color: point it at the rubric. { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } }, ]; // #region answer: one grid for the whole spread: trim, mirrored margins, two columns, leading const LEAD = 13.4; // pt: the body leading is the pitch of the baseline grid const UNIT = 8; // mm: the margins step 2:3:4:6 in units of 8 mm, like the canon's const LINES = 44; // lines per column: the text block is a whole number of leads tall const TRIM = { width: 210, height: 280 }; // mm const MM_PER_PT = 25.4 / 72; const page = { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { mirror: true, // left is the inner margin; mirror swaps it on every verso left: mm(2 * UNIT), top: mm(3 * UNIT), right: mm(4 * UNIT), // 16, 24 and 32 mm bottom: mm(TRIM.height - 3 * UNIT - LINES * LEAD * MM_PER_PT) }, // 48 mm: six units // The layout keeps to the grid whether or not the overlay draws it. baselineGrid: { enabled: true, color: col('grid') }, }; const GUTTER = 6; // mm: 162 mm of text block make two columns of 78 mm const layout = { layoutType: 'double', gutterWidth: mm(GUTTER), columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } }; const body = { fontFamily: 'Vollkorn', fontSize: pt(9.8), lineHeight: pt(LEAD) }; // Lists add no space of their own (any whole number of leads would do). The grid never // absorbs a list's top margin: the default 1.5 em (14.7 pt here) would set the items // 1.3 pt off the lines of the column beside them, until the list reaches a new column. const onGrid = { marginTop: pt(0), marginBottom: pt(0) }; // #endregion // #region heads: the book's title on versos, the essay's on rectos, folios from page 9 // Design slots skip the palette: col() writes each hex too (gotcha: palette-skips-designs). const OUTER = 4 * UNIT; // mm: the heads end where the text block does const RISE = 1.5 * UNIT; // mm: heads 12 mm into the head margin; the drop folio 12 mm below const TAB = 7; // mm from a folio to the title beside it const onPage = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(RISE) } }); const head = (id, content, parity, placement, extra = {}) => ({ kind: 'text', id, content, parity, placement, pages: 'body', // never on the opener fontFamily: 'Vollkorn SC', fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(0.8), color: col('muted'), ...extra, }); const folio = { fontFamily: 'Vollkorn', fontWeight: 700, letterSpacing: pt(0), color: col('rubric') }; const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', onPage('top-left', OUTER), folio), head('verso-title', '{title}', 'even', onPage('top-left', OUTER + TAB)), head('recto-title', '{chapterTitle}', 'odd', onPage('top-right', -(OUTER + TAB))), head('recto-folio', '{pageNumber}', 'odd', onPage('top-right', -OUTER), folio), ] }; // The opener drops its folio into the foot margin, centred under the text block. const underBlock = { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(RISE) } }; const footer = { elements: [ head('drop-folio', '{pageNumber}', 'all', underBlock, { ...folio, pages: 'opener' }), ] }; // startAt prints the folios from 9; pageIndexOffset counts the 8 pages before them, so // mirroring, parity and breakBefore follow the book (7 would add a blank page). const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } }; // #endregion // #region balance: full columns end flush, and the last page ends level // On by default: it fills a column a keep rule leaves short, and cuts the last page level. // Off: the lever that starts a short closing column a line low under a page-wide figure; // this copy never trips it, other copy can (gotcha: float-stretch-closing-page). const balancing = { stretchAfterFloats: false }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales) resourceTypes: defaultResourceTypes(LANG), // Spanish captions (gotcha: resource-types-locale) colorPalette, page, layout, bodyText: { ...body, color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('rubric'), referenceBold: false, firstLineIndent: mm(4), indentAfterHeading: false }, // hyphenation and widow rules are on // #region flow: heads that leave the grid and come back to it; lists that never leave it headings: { fontFamily: 'Playfair Display', color: col('ink'), balancing, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). // Two lines of 46 pt, each four leads tall, then one lead: the text starts on line 10. { level: 1, breakBefore: { enabled: true, parity: 'odd' }, fontSize: pt(46), lineHeight: pt(4 * LEAD), italic: true, color: col('rubric'), marginBottom: pt(LEAD) }, // A lead and a half above puts the head between two grid lines. The margin below // needs no fitting: the engine snaps the text under a head to the next grid line. { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(1.5 * LEAD) }, ] }, // Lists add no space of their own (onGrid, in the short answer): items stay on the grid. unorderedLists: { ...onGrid, bulletChar: '–', color: col('rubric'), fontWeight: 400 }, orderedLists: { ...onGrid, color: col('rubric') }, // numbers in the rubric, text in ink // #endregion // #region note: small type leaves the grid on purpose, and the flow snaps back after it paragraphStyles: [ { id: 'note', fontSize: pt(7.8), lineHeight: pt(10), color: col('muted'), boldColor: col('rubric'), firstLineIndent: pt(0), marginTop: pt(LEAD / 2) }, { id: 'colophon', fontFamily: 'Vollkorn SC', fontSize: pt(7.5), lineHeight: pt(10), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ], // #endregion captionStyle: { fontSize: pt(8), labelColor: col('rubric'), descriptionItalic: true }, header, footer, }); // #region art: the two drawings, in the page's palette (lines only: no text, no filters) const BLOCK_W = TRIM.width - 6 * UNIT; // mm: the text block, 162 mm const COLUMN_W = (BLOCK_W - GUTTER) / 2; // mm: one column, 78 mm const svgFile = (w, h, markup) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"` + ` width="${w * 10}" height="${h * 10}">` + `<rect width="${w}" height="${h}" fill="${palette.tint}"/>${markup}</svg>`; const ln = (x1, y1, x2, y2, stroke, w = 0.4) => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"` + ` stroke="${stroke}" stroke-width="${w}" stroke-linecap="round"/>`; const leaf = (x, y, w, h) => `<rect x="${x}" y="${y}" width="${w}" height="${h}"` + ` fill="${palette.paper}" stroke="${palette.rule}" stroke-width="0.3"/>`; const ruling = (x, y, w, h, n) => Array.from({ length: n }, (_, i) => // a ruled text block ln(x, y + (h * (i + 0.8)) / n, x + w, y + (h * (i + 0.8)) / n, palette.rule, 0.25)).join(''); const dot = (x, y) => `<circle cx="${x}" cy="${y}" r="0.8" fill="${palette.rubric}"/>`; // Figure 1: the canon constructed on an open spread of 2:3 pages (sizes in mm). function canonSvg(w, h) { const W = (w - 8) / 2, H = 1.5 * W, X = 4, Y = 3.5; // two 2:3 pages, the ninths under them const at = (fx, fy) => [X + fx * W, Y + fy * H]; // a point in page widths and heights const line = (a, b) => ln(...at(...a), ...at(...b), palette.rubric); let s = leaf(X, Y, W, H) + leaf(X + W, Y, W, H); s += ruling(...at(2 / 9, 1 / 9), (6 * W) / 9, (6 * H) / 9, 24); // verso text block s += ruling(...at(10 / 9, 1 / 9), (6 * W) / 9, (6 * H) / 9, 24); // recto text block s += `<circle cx="${X + W / 2}" cy="${Y + (4 * H) / 9}" r="${W / 2}" fill="none"` + ` stroke="${palette.muted}" stroke-width="0.35"/>`; // a page wide, a block tall s += ln(X + W, Y, X + W, Y + H, palette.muted, 0.5); // the spine s += line([0, 1], [2, 0]) + line([0, 0], [2, 1]); // 1 · the spread's diagonals s += line([1, 0], [0, 1]) + line([1, 0], [2, 1]); // 2 · each page's diagonal s += line([2 / 3, 1 / 3], [2 / 3, 0]) + line([4 / 3, 1 / 3], [4 / 3, 0]); // 3 · the verticals s += line([2 / 3, 0], [4 / 3, 1 / 3]) + line([4 / 3, 0], [2 / 3, 1 / 3]); // 4 · across the spine for (let i = 0; i <= 18; i++) { // the ninths, ticked under the spread const [x, y] = at(i / 9, 1); s += ln(x, y + 1.2, x, y + (i % 9 ? 2.8 : 4), palette.muted, 0.3); } for (const c of [[2 / 9, 1 / 9], [8 / 9, 1 / 9], [2 / 9, 7 / 9], [10 / 9, 1 / 9], [16 / 9, 1 / 9], [16 / 9, 7 / 9]]) s += dot(...at(...c)); // 5 · the corners the lines fix return svgFile(w, h, s); } // Figure 2: this book's page twice, at a quarter of its size. function pagesSvg(w, h) { const k = (h - 8) / TRIM.height, W = TRIM.width * k, H = TRIM.height * k, Y = 4, GAP = 20; const X1 = (w - 2 * W - GAP) / 2, X2 = X1 + W + GAP; let s = leaf(X1, Y, W, H) + leaf(X2, Y, W, H); for (let i = 1; i < 9; i++) { // the canon's ninths s += ln(X1 + (i * W) / 9, Y, X1 + (i * W) / 9, Y + H, palette.grid, 0.3); s += ln(X1, Y + (i * H) / 9, X1 + W, Y + (i * H) / 9, palette.grid, 0.3); } s += ruling(X1 + W / 9, Y + H / 9, (6 * W) / 9, (6 * H) / 9, 32); s += `<rect x="${X1 + W / 9}" y="${Y + H / 9}" width="${(6 * W) / 9}" height="${(6 * H) / 9}"` + ` fill="none" stroke="${palette.rubric}" stroke-width="0.45"/>`; const colW = COLUMN_W * k, top = Y + 3 * UNIT * k, tall = LINES * LEAD * MM_PER_PT * k; const left = X2 + 2 * UNIT * k; for (const x of [left, left + colW + GUTTER * k]) s += ruling(x, top, colW, tall, LINES); s += `<rect x="${left}" y="${top}" width="${BLOCK_W * k}" height="${tall}"` + ` fill="none" stroke="${palette.rubric}" stroke-width="0.45"/>`; s += ln(X1, Y, X1, Y + H, palette.muted, 0.6) + ln(X2, Y, X2, Y + H, palette.muted, 0.6); return svgFile(w, h, s); } // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdownの見本 · 63行 · content.en.md
title: "The Ruled Page" subtitle: "Essays on the Making of Books" --- # The Secret Canon Before a medieval scribe wrote a word, the page had already been measured. The sheets of a quire were pricked along their edges with an awl and ruled from prick to prick with a dry point or a lead point, so that every line of writing had a line to stand on and every leaf of the book had the same frame. In many manuscripts the ruling can still be seen, faint as a watermark. It records two decisions the scribes never explained: how large the written area should be, and where on the leaf it should sit. The drawing in :ref{id="canon"} gives the answer that three people, working apart in the twentieth century, measured back out of the old books themselves. In 1946 J. A. van de Graaf published, in a Dutch journal, a construction that finds the text block of a page with a straightedge alone, without measuring anything. A year later the Argentine typographer Raúl Rosarivo argued in *Divina proporción tipográfica* that Gutenberg, Peter Schöffer and Nicolas Jenson had divided their pages into ninths. And Jan Tschichold, who in his youth had championed asymmetric layout, spent his later years showing why the old proportions were so hard to improve on. Their results agree closely. The method is now known as the secret canon because nobody wrote it down, yet it can be measured in manuscript after manuscript and in the first printed books. ## Drawing the canon All the construction needs is an open spread of two pages and a straightedge: 1. Draw the two diagonals of the spread. 2. On each page, draw the diagonal that runs from the head of the spine to the foot of the fore-edge. 3. Where it crosses a diagonal of the spread, draw a vertical line up to the head of the page. 4. Join its top to the crossing on the facing page. 5. The new line cuts the page diagonal at the inner top corner of the text block. The outer top corner lies on the spread diagonal, the outer bottom corner on the page diagonal, and the rectangle closes itself. That rectangle is the scribes’ text block. The inner margin is a ninth of the page’s width and the outer margin two ninths; the head margin is a ninth of its height and the foot two ninths. On a page of two by three the four margins run 2:3:4:6, inner, head, outer, foot. The text block has the page’s proportions and is as tall as the page is wide, as the circle shows. ## Villard’s figure Medieval draughtsmen could also divide a line without a ruler. The thirteenth-century portfolio of Villard de Honnecourt, thirty-three leaves of drawings now in the Bibliothèque nationale de France, contains a figure that divides a line into equal parts with a straightedge alone: a few diagonals across a rectangle, and each crossing marks off a third, a quarter or a fifth of its side, as far as the draughtsman cares to go. Tschichold showed that the same figure finds the ninths of a page. The leaves of the portfolio, as they survive, measure about 235 by 155 millimetres, close to two by three. Rosarivo came to the ninths from the other end. Working with compass and ruler on books printed in the fifteenth century, he concluded that their type areas had been found by dividing the page into nine parts each way, the text taking six of them, and he called the 2:3 proportion of those pages the secret number. Experts at the Gutenberg Museum in Mainz examined his findings, which the *Gutenberg-Jahrbuch* later published again. :::paragraphs{style="note"} **Workshop note.** On a page of 160 by 240 millimetres the canon gives margins of 17.8 millimetres at the spine, 26.7 at the head, 35.6 at the fore-edge and 53.3 at the foot, and a text block of 106.7 by 160 millimetres, as tall as the page is wide. Divide the width and the height into nine and count: one part inside, one above, two outside and two below. ::: ## Lines to stand on The ruling fixed the lines as well as the frame. Until early in the thirteenth century most scribes wrote their first line on top of the first ruled line; within a generation they had begun to hang it below that line instead, a change regular enough for palaeographers to date manuscripts by it. N. R. Ker, who described it in 1960, placed it around 1230. The ruling drew the columns, too: pairs of vertical lines bounded each column of a two-column book, and the hairline between the columns of this page is the last trace of them. The printers kept to the discipline. Gutenberg’s Bible is set in two columns of forty lines on its first pages, forty-one on page ten and forty-two on nearly every page after it, which is why bibliographers call it the 42-line Bible. The lines were set closer together inside the same type area, so the frame held more text and the whole Bible needed less paper. ## What a working page keeps The canon was made for large books meant to last, with room in their margins for a reader’s notes. Few books can afford it now. On this page of 210 by 280 millimetres it would ask for a text block of 140 by 187, and more than half of the paper would stay blank. The page you are reading keeps three things from the canon instead: - the progression of its margins, still 2:3:4:6, now in steps of eight millimetres: 16 at the spine, 24 at the head, 32 at the fore-edge and 48 at the foot; - the spread as the unit of design: two inner margins together are as wide as one outer margin, so the white around each text block reads evenly across the open book; - the ruling itself, now a baseline grid of 13.4 points that the text of both columns stands on, on this page and on the page facing it. It gives up the ninths. Its text block, 162 by 208 millimetres, is far larger than the canon’s, and it is divided into two columns of 78 millimetres with a gutter of six between them. Set across the whole block, a line of this type would run to more than a hundred characters, too long for the eye to find its way back to the start of the next; a column holds about fifty, and exactly 44 lines. The two pages are drawn side by side in :ref{id="pages"}. A page that counts its lines can keep the feet of its columns level, too: every full column in this essay ends on the forty-fourth line. When the rules that keep a heading with its text, or keep a paragraph from leaving a single line behind, would end a column a line short, the missing line goes in as a little more space above a heading higher up, and the column still reaches the last line. On the last page of an essay the columns are cut to the same length instead, and the text ends in a level band. A baseline grid fixes where every line will stand before a word is set, as the awl and the dry point did on parchment. The lists in this essay add no space of their own, so their items fall on the same lines as the paragraphs in the column beside them. A heading may sit between two lines of the grid, with a line and a half above it and half a line below, but the text under it comes straight back to the grid. The workshop note, in smaller type, runs on a closer leading of its own and returns to the grid where it ends. A figure is treated the same way: its band, caption included, is rounded up to whole lines (seventeen for :ref{id="canon"}), so the text below a drawing at the head of a page lines up with the text on the page facing it. The grid on these pages is drawn in pale red so that the reader can check the text against it. Open the book at any spread and each line of the left page continues across the spine into a line of the right, as the scribes’ ruling ran from prick to prick across both halves of the opened sheet. :::paragraphs{style="colophon"} Set in Vollkorn, 9.8 on 13.4 points, with Playfair Display and Vollkorn SC (SIL Open Font License 1.1) Text and drawings: Postext Cookbook, CC BY 4.0 :::`; // content.<lang>.md, inlined by the Cookbook // Captions and alt text in both sample languages: [caption, altText]. const CAPTIONS = { canon: { en: ['The canon on an open spread of 2:3 pages. The diagonals fix the corners of both text ' + 'blocks; the circle, as wide as a page, is exactly as tall as its block.', 'Two facing pages crossed by red construction lines, a ruled text block where they cross ' + 'on each page, and a circle as wide as the left page.'], es: ['El canon sobre un pliego abierto de páginas 2:3. Las diagonales fijan las esquinas de ' + 'las dos cajas; el círculo, tan ancho como la página, es tan alto como su caja.', 'Dos páginas enfrentadas cruzadas por líneas rojas de construcción, una caja pautada donde ' + 'se cortan en cada página y un círculo tan ancho como la página izquierda.'], }, pages: { en: ['This book’s 210 × 280 mm page twice. Left, divided by the canon into ninths, with a ' + 'text block of 140 × 187 mm. Right, as the book sets it: margins of 16, 24, 32 and 48 ' + 'mm and two columns of 44 lines.', 'Two identical pages: the left one divided into a nine-by-nine grid around its text block, ' + 'the right one holding two ruled columns.'], es: ['La página de 210 × 280 mm de este libro, dos veces. A la izquierda, dividida en ' + 'novenos por el canon, con una caja de 140 × 187 mm. A la derecha, tal como la compone ' + 'el libro: márgenes de 16, 24, 32 y 48 mm y dos columnas de 44 líneas.', 'Dos páginas iguales: la izquierda, dividida en una cuadrícula de nueve por nueve alrededor ' + 'de su caja; la derecha, con dos columnas pautadas.'], }, }; // #region figures: one figure heads the next column, the other the next page // mm: a column wide and just short enough for a 17-lead band with its caption; a block wide const CANON = [COLUMN_W, 61.8], PAGES = [BLOCK_W, 64]; const figure = (id, [width, height], placement, [caption, altText]) => ({ id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, placement, caption, altText, createdAt: 0, updatedAt: 0, }); const resources = [ // A float never lands above the paragraph that cites it. A column 'top' float cited in // the first column can still take the head of the second, on the same page. figure('canon', CANON, { position: 'top' }, t(CAPTIONS.canon)), // A page-wide 'top' float cannot, so it waits for the next page (gotcha: top-float-next-page). // Each band is rounded up to whole leads, so the text under it stays on the grid. figure('pages', PAGES, { position: 'top', span: 'page' }, t(CAPTIONS.pages)), ]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages paint, loaded before the first build (gotcha: fonts-first). const FONTS = { // text, display and label faces (Vollkorn SC ships no italic) Vollkorn: ['400', '400i', '700'], 'Playfair Display': ['700', '700i'], 'Vollkorn SC': ['400', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('canon.svg', canonSvg(...CANON)); await loadSvg('pages.svg', pagesSvg(...PAGES)); const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'A book page on a baseline grid', es: 'Una página de libro sobre una rejilla base' }) });キット · 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上のレシピのフォルダー ↗ (新しいタブで開きます)
アレンジ
#印刷用にグリッドを隠す
enabledが切り替えるのは、仕上がったページの上に描く線だけです。どちらの場合もレイアウトは13.4ptのグリッドに従うので、行は1本も動きません。
- baselineGrid: { enabled: true, color: col('grid') },
+ baselineGrid: { enabled: false },#段末そろえの効果を見る
段末そろえを切ると、11ページは最後の8行と奥付を第1段に流し込み、第2段を空のまま残します。
-const balancing = { stretchAfterFloats: false };
+const balancing = { enabled: false };よくあるつまずき
つまずき
'top'のフロートは参照したページには入らない
フロートが自分の参照より上に置かれることはありません。そのため、Nページで参照したページ幅の'top'フロートはN+1ページの頭に置かれます。もっと前で参照するか、参照したページの下端に入れる位置'auto'または'bottom'を使ってください。 図の配置 →
つまずき
最終ページでは、topフロートが最後の段を押し下げることがある
postext 1.4.1では、章や話がページ幅のtopフロートで始まるページで終わり、その行が段の間で不均等に分かれると、stretchAfterFloatsは短いほうの段を短いまま終わらせず、フロートの下に空行を1行加えます。そのため、2つの段の始まりの行がそろわなくなります。headings.balancing.stretchAfterFloatsをfalseにするか、原稿を偶数行に収めてください。 段末そろえ →
つまずき
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から書き直してください。 セマンティックカラーパレット →
つまずき
ハイフネーションできるのは8つのロケールだけで、コードは完全一致
ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。 ハイフネーションと文書の言語 →
つまずき
Figure/TableはdefaultResourceTypes(locale)でローカライズする
設定のlocaleが決めるのはハイフネーションで、キャプションではありません。resourceTypesがなければ、組み込みの種類は英語でFigureとTableと表示されます。スペイン語ならresourceTypes: defaultResourceTypes('es')を渡し、それ以外の言語ではresourceTypesに名前を自分で書いてください。 「図」と「表」を文書の言語で →
つまずき
フロントマターの値はすべて引用符で囲む
YAMLはtitle: 1984を数値として、日付をDateオブジェクトとして読みます。文字列でない値はプレースホルダーに空で出力され、PDFにもタイトルが付きません。値はすべて引用符で囲んでください(title: "1984")。 文書のメタデータ →
つまずき
レイアウトの前にすべてのフォントを読み込む
レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →
span: 'page'を指定しadvancedDesignを持たない見出しレベルは両段をまたぐためのものですが、postext 1.4.1はその幅を1段分として測ります。「The Secret Canon」はページをまたいで1行で印刷されるのに2行分の高さを確保し、その帯を段間罫が貫きます。そのため、ここでは題を第1段に置いています。topフロートを参照元のページに置かないという規則は、ページ幅のフロートに当てはまります。段のtopフロートは、あとの段の頭であれば参照元のページにも置かれます。9ページの図1.1がその例です。- ページサイズ、余白、段間は絶対単位(mm、pt、cm、in)で指定します。そこでの
emには基準となる文字サイズがないため、ビルドは「dimensionToPx: baseFontSizePx is required for unit "em"」で止まります。 - Vollkornの語間は0.2emと狭いため、約50字の段では両端そろえの行の一部がゆるくなったり詰まったりします。見本はキャプチャのゆるい行のレポートと照らして1行ずつ調整しました。本文や行長を変えたら、もう一度確認してください。
クレジット
- 本文
- The essay “The Secret Canon” and its Spanish version “El canon secreto”, written for this recipe · Postext Cookbook · CC BY 4.0
- 画像
- The two drawings, made in code in the page's palette · Postext Cookbook · CC BY 4.0
- フォント
- Vollkorn (SIL OFL 1.1) · Playfair Display (SIL OFL 1.1) · Vollkorn SC (SIL OFL 1.1)


