跳到主要内容
食谱编号51

排版食谱 · 第7章 · 图与图片

双色孔版小志:插图只用一种专色

一本为双滚筒孔版印刷机准备的四页小志:全彩插图印成粉色的深浅网调,Canvas上由CodePen示例重新着色,PDF里由renderToPdf重新着色。

本页内容

第2–3页,共4页

  • 英文样例:尚无中文版本
  • 成品尺寸120 × 160 mm
  • 1栏
  • Epilogue 9/13
  • Anton
  • Space Mono
  • 4页
  • 难度
  • Postext 1.4.1
  • 排版用时6 ms
  • 212行代码

成品一览

Night Buses是一本关于某座城市夜班公交的免费小志:四页,120 × 160 mm,一张纸对折,供一台有蓝色和荧光粉两个滚筒的孔版印刷机印刷。配置里的每种颜色都是两种墨之一、粉色的25%网调,或者奶油色纸张本身。插图按全彩绘制(六色线路图、灰色的月亮、黑底琥珀色的线路牌),从粉色滚筒印成粉色的深浅网调,颜色越深网调越密。线路图印成粉色,同一页靠下的一幅小插图保留原来的六种颜色。封面标题排两次,粉色副本比蓝色的向右偏0.8 mm、向上偏0.5 mm。CodePen示例还能生成彩色PDF和灰度打样。

这道食谱解答

  • 怎样把所有示意图重新着色为同一种专色油墨?

简短回答

script.js · 第34–44行在完整代码中
// renderToPdf reads the ink from the config and recolours every SVG it is handed; the canvas
// paints an SVG as registered (gotcha: single-ink-canvas). So the screen gets a recoloured
// copy and the PDF the drawing as drawn: one pass each, as a second pass lightens it again.
const diagramStyle = { singleInk: true, inkColor: col('spot') };
const printFiles = new Map(); // fileId → the bytes renderToPdf embeds
async function registerArt(fileId, svg) {
  await loadSvg(fileId, applySingleInkToSvg(svg, diagramStyle.inkColor.hex)); // the canvas
  printFiles.set(fileId, new TextEncoder().encode(svg)); // the PDF, recoloured there
}
const pdfOptions = { fontProvider: fontsourceProvider,
  resourceBytes: (id) => printFiles.get(id) }; // the drawings as drawn, the PNG as it is

用料

类型
Epilogue, Anton, Space Mono(SIL OFL 1.1)
素材
无:所有图片都用代码绘制

做法

#1 · 两个滚筒,每种颜色都取自其一

script.js · 第18–30行在完整代码中
// The two drums: the blue for the type and the furniture, the fluorescent pink for the art.
const DRUMS = { ink: '#1d4fb8', spot: '#f0509a' };
const PAPER = '#f3efe6'; // cream stock: where no ink falls
// A screen prints a share of an ink's dots and lets the paper show between them.
const screen = (hex, share) => `#${[1, 3, 5].map((i) => Math.round(share
  * parseInt(hex.slice(i, i + 2), 16) + (1 - share) * parseInt(PAPER.slice(i, i + 2), 16))
  .toString(16).padStart(2, '0')).join('')}`;
const palette = { ...DRUMS, 'spot-25': screen(DRUMS.spot, 0.25), paper: PAPER };
// The hex rides with the id: design elements read the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to main-color: aimed at the spot, none of them adds a third ink.
const colorPalette = [...Object.entries(palette), ['main-color', DRUMS.spot]]
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

孔版印刷机的每种墨由自己的滚筒印出,所以配置里任何地方出现第三种颜色,都需要第三个滚筒。screen()把一种墨按比例与纸色混合,就像网屏只印一部分网点、网点之间留出纸色。粉色的25%网调是#f2c7d3,用作行内标签和题注的底色。main-color指向粉色,所以配置里没有改动的默认值都从这个滚筒印出,而不是引擎自带的蓝色#295AA3。col()在每个id旁写出十六进制值,因为1.4.1把调色板应用到文字样式,但不应用到设计元素。

#2 · 每种输出各重新着色一次

代码就是上面的简短回答。开启diagramStyle.singleInk后,renderToPdf把SVG的每种颜色变成inkColor的一个网调,浓度为1减去该颜色的相对亮度(图示样式),于是深蓝的N12印成84%,黄色的N50印成24%。1.4.1中Canvas原样绘制已注册的SVG,所以registerArt()注册一份经applySingleInkToSvg重新着色的副本,并为resourceBytes保留原图。在Canvas上和按300 dpi栅格化的PDF里取样,N12线路都是(242, 109, 171),正是公式在浓度0.836时给出的网调。再处理一次会把它减淡到44%,所以绝不把重新着色后的副本交给PDF。

#3 · 标题印两次,故意套印不准

script.js · 第58–91行在完整代码中
const OFF = { x: 0.8, y: -0.5 }; // mm: where the pink drum lands against the blue one
// Array order is paint order: the pink copy goes down first and the blue covers all but a
// sliver. Canvas and PDF paint opaque colour, so the overlap stays blue, not riso purple.
const twice = (id, { placement: { offset: { x, y }, ...rest }, ...element }) => [
  { ...element, id: `${id}-spot`, color: col('spot'),
    placement: { ...rest, offset: { x: mm(x.value + OFF.x), y: mm(y.value + OFF.y) } } },
  { ...element, id, color: col('ink'), placement: { ...rest, offset: { x, y } } }];
const rule = (id, direction, x, y, size) => ({ kind: 'rule', id, direction, color: col('ink'),
  thickness: pt(id === 'pole' ? 3 : 0.8), placement: at('page', 'top-left', x, y, size) });
// span: 'page' lets the design paint outside the text block: kept in the column, the
// issue line above it and the pole below it are cut off at the column's edges.
const cover = { id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'moon', resourceId: 'moon',
      placement: at('page', 'top-left', 28, 14, { width: mm(92), height: mm(92) }) },
    rule('wire-1', 'horizontal', 0, 30, { width: mm(TRIM.width) }),
    rule('wire-2', 'horizontal', 0, 34.5, { width: mm(TRIM.width) }),
    rule('pole', 'vertical', 98, 62, { height: mm(TRIM.height - 62) }), // off the foot
    { kind: 'box', id: 'flag', style: { backgroundColor: col('ink'), borderRadius: mm(1) },
      placement: at('page', 'top-left', 89, 62, { width: mm(18), height: mm(21) }) },
    // One word a line: the box is narrower than two of them (gotcha: design-text-newline).
    { kind: 'text', id: 'stops', content: '{attr.stops}', ...mono, fontSize: pt(9),
      lineHeight: 1.25, align: 'center', color: col('paper'), overflow: 'wrap',
      placement: at('#flag', 'top-left', 3, 2.5, { width: mm(12) }) },
    { kind: 'text', id: 'issue', content: '{attr.issue}', ...mono,
      placement: at('page', 'top-left', MARGIN.inner, 8) },
    { kind: 'text', id: 'line', content: '{attr.line}', fontFamily: 'Epilogue', fontWeight: 700,
      fontSize: pt(9), color: col('ink'), align: 'left', overflow: 'wrap',
      placement: at('page', 'top-left', MARGIN.inner, 13, { width: mm(46) }) },
    ...twice('title', { kind: 'text', content: '{titleText}', fontFamily: 'Anton', fontSize: pt(86),
      lineHeight: 0.9, // a multiple (gotcha: design-lineheight-multiple)
      textTransform: 'uppercase', align: 'left',
      overflow: 'wrap', placement: at('page', 'top-left', MARGIN.inner, 94, { width: mm(76) }) }),
  ] } } };

孔版印刷机一个滚筒接一个滚筒地印,纸张两次落下的位置绝不会完全相同。twice()返回一个文字元素的两份副本,粉色的在前,向右0.8 mm、向上0.5 mm。数组顺序就是绘制顺序,所以蓝色副本盖住粉色,只在每个字母的上缘和右缘露出一条细边。Canvas和PDF绘制的是不透明颜色,所以重叠处保持蓝色,而真正的孔版会印出紫色。这个版面设计是通栏的,因为期号那一行在版心上方,站牌杆也伸出版心底部;若设计限定在栏内,1.4.1会在栏的边缘把两者都裁掉。

#4 · 小字用深色墨

script.js · 第95–112行在完整代码中
const opener = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...mono,
    placement: at('container', 'top-left', 0, 0.5) },
  ...twice('title', { kind: 'text', content: '{titleText}', fontFamily: 'Anton',
    fontSize: pt(34), lineHeight: 0.95, textTransform: 'uppercase', align: 'left',
    overflow: 'wrap', placement: at('#kicker', 'below', 0, 2.5, { width: mm(MEASURE) }) }),
] } };
const route = { id: 'route', background: col('spot-25'), borderColor: col('spot'),
  borderWidth: pt(0.75), borderRadius: pt(1.2), fontFamily: 'Space Mono', bold: true,
  fontSize: em(0.86), color: col('ink') };
const captionStyle = { fontFamily: 'Epilogue', fontSize: pt(8), color: col('ink'),
  backgroundEnabled: true, background: col('spot-25'), padding: mm(1.6) };
const quote = { id: 'quote', backgroundEnabled: false, marginTop: pt(LEAD), marginBottom: pt(4),
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('spot') },
  padding: { top: mm(2.6), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...label, gap: mm(1.2) }, // the speaker, above the words
  body: { fontFamily: 'Anton', fontSize: pt(14), lineHeight: pt(17), color: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0) } };

在奶油色纸上,蓝色的对比度是6.4:1,荧光粉只有2.9:1,印8或9 pt的字太淡。所以引擎排的每个字都从蓝色滚筒印出。行内标签是25%网调底上的蓝色Space Mono(4.85:1),外加粉色描边;题注是同一网调底上的蓝色Epilogue。粉色印引文上方的色条、标签描边、项目符号、页码方块和标题的错位副本。插图里的文字属于插图本身,随插图印成粉色。说话人的名字是框的标题,用7.5 pt粗体Space Mono大写字母排在引文上方。

#5 · 把小插图放在短文旁边

script.js · 第116–141行在完整代码中
// The inset keeps the map's own colours. Single ink touches SVG only, so the map goes in
// as a PNG snapshot, which neither the canvas nor the PDF recolours.
async function registerSnapshot(fileId, svg, width, height) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  const canvas = new OffscreenCanvas(width, height);
  canvas.getContext('2d').drawImage(img, 0, 0, width, height);
  registerResourceImage(fileId, await createImageBitmap(canvas));
  printFiles.set(fileId, new Uint8Array(await (await canvas.convertToBlob()).arrayBuffer()));
}
const INSET = 30; // mm: the snapshot's width; its height follows the map's 100 × 69.5
const drawn = { id: 'drawn', marginTop: pt(LEAD), advancedDesign: { enabled: true,
    // A floor at the picture's height, which a design's images do not reserve (gotcha:
    // opener-image-no-reserve). The note is about as tall today; with a shorter note the
    // next block would run over the picture.
    minHeight: mm(INSET * 0.695 + 1),
    slot: { elements: [
      { kind: 'image', id: 'inset', resourceId: 'as-drawn',
        placement: at('container', 'top-left', 0, 0.5, { width: mm(INSET), height: 'auto' }) },
      { kind: 'text', id: 'label', content: '{titleText}', ...mono,
        placement: at('#inset', 'right-of', 5, 0) },
      { kind: 'text', id: 'note', content: '{attr.note}', fontFamily: 'Epilogue',
        fontSize: pt(8.5), lineHeight: 1.4, color: col('ink'), align: 'left', overflow: 'wrap',
        placement: at('#label', 'below', 0, 1.5, { width: mm(MEASURE - INSET - 5) }) },
    ] } } };

在单栏里,浮动体占满所在区域的整个宽度,所以浮动一幅小图会让区域的其余部分空着。于是这幅小插图改用标题的版面设计:### As drawn {style="drawn" note="…"}印出图片,并在图片右边把标题排成标签,下面是短文。版面设计里的图片不预留高度,所以用minHeight设一个不低于图片高度的下限。现在短文和图片差不多高;短文变短的话,下一个块会压到图片上。registerSnapshot()把同一张network()线路图画成PNG,而单色墨只对SVG重新着色,所以这幅小插图在屏幕上和PDF里都保留六种颜色。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 051 · Two-ink riso zine: art in one spot colour ═════════
// https://postext.dev/en/cookbook/riso-zine-single-ink
// Code: MIT · Text: original (CC BY 4.0) · Pictures: drawn in code (CC BY 4.0)
// Fonts: Epilogue, Anton, Space Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// A four-page zine for a risograph with a blue drum and a fluorescent pink one. The drawings
// are made in full colour and printed from the pink drum as tints of pink.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  applySingleInkToSvg,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'riso-zine-single-ink';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region inks: two drums and the paper; every colour in the config links to one of them
// The two drums: the blue for the type and the furniture, the fluorescent pink for the art.
const DRUMS = { ink: '#1d4fb8', spot: '#f0509a' };
const PAPER = '#f3efe6'; // cream stock: where no ink falls
// A screen prints a share of an ink's dots and lets the paper show between them.
const screen = (hex, share) => `#${[1, 3, 5].map((i) => Math.round(share
  * parseInt(hex.slice(i, i + 2), 16) + (1 - share) * parseInt(PAPER.slice(i, i + 2), 16))
  .toString(16).padStart(2, '0')).join('')}`;
const palette = { ...DRUMS, 'spot-25': screen(DRUMS.spot, 0.25), paper: PAPER };
// The hex rides with the id: design elements read the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to main-color: aimed at the spot, none of them adds a third ink.
const colorPalette = [...Object.entries(palette), ['main-color', DRUMS.spot]]
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion

// #region answer: drawings in any colours, printed from the pink drum
// renderToPdf reads the ink from the config and recolours every SVG it is handed; the canvas
// paints an SVG as registered (gotcha: single-ink-canvas). So the screen gets a recoloured
// copy and the PDF the drawing as drawn: one pass each, as a second pass lightens it again.
const diagramStyle = { singleInk: true, inkColor: col('spot') };
const printFiles = new Map(); // fileId → the bytes renderToPdf embeds
async function registerArt(fileId, svg) {
  await loadSvg(fileId, applySingleInkToSvg(svg, diagramStyle.inkColor.hex)); // the canvas
  printFiles.set(fileId, new TextEncoder().encode(svg)); // the PDF, recoloured there
}
const pdfOptions = { fontProvider: fontsourceProvider,
  resourceBytes: (id) => printFiles.get(id) }; // the drawings as drawn, the PNG as it is
// #endregion

const TRIM = { width: 120, height: 160 }; // mm: a 240 × 160 sheet folded once
const MARGIN = { top: 13, bottom: 16, inner: 12, outer: 10 }; // mm
const MEASURE = TRIM.width - MARGIN.inner - MARGIN.outer; // 98 mm, about 65 characters
const LEAD = 13; // body leading in pt
const label = { fontFamily: 'Space Mono', fontWeight: 700, fontSize: pt(7.5),
  textTransform: 'uppercase', color: col('ink') };
const mono = { ...label, align: 'left', overflow: 'clip' }; // a label as a design element
const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });

// #region cover: a pink moon behind blue wires, and the title printed twice off register
const OFF = { x: 0.8, y: -0.5 }; // mm: where the pink drum lands against the blue one
// Array order is paint order: the pink copy goes down first and the blue covers all but a
// sliver. Canvas and PDF paint opaque colour, so the overlap stays blue, not riso purple.
const twice = (id, { placement: { offset: { x, y }, ...rest }, ...element }) => [
  { ...element, id: `${id}-spot`, color: col('spot'),
    placement: { ...rest, offset: { x: mm(x.value + OFF.x), y: mm(y.value + OFF.y) } } },
  { ...element, id, color: col('ink'), placement: { ...rest, offset: { x, y } } }];
const rule = (id, direction, x, y, size) => ({ kind: 'rule', id, direction, color: col('ink'),
  thickness: pt(id === 'pole' ? 3 : 0.8), placement: at('page', 'top-left', x, y, size) });
// span: 'page' lets the design paint outside the text block: kept in the column, the
// issue line above it and the pole below it are cut off at the column's edges.
const cover = { id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'moon', resourceId: 'moon',
      placement: at('page', 'top-left', 28, 14, { width: mm(92), height: mm(92) }) },
    rule('wire-1', 'horizontal', 0, 30, { width: mm(TRIM.width) }),
    rule('wire-2', 'horizontal', 0, 34.5, { width: mm(TRIM.width) }),
    rule('pole', 'vertical', 98, 62, { height: mm(TRIM.height - 62) }), // off the foot
    { kind: 'box', id: 'flag', style: { backgroundColor: col('ink'), borderRadius: mm(1) },
      placement: at('page', 'top-left', 89, 62, { width: mm(18), height: mm(21) }) },
    // One word a line: the box is narrower than two of them (gotcha: design-text-newline).
    { kind: 'text', id: 'stops', content: '{attr.stops}', ...mono, fontSize: pt(9),
      lineHeight: 1.25, align: 'center', color: col('paper'), overflow: 'wrap',
      placement: at('#flag', 'top-left', 3, 2.5, { width: mm(12) }) },
    { kind: 'text', id: 'issue', content: '{attr.issue}', ...mono,
      placement: at('page', 'top-left', MARGIN.inner, 8) },
    { kind: 'text', id: 'line', content: '{attr.line}', fontFamily: 'Epilogue', fontWeight: 700,
      fontSize: pt(9), color: col('ink'), align: 'left', overflow: 'wrap',
      placement: at('page', 'top-left', MARGIN.inner, 13, { width: mm(46) }) },
    ...twice('title', { kind: 'text', content: '{titleText}', fontFamily: 'Anton', fontSize: pt(86),
      lineHeight: 0.9, // a multiple (gotcha: design-lineheight-multiple)
      textTransform: 'uppercase', align: 'left',
      overflow: 'wrap', placement: at('page', 'top-left', MARGIN.inner, 94, { width: mm(76) }) }),
  ] } } };
// #endregion

// #region inside: the article opener, route chips, the caption bar and the quote's stripe
const opener = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...mono,
    placement: at('container', 'top-left', 0, 0.5) },
  ...twice('title', { kind: 'text', content: '{titleText}', fontFamily: 'Anton',
    fontSize: pt(34), lineHeight: 0.95, textTransform: 'uppercase', align: 'left',
    overflow: 'wrap', placement: at('#kicker', 'below', 0, 2.5, { width: mm(MEASURE) }) }),
] } };
const route = { id: 'route', background: col('spot-25'), borderColor: col('spot'),
  borderWidth: pt(0.75), borderRadius: pt(1.2), fontFamily: 'Space Mono', bold: true,
  fontSize: em(0.86), color: col('ink') };
const captionStyle = { fontFamily: 'Epilogue', fontSize: pt(8), color: col('ink'),
  backgroundEnabled: true, background: col('spot-25'), padding: mm(1.6) };
const quote = { id: 'quote', backgroundEnabled: false, marginTop: pt(LEAD), marginBottom: pt(4),
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('spot') },
  padding: { top: mm(2.6), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...label, gap: mm(1.2) }, // the speaker, above the words
  body: { fontFamily: 'Anton', fontSize: pt(14), lineHeight: pt(17), color: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0) } };
// #endregion

// #region drawn: the inset beside its note, as a heading design (the heading is its label)
// The inset keeps the map's own colours. Single ink touches SVG only, so the map goes in
// as a PNG snapshot, which neither the canvas nor the PDF recolours.
async function registerSnapshot(fileId, svg, width, height) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  const canvas = new OffscreenCanvas(width, height);
  canvas.getContext('2d').drawImage(img, 0, 0, width, height);
  registerResourceImage(fileId, await createImageBitmap(canvas));
  printFiles.set(fileId, new Uint8Array(await (await canvas.convertToBlob()).arrayBuffer()));
}
const INSET = 30; // mm: the snapshot's width; its height follows the map's 100 × 69.5
const drawn = { id: 'drawn', marginTop: pt(LEAD), advancedDesign: { enabled: true,
    // A floor at the picture's height, which a design's images do not reserve (gotcha:
    // opener-image-no-reserve). The note is about as tall today; with a shorter note the
    // next block would run over the picture.
    minHeight: mm(INSET * 0.695 + 1),
    slot: { elements: [
      { kind: 'image', id: 'inset', resourceId: 'as-drawn',
        placement: at('container', 'top-left', 0, 0.5, { width: mm(INSET), height: 'auto' }) },
      { kind: 'text', id: 'label', content: '{titleText}', ...mono,
        placement: at('#inset', 'right-of', 5, 0) },
      { kind: 'text', id: 'note', content: '{attr.note}', fontFamily: 'Epilogue',
        fontSize: pt(8.5), lineHeight: 1.4, color: col('ink'), align: 'left', overflow: 'wrap',
        placement: at('#label', 'below', 0, 1.5, { width: mm(MEASURE - INSET - 5) }) },
    ] } } };
// #endregion

// Folios at the foot, outside: a pink square on the baseline, then the folio and the zine's
// name on a verso, the article and the folio on a recto.
const FOLIO_Y = TRIM.height - 10; // mm from the top edge to the folio's box
const feet = [['even', 'left', 1, '{pageNumber} · {title} · {subtitle}'],
  ['odd', 'right', -1, '{chapterTitle} · {pageNumber}']].flatMap(([parity, edge, s, content]) => [
  { kind: 'box', id: `mark-${parity}`, parity, style: { backgroundColor: col('spot') },
    placement: at('page', `top-${edge}`, s * MARGIN.outer, FOLIO_Y + 0.7, { width: mm(1.85),
      height: mm(1.85) }) }, // the label's cap height, standing on its baseline
  { kind: 'text', id: `folio-${parity}`, parity, content, ...mono, align: edge,
    placement: at('page', `top-${edge}`, s * (MARGIN.outer + 3.5), FOLIO_Y) }]);

// The back cover: a destination blind across the top and the cover's moon going down.
const back = { id: 'back', span: 'page', // the setting moon runs past the text block
  breakBefore: { enabled: true, parity: 'even' },
  header: { elements: [] }, footer: { elements: [] },
  // The blind is MEASURE / 4 tall; its room is set by hand (gotcha: opener-image-no-reserve).
  advancedDesign: { enabled: true, minHeight: mm(MEASURE / 4 + 6), slot: { elements: [
    { kind: 'image', id: 'blind', resourceId: 'blind',
      placement: at('container', 'top-left', 0, 0, { width: mm(MEASURE), height: 'auto' }) },
    { kind: 'image', id: 'moonset', resourceId: 'moon',
      placement: at('page', 'bottom-right', 30, 34, { width: mm(76), height: mm(76) }) },
  ] } } };

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette, diagramStyle, resourceTypes: [drawing],
  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 } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Epilogue', fontSize: pt(9), lineHeight: pt(LEAD), color: col('ink'),
    // Bold and italics default to main-color, the pink here: back to the blue drum. The
    // reference colour follows boldColor.
    boldColor: col('ink'), italicColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
  headings: { fontFamily: 'Anton', fontWeight: 400, color: col('ink'), levels: [
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, fontSize: pt(15), marginTop: pt(LEAD), marginBottom: pt(2) }] },
  headingStyles: [cover, drawn, back],
  chipStyles: [route], captionStyle, calloutStyles: [quote],
  unorderedLists: { color: col('spot'), marginTop: pt(2), marginBottom: pt(0) },
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Space Mono', fontSize: pt(7.5),
    lineHeight: pt(11), color: col('ink'), textAlign: 'left', marginTop: pt(LEAD) }],
  header: { elements: [] }, footer: { elements: feet },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// One unnumbered type for every picture: an empty prefix and template print no number.
const drawing = { id: 'drawing', name: 'Drawing', shortLabel: 'drawing', captionPrefix: '',
  numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' };
const svgFile = (id, width, height, extra) => ({ id, typeId: 'drawing', kind: 'svg',
  svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0, ...extra });
const resources = [
  // Cited on page 2, the map heads page 3: a top float waits for the next page's head
  // (gotcha: top-float-next-page).
  svgFile('network', 1000, 695, { placement: { position: 'top', width: 0.74, align: 'center' },
    caption: '**The network from the pink drum.** :chip[N50]{style="route"} loops round the '
      + 'rest. Terminals and changes only.',
    altText: 'Six night bus routes and a loop round the Corn Exchange, in tints of pink.' }),
  { id: 'as-drawn', typeId: 'drawing', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: 'as-drawn.png', format: 'png', width: 600, height: 417 },
    altText: 'The same map in its drawn colours: navy, red, teal, blue, orange, yellow.' },
  svgFile('moon', 960, 960,
    { altText: 'A full moon: a pale pink disc with its seas in a coarse dot screen.' }),
  svgFile('blind', 1040, 260, { altText: 'A destination blind lit up: NOT IN SERVICE.' }),
];

const markdown = String.raw`---
Markdown样例 · 38行 · content.en.mdtitle: "Night Buses" subtitle: "No. 4 · Winter" author: "Ros Adeyemi, Tom Hale, Mei Lindqvist" --- # Night Buses {style="cover" issue="No. 4 · Winter · Free" stops="Night N12 N27 N41" line="Six routes after midnight, three ridden end to end"} # After the Last Train {kicker="Brackwater, 00:10 to 06:00"} The last train leaves Station Square at 23:52. Until the first one at 05:40, about four thousand people a night ride the night buses: nurses, cleaners, bakers, kitchen staff, students, and anyone who missed the train. Five routes leave the Corn Exchange together at ten past and twenty to the hour, so a change takes three minutes, and :chip[N50]{style="route"} loops round them. The :ref{id="network" text="map opposite"} shows all six. We rode three from end to end. :chip[N12]{style="route"} **Ashgrove Hospital to Harbour Gate.** Every quarter of an hour, and 34 minutes from one end to the other. The first after midnight takes the evening shift home past the fish market. The 05:25 from the harbour brings the day shift in and empties at Ashgrove in under a minute. :::callout{type="quote" title="Dee Okafor, driver on the N27 since 2011"} At ten past three there are five buses and forty people outside the Corn Exchange. By quarter past the square is empty. ::: :chip[N27]{style="route"} **Northfield Depot to St Bride’s.** Buses run every half hour and take 41 minutes from end to end. Kiln Street’s bakers ride out on the 01:10 to light their ovens at two, and at 05:20 the driver of the last N27 collects the first loaves for the depot canteen, which has not bought bread since 2019. :chip[N41]{style="route"} **Corn Exchange to the Airport.** Buses run every half hour and take 38 minutes to the terminal, with racks for luggage. Check-in for the first flights opens at 04:45, and the 04:10 is the busiest bus of the night. ### As drawn {style="drawn" note="Drawn with a colour per route. The drum prints each colour as a screen of pink, denser the darker it was: navy N12 at 84%, yellow N50 at 24%."} # Not in Service {style="back"} ## Issue 5 is out in March It follows the cleaners who get the buses ready at Northfield Depot between 05:30 and 07:00, when the day fleet goes out. Pick up a copy: - at the Corn Exchange kiosk, open from 23:00 - in the luggage rack of any N41 - at the Northfield Depot canteen, on the counter :::paragraphs{style="colophon"} Night Buses is written by Ros Adeyemi and Tom Hale and drawn by Mei Lindqvist. Timetables from Brackwater Transport’s winter night network. Printed on a two-drum risograph in Blue and Fluorescent Pink, 300 copies on 100 g cream paper. Set in Epilogue, Anton and Space Mono (SIL OFL) · Text and drawings: CC BY 4.0 :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the map, the moon and the blind, drawn in full colour function mulberry32(seed) { return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const f2 = (n) => +n.toFixed(2); // A single-stroke capital alphabet on a 4 × 6 grid with 45° corners, like the map's lines. // SVG text in an image cannot reach web fonts (gotcha: svg-no-webfonts), so labels are paths. const GLYPHS = { A: ['0 6 0 1 1 0 3 0 4 1 4 6', '0 3.5 4 3.5'], B: ['0 0 3 0 4 1 4 2 3 3 0 3', '3 3 4 4 4 5 3 6 0 6 0 0'], C: ['4 1 3 0 1 0 0 1 0 5 1 6 3 6 4 5'], D: ['0 0 3 0 4 1 4 5 3 6 0 6 0 0'], E: ['4 0 0 0 0 6 4 6', '0 3 3 3'], G: ['4 1 3 0 1 0 0 1 0 5 1 6 3 6 4 5 4 3.5 2.5 3.5'], H: ['0 0 0 6', '4 0 4 6', '0 3 4 3'], I: ['0 0 0 6'], K: ['0 0 0 6', '4 0 0 4', '1.5 2.5 4 6'], L: ['0 0 0 6 4 6'], N: ['0 6 0 0 4 6 4 0'], O: ['1 0 3 0 4 1 4 5 3 6 1 6 0 5 0 1 1 0'], P: ['0 6 0 0 3 0 4 1 4 2 3 3 0 3'], Q: ['1 0 3 0 4 1 4 5 3 6 1 6 0 5 0 1 1 0', '2.5 4.5 4 6'], R: ['0 6 0 0 3 0 4 1 4 2 3 3 0 3', '2 3 4 6'], S: ['4 1 3 0 1 0 0 1 0 2 1 3 3 3 4 4 4 5 3 6 1 6 0 5'], T: ['0 0 4 0', '2 0 2 6'], U: ['0 0 0 5 1 6 3 6 4 5 4 0'], V: ['0 0 2 6 4 0'], X: ['0 0 4 6', '4 0 0 6'], Y: ['0 0 2 3 4 0', '2 3 2 6'], F: ['4 0 0 0 0 6', '0 3 3 3'], 0: ['1 0 3 0 4 1 4 5 3 6 1 6 0 5 0 1 1 0'], 1: ['0 1 1.5 0 1.5 6'], 2: ['0 1 1 0 3 0 4 1 4 2 0 6 4 6'], 3: ['0 0 4 0 2 2.5 3 2.5 4 3.5 4 5 3 6 1 6 0 5'], 4: ['3 6 3 0 0 4 4 4'], 5: ['4 0 0 0 0 2.5 3 2.5 4 3.5 4 5 3 6 0 6'], 7: ['0 0 4 0 1.5 6'], 8: ['1 0 3 0 4 1 4 2 3 3 1 3 0 2 0 1 1 0', '1 3 3 3 4 4 4 5 3 6 1 6 0 5 0 4 1 3'], "'": ['0.5 0 0 1.5'], }; const advance = (ch) => ({ ' ': 2.6, I: 1.6, 1: 3.2, "'": 1.6 })[ch] ?? 5.6; function letter(text, x, y, size, colour, anchor = 'start', weight = 0.17) { // size: cap height const k = size / 6; const width = [...text].reduce((w, ch) => w + advance(ch), 0) - 1.6; let cx = x - (anchor === 'middle' ? width / 2 : anchor === 'end' ? width : 0) * k; let d = ''; for (const ch of text) { for (const stroke of GLYPHS[ch] ?? []) { const n = stroke.split(' ').map(Number); for (let i = 0; i < n.length; i += 2) { d += `${i ? 'L' : 'M'}${f2(cx + n[i] * k)} ${f2(y - size + n[i + 1] * k)}`; } } cx += advance(ch) * k; } return `<path d="${d}" fill="none" stroke="${colour}" stroke-width="${f2(size * weight)}" ` + 'stroke-linecap="round" stroke-linejoin="round"/>'; } // The night network in mm, 100 × 69.5 (from y = 3), one colour per route as the designer drew it. const DARK = '#23272e'; const LINES = { N12: '#16296b', N27: '#c62a1f', N41: '#0b7d74', N8: '#3b8fd4', N3: '#ef8b1f', N50: '#f3c51a' }; function network() { const path = (nodes, colour, width, close = false) => `<path d="${nodes.map(([x, y], i) => `${i ? 'L' : 'M'}${x} ${y}`).join('')}${close ? 'Z' : ''}" fill="none" stroke="${colour}" ` + `stroke-width="${width}" stroke-linejoin="round" stroke-linecap="round"/>`; const river = path([[0, 48], [8, 48], [14, 54], [86, 54], [92, 48], [100, 48]], '#bfe0ee', 4.4); const loop = path([[32, 21], [68, 21], [74, 27], [74, 41], [68, 47], [32, 47], [26, 41], [26, 27]], LINES.N50, 1.8, true); const legs = [ // route, then its nodes from the hub outwards ['N12', [[50, 31], [50, 6]]], ['N12', [[60, 37], [85, 62], [86, 62]]], ['N27', [[40, 31], [21, 12], [14, 12]]], ['N27', [[64.5, 34], [86, 34]]], ['N3', [[35.5, 34], [14, 34]]], ['N3', [[50, 37], [50, 70]]], ['N41', [[60, 31], [79, 12], [86, 12]]], ['N8', [[40, 37], [15, 62], [14, 62]]], ].map(([id, nodes]) => path(nodes, LINES[id], 1.8)).join(''); const stops = [[50, 6], [86, 62], [14, 12], [86, 34], [14, 34], [50, 70], [86, 12], [14, 62], [50, 21], [50, 47], [26, 34], [74, 34], [69, 22], [31, 22], [69, 46], [31, 46]] .map(([x, y]) => `<circle cx="${x}" cy="${y}" r="0.95" fill="${DARK}"/>`).join(''); const hub = `<rect x="35.5" y="31" width="29" height="6" rx="3" fill="${DARK}"/>` + letter('CORN EXCHANGE', 50, 35, 2, '#ffffff', 'middle', 0.19); const badge = (id, x, y, side) => { // y: the line's axis; side: which way from the stop const w = id.length * 1.76 + 1.2; const x0 = side === 'left' ? x - 1.6 - w : side === 'right' ? x + 1.6 : x - w / 2; return `<rect x="${f2(x0)}" y="${y - 2.1}" width="${f2(w)}" height="4.2" rx="0.8" ` + `fill="${LINES[id]}"/>${letter(id, x0 + 0.62, y + 1.1, 2.2, id === 'N50' ? DARK : '#ffffff', 'start', 0.2)}`; }; const name = (text, x, y, anchor) => letter(text, x, y, 1.9, DARK, anchor); const labels = [ badge('N12', 50, 6, 'left'), name('ASHGROVE HOSPITAL', 53, 7), badge('N27', 14, 12, 'left'), name('NORTHFIELD DEPOT', 4.7, 8.2), badge('N41', 86, 12, 'right'), name('AIRPORT', 95.3, 8.2, 'end'), badge('N3', 14, 34, 'left'), name('CANAL BASIN', 4.7, 30.2), badge('N27', 86, 34, 'right'), name("ST BRIDE'S", 95.3, 30.2, 'end'), badge('N8', 14, 62, 'left'), name('UNIVERSITY', 4.7, 67.8), badge('N12', 86, 62, 'right'), name('HARBOUR GATE', 95.3, 67.8, 'end'), badge('N3', 50, 70, 'left'), name('STATION SQ', 53, 71), badge('N50', 40.7, 47, 'middle'), letter('RIVER BRACK', 32, 59.9, 1.7, '#1f5a85', 'middle', 0.19), ].join(''); return '<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="695" ' + `viewBox="0 3 100 69.5">${river}${loop}${legs}${stops}${hub}${labels}</svg>`; } // The cover's moon, 96 × 96 mm: a light grey disc, then a 45° dot screen whose dots grow and // darken with the tone over the near side's larger seas. function moon() { const rand = mulberry32(7); const cells = Array.from({ length: 9 * 9 }, rand); // value noise, 8 cells across const noise = (u, v) => { const [gx, gy] = [u * 8, v * 8]; const [i, j] = [Math.floor(gx), Math.floor(gy)]; const [s, t] = [gx - i, gy - j].map((a) => a * a * (3 - 2 * a)); const g = (a, b) => cells[Math.min(8, b) * 9 + Math.min(8, a)]; return (g(i, j) * (1 - s) + g(i + 1, j) * s) * (1 - t) + (g(i, j + 1) * (1 - s) + g(i + 1, j + 1) * s) * t; }; const R = 46; const seas = [ // north up: centre, half-axes and tilt, in moon radii [-0.58, -0.02, 0.2, 0.42, 0.3], [-0.3, -0.42, 0.27, 0.22, 0], // Procellarum, Imbrium [0.16, -0.4, 0.15, 0.14, 0], [0.36, -0.08, 0.19, 0.15, 0.4], // Serenitatis, Tranquillitatis [0.72, -0.28, 0.09, 0.11, 0], [0.6, 0.14, 0.09, 0.14, -0.3], // Crisium, Fecunditatis [0.38, 0.28, 0.08, 0.08, 0], [-0.2, 0.34, 0.15, 0.12, 0], // Nectaris, Nubium [-0.52, 0.38, 0.08, 0.08, 0], [-0.05, -0.76, 0.36, 0.06, 0.1], // Humorum, Frigoris [-0.02, -0.16, 0.08, 0.07, 0]]; // Vaporum const P = 2; // screen pitch in mm let dots = ''; for (let i = -34; i <= 34; i++) { for (let j = -34; j <= 34; j++) { const [x, y] = [(i - j) * P / Math.SQRT2, (i + j) * P / Math.SQRT2]; if (Math.hypot(x, y) > R - 0.6) continue; // the disc's edge stays a clean circle const [u, v] = [x / R, y / R]; let sea = 0; for (const [mx, my, rx, ry, a] of seas) { const [du, dv] = [u - mx, v - my]; const [p, q] = [du * Math.cos(a) + dv * Math.sin(a), dv * Math.cos(a) - du * Math.sin(a)]; sea = Math.max(sea, Math.exp(-(((p / rx) ** 2 + (q / ry) ** 2) ** 1.5))); } const m = Math.min(1, Math.max(0, (sea * (0.85 + 0.3 * noise((u + 1) / 2, (v + 1) / 2)) - 0.3) / 0.35)); // 0 on the highlands, 1 inside a sea, with a ragged shore let tone = 0.2 + 0.08 * noise((v + 1) / 2, (u + 1) / 2) + 0.55 * m * m * (3 - 2 * m); for (const [cx, cy, cr] of [[-0.12, 0.72, 0.05], [-0.32, -0.15, 0.035]]) { // bright craters if (Math.hypot(u - cx, v - cy) < cr) tone = 0.1; } const grey = Math.round(210 - 190 * tone).toString(16).padStart(2, '0'); dots += `<circle cx="${f2(x + 48)}" cy="${f2(y + 48)}" r="${f2(P * 0.6 * Math.sqrt(tone))}" ` + `fill="#${grey}${grey}${grey}"/>`; } } return '<svg xmlns="http://www.w3.org/2000/svg" width="960" height="960" viewBox="0 0 96 96">' + `<circle cx="48" cy="48" r="${R}" fill="#c8c8c8"/>${dots}</svg>`; } // The back cover's destination blind, 104 × 26 mm: amber lights on black, 5 × 7 letters. const LED = { N: ['10001', '11001', '10101', '10011', '10001', '10001', '10001'], O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'], T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'], I: ['111', '010', '010', '010', '010', '010', '111'], S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'], V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'], C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'], ' ': ['00', '00', '00', '00', '00', '00', '00'], }; function blind(text = 'NOT IN SERVICE') { const cols = [...text].flatMap((ch) => [...LED[ch][0]].map((_, c) => LED[ch].map((row) => row[c] === '1')).concat([Array(7).fill(false)])).slice(0, -1); const [W, H, P, ROWS] = [104, 26, 1.25, 13]; // the matrix has 13 rows; letters on rows 3–9 const n = Math.floor((W - 6) / P); const [x0, y0, first] = [(W - (n - 1) * P) / 2, (H - (ROWS - 1) * P) / 2, Math.floor((n - cols.length) / 2)]; let dots = ''; for (let c = 0; c < n; c++) { for (let r = 0; r < ROWS; r++) { const on = cols[c - first]?.[r - 3] ?? false; dots += `<circle cx="${f2(x0 + c * P)}" cy="${f2(y0 + r * P)}" r="${on ? 0.55 : 0.36}" ` + `fill="${on ? '#ffd35a' : '#3a3a3a'}"/>`; } } return `<svg xmlns="http://www.w3.org/2000/svg" width="1040" height="260" viewBox="0 0 ${W} ${ H}"><rect width="${W}" height="${H}" rx="2.5" fill="#161616"/>${dots}</svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Text, display and label faces, loaded before the build (gotcha: fonts-first). const FONTS = { Epilogue: ['400', '700'], Anton: ['400'], 'Space Mono': ['400', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const map = network(); await Promise.all([registerArt('network.svg', map), registerArt('moon.svg', moon()), registerArt('blind.svg', blind()), registerSnapshot('as-drawn.png', map, 600, 417)]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: 'Night Buses: a two-ink riso zine' }); offerPdf(() => renderToPdf(doc, pdfOptions), `${RECIPE}.pdf`); // A grey proof: the pages as a photocopier or a one-drum reprint would print them. offerPdf(() => renderToPdf(doc, { ...pdfOptions, colorSpace: 'grayscale' }), `${RECIPE}-grey-proof.pdf`);
工具包 · core, fonts, viewer, pdf, images:每道食谱都相同 · 310行// ─── 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 · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── 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上的食谱文件夹 ↗

变化

#换上橙色滚筒

月亮、线路图、线路牌、行内标签、色条和两个标题的错位副本都变成橙色,文字仍是蓝色;题注、短文和版权页仍写着粉色滚筒,直到你改写它们。

-const DRUMS = { ink: '#1d4fb8', spot: '#f0509a' };
+const DRUMS = { ink: '#1d4fb8', spot: '#ff6c2f' };

#印一本双色教材

白纸上用黑色文字加蓝色专色,同样的插图印成蓝色的深浅网调,就像双色课本里的示意图;题注、短文和版权页仍写着粉色滚筒,直到你改写它们。

-const DRUMS = { ink: '#1d4fb8', spot: '#f0509a' };
-const PAPER = '#f3efe6'; // 奶油色纸:不着墨的地方
+const DRUMS = { ink: '#1f1f1f', spot: '#0078bf' };
+const PAPER = '#ffffff'; // 白纸:不着墨的地方

常见问题

易错点

renderPage不给SVG重新着色,renderToPdf会

diagramStyle.singleInk由renderToPdf应用,而Canvas按原样绘制注册的SVG。注册之前先用applySingleInkToSvg给标记重新着色,屏幕和PDF才一致。 单色线图 →

易错点

SVG <img>中的文字不能使用网络字体

SVG作为图像绘制,而图像无法使用页面的网络字体,所以其中的标签会退回系统字体。把文字转成轮廓,在SVG中嵌入@font-face子集,或者把标签移到题注里。 作为资源的图和表 →

易错点

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

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

易错点

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

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

易错点

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

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

易错点

属性中的\n只在paragraphIndent > 0时换行

在设计文本元素中,属性值里写的\n只有在paragraphIndent大于零或设置了首字下沉时才另起一行,否则文字留在同一行。把paragraphIndent设为一个极小值(0.01 pt),或每行用一个属性。 页面设计中的文字、线条和框 →

易错点

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

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

易错点

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

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

易错点

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

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

易错点

排版前加载所有字体

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

  • 交给renderToPdf的要是原图。1.4.1中它会对收到的任何SVG重新着色,所以已经着过色的副本会再过一遍公式,N12线路从84%降到44%。
  • 1.4.1中applySingleInkToSvg改写十六进制颜色、通道为整数的rgb()和rgba(),以及关键字white和black。用red命名颜色、使用hsl()或让填充保持默认黑色的形状会保留原色,那就需要第三个滚筒。每个填充和描边都要给十六进制值。
  • colorSpace: 'grayscale'把postext绘制的内容变成灰色,但位图按原样嵌入,所以PNG小插图在灰度打样里仍是彩色。只有CodePen示例的第二个按钮生成打样;上面的页面是Canvas,本页提供的PDF是彩色版。
  • 开启段落间距时,1.4.1可能在一个跨到下一页、位于顶部浮动体下方的段落续文前多加一行空白。这里的文字不会触发它。改动文字后,检查线路图下方的第一行,因为多出一行就可能把小插图推到第五页。
  • CodePen示例里两个按钮都写着“Build the PDF”。第一个生成彩色PDF,第二个生成灰度打样;文件生成后,按钮换成一个“Open the PDF ↗”链接和一个带文件名的下载链接。

致谢

文本
原创文字, CC BY 4.0
字体
Epilogue (SIL OFL 1.1) · Anton (SIL OFL 1.1) · Space Mono (SIL OFL 1.1)
沙盒PDF