跳到主要内容
食谱编号70

排版食谱 · 第9章 · 完整出版物

剧本:角色表、说话人与舞台提示

把王尔德剧作的开头排成演出本。人名用梅紫色粗体,舞台提示用灰色斜体;角色表从制表符分隔的行中读取。

第2–3页,共5页

  • 英文样例:尚无中文版本
  • 成品尺寸140 × 216 mm
  • 1栏
  • Libre Baskerville 9.4/13.6
  • Abril Fatface
  • Playfair Display SC
  • 5页
  • 难度
  • Postext 1.4.1
  • 排版用时7 ms
  • 180行代码

成品一览

把王尔德《The Importance of Being Earnest》的开头排成一本五页的演出本,页面为140 × 216 mm。书名页是一张梅紫色戏单,外有双重金色边框,Importance和Earnest用粗肥体(fat face)大写字母,标题下有一朵金色康乃馨。与第一幕相对的角色表列出1895年在St James’s Theatre首演各角色的演员。每段台词以梅紫色粗体的说话人姓名开头,转行悬挂缩进5 mm,演员一眼就能找到下一句提示。舞台提示无论在台词中间还是两段台词之间,都用灰色斜体,对词时眼睛可以跳过它们。西班牙语版本是一个新译本,遵循西班牙语舞台惯例:每个人名后加破折号,舞台提示放在括号中。

这道食谱解答

  • 怎样排电影剧本或戏剧剧本:场景标题、角色名、两侧缩进的对白?
  • 怎样给正文或框里的关键词(粗体或斜体)着色?
  • 怎样做一个有表头行、合并单元格、栏宽和逐格对齐的表?
  • 怎样排不编号的插图:饰图、小花饰、标志?

简短回答

script.js · 第34–52行在完整代码中
// Speeches are plain paragraphs, the speaker's name in bold and each stage direction in
// italic; a direction between two speeches is a paragraph of its own, in a container:
//   **LANE.** Yes, sir. *[Hands them on a salver.]*
//   :::paragraphs{style="direction"}
//   *[Enter Lane.]*
//   :::
// The body's two emphasis colours then mark who speaks (plum) and what is done (grey).
const dialogue = {
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('plum'), // **ALGERNON.**
  italicColor: col('muted'), // *[Languidly.]*, in a speech or a paragraph of its own
  minWordSpacing: 0.65, maxWordSpacing: 1.8, // justified (the default); limits inside 0.6–2
  firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung
  maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
};
// Directions: smaller and centred, on the same grid. A paragraph style has no italic switch
// or colour (gotcha: style-italic-colour): the text is written *…* and takes the grey.
const direction = { id: 'direction', fontSize: pt(8.6),
  textAlign: 'center', firstLineIndent: pt(0) };

用料

类型
Libre Baskerville, Abril Fatface, Playfair Display SC(SIL OFL 1.1)
素材
  • The title page’s frame and the carnation fleuron, drawn in code in the page’s palette (Ignacio Ferro, MIT)

做法

#1 · 一幕就是一个普通标题

script.js · 第56–68行在完整代码中
// Any headings object drops the H1 break: restated (gotcha: headings-drop-h1-break).
const headings = {
  fontFamily: LABEL, fontWeight: 400, color: col('ink'),
  textAlign: 'center', // every level: the act, the cast list's heads, the scene
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // whole lines of the grid
  levels: [
    { level: 1, fontFamily: DISPLAY, fontSize: pt(24), lineHeight: pt(3 * LEAD),
      color: col('plum'), textTransform: 'uppercase',
      breakBefore: { enabled: true, parity: 'odd' } }, // a recto, facing the cast list
    { level: 2, fontSize: pt(13), lineHeight: pt(2 * LEAD) },
    { level: 3, fontSize: pt(9.5), color: col('plum') },
  ],
};

幕标题是一个一级标题,用24 pt的Abril Fatface,没有版面设计槽位。textTransform把它排成大写,13.6 pt的三条网格线让大写字母位于版心顶部以下5.5 mm处(标题)。textAlign: 'center'不能按级别设置,所以角色表的标题和Scene标签也会居中。parity为'odd'的breakBefore让这一幕从右页开始,与角色表相对。配置中必须重新写明这一项,因为只要有headings对象,一级标题的分页就会被关闭(分页前置)。

#2 · 角色表是从制表符分隔的行中读取的表

script.js · 第72–92行在完整代码中
// One row per line, cells split by tabs. A line with no tab spans the table: mergeCells
// joins its cells (gotcha: merged-cells-hiddenby). Each column has its own alignment.
function billTable(tsv, widths, align, headerRowCount = 0) {
  let model = { ...parseTSV(tsv.trim()), columnWidths: widths, headerRowCount };
  model.rows.forEach((row) => row.forEach((cell, c) => { cell.align = align[c]; }));
  tsv.trim().split('\n').forEach((line, r) => {
    if (line.includes('\t')) return;
    model.rows[r][0].align = 'center';
    const end = { row: r, col: widths.length - 1 };
    model = mergeCells(model, { start: { row: r, col: 0 }, end });
  });
  return model;
}
// One filled header cell: two side by side would show a seam (gotcha: table-fill-seams).
const tableStyle = {
  rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
  headerBackground: col('plum'), headerColor: col('paper'), headerFontFamily: LABEL,
  headerFontSize: pt(8.5), headerBold: false, // small capitals from the face itself
  bodyFontSize: pt(8.8), cellPadding: mm(1.5), // the body's face and ink, a size smaller
};
const tableStyles = [{ id: 'scenes', rules: 'none', cellPadding: mm(1) }]; // the scenes

Postext没有管道表格,所以每张表都是一个资源。各行写在content.cast.en.md中,一行对应表的一行,单元格之间用制表符分隔,parseTSV把它们转成模型(构建表格模型)。唯一没有制表符的那一行,即带剧院和日期的首演阵容,成为一个合并行:mergeCells给它的第一个单元格设colSpan: 2,第二个单元格保留在网格中,标记为hiddenBy。headerRowCount: 1让这一行排在梅紫色色带中。其他行都不填色,因为两个填色单元格并排时,Canvas会在它们之间显出一道浅色接缝。演员一栏占宽度的三分之一,其中每个单元格都右对齐。场景表用同一个函数生成,套用一个没有线的具名样式(具名表格样式)。表格中所有单元格的文字都用同一种颜色,即正文墨色,除非bodyColor指定别的颜色,所以剧中人物的粗体姓名在这里印成墨色,在对白中则是梅紫色。

#3 · 不带编号和题注的装饰图

script.js · 第96–102行在完整代码中
// ::resource{id="fleuron"} sets it where it stands (gotcha: resource-double-quotes).
const unnumbered = (id, name, defaultPlacement) => ({ id, name, shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement });
const resourceTypes = [
  unnumbered('ornament', 'Ornament', { position: 'here', width: 0.24, align: 'center' }),
  unnumbered('bill', 'Bill', { position: 'here' }),
];

captionPrefix和shortLabel为空的资源类型只印图片本身(资源类型)。它的defaultPlacement把康乃馨放在::resource{id="fleuron"}所在的位置,居中,宽为107 mm行长的0.24,约26 mm。两张表用第二个不编号的类型,所以都不会标成Table 1。

#4 · 书名页是一个标题样式

script.js · 第106–132行在完整代码中
// # The Importance of Being Earnest {style="playbill" small="The" big="Importance" …}
// Each attribute is a playbill line in its own face and size, its lineHeight a multiple
// (gotcha: design-lineheight-multiple). The ground is an image and reserves no height
// (gotcha: opener-image-no-reserve): :::pagebreak keeps the title page alone if its foot
// lines move up.
const line = (id, content, font, size, y, color, extra = {}) => ({ kind: 'text', id, content,
  fontFamily: font, fontSize: pt(size), lineHeight: 1, color: col(color), align: 'center',
  // Centred and tracked, a line sits half its tracking left of centre: x puts it back.
  placement: { anchor: { to: 'page', edge: 'top' },
    offset: { x: pt((extra.letterSpacing?.value ?? 0) / 2), y: mm(y) } }, ...extra });
const tracked = (track) => ({ textTransform: 'uppercase', letterSpacing: pt(track) });
const playbill = {
  id: 'playbill', span: 'page', // even in one column (gotcha: opener-clipped-at-top)
  header: { elements: [] }, footer: { elements: [] }, // no heads on p. 2, no folio on p. 1
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'ground', resourceId: 'playbill',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    line('kicker', '{subtitle}', LABEL, 8.5, 36, 'gilt', tracked(1.7)),
    line('small', '{attr.small}', TEXT, 17, 51, 'paper', { italic: true }),
    line('big', '{attr.big}', DISPLAY, 40, 60, 'paper', { textTransform: 'uppercase' }),
    line('link', '{attr.link}', TEXT, 17, 78, 'gilt', { italic: true }),
    line('name', '{attr.name}', DISPLAY, 60, 87, 'paper', { textTransform: 'uppercase' }),
    line('author', '{author}', LABEL, 13, 133, 'paper', tracked(3)),
    line('theatre', '{attr.theatre}', LABEL, 8, 170, 'gilt', tracked(1.6)),
    line('premiere', '{attr.premiere}', TEXT, 8.5, 176, 'paper', { italic: true }),
  ] } },
};

# The Importance of Being Earnest {style="playbill" …}把标题分放在四个属性中,版面设计用两种字体排出:名词用40 pt和60 pt的Abril Fatface,The和of Being用17 pt的Libre Baskerville斜体(标题属性)。梅紫色底、边框和康乃馨是一张用代码画出的SVG,锚定在出血边上(图片元素)。这本书只有一栏,但样式仍设了span: 'page'。如果留在栏内,版面设计会在版心的顶部和底部被裁掉,即纸张顶部以下20 mm和底部以上23.3 mm处。样式中空的header让书眉不出现在属于同一节的角色表上,空的footer让页脚页码不出现在书名页上,否则它会以梅紫色印在梅紫色底上(标题样式)。

首页标题中的图片不预留高度,所以标题只把页面占到它最低的一行文字,即176 mm处的首演信息。下面剩下的空间放不下下一个标题,截图中的页面即使去掉标题后的:::pagebreak也一样。这个分页是为以后的修改准备的:没有它,把剧院和首演信息上移到150 mm和156 mm时,The Persons of the Play会从梅紫色底页上开始。在1.4.1中,设了letterSpacing的居中行会向左偏离中线半个字距,所以line()把每个加大字距的行向右移动同样的距离。OSCAR WILDE因此只偏离中线0.07 mm,而不是0.56 mm。

#5 · 跳过每幕首页的书眉

script.js · 第136–152行在完整代码中
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontFamily: LABEL, fontSize: pt(8.5), letterSpacing: pt(0.8), color: col('muted'),
  textTransform: 'uppercase', ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(11) } },
});
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('plum') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-act', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// The act's first page carries a drop folio instead, centred under the text block.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'top', 0, folio),
  pages: 'opener', align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(8) } } }] };

左页书眉印frontmatter中的剧名,右页印来自{chapterTitle}的幕名,都用Playfair Display SC大写,页码用梅紫色排在外侧(文字元素)。每幕的第一页算作首页,因为它的第一个块是一个分页的标题,所以pages: 'body'让书眉不出现在这一页,页脚则为它印一个页脚页码。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 070 · Play script: cast list, speakers and stage directions ═══
// https://postext.dev/en/cookbook/stage-play
// Code: MIT · Text: Oscar Wilde, 1895 (PD, Gutenberg #844), Spanish: the Cookbook · Art: in code
// Fonts: Libre Baskerville, Abril Fatface, Playfair Display SC (OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  parseTSV, mergeCells } from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#221b1f', // the dialogue: a near-black with a little plum in it
  plum: '#5b2349', // the one accent: speakers' names, act titles, the title page's ground
  gold: '#b48a45', // rules and ornaments
  gilt: '#c9aa77', // gold lightened for small type on the plum ground (5.3:1)
  muted: '#6c6168', // stage directions and running heads (5.5:1 on the paper)
  rule: '#d8cbb7', // hairlines between the persons of the cast list
  paper: '#fbf6ec', // a cream stock
};
// The hex travels with the id: designs do not read the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'plum (defaults)', value: { hex: palette.plum, model: 'hex' } },
];
const [TEXT, DISPLAY, LABEL] = ['Libre Baskerville', 'Abril Fatface', 'Playfair Display SC'];
const [BODY, LEAD] = [9.4, 13.6]; // pt: the dialogue and its leading, the pitch of the grid
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in, the acting-edition size
const [TOP, INNER, OUTER, LINES] = [20, 18, 15, 36]; // mm, and 36 lines to a full page

// #region answer: a speech is a paragraph: the name in bold, the directions in italic
// Speeches are plain paragraphs, the speaker's name in bold and each stage direction in
// italic; a direction between two speeches is a paragraph of its own, in a container:
//   **LANE.** Yes, sir. *[Hands them on a salver.]*
//   :::paragraphs{style="direction"}
//   *[Enter Lane.]*
//   :::
// The body's two emphasis colours then mark who speaks (plum) and what is done (grey).
const dialogue = {
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('plum'), // **ALGERNON.**
  italicColor: col('muted'), // *[Languidly.]*, in a speech or a paragraph of its own
  minWordSpacing: 0.65, maxWordSpacing: 1.8, // justified (the default); limits inside 0.6–2
  firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung
  maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
};
// Directions: smaller and centred, on the same grid. A paragraph style has no italic switch
// or colour (gotcha: style-italic-colour): the text is written *…* and takes the grey.
const direction = { id: 'direction', fontSize: pt(8.6),
  textAlign: 'center', firstLineIndent: pt(0) };
// #endregion

// #region acts: an act title is a plain first-level heading, centred and set in capitals
// Any headings object drops the H1 break: restated (gotcha: headings-drop-h1-break).
const headings = {
  fontFamily: LABEL, fontWeight: 400, color: col('ink'),
  textAlign: 'center', // every level: the act, the cast list's heads, the scene
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // whole lines of the grid
  levels: [
    { level: 1, fontFamily: DISPLAY, fontSize: pt(24), lineHeight: pt(3 * LEAD),
      color: col('plum'), textTransform: 'uppercase',
      breakBefore: { enabled: true, parity: 'odd' } }, // a recto, facing the cast list
    { level: 2, fontSize: pt(13), lineHeight: pt(2 * LEAD) },
    { level: 3, fontSize: pt(9.5), color: col('plum') },
  ],
};
// #endregion

// #region cast: the cast list: a table from tab-separated lines, with merged rows
// One row per line, cells split by tabs. A line with no tab spans the table: mergeCells
// joins its cells (gotcha: merged-cells-hiddenby). Each column has its own alignment.
function billTable(tsv, widths, align, headerRowCount = 0) {
  let model = { ...parseTSV(tsv.trim()), columnWidths: widths, headerRowCount };
  model.rows.forEach((row) => row.forEach((cell, c) => { cell.align = align[c]; }));
  tsv.trim().split('\n').forEach((line, r) => {
    if (line.includes('\t')) return;
    model.rows[r][0].align = 'center';
    const end = { row: r, col: widths.length - 1 };
    model = mergeCells(model, { start: { row: r, col: 0 }, end });
  });
  return model;
}
// One filled header cell: two side by side would show a seam (gotcha: table-fill-seams).
const tableStyle = {
  rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
  headerBackground: col('plum'), headerColor: col('paper'), headerFontFamily: LABEL,
  headerFontSize: pt(8.5), headerBold: false, // small capitals from the face itself
  bodyFontSize: pt(8.8), cellPadding: mm(1.5), // the body's face and ink, a size smaller
};
const tableStyles = [{ id: 'scenes', rules: 'none', cellPadding: mm(1) }]; // the scenes
// #endregion

// #region ornament: a resource type for artwork with no number and no caption
// ::resource{id="fleuron"} sets it where it stands (gotcha: resource-double-quotes).
const unnumbered = (id, name, defaultPlacement) => ({ id, name, shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement });
const resourceTypes = [
  unnumbered('ornament', 'Ornament', { position: 'here', width: 0.24, align: 'center' }),
  unnumbered('bill', 'Bill', { position: 'here' }),
];
// #endregion

// #region playbill: the title page, a heading style whose design fills the sheet
// # The Importance of Being Earnest {style="playbill" small="The" big="Importance" …}
// Each attribute is a playbill line in its own face and size, its lineHeight a multiple
// (gotcha: design-lineheight-multiple). The ground is an image and reserves no height
// (gotcha: opener-image-no-reserve): :::pagebreak keeps the title page alone if its foot
// lines move up.
const line = (id, content, font, size, y, color, extra = {}) => ({ kind: 'text', id, content,
  fontFamily: font, fontSize: pt(size), lineHeight: 1, color: col(color), align: 'center',
  // Centred and tracked, a line sits half its tracking left of centre: x puts it back.
  placement: { anchor: { to: 'page', edge: 'top' },
    offset: { x: pt((extra.letterSpacing?.value ?? 0) / 2), y: mm(y) } }, ...extra });
const tracked = (track) => ({ textTransform: 'uppercase', letterSpacing: pt(track) });
const playbill = {
  id: 'playbill', span: 'page', // even in one column (gotcha: opener-clipped-at-top)
  header: { elements: [] }, footer: { elements: [] }, // no heads on p. 2, no folio on p. 1
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'ground', resourceId: 'playbill',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    line('kicker', '{subtitle}', LABEL, 8.5, 36, 'gilt', tracked(1.7)),
    line('small', '{attr.small}', TEXT, 17, 51, 'paper', { italic: true }),
    line('big', '{attr.big}', DISPLAY, 40, 60, 'paper', { textTransform: 'uppercase' }),
    line('link', '{attr.link}', TEXT, 17, 78, 'gilt', { italic: true }),
    line('name', '{attr.name}', DISPLAY, 60, 87, 'paper', { textTransform: 'uppercase' }),
    line('author', '{author}', LABEL, 13, 133, 'paper', tracked(3)),
    line('theatre', '{attr.theatre}', LABEL, 8, 170, 'gilt', tracked(1.6)),
    line('premiere', '{attr.premiere}', TEXT, 8.5, 176, 'paper', { italic: true }),
  ] } },
};
// #endregion

// #region heads: the play on the verso, the act on the recto, folios outside
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontFamily: LABEL, fontSize: pt(8.5), letterSpacing: pt(0.8), color: col('muted'),
  textTransform: 'uppercase', ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(11) } },
});
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('plum') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-act', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// The act's first page carries a drop folio instead, centred under the text block.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'top', 0, folio),
  pages: 'opener', align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(8) } } }] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // hyphenation by exact code (gotcha: hyphenation-locales)
  colorPalette,
  resourceTypes,
  page: {
    sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height),
    backgroundColor: col('paper'),
    margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
      left: mm(INNER), right: mm(OUTER), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText: dialogue,
  headings,
  headingStyles: [playbill],
  paragraphStyles: [direction, { id: 'colophon', fontSize: pt(7.5), color: col('muted'),
    textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  tableStyle,
  tableStyles,
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown样例 · 126行 · content.en.mdtitle: "The Importance of Being Earnest" subtitle: "A Trivial Comedy for Serious People" author: "Oscar Wilde" --- # The Importance of Being Earnest {style="playbill" small="The" big="Importance" link="of Being" name="Earnest" theatre="St James’s Theatre · London" premiere="First performed on 14 February 1895"} :::pagebreak ## The Persons of the Play ::resource{id="persons"} ## The Scenes of the Play ::resource{id="scenes"} :::paragraphs{style="colophon"} Text: Project Gutenberg eBook #844, in the public domain. Set in Libre Baskerville, Abril Fatface and Playfair Display SC (SIL Open Font License). ::: # First Act ::resource{id="fleuron"} ### Scene :::paragraphs{style="direction"} *Morning-room in Algernon’s flat in Half-Moon Street. The room is luxuriously and artistically furnished. The sound of a piano is heard in the adjoining room.* *[Lane is arranging afternoon tea on the table, and after the music has ceased, Algernon enters.]* ::: :::space **ALGERNON.** Did you hear what I was playing, Lane? **LANE.** I didn’t think it polite to listen, sir. **ALGERNON.** I’m sorry for that, for your sake. I don’t play accurately—any one can play accurately—but I play with wonderful expression. As far as the piano is concerned, sentiment is my forte. I keep science for Life. **LANE.** Yes, sir. **ALGERNON.** And, speaking of the science of Life, have you got the cucumber sandwiches cut for Lady Bracknell? **LANE.** Yes, sir. *[Hands them on a salver.]* **ALGERNON.** *[Inspects them, takes two, and sits down on the sofa.]* Oh!… by the way, Lane, I see from your book that on Thursday night, when Lord Shoreman and Mr. Worthing were dining with me, eight bottles of champagne are entered as having been consumed. **LANE.** Yes, sir; eight bottles and a pint. **ALGERNON.** Why is it that at a bachelor’s establishment the servants invariably drink the champagne? I ask merely for information. **LANE.** I attribute it to the superior quality of the wine, sir. I have often observed that in married households the champagne is rarely of a first-rate brand. **ALGERNON.** Good heavens! Is marriage so demoralising as that? **LANE.** I believe it *is* a very pleasant state, sir. I have had very little experience of it myself up to the present. I have only been married once. That was in consequence of a misunderstanding between myself and a young person. **ALGERNON.** *[Languidly.]* I don’t know that I am much interested in your family life, Lane. **LANE.** No, sir; it is not a very interesting subject. I never think of it myself. **ALGERNON.** Very natural, I am sure. That will do, Lane, thank you. **LANE.** Thank you, sir. *[Lane goes out.]* **ALGERNON.** Lane’s views on marriage seem somewhat lax. Really, if the lower orders don’t set us a good example, what on earth is the use of them? They seem, as a class, to have absolutely no sense of moral responsibility. :::paragraphs{style="direction"} *[Enter Lane.]* ::: **LANE.** Mr. Ernest Worthing. :::paragraphs{style="direction"} *[Enter Jack.]* *[Lane goes out.]* ::: **ALGERNON.** How are you, my dear Ernest? What brings you up to town? **JACK.** Oh, pleasure, pleasure! What else should bring one anywhere? Eating as usual, I see, Algy! **ALGERNON.** *[Stiffly.]* I believe it is customary in good society to take some slight refreshment at five o’clock. Where have you been since last Thursday? **JACK.** *[Sitting down on the sofa.]* In the country. **ALGERNON.** What on earth do you do there? **JACK.** *[Pulling off his gloves.]* When one is in town one amuses oneself. When one is in the country one amuses other people. It is excessively boring. **ALGERNON.** And who are the people you amuse? **JACK.** *[Airily.]* Oh, neighbours, neighbours. **ALGERNON.** Got nice neighbours in your part of Shropshire? **JACK.** Perfectly horrid! Never speak to one of them. **ALGERNON.** How immensely you must amuse them! *[Goes over and takes sandwich.]* By the way, Shropshire is your county, is it not? **JACK.** Eh? Shropshire? Yes, of course. Hallo! Why all these cups? Why cucumber sandwiches? Why such reckless extravagance in one so young? Who is coming to tea? **ALGERNON.** Oh! merely Aunt Augusta and Gwendolen. **JACK.** How perfectly delightful! **ALGERNON.** Yes, that is all very well; but I am afraid Aunt Augusta won’t quite approve of your being here. **JACK.** May I ask why? **ALGERNON.** My dear fellow, the way you flirt with Gwendolen is perfectly disgraceful. It is almost as bad as the way Gwendolen flirts with you. **JACK.** I am in love with Gwendolen. I have come up to town expressly to propose to her. **ALGERNON.** I thought you had come up for pleasure?… I call that business. **JACK.** How utterly unromantic you are! **ALGERNON.** I really don’t see anything romantic in proposing. It is very romantic to be in love. But there is nothing romantic about a definite proposal. Why, one may be accepted. One usually is, I believe. Then the excitement is all over. The very essence of romance is uncertainty. If ever I get married, I’ll certainly try to forget the fact. **JACK.** I have no doubt about that, dear Algy. The Divorce Court was specially invented for people whose memories are so curiously constituted. **ALGERNON.** Oh! there is no use speculating on that subject. Divorces are made in Heaven—*[Jack puts out his hand to take a sandwich. Algernon at once interferes.]* Please don’t touch the cucumber sandwiches. They are ordered specially for Aunt Augusta. *[Takes one and eats it.]*
`; // content.<lang>.md, inlined by the Cookbook const cast = String.raw`The original cast · St James’s Theatre, 14 February 1895
Markdown样例 · 9行 · content.cast.en.md**John Worthing**, J.P. *Mr. George Alexander* **Algernon Moncrieff** *Mr. Allen Aynesworth* **Rev. Canon Chasuble**, D.D. *Mr. H. H. Vincent* **Merriman**, butler *Mr. Frank Dyall* **Lane**, manservant *Mr. F. Kinsey Peile* **Lady Bracknell** *Miss Rose Leclercq* **Hon. Gwendolen Fairfax** *Miss Irene Vanbrugh* **Cecily Cardew** *Miss Evelyn Millard* **Miss Prism**, governess *Mrs. George Canninge*
`; // content.cast.<lang>.md: the persons, tab-separated const scenes = String.raw`Act I Algernon Moncrieff’s Flat in Half-Moon Street, W.
Markdown样例 · 3行 · content.scenes.en.mdAct II The Garden at the Manor House, Woolton. Act III Drawing-Room at the Manor House, Woolton. *Time: The Present.*
`; // content.scenes.<lang>.md: the acts and their places // #region art: the title page's ground and double frame, and the carnation fleuron, in mm let seed = 1895; // Mulberry32, a seeded PRNG: never Math.random() in a recipe const rand = () => { let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; const f = (n) => +n.toFixed(2); const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(palette[a].slice(i, i + 2), 16) * (1 - k) + parseInt(palette[b].slice(i, i + 2), 16) * k).toString(16).padStart(2, '0')) .join('')}`; const svgOf = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const pts = (list) => list.map(([x, y]) => `${f(x)} ${f(y)}`).join('L'); const poly = (list, fill) => `<path d="M${pts(list)}Z" fill="${fill}"/>`; const stroke = (list, color, w) => `<path d="M${pts(list)}" fill="none" stroke="${color}" ` + `stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"/>`; const quad = ([x0, y0], [cx, cy], [x1, y1], t) => [ (1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t * t * x1, (1 - t) ** 2 * y0 + 2 * (1 - t) * t * cy + t * t * y1]; // A carnation `s` mm tall standing on (cx, cy), seen from the side: three fans of pinked // petals spring from the rim of a calyx, notched apart in the ground colour behind them. const DEG = Math.PI / 180; function carnation(cx, cy, s, [back, mid, front], green, ground) { const out = []; const [rx, ry] = [cx, cy - s * 0.32]; // the rim of the calyx const fan = (from, to, r, lift, petals, fill) => { // a fan from `from`° to `to`°, r mm deep const at = (deg, k) => [rx + Math.cos(deg * DEG) * r * k, ry - lift + Math.sin(deg * DEG) * r * k * 0.92]; const edge = []; for (let i = 0; i <= petals * 6; i++) { // six teeth to a petal, each petal a rounded lobe const lobe = 0.84 + 0.16 * Math.sin((Math.PI * (i % 6)) / 6); edge.push(at(from + ((to - from) * i) / (petals * 6), (i % 2 ? 0.9 : 1) * lobe * (0.98 + rand() * 0.04))); } out.push(poly([[rx, ry - lift], ...edge], fill)); const notch = (to - from) / petals / 14; // half the angle of a notch at the edge for (let p = 1; p < petals; p++) { // a thin wedge between two petals const deg = from + ((to - from) * p) / petals; out.push(poly([at(deg, 0.66), at(deg - notch, 1.1), at(deg + notch, 1.1)], ground)); } }; fan(-162, -18, s * 0.74, 0, 7, back); fan(-146, -34, s * 0.56, s * 0.06, 5, mid); fan(-124, -56, s * 0.36, s * 0.1, 3, front); // The calyx narrows to the stem; three short sepals rise over the petals, and an outline in // the ground colour keeps it clear of them. const [a, b] = [s * 0.11, s * 0.045]; // half-widths at the rim and at the stem const cup = [[rx - a, ry + s * 0.03], [rx - a * 1.25, ry - s * 0.07], [rx - a * 0.45, ry - s * 0.01], [rx, ry - s * 0.1], [rx + a * 0.45, ry - s * 0.01], [rx + a * 1.25, ry - s * 0.07], [rx + a, ry + s * 0.03], [cx + b, cy], [cx - b, cy]]; out.push(`<path d="M${pts(cup)}Z" fill="${green}" stroke="${ground}" ` + `stroke-width="${f(s * 0.03)}" stroke-linejoin="round"/>`); return out.join(''); } // A scroll: an arm out from the stem that ends in a spiral curl. function scroll(x0, y0, dir, len, curl, color, w) { const list = []; for (let i = 0; i <= 24; i++) { const t = i / 24; list.push([x0 + dir * len * t, y0 + Math.sin(t * Math.PI) * curl * 0.3 - t * curl * 0.25]); } const [ex, ey] = list[list.length - 1]; for (let i = 1; i <= 48; i++) { const a = (i / 48) * Math.PI * 1.8; const r = curl * 0.5 * Math.exp(-0.38 * a); list.push([ex + dir * Math.sin(a) * r, ey - curl * 0.5 + Math.cos(a) * r]); } return stroke(list, color, w); } // A carnation leaf: a narrow blade along a curve from its base. function blade(p0, c, p1, w, fill) { const [left, right] = [[], []]; for (let i = 0; i <= 16; i++) { const t = i / 16; const [x, y] = quad(p0, c, p1, t); const [x2, y2] = quad(p0, c, p1, Math.min(1, t + 0.01)); const [dx, dy] = [x2 - x, y2 - y]; const k = (w * Math.sin(Math.PI * Math.min(1, t * 1.15)) ** 0.7) / 2 / (Math.hypot(dx, dy) || 1); left.push([x - dy * k, y + dx * k]); right.unshift([x + dy * k, y - dx * k]); } return poly([...left, ...right], fill); } // The fleuron, w × h mm: the carnation between two leaves and two scrolls, on `ground`. function fleuron(w, h, petals, green, ground) { const [cx, base] = [w / 2, h * 0.9]; const parts = [-1, 1].map((d) => scroll(cx + d * 0.6, base, d, w * 0.38, h * 0.46, green, h * 0.035) + blade([cx + d * 0.8, base - 0.2], [cx + d * w * 0.12, base - h * 0.02], [cx + d * w * 0.24, base - h * 0.3], h * 0.07, green)); return svgOf(w, h, parts.join('') + carnation(cx, base, h * 0.88, petals, green, ground)); } // The title page: a plum sheet, a double gold frame whose rules cross at the corners // (Oxford corners), and the carnation in gold between the title and the author. const FLEURON_Y = 109; // mm: the top of the title page's carnation function playbillArt(W, H) { const out = [`<rect width="${W}" height="${H}" fill="${palette.plum}"/>`]; const frame = (inset, w, reach) => { // four rules `inset` mm in, running `reach` mm past const [a, bx, by] = [inset, W - inset, H - inset]; for (const [p, q] of [[[a - reach, a], [bx + reach, a]], [[a - reach, by], [bx + reach, by]], [[a, a - reach], [a, by + reach]], [[bx, a - reach], [bx, by + reach]]]) { out.push(stroke([p, q], palette.gold, w)); } }; frame(9, 0.55, 3.2); frame(11, 0.22, -1.2); for (const [x, y] of [[9, 9], [W - 9, 9], [9, H - 9], [W - 9, H - 9]]) { out.push(`<circle cx="${x}" cy="${y}" r="0.9" fill="${palette.gold}"/>`); } const petals = [mix('gold', 'plum', 0.35), palette.gold, mix('paper', 'gold', 0.2)]; const sprig = fleuron(46, 16, petals, palette.gold, palette.plum) .replace(/^<svg[^>]*>|<\/svg>$/g, ''); out.push(`<g transform="translate(${(W - 46) / 2} ${FLEURON_Y})">${sprig}</g>`); return svgOf(W, H, out.join('')); } await loadSvg('playbill.svg', playbillArt(TRIM.width, TRIM.height)); await loadSvg('fleuron.svg', fleuron(30, 11, [mix('plum', 'ink', 0.4), mix('plum', 'paper', 0.12), mix('plum', 'paper', 0.45)], palette.gold, palette.paper)); const svg = (id, w, h, altText) => ({ id, typeId: 'ornament', kind: 'svg', altText, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, createdAt: 0, updatedAt: 0 }); const art = [ svg('playbill', TRIM.width, TRIM.height, 'A plum title page in a double gold frame whose ' + 'rules cross at the corners, with a gold carnation between two scrolls.'), svg('fleuron', 30, 11, 'Ornament: a plum carnation between two gold scrolls.'), ]; // #endregion const table = (id, model, styleId) => ({ id, typeId: 'bill', kind: 'table', table: { model, styleId }, createdAt: 0, updatedAt: 0 }); const resources = [...art, table('persons', billTable(cast, [2, 1], ['left', 'right'], 1)), // the first line heads it table('scenes', billTable(scenes, [3, 17], ['right', 'left']), 'scenes'), ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Libre Baskerville': ['400', '400i', '700'], 'Abril Fatface': ['400'], 'Playfair Display SC': ['400'] }; // all loaded before the first build (gotcha: fonts-first) // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown + cast + scenes); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: doc.metadata.title }); // the frontmatter's title
工具包 · 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上的食谱文件夹 ↗

变化

#让舞台提示与转行对齐

改为齐左并设5 mm首行缩进后,单行的舞台提示从台词转行开始的位置排起,场景说明则变成居中标签下两个缩进的段落。

 const direction = { id: 'direction', fontSize: pt(8.6),
-  textAlign: 'center', firstLineIndent: pt(0) };
+  textAlign: 'left', firstLineIndent: mm(5) };

#台词之间空一行

paragraphSpacing: true在每段台词后加一条空白网格线。这样英文的这一幕会排成五页而不是三页,西班牙语的排成四页。

   maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
+  paragraphSpacing: true,
 };

常见问题

易错点

传入任何headings对象都会关掉H1换页

默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →

易错点

段落样式没有斜体颜色

在postext 1.4.1中,段落样式可以设置color和boldColor,但没有italicColor:其中的斜体文字取bodyText.italicColor。于是一个淡色样式(小字、出处行)里的斜体书名会比周围的文字颜色更深。这类样式要么沿用正文的墨色,要么不用斜体。 段落样式 →

易错点

合并单元格需要hiddenBy占位:使用mergeCells

单元格按其在行数组中的位置排布,所以合并单元格延伸到的地方需要标记hiddenBy的占位单元格;像HTML那样省略它们,后面的每一列都会错位。用mergeCells来合并。 由数据生成的表 →

易错点

'here'表格从不拆分

只有浮动的表才会跨栏、跨页拆分;放在'here'的表整体移动。让长表浮动,或者让行内的表保持简短。 跨页的表 →

易错点

::resource{id="…"}只接受双引号

块级嵌入只有写成带双引号的::resource{id="…"}才能被识别;其他写法会作为一行可见文字留在正文中。 图就放在这里 →

易错点

替换调色板时,设计元素和引用颜色不会跟着变

postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →

易错点

设计文本的lineHeight是倍数,不是尺寸

在设计槽位中,文本元素的lineHeight是其字号的倍数(lineHeight: 1.05)。在postext 1.4.1中,写成pt(15)这样的尺寸值不会被拒绝:章首页的高度会算成NaN,它预留的空间(连同minHeight)被丢弃,也不给出警告,正文就排到了标题底下。 页面设计中的文字、线条和框 →

易错点

消除孤字时收紧的字距可能根本不绘制

在postext 1.4.1中,段落以孤字结尾时,排版会把它排短一行:先收紧词间距,再用最多maxRuntTracking个千分之一em的负字距。Canvas和PDF渲染器只绘制大于零的字距,所以收紧了字距的段落印出来时并没有收紧:两端对齐的行从词间空格中扣掉了这部分差额,显得拥挤,末行还可能超出行长,在栏边被裁掉。设置bodyText.maxRuntTracking: 0,保留词间距的修正,再改写重新出现的孤字。 段末孤行、段首孤行与孤字 →

易错点

frontmatter的每个值都加引号

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

易错点

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

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

易错点

排版前加载所有字体

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

易错点

留在栏内的章首页在版心顶部被裁切

在postext 1.4.1中,留在栏内的高级设计标题会在栏的上边缘被裁切:锚定在页面或出血上的框或图片可以画进左右页边距,却画不进上页边距,而且没有任何警告。给这样的标题设span: 'page',即使是单栏的书也一样:这样它的设计会作为页面的章首页区段完整地绘制出来。 设计过的章首页 →

易错点

章首页的图片从不计入它预留的高度

在postext 1.4.1中,高级设计标题计算预留高度时不算其中的图片:文字、线条和框都计入,即使它们锚定在页面上;但图片(例如出血铺满页面顶部的图)不预留任何空间,所以正文可能排到它上面。把minHeight设为正文应当开始的位置。 设计过的章首页 →

易错点

Canvas上两个相邻的填色单元格之间有一条浅缝

在postext 1.4.1中,Canvas渲染器在小数像素位置用各自的矩形填充每个表格单元格,所以两个填色单元格相接处(彩色填充的表头行单元格,或带底色的正文单元格)会露出一条纸色的细线。只给单元格合并成一个的行填色,用填充色描线,或者不给单元格填色。 表格样式 →

  • 对白中的每一段斜体都使用italicColor,包括强调,所以Lane那个强调的is会印成舞台提示的灰色。灰色只能设在bodyText上,因为在1.4.1中段落样式不能给自己的斜体单独设颜色;只有用正体,is才能保持墨色。
  • 王尔德有三段台词以单独一个词结尾:information.、that?和you.。脚本设了bodyText.maxRuntTracking: 0,原因见易错点A runt fix can tighten tracking that is never painted。用默认值时,1.4.1会用2.0 mm的负字距把*that?*拉上去,而Canvas不绘制这个字距,这一行印出来词间距只有正常的一半。只靠词间距的话,要么把minWordSpacing设为0.5,*that?*才会上移,但这一行同样被挤压;要么把行长加宽1.5 mm,但这样会有十行的词间距超过正常的1.5倍,而不是两行。英文页面宁可保留这三个孤字,也不改王尔德的原文。西班牙语译文则在凡是一行以单字母词结尾或一个音节单独成行的地方都改了措辞。
  • 人名和其他词一样会断词:第5页上Au-gusta和Gwen-dolen跨行断开。在Augusta中间加一个词连接符(U+2060)能让它保持完整,但会把上一行的词间距拉到正常的1.71倍,所以英文文本保留了连字符。
  • Playfair Display SC在Canvas上把fi画成小写连字,所以first会在小型大写字母中间印出一个小写的fi。这就是角色表色带上写The original cast的原因。

致谢

文本
  • The Importance of Being Earnest (1895): the persons and scenes of the play, the first cast and the opening of the first act · Oscar Wilde · 公有领域
  • The Spanish translation of the extract, the cast list and the scenes · Postext Cookbook · 原创
图片
  • The title page’s frame and the carnation fleuron, drawn in code in the page’s palette · Ignacio Ferro · MIT
字体
Libre Baskerville (SIL OFL 1.1) · Abril Fatface (SIL OFL 1.1) · Playfair Display SC (SIL OFL 1.1)
沙盒