跳到主要内容
食谱编号64

排版食谱 · 第3章 · 标题与章首页

书信集:日期行与署名

书信接排,不另起页;每封信的日期行取自标题属性,称呼、署名和附言由段落样式排出。

本页内容
输出
Canvas
难度
中级
Postext
已用Postext 1.4.1测试
需要≥ 1.4.1
许可证
更新于2026年9月26日
代码MIT · 文本CC BY 4.0

第4–5页,共5页

  • 英文样例:尚无中文版本
  • 成品尺寸140 × 210 mm
  • 1栏
  • Crimson Pro 10/14.4
  • IM Fell DW Pica SC
  • IM Fell French Canon
  • 5页
  • 难度
  • Postext 1.4.1
  • 排版用时9 ms
  • 164行代码

成品一览

Mon sort est changé的五页,这是一个14 × 21 cm的法文三封书信版本:两封是腓特烈二世在1740年6月成为普鲁士国王六天和十二天后写给伏尔泰的信,另一封是伏尔泰1778年4月1日的信,写于他去世前两个月。封面用代码绘制,在绿色摩洛哥皮面上、烫金双线框内,画着两封折好的信和一枚红色火漆印。书内的信接排,不另起页。每个信头用红色小型大写字母印出编号,用Fell斜体印出两位通信人,下方右对齐排着地点和日期。称呼单独占一行;署名用小型大写字母,靠右侧页边距结束全信;腓特烈的附言排在署名之后,小一号。每个右页的书眉印出该信的日期。

这道食谱解答

  • 怎样排题词、献词、署名,或带大号引号的引文框?
  • 怎样让西班牙文、法文或德文获得良好的两端对齐和断词?
  • 怎样设置书眉:左页放书名,右页放章名,页码在外侧?

简短回答

script.js · 第43–67行在完整代码中
// Each letter is a level-1 heading that carries its place and date as attributes:
//   # Frédéric à Voltaire {place="À Charlottembourg" date="6 juin 1740"}
// (a value holds no { or }, and one with " goes in single quotes: gotcha attr-values)
const letterHead = { enabled: true, slot: { elements: [
  // {number} prints numberingTemplate '{1:I}' (gotcha: heading-number-placeholders).
  { kind: 'text', id: 'number', content: 'Lettre {number}', ...sc, fontSize: pt(9),
    letterSpacing: pt(1.8), color: col('seal'), placement: at('container', 'top-left') },
  { kind: 'text', id: 'title', content: '{titleText}', ...fell, fontSize: pt(15),
    color: col('ink'),
    placement: at('#number', 'below', 0, 1) },
  // The dateline spans the measure under the title and sets its words flush right.
  { kind: 'text', id: 'dateline', content: '{attr.place}, le {attr.date}.', ...crimson,
    italic: true, fontSize: pt(10.4), color: col('ink'), align: 'right',
    placement: { ...at('#title', 'below', 0, 1.5), size: { width: 'fill' } } },
] } };
// The salutation, the signature and the postscript, each a :::paragraphs{style="…"} container:
//   :::paragraphs{style="signature"}
//   Fédéric.
//   :::
const letterParts = [
  { id: 'vedette', firstLineIndent: pt(0) }, // 'Sire,' on a line of its own, flush left
  { id: 'signature', ...sc, fontSize: pt(10.5), textAlign: 'right', marginTop: pt(LEAD / 2) },
  { id: 'postscript', fontSize: pt(9), lineHeight: pt(12.6), marginTop: pt(LEAD / 2) },
];
// Hooked up below: letterHead designs the level-1 heading, letterParts joins paragraphStyles.

用料

类型
Crimson Pro, IM Fell French Canon, IM Fell DW Pica SC(SIL OFL 1.1)
素材
  • The cover: two folded letters and a wax seal on green morocco, drawn in code (Ignacio Ferro, CC BY 4.0)

做法

#1 · 从标题读取日期行

代码就是上面的简短回答。每封信是一个1级标题,属性里存着地点和日期,信头把它们印成{attr.place}, le {attr.date}.(标题属性)。日期行的框伸到栏的右边缘(width: 'fill'),align: 'right'让文字靠这条边排;不设的话,设计文字居中。称呼、署名和附言是用:::paragraphs{style="…"}应用的段落样式。在1.4.1中,段落样式没有斜体或大小写设置,所以署名使用IM Fell DW Pica SC,这个字体家族的小写字母本身就刻成小型大写字母。

#2 · 让书信接排

script.js · 第71–78行在完整代码中
const letters = { level: 1, numberingTemplate: '{1:I}', advancedDesign: letterHead,
  // Written out: 1.4.1 drops the H1 page break for any headings object (gotcha:
  // headings-drop-h1-break), and a fixed engine would put each letter on a recto.
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD),
  // The hidden heading line is measured in the heading face: italic keeps it the IM Fell cut
  // that FONTS loads (gotcha: fonts-first). Upright, it would need the roman, which FONTS
  // leaves out; the layout would change only for a title long enough to wrap.
  italic: true };

关闭breakBefore后,一封信从上一封信结束处那条网格线往下两条网格线(marginTop)开始。在第4页上,附言用12.6 pt行距排,结束在两条网格线之间,所以第三封信上方留出约三行空白。在第2页上,第一封信排到页面底部,第二封信从第3页开始。这项设置要显式写出,因为只要配置里有headings对象,1.4.1就已经取消了1级标题前的分页,而修正后的引擎会让每封信都从右页开始(标题前分页)。numberingTemplate: '{1:I}'把书信编为I至III,信头通过{number}印出这个数字。

#3 · 让右页随信标注日期

script.js · 第82–95行在完整代码中
const HEAD_Y = 12; // mm from the top edge
const head = (id, content, parity, placement, look = {}) => ({ kind: 'text', id, content,
  parity, pages: 'body', ...sc, fontSize: pt(8.5), letterSpacing: pt(0.9), color: col('muted'),
  placement, ...look });
const folio = { ...crimson, fontSize: pt(9), letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD_Y), folio),
  head('verso-names', '{author}', 'even', at('page', 'top-left', MARGIN.outer + 8, HEAD_Y)),
  // {attr.date} reads the last letter that starts on or before the page.
  head('recto-date', '{attr.date}', 'odd', at('page', 'top-right', -(MARGIN.outer + 8), HEAD_Y),
    { ...crimson, italic: true, fontSize: pt(9.5), letterSpacing: pt(0) }),
  head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD_Y),
    folio),
] };

在页眉中,{attr.date}取在该页或之前开始的最后一个1级标题的属性。第3页印出12 juin 1740;第5页上,伏尔泰的信从第4页延续过来,印出1er avril 1778(文本元素)。书眉位于页面上边缘下方12 mm处,页码在外侧页边,文字再往里8 mm。pages: 'body'让书眉不出现在封面上,封面的标题是通栏的,所以它算作章首页。日期用它所重复的日期行的Crimson Pro斜体排,左页上的人名用书信编号的小型大写字母排。

#4 · 把封面做成标题,并结束这一页

script.js · 第99–119行在完整代码中
// The Markdown: # Mon sort \\ est changé {style="cover"}, then :::pagebreak, or the headnote
// and the first letter start on the cover (gotcha: cover-pagebreak).
const onCover = (y) => at('page', 'top', 0, y); // centred, y mm below the top edge
// numbered: false keeps the cover out of the count, so the first letter is I.
const cover = { id: 'cover', numbered: false,
  // span: 'page' although the book has one column. Kept in the column, the design is clipped
  // to the column's top and bottom (paper above and below the leather, no names) and its title
  // loses the \\ break; page 1 would also count as a 'body' page and print the running heads.
  span: 'page', advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover',
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
    { kind: 'text', id: 'names', content: '{author}', ...sc, fontSize: pt(9.5),
      letterSpacing: pt(2), color: col('gilt'), placement: onCover(18) },
    // \\ in the heading breaks the title here; lineHeight is a multiple (gotcha:
    // design-lineheight-multiple), and 'wrap' keeps the ellipsis off (overflow-ellipsis-default).
    { kind: 'text', id: 'title', content: '{titleText}', ...fell, fontSize: pt(50),
      lineHeight: 1, color: col('paper'), align: 'center', overflow: 'wrap',
      placement: onCover(24) },
    { kind: 'text', id: 'subtitle', content: '{subtitle}', ...crimson, italic: true,
      fontSize: pt(12), color: col('paper'), placement: onCover(62) },
  ] } } };

封面是第一个标题,使用cover标题样式:一个锚定在出血上的图像元素承载图画,标题、人名和副标题来自标题和前置元数据。numbered: false让它不参与计数,所以腓特烈的第一封信是I。这个设计只预留到它最下方的文字即副标题为止,不为图画留空间。如果标题后面没有:::pagebreak,编者按和第一封信就会从第1页开始,压在画出的两封信和火漆印上。

#5 · 按法语断词,保持空格舒展

script.js · 第123–137行在完整代码中
// Justification, hyphenation, whole-paragraph line breaking and the widow, orphan and runt
// rules are defaults; locale 'fr' (in the config) picks the French patterns.
// The letters keep the transcription's unspaced ; : ? and !, because a narrow no-break
// space is a place to break the line in 1.4.1 (gotcha: nbsp-breaks).
const bodyText = { fontFamily: 'Crimson Pro', fontSize: pt(10), lineHeight: pt(LEAD),
  color: col('ink'), firstLineIndent: mm(5),
  // No :ref here, but 1.4.1 leaves this one blue whatever main-color says (gotcha:
  // palette-skips-designs), and the default-skin check reads it.
  referenceColor: col('ink'),
  // A word space never shrinks below 75 % of the font's. At the default 60 %, the tightest
  // line on page 5 sets its spaces at 0.70 (each VDT line carries its justifiedSpaceRatio).
  minWordSpacing: 0.75,
  // A runt fix may add tracking 1.4.1 measures but never paints (gotcha:
  // runt-tracking-unpainted); no paragraph here needs one, edited text might.
  maxRuntTracking: 0 };

locale: 'fr'使用法语模式断词(第2页的ou-vrage,第4页的représen-tation)。两端对齐、整段断行以及段末孤行、段首孤行和孤字规则默认都已开启。minWordSpacing: 0.75让词间空格不会缩到Crimson Pro空格的四分之三以下;按默认的0.6,第5页最紧的两端对齐行会把空格排成该宽度的0.70。行距为14.4 pt时,每页容纳33行,下页边距就是这些行下面剩下的20.4 mm。

#6 · 把每种颜色都链接到调色板

script.js · 第13–29行在完整代码中
const palette = {
  ink: '#2a2320', // the text: a warm near-black
  paper: '#f6efe2', // the page, and the lettering on the cover
  seal: '#9c2b24', // the letter numbers, and the wax on the cover
  leather: '#2a4536', // the cover: a green morocco binding
  gilt: '#d0b67c', // its tooled border and the names on it
  rule: '#c8b99f', // the folds drawn on the cover
  muted: '#75695d', // the running heads
};
// A design element paints the hex written beside its paletteId (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 default colours, the italic of the headnote among them, link to 'main-color';
  // here it is the ink, not the default blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];

col()在每种颜色的调色板id旁写上它的十六进制值。在1.4.1中,调色板能作用到文本样式,但作用不到设计元素,设计元素画的是那个十六进制值:信头、书眉和封面都是如此。编者按和版权页信息的斜体使用默认斜体颜色,它链接到main-color;把这一项设为墨色后,斜体就印成墨色,而不是引擎的蓝色#295AA3。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 064 · Letters edition: datelines and signatures ════════════
// https://postext.dev/en/cookbook/letters-edition
// Code: MIT · Text: Frederick II and Voltaire, letters of 1740 and 1778 (PD) · Cover: drawn in code
// Fonts: Crimson Pro, IM Fell French Canon, IM Fell DW Pica SC (SIL OFL) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: iron-gall ink on cream paper, wax red, green morocco and gilt
const palette = {
  ink: '#2a2320', // the text: a warm near-black
  paper: '#f6efe2', // the page, and the lettering on the cover
  seal: '#9c2b24', // the letter numbers, and the wax on the cover
  leather: '#2a4536', // the cover: a green morocco binding
  gilt: '#d0b67c', // its tooled border and the names on it
  rule: '#c8b99f', // the folds drawn on the cover
  muted: '#75695d', // the running heads
};
// A design element paints the hex written beside its paletteId (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 default colours, the italic of the headnote among them, link to 'main-color';
  // here it is the ink, not the default blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
// #endregion
const TRIM = { width: 140, height: 210 }; // the French 14 × 21 format
const LEAD = 14.4; // pt: the body's leading, the grid every letter starts on
const LINES = 33; // lines of text on a full page
const TOP = 22; // mm: the top margin
const MARGIN = { top: TOP, inner: 17.5, outer: 14.5, // mm, mirrored
  bottom: TRIM.height - TOP - (LINES * LEAD * 25.4) / 72 }; // ends the page on line 33
const sc = { fontFamily: 'IM Fell DW Pica SC' }; // its lower case is cut as small capitals
const fell = { fontFamily: 'IM Fell French Canon', italic: true }; // the display italic
const crimson = { fontFamily: 'Crimson Pro' }; // the text face
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region answer: a letter head read from the heading, and styles for the letter's parts
// Each letter is a level-1 heading that carries its place and date as attributes:
//   # Frédéric à Voltaire {place="À Charlottembourg" date="6 juin 1740"}
// (a value holds no { or }, and one with " goes in single quotes: gotcha attr-values)
const letterHead = { enabled: true, slot: { elements: [
  // {number} prints numberingTemplate '{1:I}' (gotcha: heading-number-placeholders).
  { kind: 'text', id: 'number', content: 'Lettre {number}', ...sc, fontSize: pt(9),
    letterSpacing: pt(1.8), color: col('seal'), placement: at('container', 'top-left') },
  { kind: 'text', id: 'title', content: '{titleText}', ...fell, fontSize: pt(15),
    color: col('ink'),
    placement: at('#number', 'below', 0, 1) },
  // The dateline spans the measure under the title and sets its words flush right.
  { kind: 'text', id: 'dateline', content: '{attr.place}, le {attr.date}.', ...crimson,
    italic: true, fontSize: pt(10.4), color: col('ink'), align: 'right',
    placement: { ...at('#title', 'below', 0, 1.5), size: { width: 'fill' } } },
] } };
// The salutation, the signature and the postscript, each a :::paragraphs{style="…"} container:
//   :::paragraphs{style="signature"}
//   Fédéric.
//   :::
const letterParts = [
  { id: 'vedette', firstLineIndent: pt(0) }, // 'Sire,' on a line of its own, flush left
  { id: 'signature', ...sc, fontSize: pt(10.5), textAlign: 'right', marginTop: pt(LEAD / 2) },
  { id: 'postscript', fontSize: pt(9), lineHeight: pt(12.6), marginTop: pt(LEAD / 2) },
];
// Hooked up below: letterHead designs the level-1 heading, letterParts joins paragraphStyles.
// #endregion

// #region letters: numbered I, II, III and run on, two grid lines apart
const letters = { level: 1, numberingTemplate: '{1:I}', advancedDesign: letterHead,
  // Written out: 1.4.1 drops the H1 page break for any headings object (gotcha:
  // headings-drop-h1-break), and a fixed engine would put each letter on a recto.
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD),
  // The hidden heading line is measured in the heading face: italic keeps it the IM Fell cut
  // that FONTS loads (gotcha: fonts-first). Upright, it would need the roman, which FONTS
  // leaves out; the layout would change only for a title long enough to wrap.
  italic: true };
// #endregion

// #region running-heads: the correspondents on the verso, the date of the letter on the recto
const HEAD_Y = 12; // mm from the top edge
const head = (id, content, parity, placement, look = {}) => ({ kind: 'text', id, content,
  parity, pages: 'body', ...sc, fontSize: pt(8.5), letterSpacing: pt(0.9), color: col('muted'),
  placement, ...look });
const folio = { ...crimson, fontSize: pt(9), letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD_Y), folio),
  head('verso-names', '{author}', 'even', at('page', 'top-left', MARGIN.outer + 8, HEAD_Y)),
  // {attr.date} reads the last letter that starts on or before the page.
  head('recto-date', '{attr.date}', 'odd', at('page', 'top-right', -(MARGIN.outer + 8), HEAD_Y),
    { ...crimson, italic: true, fontSize: pt(9.5), letterSpacing: pt(0) }),
  head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD_Y),
    folio),
] };
// #endregion

// #region cover: page 1 is a heading style with the drawing and the title; :::pagebreak ends it
// The Markdown: # Mon sort \\ est changé {style="cover"}, then :::pagebreak, or the headnote
// and the first letter start on the cover (gotcha: cover-pagebreak).
const onCover = (y) => at('page', 'top', 0, y); // centred, y mm below the top edge
// numbered: false keeps the cover out of the count, so the first letter is I.
const cover = { id: 'cover', numbered: false,
  // span: 'page' although the book has one column. Kept in the column, the design is clipped
  // to the column's top and bottom (paper above and below the leather, no names) and its title
  // loses the \\ break; page 1 would also count as a 'body' page and print the running heads.
  span: 'page', advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover',
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
    { kind: 'text', id: 'names', content: '{author}', ...sc, fontSize: pt(9.5),
      letterSpacing: pt(2), color: col('gilt'), placement: onCover(18) },
    // \\ in the heading breaks the title here; lineHeight is a multiple (gotcha:
    // design-lineheight-multiple), and 'wrap' keeps the ellipsis off (overflow-ellipsis-default).
    { kind: 'text', id: 'title', content: '{titleText}', ...fell, fontSize: pt(50),
      lineHeight: 1, color: col('paper'), align: 'center', overflow: 'wrap',
      placement: onCover(24) },
    { kind: 'text', id: 'subtitle', content: '{subtitle}', ...crimson, italic: true,
      fontSize: pt(12), color: col('paper'), placement: onCover(62) },
  ] } } };
// #endregion

// #region text: Crimson Pro at 10/14.4 pt, set in French
// Justification, hyphenation, whole-paragraph line breaking and the widow, orphan and runt
// rules are defaults; locale 'fr' (in the config) picks the French patterns.
// The letters keep the transcription's unspaced ; : ? and !, because a narrow no-break
// space is a place to break the line in 1.4.1 (gotcha: nbsp-breaks).
const bodyText = { fontFamily: 'Crimson Pro', fontSize: pt(10), lineHeight: pt(LEAD),
  color: col('ink'), firstLineIndent: mm(5),
  // No :ref here, but 1.4.1 leaves this one blue whatever main-color says (gotcha:
  // palette-skips-designs), and the default-skin check reads it.
  referenceColor: col('ink'),
  // A word space never shrinks below 75 % of the font's. At the default 60 %, the tightest
  // line on page 5 sets its spaces at 0.70 (each VDT line carries its justifiedSpaceRatio).
  minWordSpacing: 0.75,
  // A runt fix may add tracking 1.4.1 measures but never paints (gotcha:
  // runt-tracking-unpainted); no paragraph here needs one, edited text might.
  maxRuntTracking: 0 };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'fr', // the exact code of the bundled patterns (gotcha: hyphenation-locales)
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom),
      left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, // left = inner
  layout: { layoutType: 'single' },
  bodyText,
  // A heading's own line is hidden under its design but still measured, in this face and weight.
  headings: { fontFamily: 'IM Fell French Canon', fontWeight: 400, levels: [letters] },
  headingStyles: [cover],
  paragraphStyles: [...letterParts,
    // Frederick's verses, one paragraph per line (a one-line paragraph is never stretched), with
    // a line of space above and below; the two closing alexandrines start 7 mm further left.
    { id: 'verse', firstLineIndent: mm(14), marginTop: pt(LEAD) },
    { id: 'verse-long', firstLineIndent: mm(7), marginBottom: pt(LEAD) },
    // The editor's headnote at 9.6 on 13 pt, italic through *…* in the Markdown, since a
    // paragraph style has no italic setting.
    { id: 'headnote', fontSize: pt(9.6), lineHeight: pt(13), firstLineIndent: pt(0) },
    { id: 'colophon', fontSize: pt(7.8), lineHeight: pt(10.8), textAlign: 'left',
      firstLineIndent: pt(0), marginTop: pt(3 * LEAD) }],
  header,
  footer: { elements: [] }, // the folios ride in the header
});

// #region art: the cover, drawn in code and seeded: two folded letters on green morocco
let seed = 1740; // Mulberry32, a tiny 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 n = (v) => v.toFixed(2);
const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16);
const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(channel(a, i) * (1 - k)
  + channel(b, i) * k).toString(16).padStart(2, '0')).join('')}`; // a towards b by k
const W = TRIM.width;
const H = TRIM.height;
const SHEET = mix(palette.paper, '#ffffff', 0.35);
const SHADE = mix(palette.paper, palette.rule, 0.55);

// A line of handwriting in the ink of the text: each word a looped trochoid, one loop per
// letter, about one loop in five twice as tall, slanted forward.
function scrawl(x0, y0, length, size) {
  let d = '';
  let x = x0;
  while (x < x0 + length - size) {
    const loops = 3 + Math.floor(rand() * 5);
    const heights = Array.from({ length: loops }, () => (rand() < 0.22 ? 1.8 : 0.9));
    const pts = [];
    for (let t = 0; t <= loops * Math.PI * 2; t += 0.3) {
      const h = heights[Math.min(loops - 1, Math.floor(t / (Math.PI * 2)))] * size * 0.5;
      const y = -h * (1 - Math.cos(t)); // up and back to the baseline once a letter
      pts.push(`${n(x + t * size * 0.07 - Math.sin(t) * size * 0.18 - y * 0.3)} ${n(y0 + y)}`);
    }
    d += `M${pts.join(' L')}`;
    x += loops * Math.PI * 2 * size * 0.07 + size * (0.8 + rand() * 0.5);
  }
  return `<path d="${d}" fill="none" stroke="${palette.ink}" stroke-width="${n(size * 0.08)}" `
    + 'stroke-linecap="round" stroke-linejoin="round" opacity="0.85"/>';
}

// A sheet folded into a packet, turned by `angle` about its centre, shadow first.
function sheet(cx, cy, w, h, angle, inner) {
  const turn = `translate(${cx} ${cy}) rotate(${angle})`;
  return `<g transform="${turn}"><rect x="${n(-w / 2 + 0.8)}" y="${n(-h / 2 + 1.3)}" width="${w}" `
    + `height="${h}" fill="${mix(palette.leather, '#000000', 0.5)}" opacity="0.5"/>`
    + `<rect x="${n(-w / 2)}" y="${n(-h / 2)}" width="${w}" height="${h}" fill="${SHEET}"/>`
    + `${inner(w, h)}</g>`;
}

// The front of the first packet: the address in three lines and a flourish.
const address = (w, h) => scrawl(-w * 0.2, -h * 0.14, w * 0.4, 3)
  + scrawl(-w * 0.34, h * 0.06, w * 0.68, 3) + scrawl(-w * 0.06, h * 0.26, w * 0.42, 3)
  + `<path d="M${n(-w * 0.1)} ${n(h * 0.34)} C${n(w * 0.05)} ${n(h * 0.4)} ${n(w * 0.2)} `
  + `${n(h * 0.28)} ${n(w * 0.33)} ${n(h * 0.33)}" fill="none" stroke="${palette.ink}" `
  + 'stroke-width="0.3" stroke-linecap="round" opacity="0.8"/>';

// The back of the second: two side folds, the top flap down to its tip, and the seal on it.
function sealed(w, h) {
  const tip = [0, h * 0.1];
  const folds = [[-w / 2, h / 2], [w / 2, h / 2]].map(([x, y]) =>
    `<path d="M${n(x)} ${n(y)} L${n(tip[0])} ${n(tip[1])}" stroke="${SHADE}" stroke-width="0.4"/>`);
  const flap = `<path d="M${n(-w / 2)} ${n(-h / 2)} L${n(w / 2)} ${n(-h / 2)} L${n(tip[0])} `
    + `${n(tip[1])} Z" fill="${mix(SHEET, palette.rule, 0.18)}" stroke="${SHADE}" `
    + 'stroke-width="0.35"/>';
  return folds.join('') + flap + seal(tip[0], tip[1] - 1, 8.5);
}

// Sealing wax: an uneven disc, a pressed ring, a six-petal stamp and a light edge.
function seal(cx, cy, r) {
  const pts = [];
  for (let i = 0; i < 36; i++) {
    const a = (i / 36) * Math.PI * 2;
    const rr = r * (0.9 + rand() * 0.16 + (i % 9 === 4 ? 0.14 : 0));
    pts.push(`${n(cx + Math.cos(a) * rr)} ${n(cy + Math.sin(a) * rr)}`);
  }
  const dark = mix(palette.seal, palette.ink, 0.35);
  const petals = [0, 60, 120, 180, 240, 300].map((deg) => `<ellipse cx="${n(cx)}" `
    + `cy="${n(cy - r * 0.28)}" rx="${n(r * 0.12)}" ry="${n(r * 0.26)}" fill="${dark}" `
    + `transform="rotate(${deg} ${n(cx)} ${n(cy)})"/>`).join('');
  return `<path d="M${pts.join(' L')} Z" fill="${palette.seal}"/>`
    + `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 0.66)}" fill="none" stroke="${dark}" `
    + `stroke-width="${n(r * 0.07)}"/>${petals}<circle cx="${n(cx)}" cy="${n(cy)}" `
    + `r="${n(r * 0.1)}" fill="${dark}"/><path d="M${n(cx - r * 0.72)} ${n(cy - r * 0.3)} `
    + `A${n(r * 0.8)} ${n(r * 0.8)} 0 0 1 ${n(cx - r * 0.2)} ${n(cy - r * 0.78)}" fill="none" `
    + `stroke="${mix(palette.seal, '#ffffff', 0.35)}" stroke-width="${n(r * 0.07)}" `
    + 'stroke-linecap="round"/>';
}

function coverSvg() {
  // The binding: green leather to the edges, a gilt double fillet and a lozenge at each corner.
  const tooling = [6, 7.6].map((inset, i) => `<rect x="${inset}" y="${inset}" `
    + `width="${W - 2 * inset}" height="${H - 2 * inset}" fill="none" stroke="${palette.gilt}" `
    + `stroke-width="${i ? 0.25 : 0.7}"/>`).join('') + [[6, 6], [W - 6, 6], [6, H - 6],
    [W - 6, H - 6]].map(([x, y]) => `<path d="M${x} ${y - 2.4} L${x + 2.4} ${y} L${x} ${y + 2.4} `
    + `L${x - 2.4} ${y} Z" fill="${palette.gilt}"/>`).join('');
  const body = `<rect width="${W}" height="${H}" fill="${palette.leather}"/>${tooling}`
    + sheet(W / 2 + 9, 152, 90, 58, 7, address) + sheet(W / 2 - 3, 102, 88, 55, -4, sealed);
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" `
    + `viewBox="0 0 ${W} ${H}">${body}</svg>`;
}
// The cover's resource: the design's image element names it by id, and loadSvg() below
// registers the drawing under its fileId.
const resources = [{ id: 'cover', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'cover.svg', width: W * 10, height: H * 10 },
  altText: 'A green leather cover with a gilt double fillet. Two folded letters lie on it: '
    + 'the lower one shows an address in brown-black handwriting, the upper one lies face down, '
    + 'its top flap closed by a red wax seal.' }];
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown样例 · 120行 · content.en.mdtitle: "Mon sort est changé" subtitle: "Trois lettres, 1740 et 1778" author: "Frédéric II et Voltaire" --- # Mon sort \\ est changé {style="cover"} :::pagebreak :::paragraphs{style="headnote"} *Frédéric-Guillaume I^er^, roi de Prusse, meurt à Potsdam le 31 mai 1740. Six jours plus tard, son fils, qui règne désormais sous le nom de Frédéric II, écrit de Charlottembourg à Voltaire, son correspondant depuis août 1736. Les deux premières lettres de ce choix sont de ce mois de juin. La troisième, écrite de Paris le 1^er^ avril 1778, ferme le volume de 1889 d’où viennent ces textes. Voltaire meurt à Paris le 30 mai suivant. On garde l’orthographe de l’édition; la vedette et la signature sont détachées du texte.* ::: # Frédéric à Voltaire {place="À Charlottembourg" date="6 juin 1740"} :::paragraphs{style="vedette"} Mon cher ami, ::: Mon sort est changé, et j’ai assisté aux derniers moments d’un roi, à son agonie, à sa mort. En parvenant à la royauté, je n’avais pas besoin assurément de cette leçon pour être dégoûté de la vanité des grandeurs humaines. J’avais projeté un petit ouvrage de métaphysique; il s’est changé en un ouvrage de politique. Je croyais joûter avec l’aimable Voltaire, et il me faut escrimer avec Machiavel. Enfin, mon cher Voltaire, nous ne sommes point maîtres de notre sort. Le tourbillon des événements nous entraîne, et il faut se laisser entraîner. Ne voyez en moi, je vous prie, qu’un citoyen zélé, un philosophe un peu sceptique, mais un ami véritablement fidèle. Pour dieu, ne m’écrivez qu’en homme, et méprisez avec moi les titres, les noms, et tout l’éclat extérieur. Jusqu’à présent il me reste à peine le temps de me reconnaître; j’ai des occupations infinies: je m’en donne encore de surplus; mais malgré tout ce travail, il me reste toujours du temps assez pour admirer vos ouvrages et pour puiser chez vous des instructions et des délassements. Assurez la marquise de mon estime. Je l’admire autant que ses vastes connaissances et la rare capacité de son esprit le méritent. Adieu, mon cher Voltaire; si je vis, je vous verrai, et même dès cette année. Aimez-moi toujours, et soyez toujours sincère ami avec votre ami :::paragraphs{style="signature"} Fédéric. ::: # Frédéric à Voltaire {place="À Charlottembourg" date="12 juin 1740"} :::paragraphs{style="verse"} Non, ce n’est plus du mont Rémus, Douce et studieuse retraite D’où mes vers vous sont parvenus, Que je date ces vers confus: Car dans ce moment le poète Et le prince sont confondus. Désormais mon peuple que j’aime Est l’unique Dieu que je sers: Adieu les vers et les concerts. Tous les plaisirs. Voltaire même; Mon devoir est mon Dieu suprême. Qu’il entraîne de soins divers! Quel fardeau que le diadème! Quand ce dieu sera satisfait, Alors dans vos bras, cher Voltaire, Je volerai, plus prompt qu’un trait, ::: :::paragraphs{style="verse-long"} Puiser, dans les leçons de mon ami sincère, Quel doit être d’un roi le sacré caractère. ::: Vous voyez, mon cher ami, que le changement du sort ne m’a pas tout à fait guéri de la métromanie, et que peut-être je n’en guérirai jamais. J’estime trop l’art d’Horace et de Voltaire pour y renoncer; et je suis du sentiment que chaque chose de la vie a son temps. J’avais commencé une épître sur les abus de la mode et de la coutume, lors même que la coutume de la primogéniture m’obligeait de monter sur le trône et de quitter mon épître pour quelque temps. J’aurais volontiers changé mon épître en satire contre cette même mode, si je ne savais que la satire doit être bannie de la bouche des princes. Enfin, mon cher Voltaire, je flotte entre vingt occupations, et je ne déplore que la brièveté des jours, qui me paraissent trop courts de vingt-quatre heures. Je vous avoue que la vie d’un homme qui n’existe que pour réfléchir et pour lui-même, me semble infiniment préférable à la vie d’un homme dont l’unique occupation doit être de faire le bonheur des autres. Vos vers sont charmants. Je n’en dirai rien, car ils sont trop flatteurs. Mon cher Voltaire, ne vous refusez pas plus longtemps à l’empressement que j’ai de vous voir. Faites en ma faveur tout ce que vous croyez que votre humanité comporte. J’irai à la fin d’auguste à Vesel, et peut-être plus loin. Promettez-moi de me joindre, car je ne saurais vivre heureux ni mourir tranquille sans vous avoir embrassé. Adieu. :::paragraphs{style="signature"} Fédéric. ::: :::paragraphs{style="postscript"} Mille compliments à la marquise. Je travaille des deux mains; d’un côté à l’armée, de l’autre au peuple et aux beaux-arts. ::: # Voltaire à Frédéric {place="À Paris" date="1er avril 1778"} :::paragraphs{style="vedette"} Sire, ::: Le gentilhomme français qui rendra cette lettre à Votre Majesté, et qui passe pour être digne de paraître devant Elle, pourra vous dire que si je n’ai pas eu l’honneur de vous écrire depuis longtemps, c’est que j’ai été occupé à éviter deux choses qui me poursuivaient dans Paris: les sifflets et la mort. Il est plaisant qu’à quatre-vingt-quatre ans j’aie échappé à deux maladies mortelles. Voilà ce que c’est que de vous être consacré: je me suis renommé de vous, et j’ai été sauvé. J’ai vu avec surprise et avec une satisfaction bien douce, à la représentation d’une tragédie nouvelle, que le public, qui regardait il y a trente ans Constantin et Théodose comme les modèles des princes, et même des saints, a applaudi avec des transports inouïs à des vers qui disent que Constantin et Théodose n’ont été que des tyrans superstitieux. J’ai vu vingt preuves pareilles du progrès que la philosophie a fait enfin dans toutes les conditions. Je ne désespérerais pas de faire prononcer dans un mois le panégyrique de l’empereur Julien: et assurément si les Parisiens se souviennent qu’il a rendu chez eux la justice comme Caton, et qu’il a combattu pour eux comme César, ils lui doivent une éternelle reconnaissance. Il est donc vrai, Sire, qu’à la fin les hommes s’éclairent, et que ceux qui se croient payés pour les aveugler ne sont pas toujours les maîtres de leur crever les yeux! Grâces en soient rendus à Votre Majesté! Vous avez vaincu les préjugés comme vos autres ennemis: vous jouissez de vos établissements en tout genre. Vous êtes le vainqueur de la superstition, ainsi que le soutien de la liberté germanique. Vivez plus longtemps que moi, pour affermir tous les empires que vous avez fondés. Puisse Frédéric le Grand être Frédéric l’immortel! Daignez agréer le profond respect et l’inviolable attachement de :::paragraphs{style="signature"} Voltaire. ::: :::paragraphs{style="colophon"} *Correspondance de Voltaire avec le roi de Prusse* (Paris, Librairie de la Bibliothèque nationale, 1889), texte du Project Gutenberg, nº 25734. Composé en Crimson Pro et IM Fell (licence SIL OFL). :::
`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces (gotcha: fonts-first) 'Crimson Pro': ['400', '400i'], 'IM Fell French Canon': ['400i'], 'IM Fell DW Pica SC': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadSvg('cover.svg', coverSvg()); await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Letters edition', es: 'Edición de cartas' }) });
工具包 · 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上的食谱文件夹 ↗

变化

#在书眉里写出地点

同样的属性可以给右页印出完整的日期行:À Charlottembourg, le 12 juin 1740。

-  head('recto-date', '{attr.date}', 'odd', at('page', 'top-right', -(MARGIN.outer + 8), HEAD_Y),
+  head('recto-date', '{attr.place}, le {attr.date}', 'odd',
+    at('page', 'top-right', -(MARGIN.outer + 8), HEAD_Y),

常见问题

易错点

属性值:不能含{ or };含"的值用单引号

属性值在右花括号处结束,所以不能包含{ or }。含双引号的值要放在单引号里;美元符号没有问题。 标题属性 →

易错点

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

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

易错点

{number}/{chapterNumber}打印H1编号;{numberRoman}只用于篇

{number}和{chapterNumber}打印标题格式化后的编号,但{numberRoman}、{numberDecimal}等其他数字变体只在篇页上填入。在numberingTemplate中格式化章号({1:I}),或者通过属性传入。 编号标题 →

易错点

整页封面之后加:::pagebreak

在多栏版面中,封面这类整页章首页之后,下一个块可能从同一页的第2栏开始,压在图上。在封面标题后紧跟一个:::pagebreak,就能结束这一页,而且不会多出空白页。 封面、扉页和版权页 →

易错点

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

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

易错点

不换行空格仍然会断行

在postext 1.4.1中,断行器把U+00A0当作普通空格,所以0.08 %、2.006 s或Section 2可能被拆到两行。把两部分连写(0.08%),或者改写句子。 转义与字面字符 →

易错点

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

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

易错点

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

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

易错点

设计文本的overflow默认为'ellipsis-end'

宽度放不下的设计文本元素默认以省略号结尾。需要折成多行的标题,设置overflow: 'wrap'。 页面设计中的文字、线条和框 →

易错点

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

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

易错点

排版前加载所有字体

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

易错点

设计文本不支持行内^sup^或**bold**

设计文本元素只打印纯文本,所以属性中的^1^或**bold**会原样出现。使用Unicode上标字符(¹ ² ³在latin子集中),或者再加一个字重不同的元素。 页面设计中的文字、线条和框 →

  • 右页取的是在该页或之前开始的最后一封信的日期。一页上结束一封信又开始下一封时,印的是新信的日期,就像词典书眉标出该页最后一个词条。
  • 缺少属性的标题会印出空字符串,没有警告:一封没有place="…"的信,日期行会以逗号开头。检查每封信的标题。
  • 这些信保留了转写本中不加空格的; : ?和!。法语惯例是在它们前面加一个窄不换行空格,但1.4.1像对待其他空格一样在这个空格处断行,标点就可能出现在行首。
  • Crimson Pro没有修饰字母ᵉ(U+1D49)和ʳ(U+02B3),所以用Unicode写的1ᵉʳ会用后备字体印出。编者按是正文,把1^er^排成上标;日期行和右页书眉是设计文字,印成序数词与正文同大小的1er。

致谢

文本
  • Frederick II’s letters to Voltaire of 6 and 12 June 1740 and Voltaire’s letter to him of 1 April 1778, in Correspondance de Voltaire avec le roi de Prusse (Paris, Librairie de la Bibliothèque nationale, 1889), one misprint corrected (ouvragés), the salutations and signatures set apart · Frédéric II · Voltaire · 公有领域
  • The headnote and the colophon · Ignacio Ferro · CC BY 4.0
图片
  • The cover: two folded letters and a wax seal on green morocco, drawn in code · Ignacio Ferro · CC BY 4.0
字体
Crimson Pro (SIL OFL 1.1) · IM Fell French Canon (SIL OFL 1.1) · IM Fell DW Pica SC (SIL OFL 1.1)
沙盒