跳到主要内容
食谱编号33

排版食谱 · 第6章 · 框与注释

菜谱卡片:用料与做法并排

菜谱书的两页,白色菜谱卡片上方有一个SERVES 4页签:左边是用料、标签和清单,右边是配26 pt红色序号的步骤。

页码58–59 · 第1–2页,共2页

  • 西班牙文样例:尚无中文版本
  • 成品尺寸190 × 250 mm
  • 1栏
  • Figtree 9.4/12.8
  • Caveat
  • Young Serif
  • 2页
  • 难度
  • Postext 1.8.0
  • 排版用时14 ms
  • 179行代码

成品一览

Salt & Olive Oil是一本虚构的西班牙家常菜谱,这里是其中相对的两页,成品尺寸190 × 250 mm,一页一道菜。一幅用代码画的插图出血铺满顶部104 mm:藏红花色桌面上切出一块的土豆饼,鼠尾草绿桌面上的一碗西班牙冷汤。插图上叠着眉题、42 pt Young Serif标题、表示用时、难度和季节的胶囊标签,还有一句Caveat手写体批注指向菜品。斜体引言下方是一张白色卡片,右上角有番茄红的份量页签。用料沿左栏排下,前面是橄榄绿短横,后面是标签;土豆饼的用料下方,碗、锅和盘子的线描领起一份清单。做法占满右栏,每一步以26 pt的旧式数字开头。

这道食谱解答

  • 怎样在框里分两栏(文字栏旁边是图栏)?
  • 怎样加编号标签(“BOX 1-1”)、角标图标,或带线的边栏图标?
  • 怎样定制列表:每级的项目符号、(a)/(i)编号、任务复选框、保持在网格上的间距?
  • 怎样做行内标签:键盘按键、标签、练习用的词库?
  • 怎样给章首页加作者行、提要或带首字下沉的导语?

简短回答

script.js · 第25–47行在完整代码中
// In the Markdown, :::callout{type="card" label="SERVES 4"} holds a :::columns{count=2} group
// of two nested boxes, :::callout{type="column" title="Ingredients"} and one for the method.
// The group levels its columns by cutting between blocks or lines, so loose lists would run
// the method on under the ingredients. A nested box is one block that never splits, so the
// only cut left is between the two (gotcha: callout-columns).
const TAB = 5.6; // mm: the tab's height, and how far it rises above the card
const card = { id: 'card', background: col('card'),
  padding: { top: mm(4.5), right: mm(6), bottom: mm(5), left: mm(6) },
  // No space of its own above: the tab starts on the first grid line under the opener.
  columnGap: mm(7), marginTop: mm(0),
  // label="…" on the fence prints here: a tab on the top-right corner, a cutlery pictogram
  // beside it and a rule from the far corner that makes the tab part of the card.
  label: { fontFamily: TEXT, fontSize: pt(8), color: col('card'), // bold by default
    background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
    icon: { resourceId: 'cutlery', width: mm(TAB * 6 / 8), gap: mm(1.6) }, // as tall as the tab
    rule: { enabled: true, color: col('tomato'), width: pt(1.2) } } };
// The two columns: frameless boxes whose only device is a tracked title.
const NONE = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) };
const column = { id: 'column', backgroundEnabled: false, padding: NONE,
  lists: { gap: mm(2.2), itemSpacing: pt(3.5) }, // for the steps as well as the dashes
  titleStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('tomato'), // bold by default
    textTransform: 'uppercase', letterSpacing: pt(1.5), gap: mm(2.4) } };
// config() plugs them in: calloutStyles: [card, column, prep].

用料

类型
Young Serif, Figtree, Caveat(SIL OFL 1.1)
素材
无:所有图片都用代码绘制

做法

#1 · 每栏一个自己的框

用松散列表时,框内的分栏组会在各栏最齐的地方切开,可能在块之间,也可能在一个块的行之间,于是冷汤的第一步被栏间距劈成两半:第一行排在用料栏末尾,其余部分排在做法栏开头。像上面简短回答那样嵌套一个框,它就成为一个整块,分栏组永远不会从中切开。放在其中的图片需要一个placement为'here'的::resource,因为任何其他placement都会让它成为浮动体,离开框,像任何被引用的图一样另行放置。卡片样式的label把围栏上的label="SERVES 4"变成页签:offset等于它的height,就让它立在卡片上边缘,rule从远端的角一直画到刀叉图标(标注框样式)。

#2 · 让步骤序号在两行之间居中

script.js · 第65–75行在完整代码中
// Young Serif has old-style figures: 1 and 2 stand 0.56 em, a little above its 0.50 em
// x-height, so 5.1 mm at STEP. A list number is centred 0.3 em (of the text) above the item's
// first baseline, with the canvas 'middle' baseline, which Chrome puts 0.24 em above Young
// Serif's own (gotcha: list-number-centred). DROP centres the figures on the step's first two
// lines, from the cap height of the first to the baseline of the second.
const [STEP, FIGURE, MIDDLE, CAP] = [26, 0.56, 0.24, 0.7]; // pt; em of each face
const DROP = (LEAD - CAP * BODY) / 2 + 0.3 * BODY + (FIGURE / 2 - MIDDLE) * STEP; // pt
const orderedLists = { fontFamily: DISPLAY, fontWeight: 400, // Young Serif ships 400 only
  numberFontSize: pt(STEP), color: col('tomato'),
  separatorColor: col('olive'), separatorGap: pt(0.6), // the default '.' as its own run
  numberVerticalOffset: pt(DROP) };

列表序号画在条目第一条基线稍上方并居中,所以只设numberFontSize会让26 pt的数字与第一行齐平;numberVerticalOffset把它们下移2.5 mm,移到从第一行大写字母顶部到第二行基线这一区间的中部。Young Serif用的是旧式数字:3、4、5会伸到1和2所在的基线以下。设了separatorColor后,分隔符作为单独的一段文字绘制,默认的句点就以橄榄绿印在红色数字旁(有序列表)。

#3 · 用一排线描领起清单

script.js · 第51–61行在完整代码中
// :::callout{type="prep" title="Before you start"}, closed at once, is a header: a box that
// holds only its title and icon. The icon is one picture of three drawings; a width KIT times
// its size makes its box a strip, and the picture is fitted into width × size, left of the
// title. Tasks set inside the box would start after that column, 21 mm in, so the '- [ ]'
// items follow the box, where they print the default task box, '☐'.
const [STRIP, KIT] = [5, 30 / 8]; // mm: the strip's height; the drawing's width over height
const prep = { id: 'prep', backgroundEnabled: false, padding: NONE,
  marginTop: pt(LEAD), marginBottom: column.titleStyle.gap, // the tasks follow at this gap
  icon: { kind: 'resource', resourceId: 'kit', size: mm(STRIP), width: mm(STRIP * KIT),
    align: 'center' }, // the title centred on the strip
  titleStyle: { ...column.titleStyle, color: col('olive') } };

prep框里只有标题和图标,所以它起标题栏的作用:图标width是size的3.75倍,把图标的正方形拉成18.75 × 5 mm的长条,三幅线描排满标题左侧(标注框样式)。图标在框内独占一栏,与框内其他内容并排,所以任务项放在框之后,这样它们与用料的短横对齐,而不是从21 mm处开始。- [ ]条目印出默认的任务框,unorderedLists.marginTop: 0让它们以标题栏的marginBottom紧随其后,即各栏标题保留的2.4 mm间距(无序列表)。

#4 · 把标签做成行内标签

script.js · 第79–81行在完整代码中
const chipStyles = [{ id: 'tag', fontSize: em(0.86), bold: true, background: col('tint'),
  borderWidth: pt(0), borderRadius: em(1), // no outline; a radius past half the height: a pill
  paddingX: em(0.7), paddingY: em(0.18), gap: em(0.3) }];

:chip[Vegetarian]{style="tag"}把一个词放进一个永不跨行断开的框。大于框高一半的borderRadius会被限制为一半,两端就成了半圆;borderWidth: 0时,胶囊只用底色绘制。框高11.4 pt,小于卡片12.8 pt的行距,所以第二行标签不会碰到第一行(行内标签样式)。

#5 · 一套版面设计,每道菜一幅图

script.js · 第85–116行在完整代码中
const BAND = 104; // mm: the drawing's foot; the pills sit 13 mm above it, the lead 6 mm below
const NOTE = { x: 18, y: 24, w: 70 }; // mm on the page: the box ends 2 mm before the arrow
const at = (x, y, size) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y - PAGE.top) }, size }); // y in mm from the top of the page
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), align: 'left',
  overflow: 'wrap', placement, ...extra }); // gotcha: overflow-ellipsis-default
const tracked = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(1.6) };
// Pills are text boxes chained right-of each other; each prints one heading attribute.
const pill = (id, after) => text(id, `{attr.${id}}`, TEXT, 8.4, after ? { anchor:
  { to: `#${after}`, edge: 'right-of' }, offset: { x: mm(1.8) } } : at(0, BAND - 13), {
  fontWeight: 600, box: { backgroundColor: col('card'), borderRadius: mm(3),
    padding: { top: mm(1.1), right: mm(2.8), bottom: mm(1.1), left: mm(2.8) } } });
// One heading style for every recipe; each heading names its drawing: art="tortilla".
const opener = { id: 'receta', advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: '{attr.art}', // filled in per heading, like a text
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(BAND) } } },
    text('note', '{attr.note}', HAND, 19, { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(NOTE.x), y: mm(NOTE.y) }, size: { width: mm(NOTE.w) } },
    { fontWeight: 600, align: 'right' }), // on the page, like the arrow drawn in the picture
    pill('time'), pill('level', 'time'), pill('season', 'level'),
    text('title', '{titleText}', DISPLAY, 42, { anchor: { to: '#time', edge: 'above' },
      offset: { y: mm(-3.2) }, size: { width: mm(96) } }, // two lines: the \\ in the heading
    { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
    text('kicker', '{attr.kicker}', TEXT, 8.2, { anchor: { to: '#title', edge: 'above' },
      offset: { y: mm(-2.4) }, size: { width: mm(96) } }, tracked),
    // The drawing reserves no height (gotcha: opener-image-no-reserve); the lead under it does,
    // so the card starts below the lead without a minHeight.
    text('lead', '{attr.lead}', TEXT, 10.5, at(0, BAND + 6, { width: mm(122) }),
      { italic: true, lineHeight: 1.45 }),
  ] } } };

# Tortilla \\ de patatas {style="receta" art="tortilla" kicker="…" lead="…"}选定标题样式,并用属性填入各个位置,斜体引言也在其中(标题属性)。图片元素的resourceId是'{attr.art}',和文字里的占位符一样,所以两道菜共用一个样式,由各自的标题指明插图(图片元素)。每个胶囊标签锚定在前一个的right-of,标题锚定在第一个胶囊的above,眉题在标题上方,所以标题变长时眉题上移,胶囊标签留在原来那一行。在1.4.1中插图不预留高度,但它下面的引言会预留,所以不设minHeight卡片也从引言下方开始。批注和插图一样锚定在页面上,两页上都在画出的箭头前2 mm处结束,尽管对称的页边距让右页的版心向右移了2 mm。

#6 · 把插图注册为资源

script.js · 第403–419行在完整代码中
// The opener's image element and both icons name a resource id; the resource names the file
// the canvas paints (loadSvg registers it). No :ref cites them, so none is numbered.
const ART = { // markup, width and height in mm, and the alt text
  tortilla: [tortillaArt(), PAGE.w, BAND, t({ en: 'A potato omelette with a slice pulled out, '
    + 'on a blue-rimmed plate and a red-checked napkin', es: 'Una tortilla de patatas con una '
    + 'porción separada, en un plato de borde azul sobre una servilleta de cuadros rojos' })],
  gazpacho: [gazpachoArt(), PAGE.w, BAND, t({ en: 'A bowl of gazpacho with diced vegetables and '
    + 'a spoon, on a blue-checked napkin beside two tomatoes', es: 'Un cuenco de gazpacho con '
    + 'dados de verdura y una cuchara, sobre una servilleta de cuadros azules junto a dos '
    + 'tomates' })],
  cutlery: [cutlery, 6, 8, t({ en: 'Fork and knife', es: 'Tenedor y cuchillo' })],
  kit: [kit, 8 * KIT, 8, t({ en: 'A bowl with two eggs, a frying pan and a plate',
    es: 'Un bol con dos huevos, una sartén y un plato' })] };
const resources = Object.entries(ART).map(([id, [, w, h, altText]]) => ({ id, typeId: 'figure',
  kind: 'svg', createdAt: 0, updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10,
  height: h * 10 } })); // 10 px a millimetre: the sizes only set the aspect ratio here
await Promise.all(Object.entries(ART).map(([id, [svg]]) => loadSvg(`${id}.svg`, svg)));

开篇的图片元素、页签的图标和清单标题栏的图标各自指明一个资源,Canvas绘制loadSvg为它注册的文件。没有:ref引用这些资源,所以它们都不会成为编号的图。作为图片绘制的SVG用不了页面的网页字体,所以插图里没有文字:手写批注是叠在上面的设计文字。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 033 · Recipe card: ingredients beside the method ═══════
// https://postext.dev/en/cookbook/recipe-card
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Young Serif, Figtree, Caveat (SIL OFL 1.1) · Needs postext ≥ 1.8.0
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#2b2118', muted: '#76634e', // text; folios and the colophon
  paper: '#f7eddb', card: '#fffdf8', // the cream page; the white recipe card on it
  tomato: '#bf3d29', olive: '#6b7a3a', tint: '#f6e3c1' }; // numbers and tab; dashes; tags
// The hex rides along: design elements read it, not the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the tomato, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.tomato })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, HAND] = ['Figtree', 'Young Serif', 'Caveat'];
const PAGE = { w: 190, h: 250, top: 22, inner: 18, outer: 16 }; // mm, mirrored margins
const [BODY, LEAD] = [9.4, 12.8]; // pt: the text of the cards and the notes under them

// #region answer: a white card with a servings tab, two columns inside it
// In the Markdown, :::callout{type="card" label="SERVES 4"} holds a :::columns{count=2} group
// of two nested boxes, :::callout{type="column" title="Ingredients"} and one for the method.
// The group levels its columns by cutting between blocks or lines, so loose lists would run
// the method on under the ingredients. A nested box is one block that never splits, so the
// only cut left is between the two (gotcha: callout-columns).
const TAB = 5.6; // mm: the tab's height, and how far it rises above the card
const card = { id: 'card', background: col('card'),
  padding: { top: mm(4.5), right: mm(6), bottom: mm(5), left: mm(6) },
  // No space of its own above: the tab starts on the first grid line under the opener.
  columnGap: mm(7), marginTop: mm(0),
  // label="…" on the fence prints here: a tab on the top-right corner, a cutlery pictogram
  // beside it and a rule from the far corner that makes the tab part of the card.
  label: { fontFamily: TEXT, fontSize: pt(8), color: col('card'), // bold by default
    background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
    icon: { resourceId: 'cutlery', width: mm(TAB * 6 / 8), gap: mm(1.6) }, // as tall as the tab
    rule: { enabled: true, color: col('tomato'), width: pt(1.2) } } };
// The two columns: frameless boxes whose only device is a tracked title.
const NONE = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) };
const column = { id: 'column', backgroundEnabled: false, padding: NONE,
  lists: { gap: mm(2.2), itemSpacing: pt(3.5) }, // for the steps as well as the dashes
  titleStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('tomato'), // bold by default
    textTransform: 'uppercase', letterSpacing: pt(1.5), gap: mm(2.4) } };
// config() plugs them in: calloutStyles: [card, column, prep].
// #endregion

// #region prep: a checklist under a header with a strip of three pictograms
// :::callout{type="prep" title="Before you start"}, closed at once, is a header: a box that
// holds only its title and icon. The icon is one picture of three drawings; a width KIT times
// its size makes its box a strip, and the picture is fitted into width × size, left of the
// title. Tasks set inside the box would start after that column, 21 mm in, so the '- [ ]'
// items follow the box, where they print the default task box, '☐'.
const [STRIP, KIT] = [5, 30 / 8]; // mm: the strip's height; the drawing's width over height
const prep = { id: 'prep', backgroundEnabled: false, padding: NONE,
  marginTop: pt(LEAD), marginBottom: column.titleStyle.gap, // the tasks follow at this gap
  icon: { kind: 'resource', resourceId: 'kit', size: mm(STRIP), width: mm(STRIP * KIT),
    align: 'center' }, // the title centred on the strip
  titleStyle: { ...column.titleStyle, color: col('olive') } };
// #endregion

// #region steps: big step numbers in the display face, an olive full stop after each
// Young Serif has old-style figures: 1 and 2 stand 0.56 em, a little above its 0.50 em
// x-height, so 5.1 mm at STEP. A list number is centred 0.3 em (of the text) above the item's
// first baseline, with the canvas 'middle' baseline, which Chrome puts 0.24 em above Young
// Serif's own (gotcha: list-number-centred). DROP centres the figures on the step's first two
// lines, from the cap height of the first to the baseline of the second.
const [STEP, FIGURE, MIDDLE, CAP] = [26, 0.56, 0.24, 0.7]; // pt; em of each face
const DROP = (LEAD - CAP * BODY) / 2 + 0.3 * BODY + (FIGURE / 2 - MIDDLE) * STEP; // pt
const orderedLists = { fontFamily: DISPLAY, fontWeight: 400, // Young Serif ships 400 only
  numberFontSize: pt(STEP), color: col('tomato'),
  separatorColor: col('olive'), separatorGap: pt(0.6), // the default '.' as its own run
  numberVerticalOffset: pt(DROP) };
// #endregion

// #region chips: tags for diet and occasion, as pills in the text
const chipStyles = [{ id: 'tag', fontSize: em(0.86), bold: true, background: col('tint'),
  borderWidth: pt(0), borderRadius: em(1), // no outline; a radius past half the height: a pill
  paddingX: em(0.7), paddingY: em(0.18), gap: em(0.3) }];
// #endregion

// #region opener: the drawing bled across the head, the title and pills set on it
const BAND = 104; // mm: the drawing's foot; the pills sit 13 mm above it, the lead 6 mm below
const NOTE = { x: 18, y: 24, w: 70 }; // mm on the page: the box ends 2 mm before the arrow
const at = (x, y, size) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y - PAGE.top) }, size }); // y in mm from the top of the page
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), align: 'left',
  overflow: 'wrap', placement, ...extra }); // gotcha: overflow-ellipsis-default
const tracked = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(1.6) };
// Pills are text boxes chained right-of each other; each prints one heading attribute.
const pill = (id, after) => text(id, `{attr.${id}}`, TEXT, 8.4, after ? { anchor:
  { to: `#${after}`, edge: 'right-of' }, offset: { x: mm(1.8) } } : at(0, BAND - 13), {
  fontWeight: 600, box: { backgroundColor: col('card'), borderRadius: mm(3),
    padding: { top: mm(1.1), right: mm(2.8), bottom: mm(1.1), left: mm(2.8) } } });
// One heading style for every recipe; each heading names its drawing: art="tortilla".
const opener = { id: 'receta', advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: '{attr.art}', // filled in per heading, like a text
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(BAND) } } },
    text('note', '{attr.note}', HAND, 19, { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(NOTE.x), y: mm(NOTE.y) }, size: { width: mm(NOTE.w) } },
    { fontWeight: 600, align: 'right' }), // on the page, like the arrow drawn in the picture
    pill('time'), pill('level', 'time'), pill('season', 'level'),
    text('title', '{titleText}', DISPLAY, 42, { anchor: { to: '#time', edge: 'above' },
      offset: { y: mm(-3.2) }, size: { width: mm(96) } }, // two lines: the \\ in the heading
    { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
    text('kicker', '{attr.kicker}', TEXT, 8.2, { anchor: { to: '#title', edge: 'above' },
      offset: { y: mm(-2.4) }, size: { width: mm(96) } }, tracked),
    // The drawing reserves no height (gotcha: opener-image-no-reserve); the lead under it does,
    // so the card starts below the lead without a minHeight.
    text('lead', '{attr.lead}', TEXT, 10.5, at(0, BAND + 6, { width: mm(122) }),
      { italic: true, lineHeight: 1.45 }),
  ] } } };
// #endregion

// Folios at the foot of the outer corner: the book on versos, the recipe on rectos.
const foot = (id, content, parity, edge, x, look) => text(id, content, TEXT, 7.6,
  { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-12) } },
  { color: col('muted'), parity, align: edge.endsWith('left') ? 'left' : 'right', ...look });
const folio = { fontFamily: DISPLAY, fontSize: pt(10), color: col('tomato') };
const footer = { elements: [
  foot('verso-folio', '{pageNumber}', 'even', 'bottom-left', PAGE.outer, folio),
  foot('verso-book', '{title}', 'even', 'bottom-left', PAGE.outer + 9, tracked),
  foot('recto-dish', '{chapterTitle}', 'odd', 'bottom-right', -(PAGE.outer + 9), tracked),
  foot('recto-folio', '{pageNumber}', 'odd', 'bottom-right', -PAGE.outer, folio),
] };

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  colorPalette, chipStyles, orderedLists, footer, header: { elements: [] },
  page: { width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(PAGE.top), bottom: mm(20), left: mm(PAGE.inner),
      right: mm(PAGE.outer), mirror: true } }, // left is the inner margin
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: mm(0) }, // ragged and flush: the notes under the cards
  // A designed heading's own text is hidden but still measured: in Young Serif 400, the only
  // weight it ships, not in the default Open Sans 700 that FONTS does not load.
  headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    // 'any': each recipe opens the next page, whichever side it is on. span: 'page' paints the
    // opener outside the column's clip: kept in the column, the drawing is cut at the top margin.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } },
  ] },
  headingStyles: [opener],
  // Olive dashes for the whole document: an olive lists.color on the column style would turn
  // the step numbers olive too (gotcha: box-list-colour-numbers).
  unorderedLists: { bulletChar: '–', color: col('olive'),
    marginTop: mm(0), // under the checklist's header, the header's marginBottom alone
    marginBottom: pt(LEAD / 2) }, // half a line above the tags
  calloutStyles: [card, column, prep],
  paragraphStyles: [{ id: 'colophon', fontSize: pt(7.4), lineHeight: pt(10),
    color: col('muted'), marginTop: pt(LEAD) }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown样例 · 66行 · content.es.mdtitle: "Sal y aceite" --- # Tortilla \\ de patatas {style="receta" art="tortilla" kicker="Huevos y patatas" time="45 min" level="Dificultad media" season="Todo el año" note="¡con cebolla, siempre!" lead="La de casa: patata pochada despacio en mucho aceite de oliva, huevo apenas cuajado y diez minutos de reposo para que la patata se empape. Con cebolla, como la de la abuela; sin ella, si en tu casa se discute."} :::callout{type="card" label="PARA 4"} :::columns{count=2} :::callout{type="column" title="Ingredientes"} - **6** huevos camperos grandes - **800 g** de patatas para freír - **1** cebolla mediana - **300 ml** de aceite de oliva virgen extra - Sal fina :chip[Vegetariana]{style="tag"} :chip[Sin gluten]{style="tag"} :chip[De fiambrera]{style="tag"} :::callout{type="prep" title="Antes de empezar"} ::: - [ ] Los huevos, fuera de la nevera - [ ] Un bol para el huevo y la patata - [ ] Un plato más ancho que la sartén ::: :::callout{type="column" title="Elaboración"} 1. Pela las patatas y córtalas en láminas finas; la cebolla, en juliana. Sálalas. 2. Pocha las dos en el aceite, a fuego medio y en una sartén de 24 cm, unos 20 minutos: tiernas y sin dorar. 3. Escúrrelas y guarda el aceite. Bate los huevos con sal, añade la patata aún caliente y deja reposar 10 minutos. 4. Calienta a fuego fuerte una cucharada del aceite, vierte la mezcla y cuájala dos minutos, despegando los bordes. 5. Dale la vuelta con un plato, cuájala un minuto más y sírvela templada. ::: ::: ::: **Guarda el aceite.** Colado y en un tarro, sirve para freír la próxima tortilla. # Gazpacho \\ andaluz {style="receta" art="gazpacho" kicker="Sopas frías" time="25 min + 2 h de nevera" level="Fácil" season="De junio a septiembre" note="¡bien frío!" lead="En muchas casas andaluzas se bebe en vaso, recién sacado de la nevera. Pide tomates maduros de verdad y el aceite en hilo con la batidora en marcha, que lo deja cremoso y anaranjado."} :::callout{type="card" label="PARA 6"} :::columns{count=2} :::callout{type="column" title="Ingredientes"} - **1,5 kg** de tomates de pera maduros - **1** pimiento verde italiano - **1** pepino - **1** diente de ajo - **100 g** de pan del día anterior, remojado - **120 ml** de aceite de oliva virgen extra - **3 cucharadas** de vinagre de Jerez - Sal y agua fría :chip[Vegana]{style="tag"} :chip[Sin fuego]{style="tag"} :chip[De víspera]{style="tag"} ::: :::callout{type="column" title="Elaboración"} 1. Trocea los tomates, el pimiento sin semillas, el pepino pelado y el ajo. 2. Ponlo todo en la batidora con el pan remojado, el vinagre y una cucharadita de sal; deja macerar 15 minutos. 3. Tritura 2 minutos a máxima potencia y, sin parar el motor, añade el aceite en hilo fino hasta que emulsione. 4. Pásalo por un colador fino y aligéralo con agua fría a tu gusto. 5. Rectifica de sal y vinagre y enfríalo al menos 2 horas antes de servirlo. ::: ::: ::: **Hazlo de víspera.** Aguanta dos días en la nevera, en una jarra tapada; remuévelo antes de servirlo. :::paragraphs{style="colophon"} *Sal y aceite* · Compuesto en Young Serif, Figtree y Caveat (SIL OFL) · Recetas y dibujos originales, CC BY 4.0 :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: two table-top drawings and the pictograms, in the palette's colours // No words in them: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts); // the handwritten note is a design element set in Caveat where the drawn arrow starts. const TABLE = { saffron: '#e9a23b', sage: '#b9c08a', blue: '#2f5d8a', china: '#fffaf0', gold: '#e9b659', crust: '#d4923a', brown: '#b8702a', soup: '#d6552f', oil: '#e8c547' }; const n = (v) => +v.toFixed(2); function mulberry32(seed) { // a seeded PRNG: the same drawing in every capture return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const svgDoc = (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 circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" ` + `fill="${fill}"${extra}/>`; const blob = (x, y, rx, ry, turn, fill, opacity) => `<ellipse cx="${n(x)}" cy="${n(y)}" ` + `rx="${n(rx)}" ry="${n(ry)}" transform="rotate(${n(turn)} ${n(x)} ${n(y)})" fill="${fill}" ` + `fill-opacity="${n(opacity)}"/>`; const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const stroke = (d, color, width, extra = '') => path(d, 'none', ` stroke="${color}" ` + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}`); const group = (x, y, turn, body) => `<g transform="translate(${n(x)} ${n(y)}) ` + `rotate(${n(turn)})">${body}</g>`; const polar = (cx, cy, r, deg) => [cx + r * Math.cos(deg * Math.PI / 180), cy + r * Math.sin(deg * Math.PI / 180)]; // A napkin in checks: two sets of translucent stripes, darker where they cross. function gingham(size, check, color, opacity) { let out = `<rect x="${-size / 2}" y="${-size / 2}" width="${size}" height="${size}" ` + `fill="${TABLE.china}"/>`; for (let i = 0; i < size / check; i++) { const at = -size / 2 + i * check; out += `<rect x="${n(at)}" y="${-size / 2}" width="${check / 2}" height="${size}" ` + `fill="${color}" fill-opacity="${opacity}"/>` + `<rect x="${-size / 2}" y="${n(at)}" width="${size}" height="${check / 2}" ` + `fill="${color}" fill-opacity="${opacity}"/>`; } return out; } // A shadow, white china with a blue rim, a ring of cream dots and a fine inner line. function plate(r, rim) { let out = circle(1.6, 2.4, r, palette.ink, ' fill-opacity=".16"') + circle(0, 0, r, TABLE.china) + circle(0, 0, r - rim / 2, 'none', ` stroke="${TABLE.blue}" stroke-width="${rim}"`); for (let a = 0; a < 360; a += 10) { out += circle(...polar(0, 0, r - rim / 2, a), 0.55, TABLE.china); } return out + circle(0, 0, r - rim - 1.4, 'none', ` stroke="${TABLE.blue}" stroke-width=".45"`); } // A sector path: the omelette with a slice taken out, or the slice itself. const sector = (r, a0, a1) => { const [x0, y0] = polar(0, 0, r, a0); const [x1, y1] = polar(0, 0, r, a1); return `M0 0L${n(x0)} ${n(y0)}A${r} ${r} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${n(x1)} ${n(y1)}Z`; }; function omelette(r, a0, a1, rand) { // a browned rim, a golden top mottled where it caught let out = path(sector(r, a0, a1), TABLE.crust) + path(sector(r - 1.8, a0, a1), TABLE.gold); // Spots in proportion to the sector, each kept clear of the rim and of both cut edges. const clear = (d, da) => da > 0 && d * Math.sin(Math.min(da, 90) * Math.PI / 180); // mm const inside = (d, a, rx) => d + rx < r - 2.4 && [a - a0, a1 - a].every((da) => clear(d, da) > rx + 0.4); const spot = (count, size, fill, opacity) => { for (let i = 0; i < Math.ceil(count * (a1 - a0) / 360); i++) { const rx = size * (0.5 + rand()); const [d, a] = [Math.sqrt(rand()) * r, a0 + rand() * (a1 - a0)]; const [ry, turn, alpha] = [rx * (0.4 + rand() * 0.4), rand() * 180, 0.6 + rand() * 0.6]; if (inside(d, a, rx)) out += blob(...polar(0, 0, d, a), rx, ry, turn, fill, opacity * alpha); } }; spot(24, 3.4, TABLE.crust, 0.4); // browned patches spot(12, 2.4, '#f5d98a', 0.35); // pale patches spot(48, 0.45, TABLE.brown, 0.5); // specks for (const a of [a0, a1]) { // the cut edges catch the light: a pale line along each const [x, y] = polar(0, 0, r - 1, a); out += stroke(`M0 0L${n(x)} ${n(y)}`, '#f5dc93', 0.9); } return out; } function arrow(d, tip, turn) { // a hand-drawn line and its head, as paths, never a <marker> return stroke(d, palette.ink, 0.55) + group(...tip, turn, stroke('M-2.4-1.3L0 0-2.4 1.5', palette.ink, 0.55)); } function sprig(x, y, turn, rand) { // an olive twig: paired grey-green leaves and two olives let out = stroke('M0 0C10-2 22-3 34-9', palette.olive, 0.7); for (let i = 0; i < 7; i++) { const t = 3 + i * 4.4; for (const side of [-1, 1]) { const leaf = 'M0 0C2-1.3 7-1.5 10 0C7 1.5 2 1.3 0 0Z'; out += group(t, -t * 0.18, side * (32 + rand() * 12) - 8, path(leaf, side > 0 ? palette.olive : '#8d9a5c')); } } out += circle(12, 4.6, 2.3, '#4a3a38') + circle(20, 3.4, 2.1, '#7d8a3a') + circle(11.3, 3.8, 0.6, TABLE.china, ' fill-opacity=".5"'); return group(x, y, turn, out); } const [PLATE_X, PLATE_Y] = [138, 45]; // mm: the plate or bowl, right of the title function tortillaArt() { const rand = mulberry32(23); let body = `<rect width="${PAGE.w}" height="${BAND}" fill="${TABLE.saffron}"/>`; body += group(152, 32, -11, gingham(108, 11, palette.tomato, 0.2)); const [CUT0, CUT1, PULL] = [-6, 30, 9]; // the slice in degrees; mm it is pulled out const shadow = (a0, a1) => group(0.9, 1.5, 0, path(sector(31, a0, a1), palette.ink, ' fill-opacity=".14"')); const slice = polar(0, 0, PULL, (CUT0 + CUT1) / 2); body += group(PLATE_X, PLATE_Y, 0, plate(43, 6.4) + shadow(CUT1, CUT0 + 360) + omelette(31, CUT1, CUT0 + 360, rand) + group(...slice, 0, shadow(CUT0, CUT1) + omelette(31, CUT0, CUT1, rand))); body += sprig(150, 95, -24, rand); body += arrow('M90 30C96 27 100 29 103 34', [103, 34], 62); // from the note towards the omelette return svgDoc(PAGE.w, BAND, body); } function tomato(x, y, r, turn) { // seen from above: a red disc, a green star, a highlight const star = [0, 72, 144, 216, 288].map((a) => group(0, 0, a, path('M0 0C1-1 3.2-1 4.2 0C3.2 1 1 1 0 0Z', palette.olive))).join(''); return group(x, y, turn, circle(0, 0, r, palette.tomato) + circle(-r * 0.35, -r * 0.35, r * 0.3, TABLE.china, ' fill-opacity=".25"') + star + circle(0, 0, 0.9, '#4f5c27')); } function gazpachoArt() { const rand = mulberry32(7); let body = `<rect width="${PAGE.w}" height="${BAND}" fill="${TABLE.sage}"/>`; body += group(156, 32, 9, gingham(108, 11, TABLE.blue, 0.22)); // A bowl from above: rim, pale inner wall, soup, drops of oil and a heap of diced vegetables. let bowl = plate(42, 5.8) + circle(0, 0, 32.5, '#efe5d2') + circle(0, 0, 29.5, TABLE.soup) + circle(0, 0, 29.5, 'none', ' stroke="#b8401f" stroke-width="1.2"') + path('M-24-12A26 26 0 0 1 4-26A28 28 0 0 0-24-12Z', '#e2703f'); // light on the surface for (const [x, y, rr] of [[-14, 4, 2.4], [-10, 12, 1.4], [-17, -6, 1.6], [-6, 17, 1.8], [-19, 3, 0.9], [2, 20, 1.1], [-12, -12, 1]]) { bowl += blob(x, y, rr, rr * 0.8, 20, TABLE.oil, 0.9); } const dice = ['#8fae4a', '#4f7a2a', '#f1d49a', '#b8321e', '#e3bf72']; for (let i = 0; i < 24; i++) { const [x, y] = polar(7, -5, Math.sqrt(rand()) * 11, rand() * 360); const size = 2 + rand() * 1.3; bowl += group(x, y, rand() * 90, `<rect x="${n(-size / 2)}" y="${n(-size / 2)}" ` + `width="${n(size)}" height="${n(size)}" rx=".5" fill="${dice[i % 5]}"/>`); } // A spoon resting in the soup, its handle over the rim towards the napkin. bowl += group(14, -12, -38, stroke('M6 0L40 0', palette.ink, 3.2, ' stroke-opacity=".14" transform="translate(1 1.5)"') + stroke('M6 0L40 0', '#cfcabf', 3) + blob(0, 0, 7, 4.6, 0, '#dedad0', 1) + blob(-0.8, -0.8, 4.6, 2.6, 0, TABLE.china, 0.55)); body += group(PLATE_X, PLATE_Y, 0, bowl); // Two tomatoes at the foot, shadows included, clear of the band's lower edge. const shade = (x, y, r) => circle(x + 1, y + 1.6, r, palette.ink, ' fill-opacity=".15"'); body += shade(180, 91, 9) + tomato(180, 91, 9, 12) + shade(164, 95, 6.5) + tomato(164, 95, 6.5, -30); body += arrow('M90 30C96 27 100 29 104 33', [104, 33], 55); // from the note to the bowl return svgDoc(PAGE.w, BAND, body); } const cutlery = svgDoc(6, 8, stroke('M1.6 .6V7.4M.6 .6V2.6C.6 3.4 2.6 3.4 2.6 2.6V.6', palette.tomato, 0.55) + stroke('M4.6 7.4V.6C5.8 1.4 5.8 3.6 4.6 4.4', palette.tomato, 0.55)); // The kit in line drawings: a bowl with two eggs, the frying pan and the plate that turns the // tortilla over, in a box KIT times as wide as it is tall. const egg = (x, turn) => `<ellipse cx="${x}" cy="2.5" rx="1.05" ry="1.35" ` + `transform="rotate(${turn} ${x} 2.5)" fill="none" stroke="${palette.olive}" ` + 'stroke-width=".6"/>'; const kit = svgDoc(8 * KIT, 8, egg(3, -12) + egg(5, 14) + stroke('M.6 3.9H7.4M1 3.9C1 6.4 2.4 7.4 4 7.4S7 6.4 7 3.9', palette.olive, 0.7) + stroke('M9.8 4.4H16.8M10.2 4.4L10.8 6.7C10.9 7.1 11.2 7.3 11.6 7.3H15C15.4 7.3 15.7 7.1 ' + '15.8 6.7L16.4 4.4', palette.olive, 0.7) + stroke('M16.8 5L20.2 4.1', palette.olive, 1.1) + circle(26.2, 4.2, 3.3, 'none', ` stroke="${palette.olive}" stroke-width=".7"`) + circle(26.2, 4.2, 2, 'none', ` stroke="${palette.olive}" stroke-width=".5"`)); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses. Layout measures with the browser's fonts, so the // kit loads them from Fontsource before the first build (gotcha: fonts-first). const FONTS = { Figtree: ['400', '400i', '600', '700'], 'Young Serif': ['400'], Caveat: ['600'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region pictures: the drawings and the pictograms are resources, cited by id, never by :ref // The opener's image element and both icons name a resource id; the resource names the file // the canvas paints (loadSvg registers it). No :ref cites them, so none is numbered. const ART = { // markup, width and height in mm, and the alt text tortilla: [tortillaArt(), PAGE.w, BAND, t({ en: 'A potato omelette with a slice pulled out, ' + 'on a blue-rimmed plate and a red-checked napkin', es: 'Una tortilla de patatas con una ' + 'porción separada, en un plato de borde azul sobre una servilleta de cuadros rojos' })], gazpacho: [gazpachoArt(), PAGE.w, BAND, t({ en: 'A bowl of gazpacho with diced vegetables and ' + 'a spoon, on a blue-checked napkin beside two tomatoes', es: 'Un cuenco de gazpacho con ' + 'dados de verdura y una cuchara, sobre una servilleta de cuadros azules junto a dos ' + 'tomates' })], cutlery: [cutlery, 6, 8, t({ en: 'Fork and knife', es: 'Tenedor y cuchillo' })], kit: [kit, 8 * KIT, 8, t({ en: 'A bowl with two eggs, a frying pan and a plate', es: 'Un bol con dos huevos, una sartén y un plato' })] }; const resources = Object.entries(ART).map(([id, [, w, h, altText]]) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 } })); // 10 px a millimetre: the sizes only set the aspect ratio here await Promise.all(Object.entries(ART).map(([id, [svg]]) => loadSvg(`${id}.svg`, svg))); // #endregion await loadFonts(FONTS, markdown); // The excerpt is pages 58 and 59 of the book: 57 pages come before it, so the tortilla opens // on a verso and the two recipes face each other. const doc = await buildWithFonts(() => buildDocument({ markdown, resources, continuation: { pageIndexOffset: 57, pageNumbering: { startAt: 58 } } }, config()), markdown); showPages(doc, { title: t({ en: 'Recipe card', es: 'Tarjeta de receta' }) });
工具包 · 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上的食谱文件夹 ↗

变化

#不给各栏装框,改为按块计数

去掉各栏的框后,breaks指定开启右栏的那个子块,每个列表条目、标签那一行和清单的标题栏各算一个:冷汤需要breaks="10"(前面是八种用料和标签),土豆饼是breaks="11"。各栏标题和栏样式在步骤之间留的间距也随之消失。

 :::callout{type="card" label="SERVES 6"}
-:::columns{count=2}
-:::callout{type="column" title="Ingredients"}
+:::columns{count=2 breaks="10"}
 - **1.5 kg** ripe plum tomatoes
@@ seven more ingredients and a blank line @@
 :chip[Vegan]{style="tag"} :chip[No-cook]{style="tag"} :chip[Make ahead]{style="tag"}
-:::
-:::callout{type="column" title="Method"}
+
 1. Chop the tomatoes, seeded pepper, peeled cucumber and garlic.
@@ steps 2 to 5 @@
 :::
-:::
 :::

#把页签立在左上角

'top-left'让页签左右翻转:页签移到左上角,刀叉在它右边,线从右边画过来。

     background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
+    position: 'top-left', // 默认为'top-right'

常见问题

易错点

:::columns只在框内起作用,且不会拆分

:::columns在标注框之外会被忽略,拆分的框也不会从columns组中间断开。breaks属性按子块计数,嵌套的框算一个。 框内分栏 →

易错点

列表编号以首行居中,而不是立在基线上

在postext 1.4.1中,有序列表的编号用Canvas的'middle'基线绘制,位于该项首行基线之上0.3 em(按正文字号)处,所以换用别的字体或加大numberFontSize的编号会从这里向上下两个方向扩展,而不是立在行上:展示字体会偏高,大号的步骤编号会横跨首行。用orderedLists.numberVerticalOffset调整位置,并按实际尺寸检查效果。 编号列表 →

易错点

框的lists.color也会改变列表编号的颜色

在postext 1.4.1中,只要标注框样式的lists.color与unorderedLists.color不同,它就会作用于框内有序列表的编号,即使orderedLists设了自己的颜色:用这种方式给框设橄榄色项目符号,它的步骤编号也会变成橄榄色。只想给框内的项目符号上色时,在文档层面设置unorderedLists.color,框样式里不写lists.color。 标注框 →

易错点

高于行距的行内标签会碰到下一行

框高超过行距的行内标签会碰到下一行的行内标签(chipOverlap警告)。减小它的上下内边距、边框或字号,或者加大行距。 行内标签 →

易错点

列表写'arabic',资源写'roman-upper',页码写'upper-roman'

每种编号设置对格式的拼写都不同:列表的numberFormat用'arabic'(写'decimal'会打印出"undefined"),资源类型的counterFormat用'roman-upper',页码和:::numbering用'upper-roman'。 编号列表 →

易错点

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

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

易错点

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

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

易错点

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

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

易错点

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

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

易错点

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

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

易错点

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

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

易错点

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

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

易错点

排版前加载所有字体

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

  • 1.4.1中页签的label没有textTransform和letterSpacing,所以SERVES 4和PARA 4在围栏里直接用大写输入。
  • 任务框☐不在三种字体中的任何一种里,浏览器用系统字体绘制它,形状随系统而变。
  • 宽图标要留在框自己的栏里:position: 'corner'时,1.4.1按图标高度的一半而不是宽度的一半偏移,18.75 mm的长条会超出框的右边缘16 mm。

致谢

文本
原创文字, CC BY 4.0
字体
Young Serif (SIL OFL 1.1) · Figtree (SIL OFL 1.1) · Caveat (SIL OFL 1.1)
沙盒