跳到主要内容
食谱编号54

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

用一份调色板给整份文档换色

配置中的每种颜色都带有调色板id,于是一个函数就能返回一份新配置,把音乐节节目单排成红、青绿、紫或沙色。

  • 英文样例:尚无中文版本
  • 成品尺寸200 × 200 mm
  • 2栏, 栏间距6 mm
  • Plus Jakarta Sans 9.4/13.4
  • Syne
  • 2页
  • 难度
  • Postext 1.4.1
  • 排版用时3 ms
  • 180行代码

成品一览

Riverside Music Week的节目单,这是一个河畔小镇的虚构音乐节,印成200 mm见方的单页。正面上半部是一片色块,标题用Syne 800排成46 pt,还有一段由浅色圆角竖条组成的波形;下方两栏齐左文字按天列出一周活动,每个活动带有粗体时间和价格标签。背面在一条题注条下用彩色方格网格展示这一周,然后列出周二到周五,最后是横条下的购票框和版权页信息。pen在页面上方显示四份校样:红、青绿、紫和沙色。它们的颜色来自七个按用途命名的调色板条目,外加设为色带颜色的main-color。点击一份校样,整份文档就会用那份调色板重新构建。

这道食谱解答

  • 怎样让标题、粗体字和项目符号不再显示成蓝色?
  • 怎样给正文或框里的关键词(粗体或斜体)着色?
  • 怎样在正文、题注或表注里加入色块图例?

简短回答

script.js · 第27–39行在完整代码中
function retint(way) {
  const palette = { ...NEUTRALS, ...COLOURWAYS[way] };
  // 1.4.1 applies colorPalette to text, lists, boxes, chips, captions and tables, and resolves
  // swatches and cell fills against it; design elements and referenceColor print the hex
  // written beside their id, so relink() rewrites that hex (gotcha: palette-skips-designs).
  const relink = (v) => (Array.isArray(v) ? v.map(relink) : !v || typeof v !== 'object' ? v
    : Object.hasOwn(palette, v.paletteId ?? '') ? { ...v, hex: palette[v.paletteId] }
      : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, relink(x)])));
  return { // a new object on every call: resolved configs are cached per object
    ...relink(config()), // (gotcha: config-cache-identity)
    colorPalette: entries({ ...palette, 'main-color': palette.band }), // the defaults take the band
  };
}

用料

类型
Syne, Plus Jakarta Sans(SIL OFL 1.1)
素材
无:所有图片都用代码绘制

做法

#1 · 按用途给颜色命名

script.js · 第13–23行在完整代码中
const NEUTRALS = { ink: '#1d1d1f', muted: '#5f5f66', paper: '#ffffff' };
const COLOURWAYS = { // band: colour fields · onBand: type on them · deep: accent type on paper
  red: { band: '#d7263d', onBand: '#ffffff', deep: '#b3122a', tint: '#fcdfe3' },
  teal: { band: '#2a9d8f', onBand: '#1d1d1f', deep: '#17695f', tint: '#d8eeeb' },
  violet: { band: '#6a4c93', onBand: '#ffffff', deep: '#5b3f86', tint: '#e7dff0' },
  sand: { band: '#f4a261', onBand: '#1d1d1f', deep: '#a14a16', tint: '#fde4cf' },
}; // white on teal is 3.3:1 and on sand 2.1:1, so those two set their band type in ink
const HOUSE = { ...NEUTRALS, ...COLOURWAYS.red }; // the hex config() writes beside each id
const col = (id) => ({ hex: HOUSE[id], model: 'hex', paletteId: id });
const entries = (hexes) => Object.entries(hexes).map(([id, hex]) => ({ id, name: id,
  value: { hex, model: 'hex' } })); // the shape of config.colorPalette

band填充色块,onBand给排在色块上的文字着色,deep是白纸上文字的强调色,tint是浅色填充。每套配色设定这四种,并共用三种中性色。在白底上,青绿色带的对比度是3.3:1,沙色色带是2.1:1,都太低,既不适合在色带上排白字,也不适合在纸上排色带颜色的文字。这两份调色板把onBand设为墨色;四份调色板都把粗体和标题设为deep,这是一种更深的色调,在白底上的对比度在5.99:1(沙色)到8.40:1(紫色)之间。

#2 · 链接引擎会印成蓝色的一切

script.js · 第70–96行在完整代码中
const bodyText = { fontFamily: SANS, fontSize: pt(BODY), lineHeight: pt(LEAD),
  color: col('ink'), italicColor: col('ink'), // bold: the times, key terms, boxes (inherited)
  boldColor: col('deep'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };
const headings = { fontFamily: DISPLAY, color: col('deep'), levels: [
  // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
  { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
    marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
  { level: 2, fontSize: pt(12.5), lineHeight: lines(1), marginTop: lines(1), marginBottom: pt(0) },
] };
const unorderedLists = { color: col('band'), indent: mm(3.6), gap: mm(1.4),
  marginTop: pt(0), marginBottom: pt(0) };
const chip = { fontSize: pt(7.5), bold: true, borderWidth: pt(0), borderRadius: mm(1.6),
  paddingX: mm(1.3), paddingY: pt(0.9), gap: mm(1) };
const chipStyles = [ // band = a ticket, tint = free: the same code as the grid overleaf
  { id: 'ticket', background: col('band'), color: col('onBand'), ...chip },
  { id: 'free', background: col('tint'), color: col('deep'), ...chip,
    borderWidth: pt(0.6), borderColor: col('band') }, // the outline keeps it off the white
];
const corner = (edge, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(OUTER), y: mm(y) } });
const furniture = (id, content, edge, y, style) => ({ elements: [{ kind: 'text', id, content,
  parity: 'even', pages: 'body', overflow: 'wrap', placement: corner(edge, y), ...style }] });
const header = furniture('head', '{title} · {subtitle}', 'top-left', 12, // page 2's head
  { ...caps(7.5), color: col('deep') });
const footer = furniture('folio', '{pageNumber}', 'bottom-left', -12, { fontFamily: SANS,
  fontSize: pt(8), fontWeight: 700, color: col('onBand'), box: { backgroundColor: col('band'),
    borderRadius: mm(2.4), padding: { top: mm(0.9), right: mm(2.4), bottom: mm(0.9),
      left: mm(2.4) } } });

引擎的蓝色是一个调色板条目main-color(#295AA3),它的默认值让标题、粗体、斜体、引用、项目符号和书眉都指向它。这里每一项都带有调色板id:时间和关键词用粗体deep,斜体用墨色,项目符号用band;第2页的书眉用deep,页码是band圆角底上的onBand。retint()还把main-color指向色带颜色;由于这份配置设定了每一种颜色,这在这些页面上不改变任何东西,但以后新增的样式如果不设颜色,就会用色带颜色而不是蓝色。购票框没有自己的粗体颜色,所以它继承带调色板id的bodyText.boldColor,其粗体随配色变化。

#3 · 用设计框绘制图形

script.js · 第50–66行在完整代码中
const text = (id, content, x, y, width, style) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', color: col('onBand'), placement: { anchor: { to: 'page', edge: 'top-left' },
    offset: { x: mm(x), y: mm(y) }, size: { width: mm(width) } }, ...style });
const opener = { enabled: true,
  minHeight: lines(18), // 85.1 mm: text 7 mm under the band (2.4 mm with the band box alone)
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: 'fill', height: mm(BAND) } } },
    ...waveform(), // boxes filled with col('tint'), which relink() rewrites like the rest
    text('kicker', '{attr.kicker}', INNER, 14, 150, caps(8)),
    text('title', '{titleText}', INNER, 20, TRIM - INNER - OUTER, { fontFamily: DISPLAY,
      fontSize: pt(46), fontWeight: 800, lineHeight: 0.92 }), // a multiple
    text('standfirst', '{attr.standfirst}', INNER, 71, 92, { fontFamily: SANS, fontSize: pt(10),
      lineHeight: 1.36 }), // (gotcha: design-lineheight-multiple)
  ] },
};

开篇区是一个设计槽位,里面有一个出血到上边缘的框、构成波形的29个圆角竖条和三个文本元素:标题自身的文字作为标题,眉题和导语取自它的属性。竖条是框而不是SVG,所以它们的填充只是又一个tint链接,由relink()改写;换成SVG的话,每套配色都得重画并重新注册。只靠色带框,正文会从色带下方2.4 mm处的网格线开始;minHeight从上页边距往下预留18行即85.1 mm,把第一行移到色带下方7 mm处。

#4 · 让调色板填充表格

script.js · 第177–200行在完整代码中
const DAYS = t({ en: ['Sat 12', 'Sun 13', 'Mon 14', 'Tue 15', 'Wed 16', 'Thu 17', 'Fri 18'],
  es: ['Sáb 12', 'Dom 13', 'Lun 14', 'Mar 15', 'Mié 16', 'Jue 17', 'Vie 18'] });
const WEEK = [ // t: a ticketed concert, f: a free event, one mark a day from Saturday
  [t({ en: 'Quay Stage', es: 'Escenario del Muelle' }), 't.f.fft'],
  [t({ en: 'Iron Bridge steps', es: 'Escalinata del Puente' }), 'f.....f'],
  [t({ en: 'Market Hall', es: 'Mercado de Abastos' }), '.f...f.'],
  [t({ en: 'St Clare’s Cloister', es: 'Claustro de Santa Clara' }), '.t.....'],
  [t({ en: 'Tannery Yard', es: 'Patio de la Curtiduría' }), '...t...'],
  [t({ en: 'Boathouse', es: 'Casa de las Barcas' }), '.ft..t.'],
];
const fill = { t: col('band'), f: col('tint') }; // resources are not in the config: no relink
const resources = [{ id: 'week', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
  placement: { position: 'top', span: 'page' }, // cited on page 1, it heads page 2
  caption: t({ en: 'The week at a glance', es: 'La semana de un vistazo' }),
  note: t({ en: ':swatch{color="band"} ticketed concert   :swatch{color="tint"} free event',
    es: ':swatch{color="band"} concierto con entrada   :swatch{color="tint"} acto gratuito' }),
  altText: t({ en: 'A grid of six venues by seven days; filled squares mark the events.',
    es: 'Una cuadrícula de seis escenarios por siete días; los cuadros rellenos son los actos.' }),
  table: { model: { headerRowCount: 1, columnWidths: [2.6, 1, 1, 1, 1, 1, 1, 1], rows: [
    [{ content: '', isHeader: true, background: col('paper') }, ...DAYS.map((day) => ({
      content: day, isHeader: true, align: 'center' }))],
    ...WEEK.map(([venue, marks]) => [{ content: venue, align: 'right' }, ...[...marks].map((m) =>
      (m === '.' ? { content: '' } : { content: '', background: fill[m] }))]),
  ] } } }];

网格是一个表格资源,位于配置之外,所以relink()从不碰它。不过它的单元格填充同样带有调色板id,引擎在排表格时从文档调色板解析它们;表注图例里的:swatch{color="band"}和:swatch{color="tint"}也以同样方式解析。活动列表中的行内标签使用同样两个id,所以在每套配色里,填充方块、时间旁的标签和图例色块都保持一致。

#5 · 每次点击都构建一份新配置

script.js · 第237–266行在完整代码中
const NAMES = t({ en: { red: 'Red', teal: 'Teal', violet: 'Violet', sand: 'Sand' },
  es: { red: 'Rojo', teal: 'Verde azulado', violet: 'Violeta', sand: 'Arena' } });
document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="editions">
  <div class="desk"><header><p class="kicker">${t({ en: 'Riverside Music Week · proofs',
    es: 'Música en la Ribera · pruebas' })}</p><h2>${TITLE}</h2></header><canvas id="live"
  role="img"></canvas><div class="buttons" role="group"></div></div></section>`);
const paint = (canvas, doc) => renderPageToCanvas(doc.pages[0], doc, canvas,
  { scale: (canvas.clientWidth * Math.min(devicePixelRatio, 2)) / doc.pages[0].width });
const buttons = Object.keys(COLOURWAYS).map((way) => {
  const button = document.querySelector('#editions .buttons')
    .appendChild(Object.assign(document.createElement('button'), { type: 'button' }));
  button.innerHTML = `<canvas></canvas><span>${NAMES[way]}<i>${['band', 'deep', 'tint']
    .map((id) => `<b style="background:${COLOURWAYS[way][id]}"></b>`).join('')}</i></span>`;
  paint(button.firstChild, docs[way]);
  // A fresh config on every click (retint() calls config()); the fonts are loaded by now.
  button.onclick = () => show(way, buildDocument({ markdown, resources }, retint(way)));
  return [way, button];
});
const live = document.getElementById('live');
let shown; // the document on the live page
function show(way, doc) {
  paint(live, (shown = doc));
  live.ariaLabel = `${NAMES[way]}, ${t({ en: 'page 1', es: 'página 1' })}`;
  for (const [id, button] of buttons) button.ariaPressed = String(id === way);
  showPages(doc, { title: `${TITLE} · ${NAMES[way]}` });
}
show('red', docs.red);
new ResizeObserver(() => { // canvases are bitmaps: repaint them at the desk's new size
  paint(live, shown); buttons.forEach(([way, button]) => paint(button.firstChild, docs[way]));
}).observe(live);

每个按钮都调用retint(),它再调用config(),所以每次构建都得到一个新对象,背后没有缓存的解析结果。引擎只解析一次配置,并以该对象为键缓存结果。如果保留同一份配置并就地修改它的调色板条目,下一次构建会复用旧的解析结果:在1.4.1上的一次测试中,标题、项目符号、行内标签和题注条保持了最初的颜色,而在排版时读取调色板的色块和单元格填充则换成了新颜色。给同一个对象赋一个新的colorPalette数组也不起作用。对修改后的配置做structuredClone()确实会得到新的解析,但它的设计槽位和引用颜色仍带着旧的十六进制值,所以色带、波形、版面装饰和“Table 1”仍是红色;retint()会改写这些十六进制值并返回一个新对象。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 054 · Retint a whole document from one palette ═══════════
// https://postext.dev/en/cookbook/live-palette-retint
// Code: MIT · Text: original (CC BY 4.0) · Artwork: design boxes generated in code (CC BY 4.0)
// Fonts: Syne, Plus Jakarta Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes }
  from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: three neutrals every edition shares, four entries each colourway sets
const NEUTRALS = { ink: '#1d1d1f', muted: '#5f5f66', paper: '#ffffff' };
const COLOURWAYS = { // band: colour fields · onBand: type on them · deep: accent type on paper
  red: { band: '#d7263d', onBand: '#ffffff', deep: '#b3122a', tint: '#fcdfe3' },
  teal: { band: '#2a9d8f', onBand: '#1d1d1f', deep: '#17695f', tint: '#d8eeeb' },
  violet: { band: '#6a4c93', onBand: '#ffffff', deep: '#5b3f86', tint: '#e7dff0' },
  sand: { band: '#f4a261', onBand: '#1d1d1f', deep: '#a14a16', tint: '#fde4cf' },
}; // white on teal is 3.3:1 and on sand 2.1:1, so those two set their band type in ink
const HOUSE = { ...NEUTRALS, ...COLOURWAYS.red }; // the hex config() writes beside each id
const col = (id) => ({ hex: HOUSE[id], model: 'hex', paletteId: id });
const entries = (hexes) => Object.entries(hexes).map(([id, hex]) => ({ id, name: id,
  value: { hex, model: 'hex' } })); // the shape of config.colorPalette
// #endregion

// #region answer: a colourway is a palette; retint() builds a fresh config linked to it
function retint(way) {
  const palette = { ...NEUTRALS, ...COLOURWAYS[way] };
  // 1.4.1 applies colorPalette to text, lists, boxes, chips, captions and tables, and resolves
  // swatches and cell fills against it; design elements and referenceColor print the hex
  // written beside their id, so relink() rewrites that hex (gotcha: palette-skips-designs).
  const relink = (v) => (Array.isArray(v) ? v.map(relink) : !v || typeof v !== 'object' ? v
    : Object.hasOwn(palette, v.paletteId ?? '') ? { ...v, hex: palette[v.paletteId] }
      : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, relink(x)])));
  return { // a new object on every call: resolved configs are cached per object
    ...relink(config()), // (gotcha: config-cache-identity)
    colorPalette: entries({ ...palette, 'main-color': palette.band }), // the defaults take the band
  };
}
// #endregion

const [DISPLAY, SANS, BODY, LEAD] = ['Syne', 'Plus Jakarta Sans', 9.4, 13.4]; // pt: 33 lines
const [TRIM, TOP, BOTTOM, INNER, OUTER, GUTTER] = [200, 22, 22, 17, 15, 6]; // mm: square, mirrored
const BAND = 100; // mm, trim top to the colour field's foot ('bleed' is the trim: no cut lines)
const lines = (n) => pt(n * LEAD);
const caps = (size) => ({ fontFamily: SANS, fontSize: pt(size), fontWeight: 700,
  letterSpacing: pt(size * 0.16), textTransform: 'uppercase' });

// #region opener: the colour field, a waveform of palette-linked boxes, the title on top
const text = (id, content, x, y, width, style) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', color: col('onBand'), placement: { anchor: { to: 'page', edge: 'top-left' },
    offset: { x: mm(x), y: mm(y) }, size: { width: mm(width) } }, ...style });
const opener = { enabled: true,
  minHeight: lines(18), // 85.1 mm: text 7 mm under the band (2.4 mm with the band box alone)
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: 'fill', height: mm(BAND) } } },
    ...waveform(), // boxes filled with col('tint'), which relink() rewrites like the rest
    text('kicker', '{attr.kicker}', INNER, 14, 150, caps(8)),
    text('title', '{titleText}', INNER, 20, TRIM - INNER - OUTER, { fontFamily: DISPLAY,
      fontSize: pt(46), fontWeight: 800, lineHeight: 0.92 }), // a multiple
    text('standfirst', '{attr.standfirst}', INNER, 71, 92, { fontFamily: SANS, fontSize: pt(10),
      lineHeight: 1.36 }), // (gotcha: design-lineheight-multiple)
  ] },
};
// #endregion

// #region links: every colour the engine would print in its blue, linked to a palette id
const bodyText = { fontFamily: SANS, fontSize: pt(BODY), lineHeight: pt(LEAD),
  color: col('ink'), italicColor: col('ink'), // bold: the times, key terms, boxes (inherited)
  boldColor: col('deep'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };
const headings = { fontFamily: DISPLAY, color: col('deep'), levels: [
  // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
  { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
    marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
  { level: 2, fontSize: pt(12.5), lineHeight: lines(1), marginTop: lines(1), marginBottom: pt(0) },
] };
const unorderedLists = { color: col('band'), indent: mm(3.6), gap: mm(1.4),
  marginTop: pt(0), marginBottom: pt(0) };
const chip = { fontSize: pt(7.5), bold: true, borderWidth: pt(0), borderRadius: mm(1.6),
  paddingX: mm(1.3), paddingY: pt(0.9), gap: mm(1) };
const chipStyles = [ // band = a ticket, tint = free: the same code as the grid overleaf
  { id: 'ticket', background: col('band'), color: col('onBand'), ...chip },
  { id: 'free', background: col('tint'), color: col('deep'), ...chip,
    borderWidth: pt(0.6), borderColor: col('band') }, // the outline keeps it off the white
];
const corner = (edge, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(OUTER), y: mm(y) } });
const furniture = (id, content, edge, y, style) => ({ elements: [{ kind: 'text', id, content,
  parity: 'even', pages: 'body', overflow: 'wrap', placement: corner(edge, y), ...style }] });
const header = furniture('head', '{title} · {subtitle}', 'top-left', 12, // page 2's head
  { ...caps(7.5), color: col('deep') });
const footer = furniture('folio', '{pageNumber}', 'bottom-left', -12, { fontFamily: SANS,
  fontSize: pt(8), fontWeight: 700, color: col('onBand'), box: { backgroundColor: col('band'),
    borderRadius: mm(2.4), padding: { top: mm(0.9), right: mm(2.4), bottom: mm(0.9),
      left: mm(2.4) } } });
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  // "Tabla" in Spanish (gotcha: resource-types-locale); one table: "Table 1", not "1.1"
  resourceTypes: defaultResourceTypes(LANG).map((r) => ({ ...r, numberingTemplate: '{n}' })),
  colorPalette: entries(HOUSE), // the red edition; retint() replaces it
  page: { width: mm(TRIM), height: mm(TRIM), dpi: 150, margins: { top: mm(TOP),
    bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  bodyText, headings, unorderedLists, chipStyles,
  calloutStyles: [{ id: 'tickets', backgroundEnabled: false, // no fill, a stripe on top
    stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('band') },
    padding: { top: mm(2.6), right: pt(0), bottom: pt(0), left: pt(0) },
    titleStyle: { fontFamily: DISPLAY, fontSize: pt(11), color: col('deep'), gap: mm(1.2) } }],
  tableStyle: { borderColor: col('paper'), borderWidth: pt(1.6), // white rules cut the tiles
    headerBackground: col('ink'), headerColor: col('paper'), headerFontSize: pt(7.5),
    bodyFontSize: pt(8.2), bodyColor: col('ink'), cellPadding: mm(1) },
  captionStyle: { fontSize: pt(8), color: col('onBand'), labelColor: col('onBand'),
    position: 'above', backgroundEnabled: true, background: col('band'), padding: mm(1.2),
    gap: mm(1.2), note: { fontSize: pt(7.5), color: col('muted') } },
  paragraphStyles: [{ id: 'colophon', fontSize: pt(7), lineHeight: pt(9.6), color: col('muted') }],
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown样例 · 51行 · content.en.mdtitle: "Riverside Music Week" subtitle: "Programme" author: "Almarén Music Society" --- # Riverside \\ Music Week {kicker="Almarén · 12–18 June 2027" standfirst="Fourteen events on both banks of the Almar, from the Quay Stage to the Boathouse. Eight of them are free; the rest cost €9 to €14."} The ninth Riverside Music Week keeps close to the water. Every venue is within ten minutes’ walk of the **Quay Stage**, and the **Iron Bridge** joins the two banks. :ref{id="week" style="full"} overleaf maps the week by venue and day. Free events need no ticket, but the Market Hall seats only 300, so come early. Concerts on the Quay go ahead in light rain. ## Saturday 12 June - **18.00** :chip[Free]{style="free"} The Almar Brass Band leads the opening parade from the Iron Bridge steps to the Quay Stage. - **21.30** :chip[€12]{style="ticket"} The Almar Chamber Orchestra plays Handel’s *Water Music* from two barges moored off the Quay. ## Sunday 13 June - **11.00** :chip[Free]{style="free"} Children’s percussion in the Market Hall. Bring a saucepan and a wooden spoon from home. - **17.00** :chip[Free]{style="free"} The Rowing Club choir sings shanties on the Boathouse slipway. - **20.00** :chip[€14]{style="ticket"} In St Clare’s Cloister, Cuarteto Ribera plays Haydn’s *Lark* quartet and Ravel’s Quartet in F. ## Monday 14 June - **19.00** :chip[Free]{style="free"} Fado on the Quay: Marta Loureiro sings with Portuguese and classical guitar. - **22.00** :chip[€9]{style="ticket"} The Almar Jazz Trio plays standards and its own tunes at the Boathouse. ## Tuesday 15 June - **21.30** :chip[€10]{style="ticket"} Murnau’s silent film *Sunrise* (1927) on a screen in the Tannery Yard, with a new score played live by the Tannery Ensemble. The yard opens at 21.00. ## Wednesday 16 June - **18.30** :chip[Free]{style="free"} Folk dance on the Quay. A caller walks everyone through each dance before the band starts it. ## Thursday 17 June - **18.00** :chip[Free]{style="free"} Open rehearsal: the Almar Chamber Orchestra works through *Vltava* on the Quay Stage. Sit on the steps and listen. - **20.00** :chip[Free]{style="free"} Six Almarén composers, one river song each, sung by the Community Choir in the Market Hall. - **22.30** :chip[€9]{style="ticket"} Cumbia night at the Boathouse with Los Remeros. ## Friday 18 June - **21.00** :chip[€12]{style="ticket"} Closing concert on the Quay Stage: the Almar Chamber Orchestra plays Smetana’s *Vltava*, which follows a river from its two springs to Prague. - **23.45** :chip[Free]{style="free"} The Community Choir sings at midnight on the Iron Bridge steps. Candles are handed out from 23.15. :::callout{type="tickets" title="Tickets"} A €48 **week pass** covers all six ticketed concerts. Single tickets are sold at the Market Hall box office from Monday 7 June, 10.00 to 14.00, and at the door 45 minutes before the start. **Under-16s** go free with a paying adult. ::: :::paragraphs{style="colophon"} Riverside Music Week is run by the Almarén Music Society. Programme correct on 3 May 2027; changes are posted at the box office. Set in Syne and Plus Jakarta Sans (SIL OFL) · Text CC BY 4.0. :::
`; // content.<lang>.md, inlined by the Cookbook // #region grid: the week at a glance, filled from palette ids the engine resolves itself const DAYS = t({ en: ['Sat 12', 'Sun 13', 'Mon 14', 'Tue 15', 'Wed 16', 'Thu 17', 'Fri 18'], es: ['Sáb 12', 'Dom 13', 'Lun 14', 'Mar 15', 'Mié 16', 'Jue 17', 'Vie 18'] }); const WEEK = [ // t: a ticketed concert, f: a free event, one mark a day from Saturday [t({ en: 'Quay Stage', es: 'Escenario del Muelle' }), 't.f.fft'], [t({ en: 'Iron Bridge steps', es: 'Escalinata del Puente' }), 'f.....f'], [t({ en: 'Market Hall', es: 'Mercado de Abastos' }), '.f...f.'], [t({ en: 'St Clare’s Cloister', es: 'Claustro de Santa Clara' }), '.t.....'], [t({ en: 'Tannery Yard', es: 'Patio de la Curtiduría' }), '...t...'], [t({ en: 'Boathouse', es: 'Casa de las Barcas' }), '.ft..t.'], ]; const fill = { t: col('band'), f: col('tint') }; // resources are not in the config: no relink const resources = [{ id: 'week', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'top', span: 'page' }, // cited on page 1, it heads page 2 caption: t({ en: 'The week at a glance', es: 'La semana de un vistazo' }), note: t({ en: ':swatch{color="band"} ticketed concert :swatch{color="tint"} free event', es: ':swatch{color="band"} concierto con entrada :swatch{color="tint"} acto gratuito' }), altText: t({ en: 'A grid of six venues by seven days; filled squares mark the events.', es: 'Una cuadrícula de seis escenarios por siete días; los cuadros rellenos son los actos.' }), table: { model: { headerRowCount: 1, columnWidths: [2.6, 1, 1, 1, 1, 1, 1, 1], rows: [ [{ content: '', isHeader: true, background: col('paper') }, ...DAYS.map((day) => ({ content: day, isHeader: true, align: 'center' }))], ...WEEK.map(([venue, marks]) => [{ content: venue, align: 'right' }, ...[...marks].map((m) => (m === '.' ? { content: '' } : { content: '', background: fill[m] }))]), ] } } }]; // #endregion // #region art: a waveform over the river, 29 rounded bars from a seeded generator function waveform() { let seed = 0x5eed; // Mulberry32: the same bars on every run const random = () => { 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 [x0, axis, n, width, gap] = [114, 84, 29, 2.1, 1.35]; // mm; axis: the waterline return Array.from({ length: n }, (_, i) => { const envelope = Math.sin(((i + 0.5) / n) * Math.PI) ** 0.8; const up = 3 + 26 * envelope * (0.35 + 0.65 * random()); // mm above the waterline const down = up * 0.42; // and its reflection below it return { kind: 'box', id: `bar-${i}`, style: { backgroundColor: col('tint'), borderRadius: mm(width / 2) }, placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x0 + i * (width + gap)), y: mm(axis - up) }, size: { width: mm(width), height: mm(up + down) } } }; }); } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Plus Jakarta Sans': ['400', '400i', '700'], Syne: ['700', '800'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const TITLE = t({ en: 'One programme, four palettes', es: 'Un programa, cuatro paletas' }); const build = (way) => buildWithFonts(() => buildDocument({ markdown, resources }, retint(way)), markdown); const docs = {}; // red is built last: the capture shows the last build on its pages for (const way of ['sand', 'violet', 'teal', 'red']) docs[way] = await build(way); // #region live: four buttons, each a fresh build of the whole programme in one colourway const NAMES = t({ en: { red: 'Red', teal: 'Teal', violet: 'Violet', sand: 'Sand' }, es: { red: 'Rojo', teal: 'Verde azulado', violet: 'Violeta', sand: 'Arena' } }); document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="editions"> <div class="desk"><header><p class="kicker">${t({ en: 'Riverside Music Week · proofs', es: 'Música en la Ribera · pruebas' })}</p><h2>${TITLE}</h2></header><canvas id="live" role="img"></canvas><div class="buttons" role="group"></div></div></section>`); const paint = (canvas, doc) => renderPageToCanvas(doc.pages[0], doc, canvas, { scale: (canvas.clientWidth * Math.min(devicePixelRatio, 2)) / doc.pages[0].width }); const buttons = Object.keys(COLOURWAYS).map((way) => { const button = document.querySelector('#editions .buttons') .appendChild(Object.assign(document.createElement('button'), { type: 'button' })); button.innerHTML = `<canvas></canvas><span>${NAMES[way]}<i>${['band', 'deep', 'tint'] .map((id) => `<b style="background:${COLOURWAYS[way][id]}"></b>`).join('')}</i></span>`; paint(button.firstChild, docs[way]); // A fresh config on every click (retint() calls config()); the fonts are loaded by now. button.onclick = () => show(way, buildDocument({ markdown, resources }, retint(way))); return [way, button]; }); const live = document.getElementById('live'); let shown; // the document on the live page function show(way, doc) { paint(live, (shown = doc)); live.ariaLabel = `${NAMES[way]}, ${t({ en: 'page 1', es: 'página 1' })}`; for (const [id, button] of buttons) button.ariaPressed = String(id === way); showPages(doc, { title: `${TITLE} · ${NAMES[way]}` }); } show('red', docs.red); new ResizeObserver(() => { // canvases are bitmaps: repaint them at the desk's new size paint(live, shown); buttons.forEach(([way, button]) => paint(button.firstChild, docs[way])); }).observe(live); // #endregion
工具包 · core, fonts, viewer:每道食谱都相同 · 235行// ─── 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 ───────────────────────────────────────────────────────────────────────

组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗

变化

#粗体保持墨色

强调色只留给标题、项目符号和行内标签,时间和关键词用正文颜色。

-  boldColor: col('deep'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };
+  boldColor: col('ink'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };

#给书的每一篇设定各自的颜色

如果颜色要在同一份文档里逐篇变化,参见用一个属性给各篇着色。

常见问题

易错点

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

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

易错点

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

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

易错点

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

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

易错点

齐左文字从不检查孤字

optimalLineBreaking、avoidRunts、runtPenalty和runtMinCharacters作用于Knuth–Plass断行器,而postext 1.4.1只对两端对齐的文字运行它。齐左段落逐行断行,无论这些设置怎么写,都可能以一个短词结尾。检查齐左文字的末行,改写以孤字结尾的段落。 段末孤行、段首孤行与孤字 →

易错点

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

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

易错点

用defaultResourceTypes(locale)本地化Figure/Table

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

易错点

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

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

易错点

排版前加载所有字体

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

  • 删掉relink()再构建青绿版,就能看到postext 1.4.1遗留了什么:色带、波形、白色标题、书眉、页码圆角底和引用“Table 1”保持红色版的颜色,而正文、列表、行内标签、题注条、表格和购票框随调色板变化。
  • 框里有粗体文字时,保持bodyText.boldColor已设置。框会复制它,连同调色板id;不设的话,1.4.1会复制默认值,于是在青绿版中,框外的粗体用色带颜色,框内的“week pass”却印成#295AA3。

致谢

文本
原创文字, CC BY 4.0
字体
Syne (SIL OFL 1.1) · Plus Jakarta Sans (SIL OFL 1.1)
沙盒