跳到主要内容
食谱编号47

排版食谱 · 第4章 · 书眉与页码

带书口色标的徒步导览

一本口袋导览书,每条路线一种标题样式,它的调色板给书口色标、站点编号、框的底色和路线前的空白左页重新着色。

第6–7页,共7页

  • 英文样例:尚无中文版本
  • 成品尺寸120 × 200 mm
  • 1栏
  • Albert Sans 9.4/13.2
  • Asap Condensed
  • DM Serif Display
  • 7页
  • 难度
  • Postext 1.4.1
  • 排版用时8 ms
  • 249行代码

成品一览

Lisbon on Foot的前七页,这是一本120 × 200 mm的口袋导览书。封面是一面蓝白相间的azulejos瓷砖墙,书名印在一块像里斯本路牌那样加框的铭牌上;第2页介绍三条路线,附有路线图。每条路线有一种颜色,Alfama是陶土色,Baixa是黄色;切口上的书口色标以这种颜色和路线编号,出现在这条路线每一页的同一高度。章首色带、编号站点、右页书眉和Don’t miss框的底色都随之改变。把路线2送到右页的那页空白左页,涂成路线2的黄色,并印出它的编号。正文用Albert Sans,齐左排;地名用DM Serif Display,标签用Asap Condensed。

这道食谱解答

  • 怎样在章首页和空白页上隐藏书眉,或者把空白左页涂成篇的颜色?
  • 怎样设置书眉:左页放书名,右页放章名,页码在外侧?
  • 怎样加编号标签(“BOX 1-1”)、角标图标,或带线的边栏图标?
  • 怎样在正文、题注或表注里加入色块图例?

简短回答

script.js · 第61–82行在完整代码中
// '# Alfama {style="alfama"}' opens a section that runs to the next level-1 heading. Its
// palette gives band, bandInk, onBand and wash the walk's values on the section's pages,
// in the page furniture and in the text alike.
const walkStyles = Object.keys(WALKS).map((id) => ({ id, palette: WALKS[id] }));
// The tab has one size and one place on every page, hanging from the foot of the opener
// band; only its colour and its number change from walk to walk.
const TAB = { y: BAND, width: 7, height: 24 }; // mm
const tab = (parity, edge) => text(`tab-${parity}`, '{chapterNumber}', {
  ...label, fontSize: pt(11), fontWeight: 700, color: col('onBand'), align: 'center',
  verticalAlign: 'middle', overflow: 'clip', box: { backgroundColor: col('band') },
  parity, pages: 'all', // the walk's first page is an 'opener' page: the tab goes there too
}, { ...at('bleed', edge, 0, TAB.y), ...size(mm(TAB.width), mm(TAB.height)) });
const tabs = [tab('odd', 'top-right'), tab('even', 'top-left')]; // on the fore-edge
// The blank verso that pushes a walk onto a recto belongs to that walk (gotcha:
// section-last-wins), so the field takes the next walk's colour and its numeral the number.
const blankPage = [
  box('field', { backgroundColor: col('band') },
    { ...at('bleed', 'top-left', 0, 0), ...size('fill', 'fill') }),
  tileImage('field-tiles', 'tiles-page', 'top-left', 'fill', 'auto'),
  text('field-number', '{chapterNumber}', { ...display, fontSize: pt(220), lineHeight: 1,
    color: col('onBand'), overflow: 'clip' }, at('page', 'bottom-left', MARGIN.outer, -24)),
].map((el) => ({ ...el, pages: 'blank' }));

用料

类型
Albert Sans, DM Serif Display, Asap Condensed(SIL OFL 1.1)
素材
  • The tiles, the map of the routes and its pins, drawn in code (Ignacio Ferro, CC BY 4.0)

做法

#1 · 给路线要改变的颜色命名

script.js · 第16–41行在完整代码中
const palette = {
  ink: '#1b2430', // text: a blue-black
  paper: '#fffdf8', // the page
  band: '#1f4e9c', // the section colour: tile blue until a walk replaces it
  bandInk: '#1d4a94', // the section colour for type on paper
  onBand: '#ffffff', // type set on the band
  wash: '#e8eef8', // the section's box tint
  muted: '#5e6875', // running heads, notes, chip outlines
};
// Each walk's values for those four. Baixa yellow is too light for white type, so its
// onBand is the ink. The text is recoloured by value, so the four base values must differ.
const WALKS = {
  alfama: { band: '#b9533a', bandInk: '#a1432b', onBand: '#ffffff', wash: '#f7e7e0' },
  baixa: { band: '#e2b23d', bandInk: '#7d5a0f', onBand: '#1b2430', wash: '#fbf0d5' },
  belem: { band: '#2b7a78', bandInk: '#236866', onBand: '#ffffff', wash: '#e1eeed' },
};
const NAMES = { alfama: 'Alfama', baixa: 'Baixa', belem: 'Belém' };
// A walk's palette follows the paletteId; the hex is written out too, since the document
// palette never reaches design elements (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id] ?? WALKS[id].band, model: 'hex', paletteId: id });
const entry = (id, hex) => ({ id, name: id, value: { hex, model: 'hex' } });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => entry(id, hex)),
  ...Object.entries(WALKS).map(([id, w]) => entry(id, w.band)), // for :swatch{color="alfama"}
  entry('main-color', palette.ink), // the engine's defaults link here: nothing prints blue
];

标题样式的palette在它所在分节的页面上,按id替换调色板条目(标题样式),所以每一种应当随路线变化的颜色,都链接到四个条目之一:band用于色块、色标和站点圆圈,bandInk用于纸面上的文字,onBand用于色块上的文字,wash用于框。白字在Baixa的黄色上对比度只有2:1,所以这条路线的onBand改用墨色,对比度为8:1。设计元素按id跟随,而文本按取值重新着色:与某个条目基础十六进制值相同的颜色,会取这条路线的值,所以四个基础值各不相同。第4页上的框标题和第5页上引你前往路线2的那一行,都是bandInk颜色的文本,两者都印成陶土色。col()在id旁边写出十六进制值,因为在1.4.1里文档调色板不会作用到设计元素上。

#2 · 只在正文页上放书眉

script.js · 第86–108行在完整代码中
const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, {
  ...label, fontSize: pt(7.8), letterSpacing: pt(1.3), color: col('muted'), overflow: 'clip',
  ...style }, placement), parity, pages: 'body' }); // never on openers or blank pages
const folio = { fontSize: pt(8.5), fontWeight: 700, color: col('ink') };
// Anchored to the page: from its top-right corner a negative x runs inwards
// (gotcha: negative-offsets).
const heads = [
  head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD.y), folio),
  head('verso-title', '{title}', 'even', at('page', 'top-left', MARGIN.outer + HEAD.gap, HEAD.y)),
  head('recto-title', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber} · {chapterTitle}`, 'odd',
    at('page', 'top-right', -(MARGIN.outer + HEAD.gap), HEAD.y), { color: col('bandInk') }),
  head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD.y), folio),
];
// Array order is paint order: the blank page's field comes last and covers the tab there.
const header = { elements: [...heads, ...tabs, ...blankPage] };
// A walk's first page has no running head: its folio drops to the foot.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all',
  at('container', 'top', 0, 7), folio), pages: 'opener', align: 'center' }] };
// Page 2 comes before Walk 1, where {chapterNumber} is empty, so its header has no tabs. With the
// level's break off (gotcha: style-inherits-break) and in the column, it is a 'body' page.
const introStyle = { id: 'intro', numbered: false, header: { elements: heads },
  span: 'column', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  fontSize: pt(24), lineHeight: pt(2 * LEAD), marginBottom: pt(LEAD / 2) };

排版完成后,每一页都会得到一个角色(文本元素)。路线的第一页是opener,因为它的标题会分页;为了转到右页而补的那一页是blank;其余的是body。书眉取pages: 'body',所以第3页和第7页的章首页、第6页的空白页上都没有书眉,页脚页码取'opener'。左页印frontmatter里的{title},右页用bandInk印Walk {chapterNumber} · {chapterTitle},在第5页上是陶土色。简短回答里的色标取'all'。空白页的色块在数组中排在它们后面,由于数组顺序就是绘制顺序,它在第6页上盖住了色标。第2页是引言,位于路线1之前,所以那里的{chapterNumber}为空,它的样式有自己的header,含四个书眉而没有色标。没有这个header,第2页会印出一个空的蓝色色标。

#3 · 每条路线在色带下开始,并给站点编号

script.js · 第112–140行在完整代码中
const walkLevel = { level: 1, breakBefore: { enabled: true, parity: 'odd' }, // a recto
  span: 'page', // kept in the column, the band would be cut at the top margin, 18 mm down
  // The band ends 30 mm into the text area; a floor of 8 grid lines, with nothing added
  // under it, starts the text 7.3 mm below the band.
  marginBottom: pt(0), advancedDesign: { enabled: true, minHeight: pt(8 * LEAD), slot: {
    elements: [
      box('band', { backgroundColor: col('band') },
        { ...at('bleed', 'top-left', 0, 0), ...size('fill', mm(BAND)) }),
      tileImage('tiles', 'tiles-band', 'top-right', 'auto', mm(BAND)),
      text('kicker', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber}`, { ...label,
        fontSize: pt(9), letterSpacing: pt(2), color: col('onBand') },
      at('page', 'top-left', MARGIN.inner, 17)), // walks open on rectos: inner is left
      text('title', '{titleText}', { ...display, fontSize: pt(34), lineHeight: 1,
        color: col('onBand') }, { ...at('#kicker', 'below', 0, 1), ...size(mm(70), 'auto') }),
      text('route', '{attr.route}', { fontFamily: 'Albert Sans', italic: true, fontSize: pt(10),
        color: col('onBand') }, { ...at('#title', 'below', 0, 1.5), ...size(mm(70), 'auto') }),
    ] } } };
const STOP = 5.4; // mm: the diameter of a stop's circle
const stopLevel = { level: 3, numberingTemplate: '{3}', // restarts at every walk
  // A grid line above a stop and no margin under it, so the circle can sit 3.2 mm down its
  // two grid lines: about 8.3 mm of white above a stop and 2.1 under it, 6.4 between paragraphs.
  marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: { enabled: true, slot: { elements: [
    text('number', '{number}', { ...label, fontSize: pt(9), fontWeight: 700, align: 'center',
      verticalAlign: 'middle', color: col('onBand'), overflow: 'clip',
      box: { backgroundColor: col('band'), borderRadius: mm(STOP / 2) } },
    { ...at('container', 'top-left', 0, 3.2), ...size(mm(STOP), mm(STOP)) }),
    text('name', '{titleText}', { ...display, fontSize: pt(13.5), lineHeight: 1.1,
      color: col('ink') }, { ...at('#number', 'right-of', 2.4, 0.2), ...size('fill', 'auto') }),
  ] } } };

色带锚定在出血上,这里出血就是成品边缘,色带伸进正文区30 mm。这需要span: 'page',因为在1.4.1里留在栏内的设计会在上页边距处被裁掉,色带就会从18 mm处才开始。设计在色带处结束,minHeight预留八条网格线,即37.3 mm;章首页取两者中较大的深度,所以在第3页和第7页上,正文从色带下方7.3 mm处开始。每个站点是一个三级标题,它的设计把{number}排在一个用band填充的5.4 mm圆圈里。模板{3}在每条路线重新计数,因为一级标题会重置更深层级的计数器,地图上的标记也带着同样的编号。站点上方有一条网格线的外边距,下方没有,圆圈位于标题所占的两条网格线中、往下3.2 mm处。这样圆圈上方约有8.3 mm空白,下方约有2.1 mm;段落之间相距6.4 mm。

#4 · 在框上挂一块瓷砖,行内标签保持墨色

script.js · 第168–183行在完整代码中
const calloutStyles = [{ id: 'dontmiss', title: t({ en: 'Don’t miss', es: 'No te lo pierdas' }),
  background: col('wash'), // one device: the walk's tint
  // The tile hangs half off the box's outer side, flush with its top: the left side on a verso.
  // With 6.5 mm of padding on both sides the title stays in line with the box's text there too.
  // The tile's colours are drawn into the SVG, so it stays tile blue in every walk.
  padding: { top: mm(3.4), right: mm(6.5), bottom: mm(3.4), left: mm(6.5) },
  icon: { kind: 'resource', resourceId: 'tile', size: mm(10), position: 'corner',
    cornerSide: 'outer' },
  titleStyle: { ...label, fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.5),
    color: col('bandInk') },
  body: { fontSize: pt(9), lineHeight: pt(12.4), color: col('ink') } }];
// A walk's palette does not reach chips: one linked to 'band' would stay tile blue in every
// walk, so the facts are ink on paper (gotcha: section-palette-skips-chips).
const chipStyles = [{ id: 'fact', background: col('paper'), borderColor: col('muted'),
  borderWidth: pt(0.6), borderRadius: mm(0.8), paddingX: mm(1.4), paddingY: mm(0.5),
  fontFamily: 'Asap Condensed', fontSize: em(0.92), color: col('ink'), bold: true }];

这个框没有线条或边框,只有路线的底色,以及角上一块用代码画的瓷砖(标注框样式)。cornerSide: 'outer'把瓷砖一半挂在框的外侧之外,与框顶平齐;第4页是左页,外侧就是左边,也就是标题开始的那一边。两侧各有6.5 mm的内边距,标题不会碰到瓷砖,并且无论在哪一页都与框内文字对齐。路线的调色板会给底色和标题重新着色,瓷砖则保持蓝色,因为它的颜色画在SVG里。在1.4.1里,路线的调色板作用不到行内标签:如果把它们链接到band,两条路线里的路线信息都会保持瓷砖蓝。所以行内标签用纸面上的墨色,外加一圈muted的灰色轮廓,每条路线都一样。

#5 · 用路线的色块给地图做图例

script.js · 第533–547行在完整代码中
const map = svg('routes', MAP.width, MAP.height, { placement: { position: 'here' }, ...t({ en: {
  caption: 'The three walks. The numbered pins are the stops in the text.',
  note: ':swatch{color="alfama"} Walk 1, Alfama · :swatch{color="baixa"} Walk 2, Baixa · '
    + ':swatch{color="belem"} Walk 3, Belém, 6 km west (inset)',
  altText: 'Map of central Lisbon north of the river: the yellow route runs from the '
    + 'waterfront up the Baixa grid to Rossio; the terracotta route climbs east from the '
    + 'cathedral through Alfama. An inset shows the teal Belém route.',
}, es: {
  caption: 'Los tres paseos. Los pines numerados son las paradas del texto.',
  note: ':swatch{color="alfama"} Paseo 1, Alfama · :swatch{color="baixa"} Paseo 2, Baixa · '
    + ':swatch{color="belem"} Paseo 3, Belém, 6 km al oeste (en el detalle)',
  altText: 'Plano del centro de Lisboa al norte del río: la ruta amarilla sube desde el '
    + 'muelle por la cuadrícula de la Baixa hasta el Rossio; la terracota sube hacia el este '
    + 'desde la catedral por Alfama. En un detalle, la ruta verde azulada de Belém.',
} }) });

地图是一个SVG资源,排在::resource{id="routes"}所在的位置,它有自己的资源类型,所以题注写作Map 1。说明文字用三个色块给路线做图例::swatch{color="alfama"},baixa和belem同理。色块取调色板条目的id(行内格式)。调色板从WALKS为每条路线取一个条目,图画也从同一个对象取路线颜色,所以图例与路线和色标一致。标记里的数字画成笔画,因为作为图片绘制的SVG无法使用页面的网络字体。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 047 · Walking guide with thumb tabs ═══════════════════
// https://postext.dev/en/cookbook/walking-guide-thumb-tabs
// Code: MIT · Text: original (CC BY 4.0) · Map and tiles: drawn in code (CC BY 4.0)
// Fonts: Albert Sans, DM Serif Display, Asap Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// A pocket guide to Lisbon. Each walk is a heading style that sets four colours, and the tab,
// the stop numbers, the box's tint and title and the blank page before the walk take them.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'walking-guide-thumb-tabs';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: 'band' and three partners are the entries a walk overrides
const palette = {
  ink: '#1b2430', // text: a blue-black
  paper: '#fffdf8', // the page
  band: '#1f4e9c', // the section colour: tile blue until a walk replaces it
  bandInk: '#1d4a94', // the section colour for type on paper
  onBand: '#ffffff', // type set on the band
  wash: '#e8eef8', // the section's box tint
  muted: '#5e6875', // running heads, notes, chip outlines
};
// Each walk's values for those four. Baixa yellow is too light for white type, so its
// onBand is the ink. The text is recoloured by value, so the four base values must differ.
const WALKS = {
  alfama: { band: '#b9533a', bandInk: '#a1432b', onBand: '#ffffff', wash: '#f7e7e0' },
  baixa: { band: '#e2b23d', bandInk: '#7d5a0f', onBand: '#1b2430', wash: '#fbf0d5' },
  belem: { band: '#2b7a78', bandInk: '#236866', onBand: '#ffffff', wash: '#e1eeed' },
};
const NAMES = { alfama: 'Alfama', baixa: 'Baixa', belem: 'Belém' };
// A walk's palette follows the paletteId; the hex is written out too, since the document
// palette never reaches design elements (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id] ?? WALKS[id].band, model: 'hex', paletteId: id });
const entry = (id, hex) => ({ id, name: id, value: { hex, model: 'hex' } });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => entry(id, hex)),
  ...Object.entries(WALKS).map(([id, w]) => entry(id, w.band)), // for :swatch{color="alfama"}
  entry('main-color', palette.ink), // the engine's defaults link here: nothing prints blue
];
// #endregion
const TRIM = { width: 120, height: 200 }; // a pocket guide
// Mirrored, and tighter than a book's at top and foot: a pocket guide keeps 164 mm of text.
const MARGIN = { top: 18, bottom: 18, inner: 14, outer: 16 };
const MEASURE = TRIM.width - MARGIN.inner - MARGIN.outer; // 90 mm, about 55 characters
const LEAD = 13.2; // body leading in pt: the baseline grid
const BAND = 48; // mm: a walk opener's colour band, from the top edge
const HEAD = { y: 10, gap: 7 }; // running heads from the top edge; folio to title (mm)
const label = { fontFamily: 'Asap Condensed', fontWeight: 600, textTransform: 'uppercase' };
const display = { fontFamily: 'DM Serif Display', fontWeight: 400 };
const at = (to, edge, x, y) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const size = (width, height) => ({ size: { width, height } });
const text = (id, content, style, placement) => ({ kind: 'text', id, content, overflow: 'wrap',
  align: 'left', ...style, placement });
const box = (id, style, placement) => ({ kind: 'box', id, style, placement });
const tileImage = (id, resourceId, edge, width, height) => ({ kind: 'image', id, resourceId,
  placement: { ...at('bleed', edge, 0, 0), ...size(width, height) } });

// #region answer: one tab and one blank-page field, coloured by the walk they belong to
// '# Alfama {style="alfama"}' opens a section that runs to the next level-1 heading. Its
// palette gives band, bandInk, onBand and wash the walk's values on the section's pages,
// in the page furniture and in the text alike.
const walkStyles = Object.keys(WALKS).map((id) => ({ id, palette: WALKS[id] }));
// The tab has one size and one place on every page, hanging from the foot of the opener
// band; only its colour and its number change from walk to walk.
const TAB = { y: BAND, width: 7, height: 24 }; // mm
const tab = (parity, edge) => text(`tab-${parity}`, '{chapterNumber}', {
  ...label, fontSize: pt(11), fontWeight: 700, color: col('onBand'), align: 'center',
  verticalAlign: 'middle', overflow: 'clip', box: { backgroundColor: col('band') },
  parity, pages: 'all', // the walk's first page is an 'opener' page: the tab goes there too
}, { ...at('bleed', edge, 0, TAB.y), ...size(mm(TAB.width), mm(TAB.height)) });
const tabs = [tab('odd', 'top-right'), tab('even', 'top-left')]; // on the fore-edge
// The blank verso that pushes a walk onto a recto belongs to that walk (gotcha:
// section-last-wins), so the field takes the next walk's colour and its numeral the number.
const blankPage = [
  box('field', { backgroundColor: col('band') },
    { ...at('bleed', 'top-left', 0, 0), ...size('fill', 'fill') }),
  tileImage('field-tiles', 'tiles-page', 'top-left', 'fill', 'auto'),
  text('field-number', '{chapterNumber}', { ...display, fontSize: pt(220), lineHeight: 1,
    color: col('onBand'), overflow: 'clip' }, at('page', 'bottom-left', MARGIN.outer, -24)),
].map((el) => ({ ...el, pages: 'blank' }));
// #endregion

// #region running-heads: book title on the verso, walk on the recto, folios outside
const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, {
  ...label, fontSize: pt(7.8), letterSpacing: pt(1.3), color: col('muted'), overflow: 'clip',
  ...style }, placement), parity, pages: 'body' }); // never on openers or blank pages
const folio = { fontSize: pt(8.5), fontWeight: 700, color: col('ink') };
// Anchored to the page: from its top-right corner a negative x runs inwards
// (gotcha: negative-offsets).
const heads = [
  head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD.y), folio),
  head('verso-title', '{title}', 'even', at('page', 'top-left', MARGIN.outer + HEAD.gap, HEAD.y)),
  head('recto-title', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber} · {chapterTitle}`, 'odd',
    at('page', 'top-right', -(MARGIN.outer + HEAD.gap), HEAD.y), { color: col('bandInk') }),
  head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD.y), folio),
];
// Array order is paint order: the blank page's field comes last and covers the tab there.
const header = { elements: [...heads, ...tabs, ...blankPage] };
// A walk's first page has no running head: its folio drops to the foot.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all',
  at('container', 'top', 0, 7), folio), pages: 'opener', align: 'center' }] };
// Page 2 comes before Walk 1, where {chapterNumber} is empty, so its header has no tabs. With the
// level's break off (gotcha: style-inherits-break) and in the column, it is a 'body' page.
const introStyle = { id: 'intro', numbered: false, header: { elements: heads },
  span: 'column', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  fontSize: pt(24), lineHeight: pt(2 * LEAD), marginBottom: pt(LEAD / 2) };
// #endregion

// #region levels: a walk opens under a band in its colour; its stops are numbered circles
const walkLevel = { level: 1, breakBefore: { enabled: true, parity: 'odd' }, // a recto
  span: 'page', // kept in the column, the band would be cut at the top margin, 18 mm down
  // The band ends 30 mm into the text area; a floor of 8 grid lines, with nothing added
  // under it, starts the text 7.3 mm below the band.
  marginBottom: pt(0), advancedDesign: { enabled: true, minHeight: pt(8 * LEAD), slot: {
    elements: [
      box('band', { backgroundColor: col('band') },
        { ...at('bleed', 'top-left', 0, 0), ...size('fill', mm(BAND)) }),
      tileImage('tiles', 'tiles-band', 'top-right', 'auto', mm(BAND)),
      text('kicker', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber}`, { ...label,
        fontSize: pt(9), letterSpacing: pt(2), color: col('onBand') },
      at('page', 'top-left', MARGIN.inner, 17)), // walks open on rectos: inner is left
      text('title', '{titleText}', { ...display, fontSize: pt(34), lineHeight: 1,
        color: col('onBand') }, { ...at('#kicker', 'below', 0, 1), ...size(mm(70), 'auto') }),
      text('route', '{attr.route}', { fontFamily: 'Albert Sans', italic: true, fontSize: pt(10),
        color: col('onBand') }, { ...at('#title', 'below', 0, 1.5), ...size(mm(70), 'auto') }),
    ] } } };
const STOP = 5.4; // mm: the diameter of a stop's circle
const stopLevel = { level: 3, numberingTemplate: '{3}', // restarts at every walk
  // A grid line above a stop and no margin under it, so the circle can sit 3.2 mm down its
  // two grid lines: about 8.3 mm of white above a stop and 2.1 under it, 6.4 between paragraphs.
  marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: { enabled: true, slot: { elements: [
    text('number', '{number}', { ...label, fontSize: pt(9), fontWeight: 700, align: 'center',
      verticalAlign: 'middle', color: col('onBand'), overflow: 'clip',
      box: { backgroundColor: col('band'), borderRadius: mm(STOP / 2) } },
    { ...at('container', 'top-left', 0, 3.2), ...size(mm(STOP), mm(STOP)) }),
    text('name', '{titleText}', { ...display, fontSize: pt(13.5), lineHeight: 1.1,
      color: col('ink') }, { ...at('#number', 'right-of', 2.4, 0.2), ...size('fill', 'auto') }),
  ] } } };
// #endregion

// The cover: a wall of tiles, the title on a plaque like a Lisbon street sign, the walks' keys.
const plaque = size(mm(94), 'auto'); // the width of the texts on the plaque
const rim = (id, x, y, w, h, style) => box(id, { borderColor: col('band'), ...style },
  { ...at('page', 'top-left', x, y), ...size(mm(w), mm(h)) });
const cover = { enabled: true, slot: { elements: [
  tileImage('wall', 'tiles-cover', 'top-left', 'fill', 'auto'),
  rim('plaque', 13, 88, 94, 62, { backgroundColor: col('paper'), borderWidth: pt(1.6) }),
  rim('frame', 15.2, 90.2, 89.6, 57.6, { borderWidth: pt(0.5) }), // the inner frame line
  text('kicker', t({ en: 'A pocket guide', es: 'Guía de bolsillo' }), { ...label, align: 'center',
    fontSize: pt(8.5), letterSpacing: pt(2), color: col('bandInk') }, // centred text sits left
  { ...at('page', 'top-left', 13 + 0.35, 96), ...plaque }), // by half its tracking: 1 pt, 0.35 mm
  text('title', '{titleText}', { ...display, fontSize: pt(46), lineHeight: 0.98, align: 'center',
    color: col('band') }, { ...at('#kicker', 'below', 0, 2), ...plaque }),
  text('subtitle', '{subtitle}', { fontFamily: 'Albert Sans', italic: true, fontSize: pt(10.5),
    color: col('ink'), align: 'center' }, { ...at('#title', 'below', 0, 3), ...plaque }),
  ...Object.keys(WALKS).flatMap((id, i) => [text(`key-${id}`, String(i + 1), { ...label,
    fontSize: pt(10), fontWeight: 700, align: 'center', verticalAlign: 'middle', overflow: 'clip',
    color: col(WALKS[id].onBand === palette.ink ? 'ink' : 'onBand'),
    box: { backgroundColor: col(id) } }, { ...at('page', 'top-left', 17 + i * 30, 166),
    ...size(mm(7), mm(7)) }),
  text(`name-${id}`, NAMES[id], { ...display, fontSize: pt(13), color: col('ink') },
    at(`#key-${id}`, 'right-of', 2.2, 0.4))]),
] } };

// #region box-and-chips: a tile on the box's outer corner; chips that stay in ink
const calloutStyles = [{ id: 'dontmiss', title: t({ en: 'Don’t miss', es: 'No te lo pierdas' }),
  background: col('wash'), // one device: the walk's tint
  // The tile hangs half off the box's outer side, flush with its top: the left side on a verso.
  // With 6.5 mm of padding on both sides the title stays in line with the box's text there too.
  // The tile's colours are drawn into the SVG, so it stays tile blue in every walk.
  padding: { top: mm(3.4), right: mm(6.5), bottom: mm(3.4), left: mm(6.5) },
  icon: { kind: 'resource', resourceId: 'tile', size: mm(10), position: 'corner',
    cornerSide: 'outer' },
  titleStyle: { ...label, fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.5),
    color: col('bandInk') },
  body: { fontSize: pt(9), lineHeight: pt(12.4), color: col('ink') } }];
// A walk's palette does not reach chips: one linked to 'band' would stay tile blue in every
// walk, so the facts are ink on paper (gotcha: section-palette-skips-chips).
const chipStyles = [{ id: 'fact', background: col('paper'), borderColor: col('muted'),
  borderWidth: pt(0.6), borderRadius: mm(0.8), paddingX: mm(1.4), paddingY: mm(0.5),
  fontFamily: 'Asap Condensed', fontSize: em(0.92), color: col('ink'), bold: true }];
// #endregion

const MAP_WORD = t({ en: 'Map', es: 'Plano' }); // the caption reads 'Map 1'
const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom),
      left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } },
  layout: { layoutType: 'single' },
  // Ragged text for short lines of directions. It is never hyphenated (gotcha:
  // ragged-no-hyphenation) and never checked for runts (gotcha: ragged-runts).
  bodyText: { fontFamily: 'Albert Sans', fontSize: pt(9.4), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true },
  // No lines added above the stops to fill a page: a guide's pages may end short.
  headings: { ...display, color: col('ink'), balancing: { enabled: false },
    // Any headings object drops the level-1 break: it is stated in walkLevel
    // (gotcha: headings-drop-h1-break).
    levels: [walkLevel, stopLevel] },
  headingStyles: [
    { id: 'cover', numbered: false, advancedDesign: cover,
      header: { elements: [] }, footer: { elements: [] } },
    introStyle, ...walkStyles,
  ],
  resourceTypes: [{ id: 'map', name: MAP_WORD, shortLabel: MAP_WORD, captionPrefix: MAP_WORD,
    numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }],
  captionStyle: { fontFamily: 'Asap Condensed', fontSize: pt(8.6), color: col('ink'),
    labelColor: col('bandInk'), gap: mm(2),
    note: { fontSize: pt(8), color: col('muted') } },
  calloutStyles,
  chipStyles,
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Asap Condensed', fontSize: pt(7.8),
    lineHeight: pt(10.4), color: col('muted') }, // then the line that ends a walk, in its colour
  { id: 'onward', fontFamily: 'Asap Condensed', fontSize: pt(10), color: col('bandInk'),
    boldColor: col('bandInk'), marginTop: pt(2 * LEAD) }],
  header,
  footer,
});

// #region art: the tiles, the map and its pins, drawn in code and seeded: every run the same
let seed = 1755; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe
const rand = () => {
  let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed);
  r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
  return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
const n = (v) => v.toFixed(2);
const sheet = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const fill = (c, o = 1) => `fill="${c}"${o < 1 ? ` fill-opacity="${n(o)}"` : ''}`;
const line = (c, w, o = 1) => `fill="none" stroke="${c}" stroke-width="${n(w)}" `
  + `stroke-linecap="round" stroke-linejoin="round"${o < 1 ? ` stroke-opacity="${n(o)}"` : ''}`;
const shape = (pts, paint) => `<path d="M${pts.map(([x, y]) => `${n(x)} ${n(y)}`).join(' L')} Z" `
  + `${paint}/>`;
const put = (x, y, turn, body) => `<g transform="translate(${n(x)} ${n(y)}) rotate(${turn})">`
  + `${body}</g>`;
// A petal pointing up from the origin: `len` long, `wid` at its widest.
const petal = (len, wid) => `M0 0C${n(wid)} ${n(-len * 0.3)} ${n(wid * 0.7)} ${n(-len * 0.8)} `
  + `0 ${n(-len)}C${n(-wid * 0.7)} ${n(-len * 0.8)} ${n(-wid)} ${n(-len * 0.3)} 0 0Z`;
const flower = (x, y, [axis, axisW, diag = 0, diagW = 0], paint) => [0, 45, 90, 135, 180, 225,
  270, 315].filter((turn) => diag || turn % 90 === 0).map((turn) => put(x, y, turn,
  `<path d="${turn % 90 ? petal(diag, diagW) : petal(axis, axisW)}" ${paint}/>`)).join('');

// A pattern of s-mm tiles, as on a Lisbon façade: a circle round every corner where four
// tiles meet, and a flower in the diamond each tile keeps between the circles. `glaze`
// paints the tiles; without it only the motif is drawn, to lay over a colour.
function tiles(w, h, s, ink, { glaze, tint, o = 1, x0 = 0, y0 = 0 } = {}) {
  const [cols, rows] = [Math.ceil((w - x0) / s), Math.ceil((h - y0) / s)];
  let [back, front] = [glaze ? `<rect width="${w}" height="${h}" ${fill(glaze)}/>` : '', ''];
  for (let r = -1; r <= rows; r++) {
    for (let c = -1; c <= cols; c++) {
      const [x, y] = [x0 + c * s, y0 + r * s];
      back += `<circle cx="${n(x)}" cy="${n(y)}" r="${n(s * 0.47)}" `
        + `${fill(tint ?? ink, glaze ? 1 : o * 0.35)}/>`;
      front += `<circle cx="${n(x)}" cy="${n(y)}" r="${n(s * 0.47)}" ${line(ink, s * 0.035, o)}/>`
        + (glaze ? flower(x, y, [s * 0.2, s * 0.06], fill(ink, o)) : '') // glazed tiles only
        + flower(x + s / 2, y + s / 2, [s * 0.36, s * 0.075, s * 0.17, s * 0.05], fill(ink, o))
        + `<circle cx="${n(x + s / 2)}" cy="${n(y + s / 2)}" r="${n(s * 0.06)}" `
        + `${fill(glaze ?? ink, o)}/>`;
    }
  }
  return back + front;
}
const TILE = 15; // mm: one tile on the cover and on the bands
const BLUE = { tint: '#dde6f3', glaze: '#f8f5ee' };
function coverTiles() {
  const [w, h] = [TRIM.width, 112];
  let g = tiles(w, h, TILE, palette.band, { ...BLUE, y0: -4 });
  // The joints, through the flowers where four tiles meet.
  for (let k = 1; k < 8; k++) {
    g += `<path d="M${k * TILE} 0V${h}M0 ${k * TILE - 4}H${w}" ${line('#a9a292', 0.2, 0.6)}/>`;
  }
  // At the fore-edge one tile is missing and the plaster shows, in the shadow of the tiles
  // round it; the tile beside it has lost a jagged strip along their joint.
  const [x, y] = [7 * TILE, TILE - 4];
  const edge = [[x, y], [x - 2.6, y + 1.8], [x - 1.4, y + 4.6], [x - 3.4, y + 7.4],
    [x - 1.8, y + 10.2], [x - 2.4, y + 12.6], [x, y + 13.4], [x, y + TILE]];
  const hole = (d) => [[x + TILE, y + d], ...edge.map(([a, b]) => [a + d, Math.max(b, y + d)]),
    [x + TILE, y + TILE]];
  g += shape(hole(0), fill('#a89c83')) + shape(hole(0.8), fill('#d6ccb8'));
  for (let i = 0; i < 16; i++) {
    g += `<circle cx="${n(x - 2 + rand() * (TILE + 2))}" cy="${n(y + rand() * TILE)}" `
      + `r="${n(0.12 + rand() * 0.3)}" ${fill('#b3a78f', 0.8)}/>`;
  }
  return sheet(w, h, g);
}
// The white motif laid over a walk's band, and over the page before a walk.
const bandTiles = () => sheet(3 * TILE, BAND,
  tiles(3 * TILE, BAND, TILE, '#ffffff', { o: 0.3, y0: BAND - 3 * TILE }));
const pageTiles = () => sheet(TRIM.width, TRIM.height,
  tiles(TRIM.width, TRIM.height, TILE, '#ffffff', { o: 0.2, y0: BAND - 3 * TILE }));
// The corner badge of the 'Don't miss' box: one tile, framed.
const tileIcon = () => sheet(TILE, TILE, tiles(TILE, TILE, TILE, palette.band, BLUE)
  + `<rect x="0.35" y="0.35" width="${TILE - 0.7}" height="${TILE - 0.7}" `
  + `${line(palette.band, 0.7)}/>`);

// Digits drawn as strokes in a 6 × 10 box: an SVG drawn as an image cannot use the page's
// web fonts (gotcha: svg-no-webfonts).
const GLYPH = {
  0: 'M3 0C0.6 0 0 2.6 0 5S0.6 10 3 10 6 7.4 6 5 5.4 0 3 0Z',
  1: 'M1.2 2.2L3.6 0V10',
  2: 'M0.5 2.4C0.8 0.7 2 0 3.2 0C4.8 0 5.8 1.1 5.8 2.7C5.8 4.8 3.8 6 0.4 10H6',
  3: 'M0.5 1.3C1.2 0.4 2.2 0 3.2 0C4.8 0 5.8 1 5.8 2.5C5.8 4 4.5 4.8 2.6 4.8C4.6 4.8 6 5.8 6 7.4'
    + 'C6 9.1 4.8 10 3 10C1.9 10 0.9 9.6 0.2 8.6',
  4: 'M4.4 10V0L0 7H6.2',
  5: 'M5.6 0H1.2L0.7 4.6C1.4 4 2.3 3.8 3.1 3.8C4.9 3.8 6 5 6 6.9S4.8 10 3 10C1.9 10 0.9 9.6'
    + ' 0.3 8.8',
  N: 'M0 10V0L6 10V0',
  B: 'M0 10V0H3.4C4.9 0 5.6 1 5.6 2.4S4.9 4.8 3.4 4.8H0M3.4 4.8C5.1 4.8 6 5.8 6 7.4'
    + 'S5.1 10 3.4 10H0',
  E: 'M5.6 0H0V10H5.8M0 4.8H4.4',
  'É': 'M5.6 0H0V10H5.8M0 4.8H4.4M2.4 -1.6L3.8 -3.2',
  L: 'M0 0V10H5.6',
  M: 'M0 10V0L3 6.4L6 0V10',
  m: 'M0 10V4.6M0 5.6C0 4.6 0.8 4 1.6 4S3 4.6 3 5.6V10M3 5.6C3 4.6 3.7 4 4.5 4S6 4.6 6 5.6V10',
  ' ': '',
};
const glyphs = (str, x, y, h, c, w) => [...str].map((ch, i) => `<path transform="translate(`
  + `${n(x + i * h * 0.8)} ${n(y)}) scale(${n(h / 10)})" d="${GLYPH[ch]}" `
  + `${line(c, w / (h / 10))}/>`).join('');
// A pin: a disc in the walk's colour with its stop number, on a paper ring.
const pin = ([x, y], num, w) => `<circle cx="${n(x)}" cy="${n(y)}" r="2.4" ${fill(palette.paper)}/>`
  + `<circle cx="${n(x)}" cy="${n(y)}" r="2" ${fill(w.band)}/>`
  + glyphs(String(num), x - 0.95, y - 1.3, 2.6, w.onBand, 0.42);
const route = (pts, w) => {
  const d = `M${pts.map(([x, y]) => `${n(x)} ${n(y)}`).join(' L')}`;
  return `<path d="${d}" ${line(palette.paper, 1.9)}/><path d="${d}" ${line(w.band, 1.05)}/>`;
};

// Central Lisbon, north up, at 1:20 000 (1 km = 50 mm): the Baixa's grid on the line of
// Rua Augusta, Alfama's lanes round the castle hill, the river. Drawn on a 60 mm sheet
// and cropped to 56 mm, 3 mm off the top. The routes measure 0.77 km and 1.38 km.
const MAP = { width: MEASURE, height: 56 };
const SHORE = [[0, 57], [12, 56.5], [22, 55], [32, 51.5], [44, 46.5], [54, 41.5], [64, 35.5],
  [74, 29], [84, 23], [MAP.width, 20]];
const shoreY = (x) => {
  const i = Math.max(1, SHORE.findIndex(([sx]) => sx >= x));
  const [[x0, y0], [x1, y1]] = [SHORE[i - 1], SHORE[i]];
  return y0 + ((x - x0) / (x1 - x0)) * (y1 - y0);
};
// The Baixa frame: u runs up Rua Augusta from its arch, v runs east across it.
const ARCH = [21.7, 43.8];
const baixa = (u, v) => [ARCH[0] - 0.292 * u + 0.955 * v, ARCH[1] - 0.955 * u - 0.292 * v];
const inBaixa = ([x, y]) => {
  const [dx, dy] = [x - ARCH[0], y - ARCH[1]];
  const [u, v] = [-0.292 * dx - 0.955 * dy, 0.955 * dx - 0.292 * dy];
  return u > -10.5 && u < 44 && Math.abs(v) < 10.2;
};
const STOPS = {
  baixa: [baixa(-4.8, 0), baixa(0.9, 0), baixa(32.2, -2.75)],
  alfama: [[35.2, 36.6], [49.4, 27.8], [51.6, 23], [65.6, 6.4], [78.6, 11.8]],
  belem: [[76.5, 44.2], [78.6, 53], [59.8, 54.9]],
};
function mapArt() {
  const [w, h] = [MAP.width, 60];
  const [block, water, green] = ['#e5dccb', '#d4e1ee', '#d3dcc3'];
  const paint = fill(block);
  let g = `<rect width="${w}" height="${h}" ${fill(palette.paper)}/>`
    + shape([...SHORE, [w, h], [0, h]], fill(water));
  for (let i = 0; i < 44; i++) { // ripples on the river
    const x = rand() * w;
    const y = shoreY(x) + 1.6 + rand() * (h - shoreY(x));
    g += `<path d="M${n(x)} ${n(y)} q0.9 -0.45 1.8 0" ${line('#b3c7da', 0.22)}/>`;
  }
  // The Baixa: blocks between parallel streets, Rua Augusta (v = 0) up the middle.
  for (const v0 of [-9.15, -5.95, -2.75, 0.45, 3.65, 6.85]) {
    for (let u0 = 2.2; u0 < 44; u0 += 2.7) {
      if (u0 > 25.5 && u0 < 37.5 && v0 > -6 && v0 < 0) continue; // Rossio
      g += shape([baixa(u0, v0), baixa(u0, v0 + 2.3), baixa(u0 + 2, v0 + 2.3),
        baixa(u0 + 2, v0)], paint);
    }
  }
  // Praça do Comércio: arcades on three sides, the river on the fourth.
  for (const [u0, u1, v0, v1] of [[-8.8, 0.3, -8.4, -6.3], [-8.8, 0.3, 6.3, 8.4],
    [-0.4, 1.5, -8.4, -0.55], [-0.4, 1.5, 0.55, 8.4]]) {
    g += shape([baixa(u0, v0), baixa(u0, v1), baixa(u1, v1), baixa(u1, v0)], paint);
  }
  // Everywhere else, the older town: small, uneven blocks.
  const castle = [[30, 12], [34, 9.5], [40, 11], [41, 16], [36, 18.5], [30.5, 17]];
  for (let y = 1; y < h; y += 2.9) {
    for (let x = 0.5; x < w; x += 3) {
      const [bw, bh] = [1.3 + rand() * 1.3, 1.2 + rand() * 1.2];
      const [cx, cy] = [x + rand() * 0.6, y + rand() * 0.6];
      if (cy + bh + 0.8 > shoreY(cx + bw / 2) || inBaixa([cx + bw / 2, cy + bh / 2])) continue;
      if (Math.hypot(cx - 35.5, cy - 14) < 7) continue; // the castle's hill
      const j = () => (rand() - 0.5) * 0.5;
      g += shape([[cx + j(), cy + j()], [cx + bw + j(), cy + j()], [cx + bw + j(), cy + bh + j()],
        [cx + j(), cy + bh + j()]], paint);
    }
  }
  // São Jorge castle: its walls and towers among the pines.
  for (let i = 0; i < 26; i++) {
    const [a, d] = [rand() * Math.PI * 2, 5 + rand() * 1.8];
    g += `<circle cx="${n(35.5 + Math.cos(a) * d)}" cy="${n(14 + Math.sin(a) * d * 0.8)}" `
      + `r="${n(0.5 + rand() * 0.4)}" ${fill(green)}/>`;
  }
  g += shape(castle, `${fill('#ddd3bf')} stroke="#a89c83" stroke-width="0.35"`)
    + castle.map(([x, y]) => `<rect x="${n(x - 0.6)}" y="${n(y - 0.6)}" width="1.2" height="1.2" `
      + `${fill('#a89c83')}/>`).join('');
  // The two walks in town.
  const [a, b, c] = [WALKS.alfama, WALKS.baixa, WALKS.belem];
  const [se, luzia, sol, vicente, pantheon] = STOPS.alfama;
  g += route([STOPS.baixa[0], baixa(0, 0), baixa(27, 0), baixa(29, -2.75), STOPS.baixa[2]], b)
    + route([se, [38.4, 37.8], [41.4, 36.6], [42.4, 33.4], [45.2, 32.8], [46.6, 30.2],
      [48.8, 30.4], luzia, sol, [55.2, 21.8], [55.8, 18.4], [59.6, 17.4], [61.6, 15.4],
      [60.6, 12.6], [62.8, 11.6], [63.4, 8.6], vicente, [69.8, 4.8], [74.2, 5.4], [77.2, 7.4],
      [77.4, 9.2], pantheon], a)
    + STOPS.baixa.map((p, i) => pin(p, i + 1, b)).join('')
    + STOPS.alfama.map((p, i) => pin(p, i + 1, a)).join('');
  // Belém, 6 km west, in an inset on the river at 1:45 000: the monastery, its gardens, the
  // monument on the waterfront and the tower in the water.
  const inset = 'x="57" y="40.5" width="31" height="18"';
  g += `<rect ${inset} ${fill(palette.paper)}/>`
    + `<rect x="57" y="53.2" width="31" height="5.3" ${fill(water)}/>`
    + `<rect x="71.5" y="42.4" width="12.5" height="3.2" ${fill(block)}/>`
    + `<rect x="74" y="47" width="8.5" height="3.6" ${fill(green)}/>`
    + `<path d="M57 51.6 H88" fill="none" stroke="#ffffff" stroke-width="0.8"/>` // square ends
    + `<path d="M59 55.6 L59.8 53.2" ${line(block, 0.6)}/><rect x="58.3" y="55" width="1.6" `
    + `height="1.6" ${fill(block)}/>`
    + glyphs('BELÉM', 59, 42.9, 2.2, palette.ink, 0.3)
    + route([STOPS.belem[0], [77.6, 48.8], STOPS.belem[1], [70, 52.5], [63, 52.7],
      STOPS.belem[2]], c)
    + STOPS.belem.map((p, i) => pin(p, i + 1, c)).join('')
    // The frame goes on last, over the road and the river that run to its edges.
    + `<rect ${inset} fill="none" stroke="${palette.muted}" stroke-width="0.35"/>`;
  // North, and a scale bar of 200 m.
  g += `<path d="M5 4 L6.4 9 L5 8.2 L3.6 9 Z" ${fill(palette.ink)}/>`
    + glyphs('N', 3.95, 10.2, 2.2, palette.ink, 0.3)
    + `<path d="M3 56.8 V57.8 H13 V56.8" ${line(palette.ink, 0.3)}/>`
    + glyphs('200 m', 14.4, 56, 2, palette.ink, 0.28);
  return sheet(w, MAP.height, `<g transform="translate(0 -3)">${g}</g>`);
}
const drawings = () => ({
  'tiles-cover': coverTiles(), 'tiles-band': bandTiles(), 'tiles-page': pageTiles(),
  tile: tileIcon(), routes: mapArt(),
});
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown样例 · 84行 · content.en.mdtitle: "Lisbon on Foot" subtitle: "Three walks through the old city" author: "Postext Cookbook" --- # Lisbon \\ on Foot {style="cover"} :::pagebreak # Before you set out {style="intro"} Lisbon climbs from the Tagus over seven steep hills, and in Alfama many of the streets are flights of steps. The three walks in this guide keep the climbs short. Each starts at a tram or metro stop, takes between 45 minutes and two hours with time to look around, and passes only public places. Wear shoes with some grip: the limestone paving is worn smooth and turns slippery after rain. Each walk has a colour, and the tab on the edge of its pages is printed in it: terracotta for Alfama, yellow for the Baixa, teal for Belém. ::resource{id="routes"} :::paragraphs{style="colophon"} Set in Albert Sans, DM Serif Display and Asap Condensed (SIL Open Font License). Text, map and tiles: CC BY 4.0. ::: # Alfama {style="alfama" route="From the cathedral to the Pantheon"} :chip[1.4 km]{style="fact"} :chip[1 h 30]{style="fact"} :chip[Uphill start]{style="fact"} :chip[Tram 28]{style="fact"} Alfama is the oldest quarter of Lisbon and the one that came through the earthquake of 1755 best, so its lanes still follow the medieval plan. The walk climbs from the cathedral to two viewpoints, then follows the ridge east to São Vicente and the dome of the Pantheon. The steepest part is the first 400 metres. ### Sé de Lisboa Work on the cathedral began in 1147, the year Afonso Henriques took the city from the Moors, on the site of the main mosque. With its two squat towers and battlements it looks as much like a fort as a church. Earthquakes in 1344 and 1755 brought down parts of it, and a restoration in the twentieth century removed later additions to bring back the Romanesque front you see today. Stand across the street to take in the rose window above the door, then watch for tram 28 grinding round the corner of Rua Augusto Rosa. ### Miradouro de Santa Luzia Follow the tram tracks uphill for about 400 metres. The terrace beside the small church of Santa Luzia looks over the roofs of Alfama to the river, with the dome of the Pantheon and the towers of São Vicente on the ridge to the left. A pergola shades the benches, and in summer it is covered in bougainvillea. :::callout{type="dontmiss"} The two tile panels on the south wall of the church. One shows Praça do Comércio before 1755, with the royal palace on the waterfront; the other shows Christian soldiers storming the castle in 1147. Walk 2 starts in the square the first panel shows. ::: ### Largo das Portas do Sol A few steps on, this square takes its name from the Sun Gate, one of the gates in the Moorish city wall. The statue is São Vicente, patron saint of Lisbon, holding a boat with two ravens. The story goes that ravens kept watch over his body when it was brought to the city by sea in 1173; the boat and the birds are on Lisbon’s coat of arms to this day. The view here is wider than at Santa Luzia: the whole of Alfama drops away below you to the docks, and on a clear day you can see the far shore of the estuary. ### São Vicente de Fora Follow the tram tracks east along the ridge for about 500 metres. The white church ahead is Saint Vincent Outside the Walls, named for a monastery founded here in the twelfth century beyond the city wall. The building you see went up between 1582 and 1629. Its twin towers frame a plain front. The cloisters inside are lined with eighteenth-century azulejos, among them a series of panels illustrating the fables of La Fontaine, and the monastery holds the tombs of the Braganza kings, who ruled Portugal from 1640 to 1910. ### Panteão Nacional Walk down through Campo de Santa Clara, where the Feira da Ladra flea market fills the square on Tuesdays and Saturdays, to the domed church of Santa Engrácia. Building began in 1682 and the dome was finished only in 1966, which is why the Portuguese still call a job that never ends *obras de Santa Engrácia*. Presidents, writers and the fado singer Amália Rodrigues are buried here. Stairs inside the church climb to a terrace that runs right round the dome, with the river on one side and the roofs of Alfama on the other. From the Pantheon, Santa Apolónia station is five minutes downhill. :::paragraphs{style="onward"} **Walk 2 · Baixa.** It starts at Praça do Comércio, a little over a kilometre west along the river. ::: # Baixa {style="baixa" route="From the river to Rossio"} :chip[0.8 km]{style="fact"} :chip[45 min]{style="fact"} :chip[Flat]{style="fact"} :chip[Metro Terreiro do Paço]{style="fact"} An earthquake, a tsunami and five days of fire destroyed the lower town on 1 November 1755. The Marquis of Pombal rebuilt it on a grid of straight streets, in houses framed with timber against the next tremor. ### Praça do Comércio Until 1755 this was Terreiro do Paço, the yard of the royal palace, and many Lisboetas still call it that. Arcades run round three sides; on the fourth, the marble steps of Cais das Colunas go down into the river. ### Arco da Rua Augusta The arch on the north side was finished in 1873: Glory crowns Genius and Valour at the top, over statues of Vasco da Gama and the Marquis of Pombal. Beyond it, Rua Augusta runs north between Rua do Ouro and Rua da Prata, named for the goldsmiths and the silversmiths. ### Rossio Rua Augusta ends in Praça Dom Pedro IV, known to everyone as Rossio. The black and white waves of its paving were laid in the 1840s.
`; // content.<lang>.md, inlined by the Cookbook // Every drawing is an SVG resource, sized in mm at 10 px per mm (as sheet() draws them). const svg = (id, w, h, more) => ({ id, typeId: 'map', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, ...more }); // #region map: the map in the text, keyed by swatches of the walks' colours in its note const map = svg('routes', MAP.width, MAP.height, { placement: { position: 'here' }, ...t({ en: { caption: 'The three walks. The numbered pins are the stops in the text.', note: ':swatch{color="alfama"} Walk 1, Alfama · :swatch{color="baixa"} Walk 2, Baixa · ' + ':swatch{color="belem"} Walk 3, Belém, 6 km west (inset)', altText: 'Map of central Lisbon north of the river: the yellow route runs from the ' + 'waterfront up the Baixa grid to Rossio; the terracotta route climbs east from the ' + 'cathedral through Alfama. An inset shows the teal Belém route.', }, es: { caption: 'Los tres paseos. Los pines numerados son las paradas del texto.', note: ':swatch{color="alfama"} Paseo 1, Alfama · :swatch{color="baixa"} Paseo 2, Baixa · ' + ':swatch{color="belem"} Paseo 3, Belém, 6 km al oeste (en el detalle)', altText: 'Plano del centro de Lisboa al norte del río: la ruta amarilla sube desde el ' + 'muelle por la cuadrícula de la Baixa hasta el Rossio; la terracota sube hacia el este ' + 'desde la catedral por Alfama. En un detalle, la ruta verde azulada de Belém.', } }) }); // #endregion // The other drawings are used by the designs, never placed in the text. const resources = [map, svg('tiles-cover', TRIM.width, 112), svg('tiles-band', 3 * TILE, BAND), svg('tiles-page', TRIM.width, TRIM.height), svg('tile', TILE, TILE)]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses, loaded before the first build (gotcha: fonts-first). const FONTS = { 'Albert Sans': ['400', '400i'], // text, the route under a walk's name 'DM Serif Display': ['400'], // walk names, stops, the cover 'Asap Condensed': ['600', '700'], // labels, chips, tabs, folios, captions }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); for (const [id, art] of Object.entries(drawings())) await loadSvg(`${id}.svg`, art); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Lisbon on Foot', es: 'Lisboa a pie' }) });
工具包 · core, fonts, viewer, images:每道食谱都相同 · 270行// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

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

变化

#不给空白左页涂色

去掉色块后,第6页保留色标:pages: 'all'也把它印在空白页上,是路线2的黄色,印着2。

-const header = { elements: [...heads, ...tabs, ...blankPage] };
+const header = { elements: [...heads, ...tabs] };

#加上第三条路线

WALKS里已经有Belém的颜色,walkStyles里也有它的样式,所以这条路线只需在内容里加上标题。它从第9页开始,前面的第8页是涂成青绿色、印着白色3的空白页。

+# Belém {style="belem" route="From the monastery to the tower"}
+
+Tram 15 runs from Praça da Figueira to Belém along the river.

常见问题

易错点

一页采用最后一个在该页开始的节的页眉

两个标题样式在同一页开始时,这一页采用后一个的页眉、页脚和调色板。分隔用的空白页属于它前面的章,为奇偶页补的空白页属于它后面的章。 分节书眉 →

易错点

节或篇的调色板不会改变行内标签的颜色

在postext 1.4.1中,标题样式或篇的调色板会改变其页面上的文字、框和设计元素的颜色,但行内标签保留其chipStyles条目的颜色。即使行内标签所关联的条目被该节覆盖,每页上显示的仍是文档的值。给行内标签用各节都不改的颜色(如白纸黑字),或者给每节单独一个标签样式。 行内标签 →

易错点

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

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

易错点

标题样式会继承其级别的分页设置

headingStyles中的条目没有写出的字段都取自它所属的标题级别,breakBefore也不例外。在:::pagebreak之后以H1设置样式的目录页或版权页会继承parity 'odd',结果落在一张空白页之后。给这类样式设置breakBefore: { enabled: false }。 标题样式 →

易错点

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

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

易错点

相对容器的负偏移什么也渲染不出来

自动宽度的设计文本被限制在容器内,所以相对容器的负偏移会把它推出去,结果什么也不渲染。把这类元素用明确的mm偏移锚定到页面或出血上,或者给它们一个固定宽度。 锚定设计元素 →

易错点

第1页是右页:按物理页码规划页面

第1页是右页(奇数页),第2页是第一个左页(偶数页),所以要按物理页码规划跨页:偶数页上的章首页对着它后面的奇数页。 换页与换栏 →

易错点

齐左文字从不断词

断词只用于两端对齐的文字;左对齐不齐行的文字在词间断行,所以窄栏的齐左文字行尾会很参差。把这段改为两端对齐,或加大行长。 断词与文档语言 →

易错点

齐左文字从不检查孤字

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

易错点

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

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

易错点

排版前加载所有字体

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

  • 第6页之所以存在,是因为路线1在第5页,也就是一个右页上结束。在左页上结束的路线不会留下空白页,下一条路线的颜色就只出现在它自己的页面上。
  • 在第一条编号路线之前,{chapterNumber}为空,所以印出它的元素(比如色标)在那里需要一个不含它的样式。

致谢

文本
原创文字, CC BY 4.0
图片
  • The tiles, the map of the routes and its pins, drawn in code · Ignacio Ferro · CC BY 4.0
字体
Albert Sans (SIL OFL 1.1) · DM Serif Display (SIL OFL 1.1) · Asap Condensed (SIL OFL 1.1)
沙盒