跳到主要内容
食谱编号11

排版食谱 · 第10章 · 输出与集成

同一源文件,印刷版与屏幕版

两个版本出自同一份配置:htmlViewer.overrides存放深色的屏幕版面设计,applyHtmlViewerOverrides在每次构建HTML之前把它合并进来。

本页内容
  • 英文样例:尚无中文版本
  • 成品尺寸225 × 297 mm
  • 2栏, 栏间距7 mm
  • Newsreader 10/14
  • Gloock
  • Reddit Sans
  • 4页
  • 难度
  • Postext 1.4.1
  • 排版用时61 ms
  • 250行代码

成品一览

Notes on Weather,一本小杂志的秋季栏目,用同一份Markdown排两次。纸上是一期225 × 297 mm的刊物:一个雨蓝色的篇页,三篇随笔,每篇在浅雾色色带下开篇,正文两栏两端对齐,配有雨量计、雾中山谷和风玫瑰的插图。屏幕上则是单栏齐左的Newsreader,近黑底上的浅灰字,用读者可以选择和复制的HTML文本排出,没有篇页、雾色色带和页码。窗格打开时显示雨量计,它缩小到窗格高度,用夜间配色绘制,旁边是它所在的印刷页。向上滚动可以看到Rain一篇的开篇;拖动窗格的角,文字会按新尺寸重新排版。屏幕版面设计是印刷配置中htmlViewer.overrides下的九个键。

这道食谱解答

  • 怎样为同一文档显示响应式、可滚动的HTML阅读视图?
  • 怎样用同一份配置,让屏幕版的设计比印刷版更简洁?

简短回答

script.js · 第68–97行在完整代码中
// config() stores it as htmlViewer: { overrides: screenOverrides() }; canvas and PDF ignore it.
const screenOverrides = () => ({
  colorPalette: paletteOf(night), // arrays are replaced whole: the night values
  parts: { page: false }, // no divider page; the part still names the notes after it
  // 96 dpi: the mm and pt inherited from print render at their CSS size.
  page: { dpi: 96, margins: { top: px(40), bottom: px(40), mirror: false } },
  layout: { layoutType: 'single', // one column
    fitFiguresToPage: true }, // tall figures shrink to the pane; off by default, as in print
  bodyText: { fontSize: px(17), lineHeight: px(27), textAlign: 'left', // ragged for reading
    // A paragraph may start on the last line of a screen page. With the rule on, 1.4.1 can force
    // a paragraph taller than the pane whole into a one-line gap under a figure, and off the page.
    avoidWidows: false },
  // Heading levels merge on `level`: the print level keeps everything not restated here.
  headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false },
    marginTop: px(40), marginBottom: px(26),
    advancedDesign: { minHeight: px(0), slot: { elements: screenOpener } } }] }, // no band air
  footer: { elements: [] }, // a scrolling page runs no folios (print's header is empty already)
  captionStyle: { fontSize: px(13), gap: px(10) },
  paragraphStyles: [{ ...colophon, fontSize: px(13), lineHeight: px(20) }], // restated whole
});
// The host owns the page size: the pane's, in CSS pixels (gotcha: viewer-settings-sandbox-only).
// Wide panes get wider margins, so the measure stops at MEASURE.
const MEASURE = 470; // px: about 65 characters of Newsreader at 17 px
const MIN_SIDE = 34; // px: the side margins of a narrow pane
function screenConfig({ width, height }) {
  const merged = applyHtmlViewerOverrides(config()); // print + overrides, a fresh object
  const side = px(Math.max(MIN_SIDE, (width - MEASURE) / 2));
  return relink({ ...merged, page: { ...merged.page, width: px(width), height: px(height),
    margins: { ...merged.page.margins, left: side, right: side } } });
}

用料

还用到
各栏齐底
类型
Newsreader, Gloock, Reddit Sans(SIL OFL 1.1)
素材
无:所有图片都用代码绘制

做法

#1 · 一套颜色名称,两套取值

script.js · 第14–31行在完整代码中
const day = { ink: '#1b222b', rain: '#3d6f9e', slate: '#2d3a4a', fog: '#e9edf1',
  rule: '#c8d0d8', muted: '#5f6a76', paper: '#ffffff' };
const night = { ink: '#e6e8eb', rain: '#9cc3e6', slate: '#56657a', fog: '#1b2129',
  rule: '#2e3742', muted: '#98a2ae', paper: '#111418' };
const paletteOf = (values) => // main-color: the engine's defaults follow the rain blue
  Object.entries({ ...values, 'main-color': values.rain })
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const col = (id) => ({ hex: day[id], model: 'hex', paletteId: id }); // the hex: a day fallback
// Workaround (gotcha: palette-skips-designs): 1.4.1 re-reads the palette into the text,
// table and box styles and the page background, but not into design-slot elements or
// bodyText.referenceColor, so rewrite every linked colour from the palette the config carries.
function relink(config) {
  const hex = Object.fromEntries(config.colorPalette.map(({ id, value }) => [id, value.hex]));
  const walk = (v) => (Array.isArray(v) ? v.map(walk) : !v || typeof v !== 'object' ? v
    : Object.hasOwn(hex, v.paletteId) ? { ...v, hex: hex[v.paletteId] }
      : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])));
  return walk(config);
}

配置中的每种颜色都指向一个调色板id,旁边写的十六进制值只是日间的后备值。所以屏幕版只需替换一个数组,就能换成夜间配色。简短回答中的覆盖项按键合并对象,按level合并标题层级,其他数组则整体替换,colorPalette也在其中。main-color属于调色板,所以使用它的引擎默认值也随之改变。在postext 1.4.1中,新的调色板不会传到设计元素和参照颜色,所以relink()根据合并后配置的调色板重写每一个关联颜色。

#2 · 开篇:纸上是色带,屏幕上只有文字

script.js · 第37–64行在完整代码中
const text = (id, content, fontFamily, style) => ({ kind: 'text', id, content, fontFamily,
  color: col('ink'), align: 'left', ...style,
  overflow: 'wrap' }); // titles break onto more lines (gotcha: overflow-ellipsis-default)
const kicker = text('kicker', '{partTitle} · {attr.kicker}', 'Reddit Sans',
  { fontWeight: 600, textTransform: 'uppercase', color: col('rain') });
const title = text('title', '{titleText}', 'Gloock');
const lead = text('lead', '{attr.lead}', 'Newsreader', { italic: true, hyphenate: true });
const at = (to, edge, y, x = px(0)) => ({ anchor: { to, edge }, offset: { x, y } });
const under = (id, y, width) => ({ ...at(`#${id}`, 'below', y), size: { width } });
const band = (y, height) => ({ ...at('bleed', 'top-left', y), size: { width: 'fill', height } });
const fromTop = (y, x) => at('container', 'top-left', y, x);
// minHeight sets the reservation: the band's foot below the top margin, plus AIR. The band hangs
// from the bleed and counts too, but ends higher (gotcha: opener-reserves-anchored).
const printOpener = { enabled: true, minHeight: mm(BAND - TOP + AIR), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('fog') },
    placement: band(mm(0), mm(BAND)) },
  { kind: 'rule', id: 'horizon', direction: 'horizontal', thickness: pt(2), color: col('rain'),
    placement: band(mm(BAND - 0.7), pt(2)) }, // 2 pt is 0.7 mm: the rule ends at the band's foot
  { ...kicker, fontSize: pt(8), letterSpacing: pt(1.8), placement: fromTop(mm(12)) },
  { ...title, fontSize: pt(54), lineHeight: 1.04, placement: under('kicker', mm(2.5), mm(150)) },
  { ...lead, fontSize: pt(11.5), lineHeight: 1.36, placement: under('title', mm(4), mm(118)) },
] } };
const screenOpener = [
  { ...kicker, fontSize: px(12), letterSpacing: px(2.6), placement: fromTop(px(4)) },
  { ...title, fontSize: px(48), lineHeight: 1.05, placement: under('kicker', px(6), 'fill') },
  { ...lead, fontSize: px(18), lineHeight: 1.45, color: col('muted'),
    placement: under('title', px(10), 'fill') },
];

眉题、标题和导语元素只定义一次,再展开到两套版面设计中,所以两个版本显示同样的标题属性:{attr.kicker}、{attr.lead}和篇的{partTitle}。在纸上,一级标题分页到新的一页,横跨两栏,它的版面设计从出血处挂下一条雾色色带。minHeight预留的深度是色带在上页边距以下的部分加上AIR,即色带与第一行之间的9 mm。印刷版标题用54 pt,因为每个标题都只是一个短词,也是该页唯一的大字。在屏幕上,同一级标题排成栏内标题,没有色带,也没有空白。覆盖项按level合并,所以它没有重新声明的标题设置都保持印刷版的样子。

#3 · 只在纸上存在的篇页

script.js · 第101–117行在完整代码中
const onField = { color: col('paper'), lineHeight: 1 };
const parts = {
  margins: { top: mm(212), left: mm(24), right: mm(40) }, // the fence's list sits low
  bodyStyle: { fontSize: pt(13), lineHeight: pt(19), color: col('paper'),
    numberColor: col('paper') },
  design: { elements: [
    { kind: 'box', id: 'field', style: { backgroundColor: col('rain') },
      placement: band(mm(0), 'fill') },
    { kind: 'image', id: 'strokes', resourceId: 'rain', placement: band(mm(0), 'fill') },
    text('series', '{title}', 'Reddit Sans', { ...onField, fontWeight: 600, fontSize: pt(9),
      letterSpacing: pt(2.4), textTransform: 'uppercase', placement: fromTop(mm(30), mm(24)) }),
    text('numeral', '{number}', 'Gloock', { ...onField, fontSize: pt(190),
      placement: under('series', mm(10), mm(150)) }),
    text('name', '{titleText}', 'Gloock', { ...onField, fontSize: pt(60),
      placement: under('numeral', mm(-6), mm(150)) }),
  ] },
};

在印刷版中,:::part打开雨蓝色的篇页:一个四边出血的框,一个避开文字的雨丝图像元素,取自围栏的罗马数字和篇名,以及白色的随笔列表。屏幕覆盖项设parts.page: false,于是不开新页,也不排列表。篇仍然把它的编号和标题用到其后的随笔上,屏幕版的眉题和印刷版一样读作“Autumn · Note 1”。

#4 · 每幅图画两次

script.js · 第215–245行在完整代码中
const figure = (id, edition, [width, height], placement, caption, alt) => ({ id,
  typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, placement,
  svg: { fileId: `${id}-${edition}.svg`, width, height },
  caption: caption && t(caption), altText: alt && t(alt) }); // the HTML edition's <img alt>
const figures = (edition) => [
  figure('gauge', edition, [900, 1260], { position: 'auto', span: 'column' }, {
    en: 'The rain gauge in section: the funnel feeds a tube with a tenth of its area.',
    es: 'El pluviómetro en sección: el embudo vierte en un tubo con la décima parte de su área.',
  }, {
    en: 'Rain falls on a funnel set in a can sunk in the lawn; the funnel drains into a narrow '
      + 'tube, half full, beside a graduated measuring stick.',
    es: 'La lluvia cae en un embudo sobre un vaso hundido en el césped; el embudo vierte en un '
      + 'tubo estrecho, medio lleno, junto a una regla graduada.' }),
  figure('valley', edition, [1400, 600], { position: 'bottom', span: 'page' }, {
    en: 'Radiation fog at dawn: cold air drains off the hills overnight and fills the valley.',
    es: 'Niebla de irradiación al amanecer: el aire frío baja de las lomas y llena el valle.',
  }, {
    en: 'A valley between two hills lies under layers of fog, with a church spire and a few '
      + 'trees showing above it, seen from a fenced bank under a pale sun.',
    es: 'Un valle entre dos lomas yace bajo capas de niebla; asoman la aguja de una iglesia '
      + 'y unos árboles, vistos desde un ribazo con una cerca bajo un sol pálido.' }),
  figure('rose', edition, [900, 900], { position: 'top', span: 'column' }, {
    en: 'Where a year of morning winds came from at the garden station: west and south-west.',
    es: 'De dónde vino el viento de un año de mañanas en el jardín: del oeste y del suroeste.',
  }, {
    en: 'A wind rose of sixteen petals on three rings; the longest petals point west and '
      + 'south-west, the shortest east.',
    es: 'Una rosa de los vientos de dieciséis pétalos sobre tres anillos; los más largos '
      + 'apuntan al oeste y al suroeste, los más cortos al este.' }),
  figure('rain', 'field', [2250, 2970]), // drawn by the part design, never cited: unnumbered
];

换调色板不会给SVG重新着色,所以每幅图按每套调色板各画一次。figures(edition)为两个版本生成相同的资源,各自指向该版本的文件:印刷版是gauge-day.svg,屏幕版是gauge-night.svg。id、题注、替代文本和位置都不变,所以Markdown中的每个:ref在两个版本里都解析为同一幅图、同一个编号。在屏幕版中,替代文本写入每个<img alt>,屏幕阅读器可以读到它。

#5 · 承载屏幕版

script.js · 第392–422行在完整代码中
const FOLDED = 200; // px: a pane narrower or shorter than this is hidden or squeezed; skip it
if (!pane.clientHeight) { // no style.css: a height, and a corner to drag the pane smaller, but
  // not under 400 px, where 1.4.1 sets text over the opener (gotcha: opener-taller-than-column)
  pane.style.cssText = 'height:580px;min-height:400px;min-width:240px;overflow:auto;resize:both';
}
pane.style.background = night.paper; // the pane's own ground, beside the pages and the scrollbar
const shadow = pane.attachShadow({ mode: 'open' }); // the page's selectors cannot reach in, but
// inherited text properties (letter-spacing, text-transform…) can, and the lines were measured
// without them: `all: initial` on the wrapper stops them at the edition's edge.
const inShadow = `<style>:host>div{all:initial;display:block}`
  + `::selection{background:${night.rain}55}</style>`; // selected text takes the night blue
let size = '';
function showScreen() {
  const [width, height] = [pane.clientWidth, pane.clientHeight];
  if (`${width}×${height}` === size || width < FOLDED || height < FOLDED) return; // same, or folded
  const first = !size; // the first build opens on Figure 1's page, like the print proof
  size = `${width}×${height}`;
  const screenDoc = buildDocument({ markdown, resources: figures('night') },
    screenConfig({ width, height }));
  const place = pane.scrollTop / pane.scrollHeight; // the reader's place, kept across rebuilds
  shadow.innerHTML = `${inShadow}<div>${renderToHtml(screenDoc,
    { mode: 'single', padding: 0, resourceImageUrl: imageUrl })}</div>`;
  const fig = first && screenDoc.pages.find((page) => holds(page, 'gauge')); // 'single' mode
  pane.scrollTop = fig ? fig.index * height : place * pane.scrollHeight; // stacks pane-tall pages
  screenLabel.textContent =
    `${t({ en: 'Screen', es: 'Pantalla' })} · HTML · ${width} × ${height} px`;
}
showScreen();
let timer = 0; // debounced; its first call, on observe(), finds the size unchanged
new ResizeObserver(() => { clearTimeout(timer); timer = setTimeout(showScreen, 150); })
  .observe(pane);

每个屏幕页都与窗格一样高,所以fitFiguresToPage把雨量计缩小到连同题注能放进一页为止(版面)。在CSS假定的96 dpi下,从印刷版继承来的毫米和点按标称尺寸渲染(集成HTML查看器)。示例把renderToHtml输出的绝对定位行写进一个Shadow DOM,页面的选择器够不到它们。外层容器的all: initial还挡住了letter-spacing之类的继承属性:引擎测量这些行时没有考虑它们,继承来的值会让文字互相叠印。HTML页面除非上色,否则是透明的,所以page.backgroundColor从调色板取夜间的纸色。ResizeObserver等待150 ms,只在窗格的宽度或高度变化时才用新的config()重新构建;重建之后,窗格滚回到原来高度比例的位置。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 011 · One source, print and screen editions ═══════════
// https://postext.dev/en/cookbook/print-and-screen-editions
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Newsreader, Gloock, Reddit Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, renderToHtml, applyHtmlViewerOverrides,
  clearMeasurementCache, registerResourceImage, defaultResourceTypes,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'print-and-screen-editions';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one set of colour ids, two sets of values: day for paper, night for screens
const day = { ink: '#1b222b', rain: '#3d6f9e', slate: '#2d3a4a', fog: '#e9edf1',
  rule: '#c8d0d8', muted: '#5f6a76', paper: '#ffffff' };
const night = { ink: '#e6e8eb', rain: '#9cc3e6', slate: '#56657a', fog: '#1b2129',
  rule: '#2e3742', muted: '#98a2ae', paper: '#111418' };
const paletteOf = (values) => // main-color: the engine's defaults follow the rain blue
  Object.entries({ ...values, 'main-color': values.rain })
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const col = (id) => ({ hex: day[id], model: 'hex', paletteId: id }); // the hex: a day fallback
// Workaround (gotcha: palette-skips-designs): 1.4.1 re-reads the palette into the text,
// table and box styles and the page background, but not into design-slot elements or
// bodyText.referenceColor, so rewrite every linked colour from the palette the config carries.
function relink(config) {
  const hex = Object.fromEntries(config.colorPalette.map(({ id, value }) => [id, value.hex]));
  const walk = (v) => (Array.isArray(v) ? v.map(walk) : !v || typeof v !== 'object' ? v
    : Object.hasOwn(hex, v.paletteId) ? { ...v, hex: hex[v.paletteId] }
      : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])));
  return walk(config);
}
// #endregion
const px = (value) => ({ value, unit: 'px' }); // screen sizes, written as CSS pixels
const [TOP, BAND, AIR] = [22, 86, 9]; // mm: top margin, the fog band's depth, air under it

// #region opener: print opens each note under a fog band; the screen keeps the words only
const text = (id, content, fontFamily, style) => ({ kind: 'text', id, content, fontFamily,
  color: col('ink'), align: 'left', ...style,
  overflow: 'wrap' }); // titles break onto more lines (gotcha: overflow-ellipsis-default)
const kicker = text('kicker', '{partTitle} · {attr.kicker}', 'Reddit Sans',
  { fontWeight: 600, textTransform: 'uppercase', color: col('rain') });
const title = text('title', '{titleText}', 'Gloock');
const lead = text('lead', '{attr.lead}', 'Newsreader', { italic: true, hyphenate: true });
const at = (to, edge, y, x = px(0)) => ({ anchor: { to, edge }, offset: { x, y } });
const under = (id, y, width) => ({ ...at(`#${id}`, 'below', y), size: { width } });
const band = (y, height) => ({ ...at('bleed', 'top-left', y), size: { width: 'fill', height } });
const fromTop = (y, x) => at('container', 'top-left', y, x);
// minHeight sets the reservation: the band's foot below the top margin, plus AIR. The band hangs
// from the bleed and counts too, but ends higher (gotcha: opener-reserves-anchored).
const printOpener = { enabled: true, minHeight: mm(BAND - TOP + AIR), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('fog') },
    placement: band(mm(0), mm(BAND)) },
  { kind: 'rule', id: 'horizon', direction: 'horizontal', thickness: pt(2), color: col('rain'),
    placement: band(mm(BAND - 0.7), pt(2)) }, // 2 pt is 0.7 mm: the rule ends at the band's foot
  { ...kicker, fontSize: pt(8), letterSpacing: pt(1.8), placement: fromTop(mm(12)) },
  { ...title, fontSize: pt(54), lineHeight: 1.04, placement: under('kicker', mm(2.5), mm(150)) },
  { ...lead, fontSize: pt(11.5), lineHeight: 1.36, placement: under('title', mm(4), mm(118)) },
] } };
const screenOpener = [
  { ...kicker, fontSize: px(12), letterSpacing: px(2.6), placement: fromTop(px(4)) },
  { ...title, fontSize: px(48), lineHeight: 1.05, placement: under('kicker', px(6), 'fill') },
  { ...lead, fontSize: px(18), lineHeight: 1.45, color: col('muted'),
    placement: under('title', px(10), 'fill') },
];
// #endregion

// #region answer: the screen edition lives in the same config, as overrides of the print one
// config() stores it as htmlViewer: { overrides: screenOverrides() }; canvas and PDF ignore it.
const screenOverrides = () => ({
  colorPalette: paletteOf(night), // arrays are replaced whole: the night values
  parts: { page: false }, // no divider page; the part still names the notes after it
  // 96 dpi: the mm and pt inherited from print render at their CSS size.
  page: { dpi: 96, margins: { top: px(40), bottom: px(40), mirror: false } },
  layout: { layoutType: 'single', // one column
    fitFiguresToPage: true }, // tall figures shrink to the pane; off by default, as in print
  bodyText: { fontSize: px(17), lineHeight: px(27), textAlign: 'left', // ragged for reading
    // A paragraph may start on the last line of a screen page. With the rule on, 1.4.1 can force
    // a paragraph taller than the pane whole into a one-line gap under a figure, and off the page.
    avoidWidows: false },
  // Heading levels merge on `level`: the print level keeps everything not restated here.
  headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false },
    marginTop: px(40), marginBottom: px(26),
    advancedDesign: { minHeight: px(0), slot: { elements: screenOpener } } }] }, // no band air
  footer: { elements: [] }, // a scrolling page runs no folios (print's header is empty already)
  captionStyle: { fontSize: px(13), gap: px(10) },
  paragraphStyles: [{ ...colophon, fontSize: px(13), lineHeight: px(20) }], // restated whole
});
// The host owns the page size: the pane's, in CSS pixels (gotcha: viewer-settings-sandbox-only).
// Wide panes get wider margins, so the measure stops at MEASURE.
const MEASURE = 470; // px: about 65 characters of Newsreader at 17 px
const MIN_SIDE = 34; // px: the side margins of a narrow pane
function screenConfig({ width, height }) {
  const merged = applyHtmlViewerOverrides(config()); // print + overrides, a fresh object
  const side = px(Math.max(MIN_SIDE, (width - MEASURE) / 2));
  return relink({ ...merged, page: { ...merged.page, width: px(width), height: px(height),
    margins: { ...merged.page.margins, left: side, right: side } } });
}
// #endregion

// #region part: the divider page, a rain field bled off every edge under the part's numeral
const onField = { color: col('paper'), lineHeight: 1 };
const parts = {
  margins: { top: mm(212), left: mm(24), right: mm(40) }, // the fence's list sits low
  bodyStyle: { fontSize: pt(13), lineHeight: pt(19), color: col('paper'),
    numberColor: col('paper') },
  design: { elements: [
    { kind: 'box', id: 'field', style: { backgroundColor: col('rain') },
      placement: band(mm(0), 'fill') },
    { kind: 'image', id: 'strokes', resourceId: 'rain', placement: band(mm(0), 'fill') },
    text('series', '{title}', 'Reddit Sans', { ...onField, fontWeight: 600, fontSize: pt(9),
      letterSpacing: pt(2.4), textTransform: 'uppercase', placement: fromTop(mm(30), mm(24)) }),
    text('numeral', '{number}', 'Gloock', { ...onField, fontSize: pt(190),
      placement: under('series', mm(10), mm(150)) }),
    text('name', '{titleText}', 'Gloock', { ...onField, fontSize: pt(60),
      placement: under('numeral', mm(-6), mm(150)) }),
  ] },
};
// #endregion

const foot = text('foot', '{pageNumber}   {title} · {partTitle}', 'Reddit Sans', {
  fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.5), textTransform: 'uppercase',
  color: col('muted'), align: 'center', placement: { ...at('container', 'top', mm(9)),
    size: { width: 'fill' } },
  pages: 'opener' }); // the notes, not the part page: each fits its opening page (a note that ran
// on would need a copy of this element with pages: 'body')
const colophon = { id: 'colophon', fontFamily: 'Reddit Sans', fontSize: pt(7.5),
  lineHeight: pt(11), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0),
  marginTop: pt(14) };

// A factory: the engine caches resolved configs per object (gotcha: config-cache-identity).
const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  // The locale does not name the figures (gotcha: resource-types-locale): "Figura" in Spanish,
  // one count for the whole issue, and a lower-case "fig." in Spanish running text.
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}',
    resetOn: 'never', ...(type.id === 'figure' && { shortLabel: t({ en: 'Fig.', es: 'fig.' }) }),
  })),
  colorPalette: paletteOf(day),
  page: { width: mm(225), height: mm(297), dpi: 150, backgroundColor: col('paper'), // dark at night
    margins: { top: mm(TOP), bottom: mm(24), left: mm(20), right: mm(16), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(7) },
  bodyText: { fontFamily: 'Newsreader', fontSize: pt(10), lineHeight: pt(14), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('rain'),
    textAlign: 'justify', // the default, stated for contrast with the screen's ragged 'left'
    firstLineIndent: mm(4.5), indentAfterHeading: false }, // hyphenation, widows: on by default
  headings: { fontFamily: 'Gloock', fontWeight: 400, color: col('ink'),
    // Off: 1.4.1 drops the column under a closing page's column float a line (here English Rain
    // under the gauge, Spanish Wind under the rose; gotcha: float-stretch-closing-page).
    balancing: { stretchAfterFloats: false }, levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
      marginTop: pt(0), marginBottom: pt(0), advancedDesign: printOpener },
  ] },
  captionStyle: { fontFamily: 'Reddit Sans', fontSize: pt(8), color: col('muted'),
    labelColor: col('rain'), gap: mm(2.4) },
  parts, paragraphStyles: [colophon], header: { elements: [] }, footer: { elements: [foot] },
  htmlViewer: { overrides: screenOverrides() }, // canvas and PDF ignore it; an HTML host applies it
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown样例 · 50行 · content.en.mdtitle: "Notes on Weather" subtitle: "Autumn" author: "Postext Cookbook" --- :::part{number="I" title="Autumn"} 1. Rain, and thirty-one years of the garden gauge 2. Fog, where it comes from and when it lifts 3. Wind, from the shed vane to the Beaufort scale ::: # Rain {kicker="Note 1" lead="A copper can at the end of the garden has kept this household’s rain for thirty-one years, and its book holds over eleven thousand readings to a tenth of a millimetre."} Rain is measured as a depth: the height the water would stand if none of it ran off, sank in or dried. A millimetre of rain is a litre on every square metre of ground, so a gardener and a hydrologist can use the same number. The instrument that counts it has hardly changed in a century and a half; :ref{id="gauge"} shows it in section. A funnel of known width catches the rain and leads it into a narrow inner tube with a tenth of the funnel’s area, so every millimetre that falls stands ten millimetres deep inside, deep enough to read to a tenth with a graduated stick. What overflows the tube in a downpour waits in the outer can and is measured afterwards. The reading is made at eight o’clock every morning, whatever the weather, and written down before breakfast. It is dull work. Climatologists describe a place by its averages over thirty years, and our book now covers thirty-one. In that time the gauge has recorded an average of 612 millimetres a year, from 402 in the driest year to 871 in the wettest. No gauge catches all the rain. Wind carries drops past the mouth of the funnel, so a gauge on an exposed post catches less than falls on the lawn around it, and one beside a wall or a tree catches less again. The rule is to stand it at least twice as far from any obstacle as the obstacle is tall, with its rim a foot above the grass. Ours stands alone at the far end of the lawn for that reason. A raindrop’s shape depends on its size, and it is never the teardrop of the weather map. The smallest are perfect spheres, held round by their own surface tension. Above two millimetres or so, the air pushing up from below flattens them into something closer to a bread roll, and past five or six millimetres they are torn into smaller drops as they fall. A large drop reaches the ground at about nine metres a second, hard enough to drum on leaves and roofs, which is why a summer shower can be heard before it is felt. The heaviest rain falls from the tallest clouds, and it seldom lasts. Long autumn rain comes instead from wide sheets of cloud along a warm front, the kind that close in by degrees through a morning: first a haze across the sun, then a grey lid, then the first drops on the window. By evening the gauge may hold ten or fifteen millimetres. # Fog {kicker="Note 2" lead="Fog is a cloud that touches the ground. In autumn it fills the valley below the house after most clear nights, and it is usually gone by eleven."} Meteorologists call it fog when the visibility drops below one kilometre; above that, it is mist. Either way it is made of the same stuff as a cloud: droplets of water about a hundredth of a millimetre across, so small that they hang in the air instead of falling. A cubic metre of thick fog holds less than half a gram of water, a tenth of a teaspoon, and a layer a few dozen metres deep can hide a village up to its church spire, as :ref{id="valley"} shows. The fog of autumn mornings is usually radiation fog. On a clear, still night the ground gives up the day’s warmth to the open sky and chills the layer of air lying on it. When that air cools to its dew point, the vapour it carries condenses into droplets. Cold air is heavy, so it drains downhill and pools in hollows and river valleys, and at dawn the high ground stands in sunshine above a white, level sea. A wind of more than a few metres a second would stir the cold layer into the warmer air above it, and a cloudy night would keep the ground from cooling. As the sun climbs it warms the ground, the ground warms the air, and the fog thins from below, often rising into a low grey ceiling before it breaks up. Sea fog is another matter. It forms when warm, moist air drifts over cold water, and because the wind that brought it keeps feeding it, it can sit on a coast for days. Fog is white for the same reason a cloud is: its droplets scatter every colour of sunlight alike, red as much as blue. People who live in foggy valleys learn its habits. In ours the first fog of the year comes after the first long, clear night of September. It settles earliest by the river and lingers longest behind the mill. If the church tower comes out of it before nine, the afternoon will be fine. # Wind {kicker="Note 3" lead="We read the wind in what it moves: smoke, leaves, the sea, the washing on the line. In 1805 a naval officer gave it numbers."} Wind is air on its way from high pressure to low. It never takes the straight road: the turning of the Earth bends it aside, so that in the northern hemisphere it circles a low anticlockwise, and near the ground friction slows it and tilts it inwards. A wind is named for where it comes from, not where it goes, so a west wind blows from the west, off the ocean, and often brings the rain. In our garden it is the commonest wind of all, as the rose in :ref{id="rose"} makes plain. The rose is a year of mornings read from the vane on the shed roof. A vane turns its broad tail downwind, so its arrow always points into the wind, where the air is coming from. At eight each morning we note the nearest of its sixteen points, and each petal of the rose is as long as the share of mornings the wind blew from that point. Calm mornings, about one in eight, point nowhere and are left out. Checked against the gauge’s book, the vane stood between south-west and west on most wet mornings. Francis Beaufort, an officer of the Royal Navy, wrote his scale for ships’ logs and described each force by what it did to a warship’s sails. It was later rewritten for land, and the land version still works without an anemometer. At force 2 you feel the wind on your face and the leaves rustle. At force 4 it raises dust and scraps of paper and moves small branches. At force 6 large branches swing and an umbrella is hard to hold. At force 8 twigs break off the trees and walking into it is hard work. The scale stops at force 12, hurricane force, a mean wind of 118 kilometres an hour or more. Every valley has its own winds and its own names for them. Sailors and farmers named the winds long before anyone measured them, and the names still say where a wind comes from and what it brings: the mistral that pours down the Rhône valley, cold and dry; the föhn that warms the northern slopes of the Alps; the bora that falls on the Adriatic in winter, in gusts that can overturn a lorry; the sea breeze that sets in on a summer afternoon and dies at dusk. In Provence the mistral is said to blow for three, six or nine days. Weather stations measure the wind ten metres above open ground and average it over ten minutes, because wind is never steady. It comes in gusts and lulls, eddies round buildings and hedges, and blows harder over the sea than over a town; a gust can be half as strong again as the average around it. That is why a forecast gives two figures, a mean speed and a gust speed, and on an open hill you plan for the second. :::paragraphs{style="colophon"} *Notes on Weather*, part I. Set in Newsreader, Gloock and Reddit Sans (SIL Open Font License). Text and drawings: original, CC BY 4.0. :::
`; // content.<lang>.md, inlined by the Cookbook // #region figures: the same resources for both editions, each pointing at its edition's drawing const figure = (id, edition, [width, height], placement, caption, alt) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, placement, svg: { fileId: `${id}-${edition}.svg`, width, height }, caption: caption && t(caption), altText: alt && t(alt) }); // the HTML edition's <img alt> const figures = (edition) => [ figure('gauge', edition, [900, 1260], { position: 'auto', span: 'column' }, { en: 'The rain gauge in section: the funnel feeds a tube with a tenth of its area.', es: 'El pluviómetro en sección: el embudo vierte en un tubo con la décima parte de su área.', }, { en: 'Rain falls on a funnel set in a can sunk in the lawn; the funnel drains into a narrow ' + 'tube, half full, beside a graduated measuring stick.', es: 'La lluvia cae en un embudo sobre un vaso hundido en el césped; el embudo vierte en un ' + 'tubo estrecho, medio lleno, junto a una regla graduada.' }), figure('valley', edition, [1400, 600], { position: 'bottom', span: 'page' }, { en: 'Radiation fog at dawn: cold air drains off the hills overnight and fills the valley.', es: 'Niebla de irradiación al amanecer: el aire frío baja de las lomas y llena el valle.', }, { en: 'A valley between two hills lies under layers of fog, with a church spire and a few ' + 'trees showing above it, seen from a fenced bank under a pale sun.', es: 'Un valle entre dos lomas yace bajo capas de niebla; asoman la aguja de una iglesia ' + 'y unos árboles, vistos desde un ribazo con una cerca bajo un sol pálido.' }), figure('rose', edition, [900, 900], { position: 'top', span: 'column' }, { en: 'Where a year of morning winds came from at the garden station: west and south-west.', es: 'De dónde vino el viento de un año de mañanas en el jardín: del oeste y del suroeste.', }, { en: 'A wind rose of sixteen petals on three rings; the longest petals point west and ' + 'south-west, the shortest east.', es: 'Una rosa de los vientos de dieciséis pétalos sobre tres anillos; los más largos ' + 'apuntan al oeste y al suroeste, los más cortos al este.' }), figure('rain', 'field', [2250, 2970]), // drawn by the part design, never cited: unnumbered ]; // #endregion // #region art: a rain gauge in section, a fogged valley, a wind rose, the part page's rain function mulberry(seed) { // a seeded PRNG: the same drawing on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let x = Math.imul(seed ^ (seed >>> 15), 1 | seed); x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; return ((x ^ (x >>> 14)) >>> 0) / 4294967296; }; } const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">${body}</svg>`; const f1 = (n) => n.toFixed(1); const between = (random, [a, b]) => a + random() * (b - a); // Slanted rain strokes; `clear` lists boxes [x0, y0, x1, y1] the strokes stay out of. const streaks = (random, n, { x, y, len, width, color, alpha, clear = [] }) => Array.from({ length: n }, () => { const [x0, y0, l] = [between(random, x), between(random, y), between(random, len)]; const [o, w] = [between(random, alpha).toFixed(2), between(random, width).toFixed(2)]; const hits = clear.some(([a, b, c, d]) => x0 > a && x0 - 0.28 * l < c && y0 + l > b && y0 < d); return hits ? '' : `<path d="M${f1(x0)} ${f1(y0)}l${f1(-0.28 * l)} ${f1(l)}" stroke="${color}" ` + `stroke-opacity="${o}" stroke-width="${w}" stroke-linecap="round"/>`; }).join(''); const shape = (tag, attrs) => `<${tag} ${Object.entries(attrs) .map(([k, v]) => `${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}="${v}"`).join(' ')}/>`; function gauge(p) { // 300 × 420: rain, the funnel, the can sunk in the lawn, the tube, a stick const random = mulberry(7); const line = { stroke: p.ink, strokeWidth: 2 }; const ticks = Array.from({ length: 23 }, (_, i) => `M246 ${386 - i * 9}h${i % 5 === 0 ? 14 : 7}`).join(''); const grass = Array.from({ length: 42 }, (_, i) => `M${f1(3 + i * 7.2 + random() * 4)} 356` + `l${f1((random() - 0.5) * 7)} -${f1(5 + random() * 9)}`).join(''); return svg(300, 420, streaks(random, 44, { x: [40, 292], y: [0, 72], len: [12, 24], width: [1.6, 2.6], color: p.rain, alpha: [0.35, 0.95] }) + shape('path', { d: 'M70 104H230', stroke: p.muted, strokeWidth: 1.4 }) + shape('path', { d: 'M70 104l7 -4v8zM230 104l-7 -4v8z', fill: p.muted }) // arrowheads + shape('rect', { x: 0, y: 355, width: 300, height: 65, fill: p.rule, fillOpacity: 0.55 }) + shape('path', { d: grass, stroke: p.slate, strokeWidth: 1.6, strokeLinecap: 'round' }) + shape('rect', { x: 76, y: 128, width: 148, height: 268, rx: 5, fill: p.fog, ...line, strokeWidth: 2.5 }) + shape('rect', { x: 124, y: 196, width: 52, height: 192, fill: p.paper, ...line }) + shape('rect', { x: 126, y: 290, width: 48, height: 96, fill: p.rain, fillOpacity: 0.85 }) + shape('path', { d: 'M70 118h160v10H70z', fill: p.slate }) + shape('path', { d: 'M72 128h156l-72 60h-12z', fill: p.rule, ...line }) + shape('rect', { x: 144, y: 184, width: 12, height: 16, fill: p.rule, ...line }) + shape('rect', { x: 240, y: 176, width: 26, height: 214, fill: p.paper, ...line, strokeWidth: 1.6 }) + shape('rect', { x: 241, y: 290, width: 24, height: 99, fill: p.rain, fillOpacity: 0.3 }) + shape('path', { d: ticks, stroke: p.ink, strokeWidth: 1.2 })); } function valley(p, mist) { // 700 × 300: dawn over a valley full of fog, seen from a bank const random = mulberry(3); const skyline = (y0, amp, step) => { // a gentle ridge (or fog top) from edge to edge const y = () => f1(y0 + (random() - 0.5) * amp); let d = `M0 300V${y0}`; for (let x = step; x <= 700; x += step) d += `Q${x - step / 2} ${y()} ${x} ${y()}`; return `${d}V300Z`; }; const slopes = 'M0 300V118C80 114 160 150 240 208C280 236 300 262 318 300Z' + 'M700 300V126C630 122 550 158 480 210C446 236 424 262 408 300Z'; const crowns = [[292, 176, 11], [311, 172, 9], [326, 178, 8], [424, 174, 10], [441, 178, 8]] .map(([cx, cy, r]) => shape('circle', { cx, cy, r, fill: p.slate })); const fog = [[168, 0.3], [182, 0.38], [198, 0.45], [214, 0.5]].map(([y, a]) => // stacked shape('path', { d: skyline(y, 7, 70), fill: mist, fillOpacity: a })); const posts = [96, 150, 204, 258, 312].map((x, i) => `M${x} ${254 - i * 1.5}v-22`).join(''); return svg(700, 300, shape('rect', { width: 700, height: 300, fill: p.fog }) // the sky + shape('circle', { cx: 566, cy: 66, r: 24, fill: p.rule }) + shape('path', { d: skyline(128, 26, 100), fill: p.rule }) + shape('path', { d: skyline(156, 22, 70), fill: p.muted, fillOpacity: 0.55 }) + shape('rect', { y: 214, width: 700, height: 86, fill: p.muted }) // the fogged valley floor + shape('path', { d: slopes, fill: p.slate }) + crowns.join('') + shape('path', { d: 'M358 252V162h14V252zM358 162l7 -24l7 24z', fill: p.slate }) // tower + fog.join('') + shape('path', { d: 'M0 300V250C130 238 250 242 380 258C480 270 590 262 700 246V300Z', fill: p.slate }) // the bank we watch from, which gives the drawing its foot + shape('path', { d: `${posts}M96 239L312 233`, stroke: p.slate, strokeWidth: 2.4 })); } function rose(p) { // 300 × 300: where a year of morning winds came from, in sixteen petals const share = [6, 4, 3, 2, 2, 3, 4, 6, 9, 12, 19, 22, 24, 14, 9, 7]; // N first, clockwise const petal = (s, i) => { const [a, r] = [((i * 22.5 - 90) * Math.PI) / 180, s * 5.4]; // length ∝ share const end = (d) => `${f1(150 + r * Math.cos(a + d))} ${f1(150 + r * Math.sin(a + d))}`; return shape('path', { d: `M150 150L${end(-0.17)}A${f1(r)} ${f1(r)} 0 0 1 ${end(0.17)}Z`, fill: s > 12 ? p.rain : p.slate, fillOpacity: s > 12 ? 0.95 : 0.55 }); }; const rings = [40, 80, 120].map((r) => shape('circle', { cx: 150, cy: 150, r, fill: 'none', stroke: p.rule, strokeWidth: 1.2 })); return svg(300, 300, rings.join('') + share.map(petal).join('') + shape('path', { d: 'M150 18V282M18 150H282', stroke: p.rule, strokeWidth: 1 }) + shape('path', { d: 'M144 14V2L156 14V2', fill: 'none', stroke: p.ink, strokeWidth: 2 }) // N + shape('circle', { cx: 150, cy: 150, r: 5, fill: p.ink })); } async function drawFigures() { // every figure in both palettes, and the part page's rain const words = [[20, 26, 80, 36], [20, 100, 112, 128], [20, 206, 140, 238]]; // mm: the type await loadSvg('rain-field.svg', svg(225, 297, streaks(mulberry(19), 320, { x: [-10, 245], y: [-14, 292], len: [6, 18], width: [0.3, 0.8], color: day.paper, alpha: [0.12, 0.45], clear: words }))); for (const [edition, p, mist] of [['day', day, day.paper], ['night', night, night.muted]]) { await loadSvg(`gauge-${edition}.svg`, gauge(p)); await loadSvg(`valley-${edition}.svg`, valley(p, mist)); // night fog: pale, not black await loadSvg(`rose-${edition}.svg`, rose(p)); } } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) Newsreader: ['400', '400i', '700'], Gloock: ['400'], 'Reddit Sans': ['400', '400i', '600', '700'] }; // 400: captions and the colophon // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await drawFigures(); const doc = await buildWithFonts( // print: every page on the kit's desk, and one beside the screen () => buildDocument({ markdown, resources: figures('day') }, config()), markdown); showPages(doc, { title: t({ en: 'One source, print and screen editions', es: 'Un solo original, ediciones impresa y de pantalla' }) }); // The two editions side by side, above the desk. index.html and style.css lay them out; pasted // on its own, the script writes the same markup, and the screen region gives the pane a height. if (!document.getElementById('editions')) { document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="editions"> <figure class="edition"><canvas id="proof" role="img" style="width:300px"></canvas> <figcaption></figcaption></figure><figure class="edition screen"> <div id="screen" role="region" tabindex="0"></div><figcaption></figcaption></figure></section>`); } const [proof, pane] = [document.getElementById('proof'), document.getElementById('screen')]; const [proofLabel, screenLabel] = document.querySelectorAll('#editions figcaption'); const holds = (page, id) => [...(page.floats ?? []), ...page.columns.flatMap((c) => c.blocks)] .some((block) => block.resourceBlock?.resource.id === id); const note = doc.pages.find((page) => holds(page, 'gauge')) ?? doc.pages[0]; // Figure 1's page const density = Math.min(window.devicePixelRatio || 1, 2); renderPageToCanvas(note, doc, proof, { scale: (density * proof.clientWidth) / note.width }); const { width: trimW, height: trimH } = config().page; proofLabel.textContent = `${t({ en: 'Print', es: 'Impresa' })} · canvas · ` + `${trimW.value} × ${trimH.value} mm`; proof.setAttribute('aria-label', `${t({ en: 'Print edition, page', es: 'Edición impresa, página' })} ${note.index + 1}`); pane.setAttribute('aria-label', t({ en: 'Screen edition', es: 'Edición de pantalla' })); // #region screen: the HTML edition in a Shadow DOM, laid out again when its pane resizes const FOLDED = 200; // px: a pane narrower or shorter than this is hidden or squeezed; skip it if (!pane.clientHeight) { // no style.css: a height, and a corner to drag the pane smaller, but // not under 400 px, where 1.4.1 sets text over the opener (gotcha: opener-taller-than-column) pane.style.cssText = 'height:580px;min-height:400px;min-width:240px;overflow:auto;resize:both'; } pane.style.background = night.paper; // the pane's own ground, beside the pages and the scrollbar const shadow = pane.attachShadow({ mode: 'open' }); // the page's selectors cannot reach in, but // inherited text properties (letter-spacing, text-transform…) can, and the lines were measured // without them: `all: initial` on the wrapper stops them at the edition's edge. const inShadow = `<style>:host>div{all:initial;display:block}` + `::selection{background:${night.rain}55}</style>`; // selected text takes the night blue let size = ''; function showScreen() { const [width, height] = [pane.clientWidth, pane.clientHeight]; if (`${width}×${height}` === size || width < FOLDED || height < FOLDED) return; // same, or folded const first = !size; // the first build opens on Figure 1's page, like the print proof size = `${width}×${height}`; const screenDoc = buildDocument({ markdown, resources: figures('night') }, screenConfig({ width, height })); const place = pane.scrollTop / pane.scrollHeight; // the reader's place, kept across rebuilds shadow.innerHTML = `${inShadow}<div>${renderToHtml(screenDoc, { mode: 'single', padding: 0, resourceImageUrl: imageUrl })}</div>`; const fig = first && screenDoc.pages.find((page) => holds(page, 'gauge')); // 'single' mode pane.scrollTop = fig ? fig.index * height : place * pane.scrollHeight; // stacks pane-tall pages screenLabel.textContent = `${t({ en: 'Screen', es: 'Pantalla' })} · HTML · ${width} × ${height} px`; } showScreen(); let timer = 0; // debounced; its first call, on observe(), finds the size unchanged new ResizeObserver(() => { clearTimeout(timer); timer = setTimeout(showScreen, 150); }) .observe(pane); // #endregion
工具包 · 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上的食谱文件夹 ↗

变化

#日间阅读

去掉夜间调色板,窗格和插图改用日间的取值;屏幕版就会显示白色页面、深色文字和日间插图。

-  colorPalette: paletteOf(night), // 数组整体替换:用夜间取值
   parts: { page: false }, // 不开篇页;篇仍为其后的随笔命名
@@
-pane.style.background = night.paper; // 窗格自身的底色,位于页面和滚动条旁
+pane.style.background = day.paper; // 窗格自身的底色,位于页面和滚动条旁
@@
-  const screenDoc = buildDocument({ markdown, resources: figures('night') },
+  const screenDoc = buildDocument({ markdown, resources: figures('day') },

#给印刷版开篇加上色带上的章号

如果印刷版要更醒目的开篇,用饱和色带和大号章号,参见满版出血色带上的章首页;屏幕覆盖项保持不变。

常见问题

易错点

HTML输出没有栏间线、网格和标记,且背景透明

renderToHtml不画栏间线、基线网格和裁切标记,页面也是透明的,除非传入background;在深色页面上,就成了深色背景上的深色文字。 HTML阅读版 →

易错点

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

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

易错点

配置按对象身份缓存:每次新建一个对象

引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →

易错点

有些htmlViewer设置只在沙盒中有效

htmlViewer.maxCharsPerLine、columnGap和optimalLineBreaking由沙盒的查看器读取,renderToHtml不读。在你自己的页面中,自己设定页面尺寸,把mode和columnGap传给renderToHtml;覆盖设置用applyHtmlViewerOverrides应用。 HTML阅读版 →

易错点

排版前加载所有字体

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

易错点

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

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

易错点

章首页预留的高度一直到最低的页面锚定元素

高级设计的章首页会预留到其最低元素为止的高度,标题下方锚定在页面或出血上的元素也算在内,所以页脚处的装饰会把正文推到下一页。把这类装饰放在标题上方,移到页眉或页脚槽位,或者用minHeight设定预留高度。 设计过的章首页 →

易错点

用defaultResourceTypes(locale)本地化Figure/Table

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

易错点

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

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

易错点

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

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

易错点

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

在postext 1.4.1中,当一章或一篇文章结束在以整页宽顶部浮动体开头的页面上,而且各栏的行数分配不均时,stretchAfterFloats会在较短的一栏中、浮动体下方加一个空行,而不是让它提前结束,于是两栏不再从同一行开始。把headings.balancing.stretchAfterFloats设为false,或者把文字调整到偶数行。 各栏齐底 →

易错点

齐左文字可能让标点与粗体或:ref分开

在postext 1.4.1中,不两端对齐的文字(框内正文、齐左段落)可能在粗体或斜体片段(或:ref)与紧挨着它的标点之间断行:句号可能出现在下一行行首,引用前的'('可能留在上一行行尾。两端对齐的文字从不在这里断行。逐一检查每个版本中的框,遇到这种情况就改写句子,让这个片段落在行中间。 粗体、斜体及其颜色 →

  • 每次修改后都检查每篇随笔的结尾。如果某篇的最后一段把两行的尾巴推到新的一页(段末孤行控制让这两行待在一起),postext 1.4.1会把该页的栏高设为一行,画布会把第二行裁掉。两个示例都做过字数调整,没有哪篇以这种方式结束。
  • 在齐左文本中,1.4.1可能把一行断在引用前的“(”上;在读者可以调整尺寸的窗格里,总有某个宽度会出现这种断行。所以这些随笔写“as Fig. 2 shows”,而从不写“(Fig. 2)”。
  • 允许屏幕版的段落从页面最后一行开始(avoidWidows: false)。打开这条规则时,1.4.1可能把一个比页面还高的段落整个塞进图下方的空隙,让它超出页脚;240 × 460或260 × 500 px的窗格就会出现这种情况。
  • 窗格至少保持400 px高(示例中的min-height)。在高度不到约350 px的窄窗格里,屏幕版开篇比页面的内容区还高;postext 1.4.1于是放弃全部预留,把第一段排在标题上面。
  • 排版之前先给窗格定好尺寸。没有宽度的窗格,无论是被隐藏还是在flex行里被挤掉,都会得到零宽的页面,所以showScreen()会跳过比FOLDED(200 px)更窄或更矮的窗格。

致谢

文本
原创文字, CC BY 4.0
字体
Newsreader (SIL OFL 1.1) · Gloock (SIL OFL 1.1) · Reddit Sans (SIL OFL 1.1)
沙盒