跳到主要内容
食谱编号9

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

浮动到引用处的图

一个双栏章节,含七幅编号的图:六幅从首次:ref处浮动到其位置设置允许的第一个空位,一幅放在::resource指定的地方。

本页内容
类型
教科书
输出
Canvas
难度
高级
Postext
已用Postext 1.8.3测试
需要≥ 1.4.1
许可证
更新于2026年9月28日
代码MIT · 文本CC BY 4.0

页码28–29 · 第2–3页,共4页

  • 西班牙文样例:尚无中文版本
  • 成品尺寸200 × 250 mm
  • 2栏, 栏间距6 mm
  • Faustina 9.4/13.4
  • IBM Plex Sans Condensed
  • Montserrat
  • 4页
  • 难度
  • Postext 1.8.3
  • 排版用时347 ms
  • 239行代码

成品一览

这是地貌学教科书《Mountain Landforms》的第2章,页面尺寸为200 × 250 mm。章首页上,一条带章号的蓝色飘带从页顶垂下,旁边是标题所在的冰蓝色色块;后面是两栏两端对齐的Faustina。七幅图是用扩散模型生成、再用代码加上标注的画,按首次提及的顺序编号。其中六幅从首次引用它的段落算起,浮动到其位置设置允许的第一个空位。Figure 2.1设为auto,落在章首页底部;2.3排在第28页右栏顶部,2.2填满同一页的底部;2.4和2.5分别排在第29页两栏的顶部。Figure 2.6放在正文嵌入它的地方。Figure 2.7是一个top浮动体,在第30页被引用,本应等到第31页,但浮动体不能离开所在的章,所以它排到了第30页底部。

这道食谱解答

  • 怎样加一幅带编号题注的图,并在正文里引用它(“见图3.2”)?
  • 怎样控制图的位置:页面顶部、横跨两栏、就放在这里,还是放在边栏里?
  • 怎样让“图”和“表”的标签使用文档的语言?
  • 怎样用代码(资源)而不是Markdown的![]()添加图片和表格?

简短回答

script.js · 第262–298行在完整代码中
// In the Markdown, :ref{id="valleys" case="lower"} prints 'fig. 2.1' and places Figure 2.1.
// Captions, credits and alt texts come from content.figures.<lang>.md.
const figure = (id, height, placement) => {
  if (!TEXTS[id]) throw new Error(`content.figures has no caption block for "${id}"`);
  const [caption, note, altText] = TEXTS[id];
  // An SVG fills the width of its slot (a column or the text block, or a fraction of
  // either), so its width and height only give its shape.
  const width = (placement.span === 'page' ? MEASURE : COLUMN) * (placement.width ?? 1);
  return { id, typeId: 'figure', kind: 'svg', caption, note, altText,
    svg: { fileId: `${id}.svg`, width, height }, placement, createdAt: 0, updatedAt: 0 };
};
// In any order: the first mention of each one in the text, a :ref or a ::resource line,
// decides its number.
const resources = [
  // Cited on the opener page: 'auto' may take that page's foot band, where 'top'
  // could only open the next page (gotcha: top-float-next-page).
  figure('valleys', 56, { position: 'auto', span: 'page' }),
  // Across both columns, but only in a foot band: the page it is cited on, if both
  // columns still have room there, else the foot of the next page.
  figure('profile', 60, { position: 'bottom', span: 'page' }),
  // A column figure that takes only a column head: the next one still empty after its
  // citation, here the right column of the same page, above the text that follows it.
  figure('cirque', 48, { position: 'top' }),
  // Cited in the same sentence, the two take the next two column heads, side by side.
  figure('abrasion', 48, { position: 'top' }),
  figure('plucking', 48, { position: 'top' }),
  // No float: set exactly where ::resource{id="roche"} stands. In postext 1.4.1 an inline
  // figure gets a grid line above it but only the grid snap below, so the Markdown follows
  // it with :::space{lines=1} (gotcha: here-figure-no-space-after).
  figure('roche', 42, { position: 'here' }),
  // A band of its own, half the text width and centred. It is cited on the chapter's last
  // page, where a 'top' float would wait for the next page; a float cannot leave its
  // chapter, so this one goes to the foot of the last page. A float is queued where its
  // citing paragraph starts, so that paragraph starts on the last page
  // (gotcha: float-queues-at-paragraph).
  figure('moraines', 50, { position: 'top', span: 'page', width: 0.5, align: 'center' }),
];

用料

还用到
段落样式
类型
Faustina, Montserrat, IBM Plex Sans Condensed(SIL OFL 1.1)
素材
  • abrasion-1080.jpg
  • cirque-1080.jpg
  • moraines-1080.jpg
  • plucking-1080.jpg
  • profile-1400.jpg
  • roche-1080.jpg
  • valleys-1536.jpg
  • Figure 2.1: a V-shaped river valley and a U-shaped glacial valley (Generated With Diffusion Models, 原创)
  • Figure 2.2: long profile of a valley glacier (Generated With Diffusion Models, 原创)
  • Figure 2.3: a glacial cirque in section (Generated With Diffusion Models, 原创)
  • Figure 2.4: abrasion at the base of the ice (Generated With Diffusion Models, 原创)
  • Figure 2.5: plucking on a rock step (Generated With Diffusion Models, 原创)
  • Figure 2.6: a roche moutonnée (Generated With Diffusion Models, 原创)
  • Figure 2.7: lateral, medial and terminal moraines in plan (Generated With Diffusion Models, 原创)

做法

#1 · 页面和图共用一套调色板

script.js · 第19–37行在完整代码中
const palette = {
  ink: '#1b2227', // text: a cold near-black
  glacier: '#34729a', // the accent: kicker, ribbon, caption labels, references, folios, water
  ice: '#e3f1f8', // the opener slab
  rock: '#5b5a57', // bedrock in the drawings
  moss: '#7d8f4e', // valley floors and pines
  rule: '#c6d3db', // the hairline under the running heads
  muted: '#5d6a72', // running heads, credit notes, the colophon
  paper: '#ffffff',
};
// A linked colour carries its hex too: postext 1.4.1 design slots and referenceColor read
// the hex, not the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color' (#295aa3): pointing it at the accent keeps
  // that second blue off the page.
  { id: 'main-color', name: 'glacier (defaults)', value: { hex: palette.glacier, model: 'hex' } },
];

配置中的每种颜色都链接到这里的某一项,而画作也是用同样的石板灰、冰川蓝和苔绿生成的,所以图与页面的颜色一致。画作保留自己的颜色:给glacier换一个值,会改变飘带、色块和标注,但不会改变图中冰的颜色。强调色足够深,可以用于小字:在白底上对比度为5.2:1,用于题注标签和引用;在冰蓝色块上为4.5:1,用于眉题。每幅图都是一个SVG,把画作以JPEG data URL嵌入,并在上面用文字排出标注,带一圈细细的白色描边,所以标注保持清晰,并随版本的语言变化。标注用IBM Plex Sans Condensed,嵌入在每个SVG里,因为作为图像加载的SVG无法使用页面的网络字体。

#2 · 用读者的语言给图命名

script.js · 第48–57行在完整代码中
const captions = () => ({
  // config.locale sets hyphenation, not captions (gotcha: resource-types-locale):
  // 'Figura 2.3' and 'Fig. 2.3' come from the localised types, numbered {h1}.{n} per chapter.
  resourceTypes: defaultResourceTypes(LANG),
  captionStyle: { // the text colour follows bodyText; the note is 0.85 × the caption size
    fontFamily: LABEL, fontSize: pt(8.3), gap: mm(2.2),
    labelColor: col('glacier'), descriptionItalic: true, // the label is bold by default
    note: { color: col('muted'), gap: mm(0.6) }, // the credit line
  },
});

defaultResourceTypes(LANG)用样例的语言给类型命名:这里的题注是Figure 2.3,西班牙文版是Figura 2.3;如果只设locale: 'es',正文会按西班牙语断词,但所有题注仍是英文。每处引用再采用所在句子需要的形式:case="lower"得到*(fig. 2.1),再加上style="full"得到(figure 2.2);复数之后用style="number"(figures 2.4 and 2.5);text="…"用于the chapter’s first figure*这样的短语,不印编号。这个短语指回一幅已经放好的图;如果作为首次提及,它仍然会给图编号并确定其位置。

#3 · 章首页的飘带和冰色块

script.js · 第61–113行在完整代码中
const at = (to, edge, x, y, width, height) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width), height: height ? mm(height) : 'auto' } }) });
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  align: 'left', ...extra });
const caps = (size) => ({ fontWeight: 600, textTransform: 'uppercase',
  letterSpacing: pt(size * 0.18) }); // capitals tracked 0.18 em
// Opener texts break onto more lines instead of ending in '…' (gotcha: overflow-ellipsis-default).
const wrap = { overflow: 'wrap' };
const [SLAB, RIBBON, RIBBON_END] = [64, 30, 70]; // mm: slab height; ribbon width and length
const [TEXT_X, KICKER_Y] = [RIBBON + 8, 10]; // mm: the opener texts start 8 mm right of the ribbon
const [TITLE_W, LEAD_W] = [118, 112]; // mm: the title's measure, and a shorter standfirst
const opener = {
  enabled: true,
  // At least 5 mm under the slab; the reserve then rounds up to whole 13.4 pt grid lines,
  // so here 69 mm becomes 15 lines (70.9 mm) and the text starts about 7 mm under the slab.
  minHeight: mm(SLAB + 5),
  slot: { elements: [
    { kind: 'box', id: 'slab', style: { backgroundColor: col('ice') }, // runs off the fore-edge
      placement: at('container', 'top-left', 0, 0, MEASURE + OUTER, SLAB) },
    { kind: 'box', id: 'ribbon', style: { backgroundColor: col('glacier') }, // hangs from the head
      placement: at('page', 'top-left', INNER, 0, RIBBON, RIBBON_END) },
    text('numeral', '{chapterNumber}', DISPLAY, 80, 'paper', // an 80 pt line box is 28 mm tall:
      at('page', 'top-left', INNER, RIBBON_END - 31, RIBBON), // it ends 3 mm above the foot
      { fontWeight: 800, lineHeight: 1, align: 'center' }),
    text('kicker', t({ en: 'Chapter {chapterNumber} · {attr.topic}',
      es: 'Capítulo {chapterNumber} · {attr.topic}' }), LABEL, 8.5, 'glacier',
    at('container', 'top-left', TEXT_X, KICKER_Y), { ...caps(8.5), ...wrap }),
    text('title', '{titleText}', DISPLAY, 27, 'ink', at('#kicker', 'below', 0, 2.6, TITLE_W),
      { fontWeight: 800, lineHeight: 1.06, ...wrap }),
    text('lead', '{attr.lead}', TEXT, 10.6, 'ink', at('#title', 'below', 0, 4.2, LEAD_W),
      { italic: true, lineHeight: 1.38, hyphenate: true, ...wrap }),
  ] },
};
const HAIRLINE = TOP - 5; // mm from the top edge: the rule under the running heads
const HEAD_Y = HAIRLINE - 4.4; // the running heads' line box, 4.4 mm above the hairline
const head = (id, content, parity, edge, x, extra) => text(id, content, LABEL, 7.6, 'muted',
  at('page', edge, x, HEAD_Y), { ...caps(7.6), parity, pages: 'body', ...extra });
const folio = (id, parity, edge, x, extra) => text(id, '{pageNumber}', DISPLAY, 8.5, 'glacier',
  at('page', edge, x, HEAD_Y), { fontWeight: 800, parity, pages: 'body', ...extra });
const header = { elements: [ // outer corners, over a hairline; never on the opener
  folio('verso-folio', 'even', 'top-left', OUTER),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8), { align: 'right' }),
  folio('recto-folio', 'odd', 'top-right', -OUTER, { align: 'right' }),
  { kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', color: col('rule'),
    thickness: pt(0.5), placement: { ...at('container', 'top-left', 0, HAIRLINE),
      size: { width: 'fill', height: 'auto' } } },
] };
const footer = { elements: [ // the drop folio: on the opener only, centred 9 mm under the text
  text('drop-folio', '{pageNumber}', DISPLAY, 8.5, 'glacier', at('container', 'top', 0, 9),
    { fontWeight: 800, align: 'center', pages: 'opener' })] };

章首页由两个框和四段文字组成:一条从页顶垂下、底端带章号的飘带,一块伸出书口的冰色块,取自标题topic属性的眉题,以及依次挂在它下方的标题和导语。minHeight预留出色块及其下方至少5 mm的空间,向上取整到整数个网格行(这里约7 mm),所以Figure 2.1仍能放进同一页的底部区域。书眉位于外侧角上,下面是一条rule颜色的细线,pages: 'body'让章首页不印书眉,章首页改用排在页脚的页码。

#4 · 排版前拒绝未知的id

script.js · 第302–326行在完整代码中
// An unknown :ref prints '?' and a figure nobody names is never placed, and postext 1.4.1
// warns about neither (gotcha: unknown-ref-silent). The engine's own parser lists the
// mentions exactly as numbering and placement read them; an embed needs double quotes
// (gotcha: resource-double-quotes).
function checkFigures() {
  const [named, embedded] = [[], new Set()];
  for (const block of parseMarkdown(markdown)) {
    if (block.type === 'resourceBlock' && block.resourceId) {
      named.push(block.resourceId);
      embedded.add(block.resourceId);
    }
    for (const span of block.spans) if (span.ref?.resourceId) named.push(span.ref.resourceId);
  }
  const ids = resources.map((r) => r.id);
  const types = new Set(captions().resourceTypes.map((type) => type.id));
  const problems = [
    ...[...new Set(named)].filter((id) => !ids.includes(id)).map((id) => `unknown id "${id}"`),
    ...ids.filter((id, i) => ids.indexOf(id) !== i).map((id) => `"${id}" is defined twice`),
    ...ids.filter((id) => !named.includes(id)).map((id) => `"${id}" is never cited`),
    ...resources.filter((r) => r.placement.position === 'here' && !embedded.has(r.id))
      .map((r) => `"${r.id}" is placed 'here' but no ::resource line embeds it`),
    ...resources.filter((r) => !types.has(r.typeId)).map((r) => `"${r.id}": no type ${r.typeId}`),
  ];
  if (problems.length) throw new Error(`Figures: ${problems.join('; ')}`);
}

指向不存在的资源id的:ref会印出“?”,没人提及的图永远不会被放置;沙盒会对前一种情况发出警告,但postext 1.4.1对这两种情况都不会给pen任何警告。检查用引擎自己的解析器parseMarkdown读取Markdown,所以能找到编号和定位时会读取的每个:ref和::resource。未知或重复的id、未被引用的图、没有::resource行的here图,以及缺失的类型,都会在排第一页之前变成一个错误。

#5 · 从全书的当前位置开始计数

script.js · 第451–463行在完整代码中
const face = await labelFace();
for (const { id, svg: { fileId, width, height } } of resources) { // each under its svg.fileId
  const art = await dataUrl(PAINTINGS[id]);
  await loadSvg(fileId, svg(width, height, face, `<image href="${art}" width="${n2(width)}" `
    + `height="${n2(height)}" preserveAspectRatio="none"/>${DRAWINGS[id](width, height)}`));
}
// One chapter came before: figures number 2.1, 2.2… and the folios start at 27.
const continuation = { pageNumbering: { startAt: 27 }, // odd, to match the recto of page 1
  headings: { h1: 1, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 2
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), words);
showPages(doc, { title: t({ en: 'Figures that float to where you cite them',
  es: 'Figuras que flotan hasta donde las citas' }) });

这是一本较长的书的第2章,所以接续信息记录了它之前的一章:按{h1}.{n}编号的图从2.1开始,页码从27开始。在第28页上,Figure 2.3排在Figure 2.2上方,后者编号较小,因为正文先引用它。Markdown的![]()会被去掉,所以每幅图都是一个资源,其SVG在排版前以svg.fileId注册。每幅图的位置都取决于它在哪里被引用:2.1用auto,因为色块占满了章首页的顶部;纵剖面用bottom,它还能占用引用它那一页的底部;各栏中的图用top,排在接下来空出的栏顶;2.7也用top,它最终排在第30页底部,因为浮动体不会离开所在的章。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 009 · Figures that float to where you cite them ═════════
// https://postext.dev/en/cookbook/figures-float-where-cited
// Code: MIT · Text: original (CC BY 4.0) · Figures: diffusion models, labels in code
// Fonts: Faustina, Montserrat, IBM Plex Sans Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1
//
// Chapter 2 of a geomorphology textbook. Six of its seven figures float, each to the first
// free slot its placement allows, counting from the paragraph that first cites it. Figure 2.6
// is set where ::resource embeds it. The figures are numbered in order of first mention.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes, parseMarkdown,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('es' | 'en')
const RECIPE = 'figures-float-where-cited';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: eight named colours; the paintings were made to match them
const palette = {
  ink: '#1b2227', // text: a cold near-black
  glacier: '#34729a', // the accent: kicker, ribbon, caption labels, references, folios, water
  ice: '#e3f1f8', // the opener slab
  rock: '#5b5a57', // bedrock in the drawings
  moss: '#7d8f4e', // valley floors and pines
  rule: '#c6d3db', // the hairline under the running heads
  muted: '#5d6a72', // running heads, credit notes, the colophon
  paper: '#ffffff',
};
// A linked colour carries its hex too: postext 1.4.1 design slots and referenceColor read
// the hex, not the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color' (#295aa3): pointing it at the accent keeps
  // that second blue off the page.
  { id: 'main-color', name: 'glacier (defaults)', value: { hex: palette.glacier, model: 'hex' } },
];
// #endregion
const TEXT = 'Faustina'; // one family each for text, display and labels
const DISPLAY = 'Montserrat';
const LABEL = 'IBM Plex Sans Condensed';
const LEAD = 13.4; // body leading in pt: the grid every float band snaps to
const [PAGE_W, PAGE_H, TOP, BOTTOM, INNER, OUTER, GUTTER] = [200, 250, 22, 20, 18, 14, 6]; // mm
const MEASURE = PAGE_W - INNER - OUTER; // 168 mm: the text block, and a page-wide figure
const COLUMN = (MEASURE - GUTTER) / 2; // 81 mm: a column, and a column figure

// #region captions: the type name in the document's language; bold label, italic description
const captions = () => ({
  // config.locale sets hyphenation, not captions (gotcha: resource-types-locale):
  // 'Figura 2.3' and 'Fig. 2.3' come from the localised types, numbered {h1}.{n} per chapter.
  resourceTypes: defaultResourceTypes(LANG),
  captionStyle: { // the text colour follows bodyText; the note is 0.85 × the caption size
    fontFamily: LABEL, fontSize: pt(8.3), gap: mm(2.2),
    labelColor: col('glacier'), descriptionItalic: true, // the label is bold by default
    note: { color: col('muted'), gap: mm(0.6) }, // the credit line
  },
});
// #endregion

// #region furniture: an ice slab off the fore-edge, a ribbon from the head, running heads
const at = (to, edge, x, y, width, height) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width), height: height ? mm(height) : 'auto' } }) });
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  align: 'left', ...extra });
const caps = (size) => ({ fontWeight: 600, textTransform: 'uppercase',
  letterSpacing: pt(size * 0.18) }); // capitals tracked 0.18 em
// Opener texts break onto more lines instead of ending in '…' (gotcha: overflow-ellipsis-default).
const wrap = { overflow: 'wrap' };
const [SLAB, RIBBON, RIBBON_END] = [64, 30, 70]; // mm: slab height; ribbon width and length
const [TEXT_X, KICKER_Y] = [RIBBON + 8, 10]; // mm: the opener texts start 8 mm right of the ribbon
const [TITLE_W, LEAD_W] = [118, 112]; // mm: the title's measure, and a shorter standfirst
const opener = {
  enabled: true,
  // At least 5 mm under the slab; the reserve then rounds up to whole 13.4 pt grid lines,
  // so here 69 mm becomes 15 lines (70.9 mm) and the text starts about 7 mm under the slab.
  minHeight: mm(SLAB + 5),
  slot: { elements: [
    { kind: 'box', id: 'slab', style: { backgroundColor: col('ice') }, // runs off the fore-edge
      placement: at('container', 'top-left', 0, 0, MEASURE + OUTER, SLAB) },
    { kind: 'box', id: 'ribbon', style: { backgroundColor: col('glacier') }, // hangs from the head
      placement: at('page', 'top-left', INNER, 0, RIBBON, RIBBON_END) },
    text('numeral', '{chapterNumber}', DISPLAY, 80, 'paper', // an 80 pt line box is 28 mm tall:
      at('page', 'top-left', INNER, RIBBON_END - 31, RIBBON), // it ends 3 mm above the foot
      { fontWeight: 800, lineHeight: 1, align: 'center' }),
    text('kicker', t({ en: 'Chapter {chapterNumber} · {attr.topic}',
      es: 'Capítulo {chapterNumber} · {attr.topic}' }), LABEL, 8.5, 'glacier',
    at('container', 'top-left', TEXT_X, KICKER_Y), { ...caps(8.5), ...wrap }),
    text('title', '{titleText}', DISPLAY, 27, 'ink', at('#kicker', 'below', 0, 2.6, TITLE_W),
      { fontWeight: 800, lineHeight: 1.06, ...wrap }),
    text('lead', '{attr.lead}', TEXT, 10.6, 'ink', at('#title', 'below', 0, 4.2, LEAD_W),
      { italic: true, lineHeight: 1.38, hyphenate: true, ...wrap }),
  ] },
};
const HAIRLINE = TOP - 5; // mm from the top edge: the rule under the running heads
const HEAD_Y = HAIRLINE - 4.4; // the running heads' line box, 4.4 mm above the hairline
const head = (id, content, parity, edge, x, extra) => text(id, content, LABEL, 7.6, 'muted',
  at('page', edge, x, HEAD_Y), { ...caps(7.6), parity, pages: 'body', ...extra });
const folio = (id, parity, edge, x, extra) => text(id, '{pageNumber}', DISPLAY, 8.5, 'glacier',
  at('page', edge, x, HEAD_Y), { fontWeight: 800, parity, pages: 'body', ...extra });
const header = { elements: [ // outer corners, over a hairline; never on the opener
  folio('verso-folio', 'even', 'top-left', OUTER),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8), { align: 'right' }),
  folio('recto-folio', 'odd', 'top-right', -OUTER, { align: 'right' }),
  { kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', color: col('rule'),
    thickness: pt(0.5), placement: { ...at('container', 'top-left', 0, HAIRLINE),
      size: { width: 'fill', height: 'auto' } } },
] };
const footer = { elements: [ // the drop folio: on the opener only, centred 9 mm under the text
  text('drop-folio', '{pageNumber}', DISPLAY, 8.5, 'glacier', at('container', 'top', 0, 9),
    { fontWeight: 800, align: 'center', pages: 'opener' })] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  ...captions(),
  colorPalette,
  page: { width: mm(PAGE_W), height: mm(PAGE_H), dpi: 150, // a compact textbook trim
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
      mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  bodyText: { // justified serif; first lines indented 4 mm, except after a heading
    fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('glacier'), // citations in the accent, like the caption labels they name
    firstLineIndent: mm(4), indentAfterHeading: false },
  headings: {
    fontFamily: DISPLAY, fontWeight: 800, color: col('ink'),
    // Columns end flush by adding grid lines above the H2s. Beside a float band a column can
    // come up several lines short; one line per heading (the default is 4) keeps a section
    // head from floating in a gap, and the balancer's other levers take what is left.
    balancing: { maxLinesPerHeading: 1 },
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, fontSize: pt(27), span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
      { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}',
        marginTop: pt(LEAD), marginBottom: pt(0) }, // one grid line above, none below
    ],
  },
  unorderedLists: { color: col('glacier'), marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.2), lineHeight: pt(10),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown样例 · 67行 · content.es.mdtitle: "Relieves de montaña" subtitle: "Introducción a la geomorfología" --- # Cómo esculpen los glaciares el paisaje {topic="Geomorfología glaciar" lead="El hielo baja por el valle unas decenas de metros al año, demasiado despacio para verlo, pero en unas pocas glaciaciones convierte la estrecha V de un río en una ancha U. La roca guarda cada etapa, del circo a las morrenas."} Quien sube a un valle de alta montaña después de haber recorrido otro excavado solo por un río nota enseguida la diferencia. El valle fluvial es estrecho y tiene forma de V: el río ahonda su cauce y las laderas se desmoronan tras él. El valle por el que pasó un glaciar es ancho, de fondo plano y paredes casi verticales, con la forma de una U (:ref{id="valleys" case="lower"}). El hielo llena el valle de pared a pared y lo lima a la vez por el fondo y por los lados. Hace unos veinte mil años, en el momento de máxima extensión de la última glaciación, el hielo cubría buena parte del norte de Europa y bajaba por los valles del Pirineo hasta cotas inferiores a los mil metros. También había glaciares en los Picos de Europa, en Gredos y en Sierra Nevada. Casi todos han desaparecido, pero el relieve conserva sus huellas con tanta nitidez que se puede reconstruir el tamaño de un glaciar que se fundió hace milenios. Este capítulo explica cuáles son esas huellas y cómo se leen. ## El hielo que fluye Un glaciar nace donde cae más nieve de la que se funde. Año tras año, cada capa queda enterrada bajo la siguiente y su peso expulsa el aire de entre los copos. La nieve recién caída pesa unos cien kilogramos por metro cúbico; al compactarse se convierte en neviza, un material granuloso, y después en hielo compacto y azulado, que supera los ochocientos. En los Alpes la transformación dura unas décadas; en la Antártida, donde nieva tan poco que cada año suma apenas unos centímetros, puede llevar siglos. Todo glaciar tiene dos mitades (:ref{id="profile" style="full" case="lower"}). En la parte alta, la zona de acumulación, cada invierno deja más nieve de la que el verano consigue fundir. En la parte baja, la zona de ablación, ocurre lo contrario: el hielo se pierde y el glaciar solo se mantiene porque le llega hielo de arriba. La frontera entre ambas, la línea de equilibrio, se reconoce a finales del verano como el límite de la nieve del año sobre el hielo desnudo. Si el clima se enfría, la línea baja y el frente avanza; si se calienta, la línea sube y el frente retrocede. Cuando el hielo del circo de cabecera (:ref{id="cirque" case="lower"}) alcanza unas decenas de metros de espesor, empieza a fluir bajo su propio peso. El hielo se deforma despacio y sin romperse, como lo haría una masa de brea, mientras los treinta metros de arriba, con demasiado poco peso encima para fluir, se rompen en grietas. Donde el lecho está húmedo, el glaciar además resbala sobre una película de agua de fusión. Así avanzan los glaciares de valle, entre unas decenas y unos cientos de metros al año, más deprisa en el centro que junto a las paredes, donde el rozamiento los frena. Louis Agassiz lo comprobó en la década de 1840 con una hilera de estacas clavada de un borde a otro del glaciar del Unteraar, en Suiza: con los años, la hilera se curvó valle abajo por el centro. Incluso un glaciar en retirada sigue fluyendo hacia abajo. Si su frente retrocede es porque cada verano se funde allí más hielo del que llega. ## Donde nacen los glaciares Un circo es una hondonada con forma de sillón excavada en la cabecera del valle. El hielo se acumula en el fondo, gira pendiente abajo como en una cuchara y rebaja el suelo por debajo del borde; cuando el glaciar se funde, la cubeta se llena de agua y nace un lago. Para ahondarla, el hielo trabaja con dos herramientas complementarias, las de las figuras :ref{id="abrasion" style="number"} y :ref{id="plucking" style="number"}. La pared del fondo, mientras tanto, retrocede: dos circos que crecen espalda con espalda afilan entre ellos una arista, y tres o más que atacan una misma cumbre la dejan convertida en un pico piramidal, como el Cervino. En el Pirineo, la mayoría de los circos miran al norte o al este, donde la nieve dura más, y muchos guardan uno de esos lagos, que en Aragón llaman ibones. ## Las herramientas del hielo El hielo es más blando que casi cualquier roca y por sí solo apenas la rayaría; erosiona su lecho con dos herramientas. La primera herramienta es la abrasión. Los cantos incrustados en la base del glaciar rayan el lecho como una lija y dejan estrías paralelas que señalan, milenios después, la dirección en que se movía el hielo; el polvo que producen, la harina de roca, da a los lagos glaciares su color turquesa lechoso. La segunda es el arranque: el agua de fusión se cuela en las grietas del lecho, vuelve a helarse y suelda los bloques al hielo, que se los lleva al avanzar. Las dos herramientas actúan a la vez sobre cualquier resalte del lecho, y el resultado es una de las formas más características del paisaje glaciar, la roca aborregada: ::resource{id="roche"} :::space{lines=1} Su cara de aguas arriba, pulida por abrasión, es suave y tendida; la de aguas abajo, donde el glaciar arrancó bloques, es abrupta y rugosa. Basta mirar hacia dónde apunta la cara áspera para saber hacia dónde iba el hielo. El nombre francés, *roche moutonnée*, lo acuñó el naturalista ginebrino Horace-Bénédict de Saussure a finales del siglo XVIII. ## Valles en artesa El trabajo de esas herramientas, sumado durante decenas de miles de años, transforma el valle entero. El glaciar endereza el valle sinuoso del río y trunca los espolones que separaban sus curvas. Ensancha y ahonda el fondo hasta darle el perfil en U de :ref{id="valleys" text="la primera figura del capítulo"}, escalonado en cubetas y umbrales, y el valle de Ordesa, en el Pirineo aragonés, es una artesa de manual. Como un glaciar grueso excava más que uno delgado, el valle principal se hunde más que los de sus afluentes: cuando el hielo desaparece, los valles laterales quedan colgados a cientos de metros sobre el principal y sus arroyos saltan en cascada, como en el valle de Yosemite, en California. Donde el mar ha invadido una artesa se forma un fiordo; el de Sogn, en Noruega, penetra más de doscientos kilómetros tierra adentro y supera los mil trescientos metros de profundidad. ## Lo que deja el glaciar Todo lo que el glaciar arranca acaba depositado en algún sitio. Los derrubios que caen de las laderas viajan sobre los bordes del hielo y forman morrenas laterales; donde dos glaciares se unen, sus morrenas laterales se funden en una morrena central que recorre el hielo como una franja oscura. En el frente, el glaciar suelta su carga como una cinta transportadora y levanta un arco de derrubios, la morrena frontal, que señala hasta dónde llegó la lengua de hielo en su máximo avance. Las morrenas son mezclas caóticas de arcilla, arena, cantos y bloques de todos los tamaños, sin la clasificación que el agua impone a sus sedimentos. Algunos bloques, los erráticos, viajaron decenas de kilómetros y descansan hoy sobre rocas de naturaleza muy distinta. Muchas morrenas frontales retienen lagos (:ref{id="moraines" case="lower"}). El de Sanabria, en Zamora, el mayor lago de origen glaciar de la península, está represado por las morrenas del glaciar que bajaba de la sierra Segundera. ## Cómo leer un paisaje glaciar Bastan cuatro huellas para reconocer el paso de un glaciar por un valle que hoy no tiene hielo: - **El perfil.** Una artesa en U, de fondo plano y paredes abruptas, como la de la :ref{id="valleys" style="full" case="lower"}. - **Los valles colgados.** Afluentes que terminan a media ladera y cuyos arroyos caen en cascada. - **La roca.** Superficies pulidas y estriadas y rocas aborregadas, con la cara abrupta vuelta valle abajo (:ref{id="roche" case="lower"}). - **Los depósitos.** Morrenas sin clasificar, bloques erráticos y los lagos que represan. Ninguna de esas huellas basta por sí sola: un río también pule los cantos, y un desprendimiento deja derrubios caóticos al pie de una ladera. Juntas, y repetidas valle tras valle, prueban que allí hubo un glaciar. ## Glaciares en retirada Los glaciares que quedan en la península son pequeños y están todos en el Pirineo, en las caras norte de sus cumbres más altas: el Aneto, la Maladeta, el Monte Perdido. Han perdido la mayor parte de su superficie desde mediados del siglo XIX, cuando terminó la Pequeña Edad de Hielo, y varios se han reducido a heleros, masas de hielo que ya no fluyen. En muchos veranos, la línea de equilibrio sube hoy por encima de sus cumbres: el glaciar entero queda en la zona de ablación de la :ref{id="profile" style="full" case="lower"} y el hielo que pierde ya no se repone. Lo que queda se refugia a la sombra de las paredes norte, alimentado tanto por los aludes y la nieve que arrastra el viento como por la que cae. Los glaciólogos siguen ese retroceso con los métodos de Agassiz y con otros nuevos: estacas de ablación que cada verano asoman un poco más, fotografías repetidas desde los mismos puntos y modelos del terreno levantados con láser y con drones, que comparados año tras año dan el volumen de hielo perdido. Cuando desaparezca el último, el macizo de la Maladeta se parecerá a la sierra de Gredos, que perdió sus glaciares hace más de diez mil años y conserva lagunas en los circos y morrenas que cierran los valles. :::paragraphs{style="colophon"} Compuesto en Faustina, Montserrat e IBM Plex Sans Condensed (SIL Open Font License) · Texto: CC BY 4.0 · Figuras: modelos de difusión :::
`; // content.<lang>.md, inlined by the Cookbook // Caption, credit note ('-' for none) and alt text of each figure, one block per figure. const figureTexts = String.raw`valleys
Markdown样例 · 33行 · content.figures.es.mdUn río abre un valle en V; un glaciar lo ensancha en U. A trazos, la V que borró el hielo. Secciones esquemáticas, sin escala. Dos secciones de valle: a la izquierda, un valle fluvial en V con un río en el fondo; a la derecha, un valle glaciar en U lleno de hielo, con el antiguo perfil en V a trazos. profile Perfil de un glaciar: el hielo nacido sobre la línea de equilibrio baja a fundirse bajo ella. Exageración vertical ×2. Sección longitudinal de un glaciar desde el circo hasta el frente, con la zona de acumulación nevada, la línea de equilibrio, flechas de flujo y la morrena frontal. cirque Circo glaciar en sección. El hielo gira en la cubeta y la ahonda por debajo del umbral. - Sección de un circo: pared abrupta, rimaya, hielo que gira en una cubeta y umbral rocoso aguas abajo. abrasion Abrasión: los cantos presos en la base del hielo rayan el lecho. - Detalle de la base de un glaciar: cantos incrustados en el hielo rayan la roca y dejan estrías y harina de roca. plucking Arranque: el agua se hiela en las diaclasas y el hielo se lleva los bloques. - Detalle del lado de aguas abajo de un resalte rocoso: agua helada en las diaclasas y un bloque que el hielo arranca. roche Roca aborregada. El hielo pulió la cara tendida y arrancó bloques de la abrupta. El hielo iba de izquierda a derecha. Perfil de una roca aborregada: una cara suave y tendida a la izquierda y otra escalonada y abrupta a la derecha. moraines Dos glaciares se unen: sus morrenas laterales forman la central, y la frontal represa un lago. Vista en planta, sin escala. Plano de dos lenguas de hielo que confluyen, con morrenas laterales y central; bajo el frente, el arco de la morrena frontal retiene un lago que solo cruza su arroyo de desagüe.
`; const TEXTS = Object.fromEntries(figureTexts.trim().split(/\n\s*\n/) .map((block) => block.split('\n').map((line) => line.trim())) .map(([id, caption, note, alt]) => [id, [caption, note === '-' ? undefined : note, alt]])); // #region answer: six figures float to the first slot their placement allows; one stays put // In the Markdown, :ref{id="valleys" case="lower"} prints 'fig. 2.1' and places Figure 2.1. // Captions, credits and alt texts come from content.figures.<lang>.md. const figure = (id, height, placement) => { if (!TEXTS[id]) throw new Error(`content.figures has no caption block for "${id}"`); const [caption, note, altText] = TEXTS[id]; // An SVG fills the width of its slot (a column or the text block, or a fraction of // either), so its width and height only give its shape. const width = (placement.span === 'page' ? MEASURE : COLUMN) * (placement.width ?? 1); return { id, typeId: 'figure', kind: 'svg', caption, note, altText, svg: { fileId: `${id}.svg`, width, height }, placement, createdAt: 0, updatedAt: 0 }; }; // In any order: the first mention of each one in the text, a :ref or a ::resource line, // decides its number. const resources = [ // Cited on the opener page: 'auto' may take that page's foot band, where 'top' // could only open the next page (gotcha: top-float-next-page). figure('valleys', 56, { position: 'auto', span: 'page' }), // Across both columns, but only in a foot band: the page it is cited on, if both // columns still have room there, else the foot of the next page. figure('profile', 60, { position: 'bottom', span: 'page' }), // A column figure that takes only a column head: the next one still empty after its // citation, here the right column of the same page, above the text that follows it. figure('cirque', 48, { position: 'top' }), // Cited in the same sentence, the two take the next two column heads, side by side. figure('abrasion', 48, { position: 'top' }), figure('plucking', 48, { position: 'top' }), // No float: set exactly where ::resource{id="roche"} stands. In postext 1.4.1 an inline // figure gets a grid line above it but only the grid snap below, so the Markdown follows // it with :::space{lines=1} (gotcha: here-figure-no-space-after). figure('roche', 42, { position: 'here' }), // A band of its own, half the text width and centred. It is cited on the chapter's last // page, where a 'top' float would wait for the next page; a float cannot leave its // chapter, so this one goes to the foot of the last page. A float is queued where its // citing paragraph starts, so that paragraph starts on the last page // (gotcha: float-queues-at-paragraph). figure('moraines', 50, { position: 'top', span: 'page', width: 0.5, align: 'center' }), ]; // #endregion // #region check: every cited id exists and every figure gets placed, before the build // An unknown :ref prints '?' and a figure nobody names is never placed, and postext 1.4.1 // warns about neither (gotcha: unknown-ref-silent). The engine's own parser lists the // mentions exactly as numbering and placement read them; an embed needs double quotes // (gotcha: resource-double-quotes). function checkFigures() { const [named, embedded] = [[], new Set()]; for (const block of parseMarkdown(markdown)) { if (block.type === 'resourceBlock' && block.resourceId) { named.push(block.resourceId); embedded.add(block.resourceId); } for (const span of block.spans) if (span.ref?.resourceId) named.push(span.ref.resourceId); } const ids = resources.map((r) => r.id); const types = new Set(captions().resourceTypes.map((type) => type.id)); const problems = [ ...[...new Set(named)].filter((id) => !ids.includes(id)).map((id) => `unknown id "${id}"`), ...ids.filter((id, i) => ids.indexOf(id) !== i).map((id) => `"${id}" is defined twice`), ...ids.filter((id) => !named.includes(id)).map((id) => `"${id}" is never cited`), ...resources.filter((r) => r.placement.position === 'here' && !embedded.has(r.id)) .map((r) => `"${r.id}" is placed 'here' but no ::resource line embeds it`), ...resources.filter((r) => !types.has(r.typeId)).map((r) => `"${r.id}": no type ${r.typeId}`), ]; if (problems.length) throw new Error(`Figures: ${problems.join('; ')}`); } // #endregion // #region art: seven paintings (JPEGs in assets/) under vector labels in the book's language // Each figure is an SVG: the painting, embedded as a data URL at the figure's printed size in // mm, then its labels as text, so they stay sharp and follow the edition's language. const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(hex.slice(i, i + 2), 16) * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k).toString(16).padStart(2, '0')).join('')}`; const C = { ink: palette.ink, flow: mix(palette.glacier, palette.ink, 0.4), snow: palette.paper }; const n2 = (v) => +v.toFixed(2); // A hairline, drawn over a wider white one so it reads on rock and ice alike. const hairline = ([x1, y1], [x2, y2]) => [['#ffffff', 0.55], [C.ink, 0.18]].map(([c, w]) => `<path d="M${n2(x1)} ${n2(y1)}L${n2(x2)} ${n2(y2)}" stroke="${c}" stroke-width="${w}" ` + 'stroke-linecap="round"/>').join(''); // A label with a thin white halo, and an optional leader to the point it names. function label(x, y, words, { anchor = 'start', to, bold = false, color = C.ink } = {}) { const halo = color === C.snow ? C.ink : '#ffffff'; const leader = to ? hairline([to[0], to[1]], [to[2] ?? x, to[3] ?? y - 0.9]) : ''; return `${leader}<text x="${n2(x)}" y="${n2(y)}" text-anchor="${anchor}" fill="${color}"` + ` stroke="${halo}" stroke-width="0.5" stroke-linejoin="round" paint-order="stroke"` + `${bold ? ' font-weight="600"' : ''}>${words}</text>`; } const L = (en, es) => t({ en, es }); // An SVG loaded as an <img> has no access to the page's web fonts (gotcha: svg-no-webfonts), // so each drawing embeds the two weights its labels use. The latin subsets cover the English // and Spanish labels. const LABEL_MM = 2.45; // the label size in the drawings' millimetres: about 7 pt in print async function labelFace() { const id = fontsourceId(LABEL); const faces = await Promise.all(['400', '600'].map(async (weight) => { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}-` + 'normal.woff2'; const res = await fetch(url); if (!res.ok) throw new Error(`Label face not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); let bin = ''; for (let i = 0; i < bytes.length; i += 8192) { bin += String.fromCharCode(...bytes.subarray(i, i + 8192)); } return `@font-face{font-family:L;font-weight:${weight};` + `src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2')}`; })); return `${faces.join('')}text{font-family:L;font-size:${LABEL_MM}px}`; } // The viewBox is the figure's printed size in mm; the SVG's own size is set in mm too. const svg = (w, h, face, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${n2(w)}mm" ` + `height="${n2(h)}mm" viewBox="0 0 ${n2(w)} ${n2(h)}"><style>${face}</style>${body}</svg>`; // The labels of each figure, in its millimetres, placed on its painting. const DRAWINGS = { valleys: () => label(14, 5, L('River valley', 'Valle fluvial'), { bold: true }) + label(100, 5, L('Glacial valley', 'Valle glaciar'), { bold: true }) + label(47, 50.4, L('river', 'río'), { to: [43.4, 46.6, 46.8, 49.4] }) + label(126, 21, L('ice', 'hielo'), { anchor: 'middle', bold: true }) + label(125.5, 53.2, L('earlier V-shaped valley', 'antiguo valle en V'), { anchor: 'middle', to: [125.5, 48, 125.5, 51.3] }), profile: () => label(48, 8, L('accumulation zone', 'zona de acumulación'), { anchor: 'middle', bold: true, color: C.flow }) + label(100, 20, L('ablation zone', 'zona de ablación'), { anchor: 'middle', bold: true, color: C.flow }) + label(57, 19.6, L('equilibrium line', 'línea de equilibrio'), { to: [52.8, 27, 56.6, 20.2] }) + label(108, 43.3, L('ice flow', 'flujo del hielo'), { bold: true, color: C.flow }) + label(166, 41.6, L('terminal moraine', 'morrena frontal'), { anchor: 'end', to: [146, 45.5, 150, 42.2] }) + label(4, 57.4, L('bedrock', 'lecho rocoso')), cirque: () => label(2.5, 30, L('back wall', 'pared')) + label(26, 6.6, L('bergschrund', 'rimaya'), { to: [20, 12.5, 25.6, 7.2] }) + label(30, 22.6, L('rotation', 'rotación'), { bold: true, color: C.flow }) + label(33, 40, L('basin', 'cubeta')) + label(66, 17.4, L('rock lip', 'umbral'), { anchor: 'middle', to: [60.5, 22.8, 64.4, 18.3] }), abrasion: () => label(55.5, 8.3, L('ice moves', 'el hielo avanza'), { bold: true, color: C.flow }) + label(24, 16.6, L('stones in the ice', 'cantos presos en el hielo'), { to: [38.5, 22.6, 38, 17.4] }) + label(20, 37, L('striations', 'estrías'), { anchor: 'end', to: [24, 29.5, 18, 35.8] }) + label(40.5, 37, L('rock flour', 'harina de roca'), { to: [32, 26, 40.5, 35.8] }), plucking: () => label(30, 9.3, L('ice moves', 'el hielo avanza'), { bold: true, color: C.flow }) + label(63.5, 18.5, L('plucked block', 'bloque arrancado'), { to: [60, 21, 63.3, 19.2] }) + label(4, 43, L('ice in the joints', 'hielo en las diaclasas'), { to: [25.2, 32, 14, 41.3] }), roche: () => label(4, 5, L('ice, long gone', 'el hielo, hoy fundido'), { bold: true, color: C.flow }) + label(25, 16.5, L('abrasion: smooth', 'abrasión: pulida'), { anchor: 'end', to: [30, 18.8, 25.5, 16.9] }) + label(63, 11.2, L('plucking: rough', 'arranque: rugosa'), { to: [57.5, 15.5, 63.2, 11.9] }), // Placed on a 100.8 × 60 mm drawing; k scales the positions, not the type, to the figure. moraines: (w) => { const k = w / 100.8; const at = (x, y, words, o = {}) => label(x * k, y * k, words, { ...o, ...(o.to && { to: o.to.map((v) => v * k) }) }); return at(4, 25.5, L('lateral moraine', 'morrena lateral'), { to: [37, 16, 21, 24.3] }) + at(62, 33, L('medial moraine', 'morrena central'), { to: [50.5, 33, 61.5, 32.3] }) + at(50, 45, L('lake', 'lago'), { anchor: 'middle', bold: true, color: C.snow }) + at(97.5, 55, L('terminal moraine', 'morrena frontal'), { anchor: 'end', to: [60, 49, 78, 54] }); }, }; // A painting as a data URL: an SVG drawn as an image cannot fetch anything itself. async function dataUrl(url) { const res = await fetch(url); if (!res.ok) throw new Error(`Painting not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); let bin = ''; for (let i = 0; i < bytes.length; i += 8192) { bin += String.fromCharCode(...bytes.subarray(i, i + 8192)); } return `data:image/jpeg;base64,${btoa(bin)}`; } const PAINTINGS = { // each figure's painting, a file in assets/, named by its width in pixels valleys: asset('valleys-1536.jpg'), profile: asset('profile-1400.jpg'), cirque: asset('cirque-1080.jpg'), abrasion: asset('abrasion-1080.jpg'), plucking: asset('plucking-1080.jpg'), roche: asset('roche-1080.jpg'), moraines: asset('moraines-1080.jpg') }; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the layout uses, loaded before the build (gotcha: fonts-first) Faustina: ['400', '400i', '700'], // text Montserrat: ['800'], // display: title, section heads, numeral, folios 'IBM Plex Sans Condensed': ['400', '400i', '600', '700'], // labels: kicker, heads, captions }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const words = `${markdown}\n${figureTexts}`; // captions too: their letters decide the subsets await loadFonts(FONTS, words); checkFigures(); // a wrong id stops here, and the viewer's bar says why // #region build: register the drawings, then set chapter 2 of a longer book const face = await labelFace(); for (const { id, svg: { fileId, width, height } } of resources) { // each under its svg.fileId const art = await dataUrl(PAINTINGS[id]); await loadSvg(fileId, svg(width, height, face, `<image href="${art}" width="${n2(width)}" ` + `height="${n2(height)}" preserveAspectRatio="none"/>${DRAWINGS[id](width, height)}`)); } // One chapter came before: figures number 2.1, 2.2… and the folios start at 27. const continuation = { pageNumbering: { startAt: 27 }, // odd, to match the recto of page 1 headings: { h1: 1, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 2 const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), words); showPages(doc, { title: t({ en: 'Figures that float to where you cite them', es: 'Figuras que flotan hasta donde las citas' }) }); // #endregion
工具包 · core, fonts, viewer, images:每道食谱都相同 · 270行// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

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

变化

#图号不带章号

从编号中去掉章号,图就印成1到7;如果要全书统一计数,用buildBundle排各章,或者把前一章的continuationAfter()传给后一章。

-  resourceTypes: defaultResourceTypes(LANG),
+  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type,
+    numberingTemplate: '{n}', resetOn: 'never' })),

#把题注放在上方的色条上

题注移到图的上方,放在调色板冰蓝色的色条上,出处行仍留在图下方;需要重新调整正文,因为这样两个版本都会排到第五页。

   captionStyle: { // 文字颜色跟随bodyText;注释为题注字号的0.85倍
+    position: 'above', backgroundEnabled: true, background: col('ice'),
     fontFamily: LABEL, fontSize: pt(8.3), gap: mm(2.2),

#把图放在边栏里

一栏半的页面给图和题注留出一条正文从不进入的外侧通道:参见带边注栏的教科书。

#用同样的方式浮动表格

表格也是资源,按同样的规则引用和定位,长表还会跨页拆分:参见技术数据表。

常见问题

易错点

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

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

易错点

图在引用它的段落开始处排队

图在引用它的段落开始时就进入队列,而不是在:ref所在的那一行。如果这个段落从一页的底部开始,而引用落在下一页,'top'图可能出现在下一页顶部、引用它的句子之前。把:ref放在段落靠前的位置,或者从它开始另起一段。 决定图位置的引用 →

易错点

行内图上方有间距,下方没有

在postext 1.4.1中,::resource以位置'here'排入的图,上方有一个网格行的间距,下方却只有下一行对齐基线网格时剩下的空间:可能是整整一行,也可能几乎没有,于是下一段可能紧贴在题注下面开始。在::resource这一行之后加:::space{lines=1};和其他:::space一样,它在栏顶会被丢弃。 图就放在这里 →

易错点

未知的:ref id打印'?',引擎不报警告

引用了不存在的资源id的:ref会打印"?",什么也不放置,只有沙盒会就此给出警告。检查每个被引用的id是否存在。 决定图位置的引用 →

易错点

用defaultResourceTypes(locale)本地化Figure/Table

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

易错点

::resource{id="…"}只接受双引号

块级嵌入只有写成带双引号的::resource{id="…"}才能被识别;其他写法会作为一行可见文字留在正文中。 图就放在这里 →

易错点

大多数警告只在沙盒中出现

未知的id、样式和指令,缺失的字体和过松的行,都由沙盒检查,而不是引擎:CodePen示例只能得到doc.warnings和parseMarkdownWithIssues。未知样式会悄悄退回默认,未知指令会作为文字打印出来,所以要核对你的id。 警告与诊断 →

易错点

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

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

易错点

SVG插图中不要用<marker>或滤镜(会退回位图)

SVG图只有在不含<marker>、滤镜和蒙版时,才能在PDF中保持矢量;否则会退回位图,而且层层嵌套的滤镜可能让它在Chrome中变成空白。箭头用路径来画。 作为资源的图和表 →

易错点

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

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

易错点

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

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

易错点

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

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

易错点

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

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

易错点

排版前加载所有字体

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

沙盒检查 · unknownResourceId

未知资源

原因. 某个:ref或::resource指定的id没有对应的资源;引用处印出“?”,也不会放置任何内容。

解决. 改正id(只能用双引号),或添加该资源。 文档 →

沙盒检查 · danglingTypeRef

未知资源类型

原因. 某个资源的typeId指向的类型已不在resourceTypes中定义,因此改用默认类型。

解决. 定义该类型,或让资源指向一个现有类型。 文档 →

  • 在第30页上,关于湖泊的段落在第一句就引用了Figure 2.7,因为图会在引用它的段落开始处排队。如果那句话是上一段的末句,而上一段从第29页开始,这幅图就会排在第30页顶部,位于引用它的那一行之上。

致谢

文本
原创文字, CC BY 4.0
图片
  • Figure 2.1: a V-shaped river valley and a U-shaped glacial valley · Generated With Diffusion Models · 原创
  • Figure 2.2: long profile of a valley glacier · Generated With Diffusion Models · 原创
  • Figure 2.3: a glacial cirque in section · Generated With Diffusion Models · 原创
  • Figure 2.4: abrasion at the base of the ice · Generated With Diffusion Models · 原创
  • Figure 2.5: plucking on a rock step · Generated With Diffusion Models · 原创
  • Figure 2.6: a roche moutonnée · Generated With Diffusion Models · 原创
  • Figure 2.7: lateral, medial and terminal moraines in plan · Generated With Diffusion Models · 原创
字体
Faustina (SIL OFL 1.1) · Montserrat (SIL OFL 1.1) · IBM Plex Sans Condensed (SIL OFL 1.1)
沙盒