跳到主要内容
食谱编号13

排版食谱 · 第1章 · 页面与网格

排在基线网格上的书页

左右对称的双栏页面,版心高度恰好是44个行距,因此正文在栏间和书脊两侧都落在同一套网格上。

本页内容

页码9 · 第1页,共3页

  • 英文样例:尚无中文版本
  • 成品尺寸210 × 280 mm
  • 2栏, 栏间距6 mm
  • Vollkorn 9.8/13.4
  • Playfair Display
  • Vollkorn SC
  • 3页
  • 难度
  • Postext 1.4.1
  • 排版用时37 ms
  • 180行代码

成品一览

书籍制作文集The Ruled Page的第9页到第11页,收的是“The Secret Canon”,一篇讨论中世纪抄写员所定页面比例的随笔。它的页边距遵循文中描述的比例法则,即左右对称的2:3:4:6,成品尺寸210 × 280 mm,版心是两栏Vollkorn。页面上用浅红色画出基线网格,两栏以及左右两页的正文都落在网格上。Playfair Display标题占满章首页的第一栏,第二栏栏首是比例法则的示意图。小标题偏离网格半行,其下的正文重新回到网格上。一段字号较小的工作室注释使用自己的10 pt行距。在第11页,随笔在一幅通栏图下以两栏等长结束,版权说明收尾于第二栏。

这道食谱解答

  • 怎样设置书页:成品尺寸、内外对称的页边距、两栏和栏间距?
  • 怎样把文字锁定在基线网格上,让各栏和对页的行对齐?
  • 怎样让各栏底部齐平,最后一页也排得平衡(垂直两端对齐)?
  • 怎样避免段末孤行、段首孤行和只有一个词的末行(孤字),并让标题与正文不分开?

简短回答

script.js · 第33–54行在完整代码中
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) };

用料

类型
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 · 让标题离开网格,但列表不行

script.js · 第104–117行在完整代码中
  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个行距会让标题落在两条网格线之间,标题下的正文则对齐到下一条网格线(基线网格)。列表在第一项之前不会对齐网格,所以简短回答把列表的外边距设为零(任何整数倍行距也可以);如果用默认的1.5 em,第10页的三个列表项会比旁边一栏的行线低1.3 pt。标题的两行46 pt各占四个行距,下外边距再占一个,所以随笔从第10行开始。

第10页:第二栏里的列表与旁边段落的网格保持一致;工作室注释落在网格线之间,下面的标题偏离网格半行,标题下的正文又回到网格上。

#2 · 有意离开网格,再回来

script.js · 第120–125行在完整代码中
  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) },
  ],

注释是一个:::paragraphs容器,字号7.8 pt、行距10 pt,不在网格上。最后一段结束后,位置向上取整到下一条网格线,所以后面的标题与跟在正文后时的位置相同(:::paragraphs容器)。版权说明用同样的容器,字号7.5 pt、行距10 pt:四行占40 pt,比三个行距少0.2 pt,因此随笔的最后一栏与旁边一栏齐底。

#3 · 把图放在网格能消化的地方

script.js · 第285–298行在完整代码中
// 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)),
];

浮动体永远不会落在引用它的段落之上(位置)。比例法则示意图在第一栏被引用,仍然可以占据第9页第二栏的栏首;占满页宽的对比图在第10页被引用,要等到第11页。每个图带的高度都向上取整为整数个行距,所以图下的正文从网格线开始,与旁边一栏和对页齐平。图的尺寸要考虑这种取整:比例法则示意图宽61.8 mm时,连同题注占17个行距;宽62.5 mm就要占18个,题注下会留出将近一行的空白。

第11页:通栏图下的正文从网格线开始,随笔以两栏齐底结束,版权说明收尾于第二栏。

#4 · 让各栏齐底来补齐栏位

script.js · 第88–91行在完整代码中
// 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个行距,文稿也按它调整过。与下段同页,以及段末孤行、段首孤行和孤字规则默认都是开启的。如果其中某条规则让一栏少了一行,各栏齐底会把缺少的空间加到前面某个标题的上方(各栏齐底),在那里显出一道空隙,所以要调整文稿,直到各栏齐底无需填补为止。在最后一页,它把剩下的行分配好,让两栏结束在同一行。

#5 · 页边距左右对称,页码从9开始

script.js · 第58–84行在完整代码中
// 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 } };

简短回答算出了下页边距:280 mm的成品高度减去24 mm的上页边距,再减去恰好LINES个行距的版心(44 × 13.4 pt = 208 mm),剩下48 mm,即六个单位,符合2:3:4:6的比例。设了mirror: true后,作为left设定的16 mm就是内页边距,在右页位于左侧,在左页位于右侧(对称页边距),所以锚定在距页面边缘OUTER毫米处的书眉会在切口一侧与版心同时结束;pages: 'body'让章首页不印书眉。startAt: 9印出第9到第11页的页码,pageIndexOffset: 8计入之前的八页,于是左右对称、书眉的奇偶和breakBefore: 'odd'都与装订成书时一致。如果偏移量设为7,第一页会算作左页,奇数页分页会在随笔前插入一张空白页。

完整食谱

沙盒
// ═══ 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.mdtitle: "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.4 pt的网格,所以没有任何一行会移动。

-  baselineGrid: { enabled: true, color: col('grid') },
+  baselineGrid: { enabled: false },

#看看各栏齐底做了什么

关闭各栏齐底后,第11页会把最后八行和版权说明都排进第一栏,第二栏留空。

-const balancing = { stretchAfterFloats: false };
+const balancing = { enabled: false };

常见问题

易错点

'top'浮动体不会出现在引用它的那一页

浮动体不会排在自己的引用之前,所以在第N页引用的整页宽'top'浮动体会出现在第N+1页的顶部。把引用提前,或者使用position 'auto'或'bottom',它们可以占用引用页的底部。 图的放置 →

易错点

在收尾页上,顶部浮动体可能把最后一栏往下推

在postext 1.4.1中,当一章或一篇文章结束在以整页宽顶部浮动体开头的页面上,而且各栏的行数分配不均时,stretchAfterFloats会在较短的一栏中、浮动体下方加一个空行,而不是让它提前结束,于是两栏不再从同一行开始。把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旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →

易错点

只有8种语言区域能断词,且须代码完全一致

断词支持en-us、es、fr、de、it、pt、ca和nl,须完全匹配:'es-ES'或其他任何语言都会悄悄退回美式英语。 断词与文档语言 →

易错点

用defaultResourceTypes(locale)本地化Figure/Table

配置的locale决定断词,不决定题注:没有resourceTypes时,内置类型用英文写作Figure和Table。西班牙语传入resourceTypes: defaultResourceTypes('es');其他语言请在resourceTypes中自己写出名称。 用你的语言显示“图”和“表” →

易错点

frontmatter的每个值都加引号

YAML会把title: 1984读成数字,把日期读成Date对象;非字符串的值在占位符中打印为空,PDF也会没有标题。每个值都加引号:title: "1984"。 文档元数据 →

易错点

排版前加载所有字体

排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →

  • 设了span: 'page'而没有advancedDesign的标题级别本应横跨两栏,但postext 1.4.1按一栏的宽度来测量它:“The Secret Canon”在页面上印成一行,却预留了两行的高度,栏间线还穿过这条标题带。所以这里的标题留在第一栏。
  • top浮动体永远不会落在引用页的规则,适用于占满页宽的浮动体。栏内的top浮动体可以落在引用页上,位于后面一栏的栏首,就像第9页的图1.1那样。
  • 页面尺寸、页边距和栏间距使用绝对单位(mm、pt、cm、in)。在这些地方用em没有可参照的字号,构建会停止并报错“dimensionToPx: baseFontSizePx is required for unit "em"”。
  • Vollkorn的词间距很窄,只有0.2 em,所以在大约50个字符宽的栏里,有些两端对齐的行会显得松散或拥挤。示例文字是对照截图的松行报告逐行调整过的;正文或行长有任何改动后都要重新检查。

致谢

文本
  • 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)
沙盒