# 学习读本里按颜色分类的高亮

> 一本历史读本，关键词用三种颜色高亮：术语绿色、日期琥珀色、人名地名蓝色，三种样式都在 markStyles 里定义并链接到调色板。

- HTML版本: https://postext.dev/zh/cookbook/colour-coded-study-highlights
- 食谱 No. 180 · 字体与文字 · 难度 2 (中级) · 输出: Canvas
- 体裁: 教科书
- 需要 postext ≥ 1.65.0 · 已用 1.65.0 测试，日期 2026-10-31
- 页面: [1](https://postext.dev/cookbook/colour-coded-study-highlights/en/p01.webp?v=d1c18f3b), [2](https://postext.dev/cookbook/colour-coded-study-highlights/en/p02.webp?v=d1c18f3b), [3](https://postext.dev/cookbook/colour-coded-study-highlights/en/p03.webp?v=d1c18f3b), [4](https://postext.dev/cookbook/colour-coded-study-highlights/en/p04.webp?v=d1c18f3b)
- 在沙盒中打开: https://postext.dev/zh/sandbox#recipe=colour-coded-study-highlights&lang=en (.postext: https://postext.dev/cookbook/colour-coded-study-highlights/en/colour-coded-study-highlights.postext)
- 最后更新: 2026-10-31
- 其他语言: [en](https://postext.dev/en/cookbook/colour-coded-study-highlights.md), [es](https://postext.dev/es/cookbook/colour-coded-study-highlights.md), [ca](https://postext.dev/ca/cookbook/colour-coded-study-highlights.md), [de](https://postext.dev/de/cookbook/colour-coded-study-highlights.md), [fr](https://postext.dev/fr/cookbook/colour-coded-study-highlights.md), [it](https://postext.dev/it/cookbook/colour-coded-study-highlights.md), [pt](https://postext.dev/pt/cookbook/colour-coded-study-highlights.md), [ja](https://postext.dev/ja/cookbook/colour-coded-study-highlights.md), [ar](https://postext.dev/ar/cookbook/colour-coded-study-highlights.md)

## 简单来说

一篇讲印刷术历史的学校读物。关键术语标成绿色，年代标成琥珀色，人物和地点标成蓝色，第一页的图例说明每种颜色的含义。

## 成品一览

学校历史读本《Readings in Early Modern History》中某单元的第二篇读物：四页，讲1450年到1550年间印刷术如何从美因茨传遍欧洲，页面 170 × 240 mm。编者像一个认真的学生那样给正文划了重点，而且整本书用同一套颜色：关键术语铺绿色，年代铺琥珀色，人物和地点铺浅蓝色。标题下的图例直接用这三种颜色说明代码，末页的术语表每个词条都以绿色术语开头。高亮只是铺在普通文字下面的色带，所以行的断行、断词和两端对齐都和没有色带时完全一样。

**这份食谱回答的问题:**

- 怎样用各有含义的颜色（术语、日期、人名）给学习材料划重点？

## 简短回答

```js
// script.js, 行 37–52
// :mark[Mainz]{style="name"} in the text picks a style by id. The band is painted under
// words that stay ordinary text, so the lines break, hyphenate and justify as if the
// mark were not there, and a mark that runs over a line end gets a band on each line.
const band = (id, name) => ({
  id, name,
  background: col(id), // pale enough for ink at full contrast on top
  color: col('ink'), // the words keep the colour of the text
  // borderRadius: 0 is the default (square ends, as a highlighter pen leaves them)
  paddingX: em(0.12), // the band reaches a little past the first and the last letter
  paddingTop: em(0.04), paddingBottom: em(0.1), // and down past the descenders
});
const markStyles = [
  band('term', t({ en: 'Key term', es: 'Término clave' })),
  band('date', t({ en: 'Date', es: 'Fecha' })),
  band('name', t({ en: 'Name', es: 'Nombre' })),
];
```

## 用料

**讲解**

- [高亮标记](https://postext.dev/zh/docs/document-format-inline.md#高亮标记): 铺在短语后面的色带，就像课本给关键词划重点：文字仍是普通正文，可以跨行，色带跟着每一行上的那一段。

**还用到**

- [标注框](https://postext.dev/zh/docs/configuration-callouts.md#标注框样式)
- [语义调色板](https://postext.dev/zh/docs/configuration-fonts-colors-viewers.md#调色板)
- [设计过的章首页](https://postext.dev/zh/docs/configuration-headings.md#通栏与高级设计)
- [页面设计中的图片](https://postext.dev/zh/docs/configuration-headers-footers.md#图像元素)
- [标题属性](https://postext.dev/zh/docs/document-format.md#标题属性)
- [书眉与页码](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚)
- [按页面角色显示书眉](https://postext.dev/zh/docs/configuration-headers-footers.md#文本元素)
- [对称页边距](https://postext.dev/zh/docs/configuration-page-layout.md#对称页边距)
- [段落样式](https://postext.dev/zh/docs/configuration-styles.md#段落样式)
- [段末孤行、段首孤行与孤字](https://postext.dev/zh/docs/configuration-page-flow.md#段首孤行段末孤行孤字与不拆分规则)
- [参考文献与术语表](https://postext.dev/zh/docs/configuration-styles.md#段落样式)
- [用你的语言显示“图”和“表”](https://postext.dev/zh/docs/configuration-resources.md#资源类型)
- [自定义资源类型](https://postext.dev/zh/docs/configuration-resources.md#资源类型)
- [作为资源的图和表](https://postext.dev/zh/docs/document-format-resources.md#资源)

**配置一览**

- [`bodyText`](https://postext.dev/zh/docs/configuration-text.md#正文), [`calloutStyles`](https://postext.dev/zh/docs/configuration-callouts.md#标注框样式), [`captionStyle`](https://postext.dev/zh/docs/configuration-resources.md#题注样式), [`colorPalette`](https://postext.dev/zh/docs/configuration-fonts-colors-viewers.md#调色板), [`footer`](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚), [`header`](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚), [`headings`](https://postext.dev/zh/docs/configuration-headings.md#标题), [`layout`](https://postext.dev/zh/docs/configuration-page-layout.md#版式), [`locale`](https://postext.dev/zh/docs/configuration-language.md#断词), [`markStyles`](https://postext.dev/zh/docs/configuration-styles.md#高亮样式), [`orderedLists`](https://postext.dev/zh/docs/configuration-lists-math.md#有序列表), [`page`](https://postext.dev/zh/docs/configuration-page-layout.md#页面), [`paragraphStyles`](https://postext.dev/zh/docs/configuration-styles.md#段落样式), [`resourceTypes`](https://postext.dev/zh/docs/configuration-resources.md#资源类型), [`unorderedLists`](https://postext.dev/zh/docs/configuration-lists-math.md#无序列表)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/zh/docs/configuration-programmatic-usage.md#排版前加载字体), [`defaultResourceTypes`](https://postext.dev/zh/docs/configuration-resources.md#资源类型), [`registerResourceImage`](https://postext.dev/zh/docs/architecture.md#api接口), [`renderPageToCanvas`](https://postext.dev/zh/docs/configuration-programmatic-usage.md#把页面渲染为位图)

**字体**

- Source Serif 4 (OFL-1.1), Source Sans 3 (OFL-1.1)

## 做法

### 1 · 每类词一个样式

代码就是上面的[简短回答](https://postext.dev/zh/cookbook/colour-coded-study-highlights.md#简短回答)。`markStyles` 声明三个有名字的样式，正文里每处高亮选一个：`:mark[Johann Fust]{style="name"}`、`:mark[1455]{style="date"}`、`:mark[colophon]{style="term"}`（[高亮标记](https://postext.dev/zh/docs/document-format-inline.md#高亮标记)）。每个样式表示一种含义（`name` 是编辑时看到的名字），于是 Markdown 记下的是一个词为什么被标出，而不是它用什么颜色。改一个样式的颜色，全书所有日期都跟着变。两端是方的，postext 1.52 起默认如此（[高亮样式](https://postext.dev/zh/docs/configuration-styles.md#高亮样式)）：荧光笔画出来的两端是方的，圆角色带看起来像标签。

### 2 · 调色板里的三种浅色

```js
// script.js, 行 14–29
const palette = {
  ink: '#1f1c19', // text: a warm near-black
  accent: '#8a3220', // kickers, rules, numbers: an iron-gall red
  tint: '#f5f0e7', // the key's background
  rule: '#d3cabc', // hairlines
  muted: '#6b645b', // running heads, colophon
  paper: '#ffffff',
  term: '#cce8c8', // key terms: a pale green
  date: '#fbe0a1', // dates: amber
  name: '#cfe0f4', // people and places: a pale blue
};
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' } })),
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
```

三条色带和正文墨色、强调色一样是调色板条目，在 Sandbox 里打开这本书的人改一个色样就能换掉整套颜色。它们很浅，亮度接近方框的底色，上面的文字对比度保持在 12.6:1 以上；三种颜色在同一行里相邻时也分得清，比如第3页的“In 1517 Martin Luther”。高亮不占地方：`paddingX` 向短语旁边的空格伸出 0.12 em，`paddingBottom` 把色带延到下伸部以下，两者都画在原本就有的空白里。

### 3 · 开篇：标题上方的画

```js
// script.js, 行 56–81
const opener = {
  enabled: true,
  minHeight: mm(122), // the floor of the opener: the key starts on the same line
  marginBottom: pt(0),
  slot: {
    elements: [
      { kind: 'image', id: 'press', resourceId: 'press', fit: 'cover',
        placement: { anchor: { to: 'page', edge: 'top-left' },
          size: { width: mm(170), height: mm(96) } } },
      { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: SANS,
        fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.6),
        textTransform: 'uppercase', color: col('accent'), align: 'left',
        placement: { anchor: { to: 'container', edge: 'top-left' },
          offset: { x: mm(0), y: mm(82) } } },
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SERIF,
        fontSize: pt(26), lineHeight: 1.08, fontWeight: 700, color: col('ink'), align: 'left',
        placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { x: mm(0), y: mm(2) },
          size: { width: mm(126), height: 'auto' } } },
      { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', fontFamily: SERIF,
        italic: true, fontSize: pt(10.5), lineHeight: LEAD / 10.5, color: col('ink'),
        align: 'left',
        placement: { anchor: { to: '#title', edge: 'below' }, offset: { x: mm(0), y: mm(3) },
          size: { width: mm(118), height: 'auto' } } },
    ],
  },
};
```

一级标题印的是一个设计。画锚定在页面上，所以从上边和左右两边出血，170 × 96 mm，用 `fit: 'cover'`。栏目标签和导语来自标题行上的属性（[标题属性](https://postext.dev/zh/docs/document-format.md#标题属性)），图例则是一个普通的 `:::callout`，列表项分别以三种高亮开头。`minHeight` 决定开篇的深度：不管标题多长，图例都从同一行开始。

### 4 · 按奇偶页排的书眉

```js
// script.js, 行 85–106
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body',
  fontFamily: SANS, fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.2),
  textTransform: 'uppercase', color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(13) } },
  ...extra,
});
const folio = { fontWeight: 700, color: col('ink') };
const header = {
  elements: [
    head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
    head('verso-title', '{title}', 'even', 'top-left', OUTER + 9),
    head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 9)),
    head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
  ],
};
const footer = {
  elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, {
    ...folio, pages: 'opener',
    placement: { anchor: { to: 'container', edge: 'top' }, offset: { x: mm(0), y: mm(9) } },
  })],
};
```

左页印丛书名，右页印本篇标题，两者都用 `parity` 筛选，并用 `pages: 'body'` 避开开篇页（[文本元素](https://postext.dev/zh/docs/configuration-headers-footers.md#文本元素)）。开篇页的页码放在页脚。

## 完整食谱

一个文件，由食谱文件夹合成，示例文本和排版食谱的公共工具包都已内联；它自己排出页面。要运行它，把它放进空白页面的`<script type="module">`，或粘贴到新建CodePen的JS面板里（设为模块）。它从esm.sh导入postext，不需要安装或构建。

- 食谱文件夹: https://github.com/drnachio/postext/tree/main/cookbook/colour-coded-study-highlights

### script.js

```js
// ═══ Postext Cookbook · Nº 180 · Colour-coded highlights in a study reader ════════
// https://postext.dev/en/cookbook/colour-coded-study-highlights
// Code: MIT · Text: original (CC BY 4.0) · Picture: painting made for the recipe
// Fonts: Source Serif 4, Source Sans 3 (SIL OFL 1.1) · Needs postext ≥ 1.65.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, defaultResourceTypes,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'colour-coded-study-highlights';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: semantic colours, three of them for the highlighter
const palette = {
  ink: '#1f1c19', // text: a warm near-black
  accent: '#8a3220', // kickers, rules, numbers: an iron-gall red
  tint: '#f5f0e7', // the key's background
  rule: '#d3cabc', // hairlines
  muted: '#6b645b', // running heads, colophon
  paper: '#ffffff',
  term: '#cce8c8', // key terms: a pale green
  date: '#fbe0a1', // dates: amber
  name: '#cfe0f4', // people and places: a pale blue
};
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' } })),
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
// #endregion
const OUTER = 20; // outer margin in mm: the running heads align to it
const LEAD = 14.5; // body leading in pt
const SANS = 'Source Sans 3';
const SERIF = 'Source Serif 4';

// #region answer: three highlight styles, one per kind of word, linked to the palette
// :mark[Mainz]{style="name"} in the text picks a style by id. The band is painted under
// words that stay ordinary text, so the lines break, hyphenate and justify as if the
// mark were not there, and a mark that runs over a line end gets a band on each line.
const band = (id, name) => ({
  id, name,
  background: col(id), // pale enough for ink at full contrast on top
  color: col('ink'), // the words keep the colour of the text
  // borderRadius: 0 is the default (square ends, as a highlighter pen leaves them)
  paddingX: em(0.12), // the band reaches a little past the first and the last letter
  paddingTop: em(0.04), paddingBottom: em(0.1), // and down past the descenders
});
const markStyles = [
  band('term', t({ en: 'Key term', es: 'Término clave' })),
  band('date', t({ en: 'Date', es: 'Fecha' })),
  band('name', t({ en: 'Name', es: 'Nombre' })),
];
// #endregion

// #region opener: the painting bleeds off the top of the page, the title sits under it
const opener = {
  enabled: true,
  minHeight: mm(122), // the floor of the opener: the key starts on the same line
  marginBottom: pt(0),
  slot: {
    elements: [
      { kind: 'image', id: 'press', resourceId: 'press', fit: 'cover',
        placement: { anchor: { to: 'page', edge: 'top-left' },
          size: { width: mm(170), height: mm(96) } } },
      { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: SANS,
        fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.6),
        textTransform: 'uppercase', color: col('accent'), align: 'left',
        placement: { anchor: { to: 'container', edge: 'top-left' },
          offset: { x: mm(0), y: mm(82) } } },
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SERIF,
        fontSize: pt(26), lineHeight: 1.08, fontWeight: 700, color: col('ink'), align: 'left',
        placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { x: mm(0), y: mm(2) },
          size: { width: mm(126), height: 'auto' } } },
      { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', fontFamily: SERIF,
        italic: true, fontSize: pt(10.5), lineHeight: LEAD / 10.5, color: col('ink'),
        align: 'left',
        placement: { anchor: { to: '#title', edge: 'below' }, offset: { x: mm(0), y: mm(3) },
          size: { width: mm(118), height: 'auto' } } },
    ],
  },
};
// #endregion

// #region running-heads: series title on the verso, reading title on the recto
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body',
  fontFamily: SANS, fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.2),
  textTransform: 'uppercase', color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(13) } },
  ...extra,
});
const folio = { fontWeight: 700, color: col('ink') };
const header = {
  elements: [
    head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
    head('verso-title', '{title}', 'even', 'top-left', OUTER + 9),
    head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 9)),
    head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
  ],
};
const footer = {
  elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, {
    ...folio, pages: 'opener',
    placement: { anchor: { to: 'container', edge: 'top' }, offset: { x: mm(0), y: mm(9) } },
  })],
};
// #endregion

const config = {
  locale: t({ en: 'en-us', es: 'es' }),
  resourceTypes: defaultResourceTypes(LANG),
  colorPalette,
  markStyles,
  page: {
    sizePreset: 'custom', width: mm(170), height: mm(240), dpi: 150,
    margins: { top: mm(22), bottom: mm(22), left: mm(22), right: mm(OUTER), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: SERIF, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: {
    fontFamily: SERIF, color: col('ink'), fontWeight: 700,
    levels: [
      { level: 1, fontSize: pt(26), breakBefore: { parity: 'odd' }, advancedDesign: opener },
      { level: 2, fontFamily: SANS, fontSize: pt(12), lineHeight: pt(LEAD), fontWeight: 700,
        color: col('accent'), marginTop: pt(LEAD), marginBottom: pt(0) },
    ],
  },
  unorderedLists: { bulletChar: '–', color: col('accent'), fontWeight: 400,
    marginTop: pt(0), marginBottom: pt(0) },
  orderedLists: { numberFormat: 'arabic', color: col('accent'), fontWeight: 700,
    marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [
    // The glossary: each green term opens its entry, the turnovers hang 1.5 em.
    { id: 'glossary', fontSize: pt(9.5), lineHeight: pt(13), textAlign: 'left',
      hangingIndent: em(1.5) },
    { id: 'colophon', fontFamily: SANS, fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  // The key: a tint with a top stripe, its title in the label face.
  calloutStyles: [
    { id: 'key', title: t({ en: 'Key', es: 'Clave' }), background: col('tint'),
      stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('accent') },
      padding: { top: mm(3), right: mm(4), bottom: mm(3), left: mm(4) },
      titleStyle: { fontFamily: SANS, fontSize: pt(8), fontWeight: 700,
        textTransform: 'uppercase', letterSpacing: pt(1.2), color: col('accent') },
      body: { fontFamily: SANS, fontSize: pt(9.5), lineHeight: pt(13), color: col('ink'),
        firstLineIndent: pt(0), textAlign: 'left', boldColor: col('ink'),
        italicColor: col('ink') } },
  ],
  captionStyle: { fontFamily: SANS, fontSize: pt(8), color: col('ink'), labelBold: true,
    labelColor: col('accent'), note: { color: col('muted') } },
  header,
  footer,
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Readings in Early Modern History"
author: "Study edition"
---

# How Printing Spread Across Europe {kicker="Unit 4 · Reading 2" standfirst="In a hundred years a craft from one German town filled Europe with books. This reading follows the press from Mainz to more than two hundred towns and asks what changed when books became cheap."}

:::callout{type="key" title="Reading the highlights"}
The editors have marked three kinds of words in this reading. Use the same colours in your own notes.

- :mark[Key term]{style="term"}: a word the glossary at the end of the unit defines.
- :mark[Date]{style="date"}: a year or a period to place on your timeline.
- :mark[Name]{style="name"}: a person or a place in the story.
:::

## A workshop in Mainz

Around :mark[1450]{style="date"} a goldsmith named :mark[Johannes Gutenberg]{style="name"} was running a printing workshop in :mark[Mainz]{style="name"}, a city on the Rhine. Printing itself was not new. Printers in China had cut whole pages into wooden blocks for centuries, and Korean printers had cast metal type by the :mark[thirteenth century]{style="date"}. What Gutenberg put together was a system that suited the twenty-odd letters of the Latin alphabet.

The system had four parts. The first was :mark[movable type]{style="term"}: single letters cast in metal, set in lines, printed and then put back in the case for the next page. A hand mould cast thousands of identical letters from an alloy of lead, tin and antimony. An ink made with linseed oil clung to the metal where a watery ink would have run off. And a :mark[screw press]{style="term"}, of the kind used to press grapes and to flatten wet paper, pushed the paper evenly onto the inked type.

The first large book from the workshop was a Latin Bible in two volumes, set in two columns of 42 lines, finished by :mark[1455]{style="date"}. About 180 copies were printed, most on paper and some on vellum. A single scribe could spend years copying one Bible; the workshop produced its whole edition in about the same time.

Gutenberg had borrowed heavily to build all this. His lender, the Mainz merchant :mark[Johann Fust]{style="name"}, took him to court in :mark[1455]{style="date"} and kept much of the equipment. Fust carried on the business with :mark[Peter Schöffer]{style="name"}, who had trained in the workshop. Their Psalter of :mark[1457]{style="date"} is the first printed book whose :mark[colophon]{style="term"}, the closing note of a book, names its printers and the day it was finished.

## The craft travels

For about ten years printing stayed close to Mainz. In :mark[1462]{style="date"} the city was sacked in a war between two rival archbishops, and many of its craftsmen left. Printers trained on the Rhine set up wherever they found readers, paper and money: at :mark[Subiaco]{style="name"}, near Rome, in :mark[1465]{style="date"}; in :mark[Venice]{style="name"} in :mark[1469]{style="date"}; in :mark[Paris]{style="name"}, in rooms at the Sorbonne, in :mark[1470]{style="date"}. A press was at work in :mark[Segovia]{style="name"} around :mark[1472]{style="date"}, and :mark[Valencia]{style="name"} had one by :mark[1474]{style="date"}.

:mark[William Caxton]{style="name"}, an English merchant who had learned the craft in :mark[Cologne]{style="name"}, set up his press at :mark[Westminster]{style="name"} in :mark[1476]{style="date"}. Most printers of the time worked in Latin, the language of the Church and the universities, and sold across borders. Caxton printed mostly in the :mark[vernacular]{style="term"}, the language people spoke at home: romances, histories and Chaucer's *Canterbury Tales*, for readers who had no Latin.

Books printed before :mark[1501]{style="date"} are called :mark[incunabula]{style="term"}, from the Latin word for a cradle: the books of printing's infancy. Catalogues today list about 30,000 editions from those fifty years. By :mark[1500]{style="date"} presses had worked in more than 250 towns, and estimates of the copies they printed run from eight to twenty million.

## Venice and the pocket book

By the end of the century :mark[Venice]{style="name"} had more printing houses than any other city in Europe. It had paper mills on the mainland, ships to carry books to every port of the Mediterranean, and buyers with money. :mark[Aldus Manutius]{style="name"} opened his press there in the :mark[mid-1490s]{style="date"} and printed the Greek classics in editions prepared by scholars. His books carried a printer's mark, a dolphin wrapped round an anchor, so that buyers could tell them from the copies other printers soon made.

In :mark[1501]{style="date"} Aldus issued a Virgil in :mark[octavo]{style="term"}, a sheet folded three times to make sixteen pages, so that the book fitted in a hand. It was set in a slanting letter cut by :mark[Francesco Griffo]{style="name"}, which we now call :mark[italic]{style="term"}. The narrow letters fitted more words on each line, so the small books needed less paper and cost less. Readers could carry a poet in a pocket instead of reading him at a desk.

## Print and the Reformation

In :mark[1517]{style="date"} :mark[Martin Luther]{style="name"}, who taught theology at :mark[Wittenberg]{style="name"}, wrote ninety-five theses against the sale of indulgences. Printers in several German cities reprinted them within weeks. Luther went on to write short :mark[pamphlets]{style="term"} in German: a few pages each, quick to set, cheap to buy and easy to pass from hand to hand. Historians estimate that between :mark[1518]{style="date"} and :mark[1525]{style="date"} his writings made up about a third of all the books printed in German.

His opponents answered in print too, and the quarrel over the Church was argued in thousands of pamphlets read aloud in taverns and market squares. Printing did not cause the Reformation, but it carried each side's arguments further and faster than any preacher could.

## Check your reading

1. Name the four parts of Gutenberg's system, and say what each one did.
2. Why did the sack of Mainz in 1462 help printing to spread?
3. What is a colophon, and why would a historian be glad to find one?
4. Caxton chose the vernacular. Who were his readers, and why did that choice sell books?
5. Put the amber dates of this reading on a timeline from 1450 to 1525.

## Glossary

:::paragraphs{style="glossary"}
**:mark[colophon]{style="term"}** A note at the end of a book that gives the printer, the place and the date of printing.

**:mark[incunabula]{style="term"}** Books printed in Europe before 1501. One such book is an incunable.

**:mark[italic]{style="term"}** A slanting style of type, first cut by Francesco Griffo for Aldus Manutius.

**:mark[movable type]{style="term"}** Single letters cast one by one, which can be set in lines, printed and used again.

**:mark[octavo]{style="term"}** A book whose sheets are folded three times, giving sixteen pages to the sheet.

**:mark[pamphlet]{style="term"}** A short booklet of a few unbound pages, quick and cheap to print.

**:mark[screw press]{style="term"}** A press in which a turning screw drives a flat plate down onto the paper and the type.

**:mark[vernacular]{style="term"}** The language people speak at home, as opposed to Latin.
:::

:::paragraphs{style="colophon"}
Set in Source Serif 4 and Source Sans 3 (SIL Open Font License) · Text: original, CC BY 4.0 · Opening picture: a painting made for this edition.
:::
`; // content.<lang>.md: the reading, its key and questions

// #region art: a printing workshop around 1460, a painting in assets/ (no lettering)
const resources = [{ id: 'press', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'press-1400.jpg', format: 'jpeg', width: 1400, height: 790 },
  altText: t({
    en: 'A printing workshop: a compositor picks type from a case by the window while a '
      + 'pressman pulls the bar of a wooden screw press; printed sheets hang drying overhead.',
    es: 'Un taller de imprenta: un cajista toma tipos de la caja junto a la ventana mientras '
      + 'un prensista tira de la barra de una prensa de husillo; arriba se secan los pliegos.',
  }) }];
await loadImage(resources[0].bitmap.fileId, asset(resources[0].bitmap.fileId));
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Source Serif 4': ['400', '400i', '700'],
  'Source Sans 3': ['400', '600', '700'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS));
showPages(doc, { title: t({ en: 'A study reader in three highlight colours',
  es: 'Un libro de lecturas resaltado en tres colores' }) });

// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout and asks the kit
// for Fontsource's files, the ones the PDF embeds too.

/** buildDocumentWithFonts' options: the resolver, and FONTS' faces loaded
 *  with the config's. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's files with their ranges: latin, then latin-ext and greek when
 *  `text` needs them (kitSubsetsFor). Null for a face Fontsource lacks. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(await fontsourceWeights(family, style)).includes(weight)) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** Each file's unicode-range (a function: the kit runs after the pen's code). */
function kitRanges() {
  return {
    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',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks: loads `faces` (as FONTS) for the
 *  sample `text`; `optional` skips a face Fontsource lacks. Files are added in
 *  the order asked, the last winning a shared character. Resolves to the count. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta && (await fontsourceWeights(family, style)).includes(weight))) continue;
      for (const subset of subsets) {
        const url = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this face (fonts.check() says yes to any family). */
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;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family, id = fontsourceId(family)) {
  return (fontsourceMeta.c ??= {})[id] ??= fetch(`https://api.fontsource.org/v1/fonts/${id}`)
    .then((res) => (res.ok ? res.json() : null), () => null);
}

/** The weights the package ships in `style`: the API pairs none (no Libre
 *  Caslon 700 italic file), so italics are read from latin-italic.css. */
function fontsourceWeights(family, style, id = fontsourceId(family)) {
  return (fontsourceWeights.c ??= {})[id + style] ??= fontsourceMeta(family).then(async (m) => {
    if (!m?.styles.includes(style)) return m ? [] : [400, 700];
    const css = style === 'italic' ? await fetch(`${await fontsourceAt(id)}latin-italic.css`).then((r) => (r.ok ? r.text() : ''), () => '') : '';
    const w = [...css.matchAll(/weight: (\d+)/g)].map((x) => +x[1]);
    return w.length ? w : m.weights;
  });
}

// ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

/** 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 ───────────────────────────────────────────────────────────────────────
```

## 变化

### 让色带两端变圆

圆角取字号的五分之一，就像记号笔画出的柔和两端。

```diff
-  // borderRadius: 0 is the default (square ends, as a highlighter pen leaves them)
+  borderRadius: em(0.2), // soft ends, as a felt-tip pen leaves them
```

### 每行一条完整色带

设置 `decorationBreak: 'clone'` 后，跨行的高亮在每一段的两端都有留白，比如第3页的“pamphlets”在“pam-”后断开的地方。

```diff
   paddingX: em(0.12), // the band reaches a little past the first and the last letter
+  decorationBreak: 'clone', // each line piece gets a whole band
```

## 常见问题

- 只靠颜色，黑白复印后就看不出来了，而很多读物正是这样复印后发到课堂上的。术语表另外把术语设为粗体；如果读者会复印这些页面，就给三类词中的一类再加一个自己的标志。
- `markStyles` 里没有声明的样式 id 会退回第一个样式，页面上不会报错（Sandbox 会提示）。id 要短，并检查正文里的拼写。

## 致谢

- 食谱: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 文字: The reading, its key, the review questions and the colophon: Ignacio Ferro, CC-BY-4.0
- 图片: The printing workshop on the opening page, a painting: Generated With Diffusion Models, 原创
- 字体: Source Serif 4 (OFL-1.1), Source Sans 3 (OFL-1.1)
- 代码: MIT · 示例内容: CC-BY-4.0

## 相关食谱

- [No. 076 · 拼音识字课本：每个字上都标读音](https://postext.dev/zh/cookbook/pinyin-primer.md): 一本香港启蒙读本里的单字注音：{人之初|rén zhī chū}在每个汉字上方居中排一个拼音音节，字体为Andika，放在28 pt的行间空隙里。 · 难度 2 (中级) · 教科书, 练习册与习题
- [No. 163 · 用ABC写谱例的和声教程](https://postext.dev/zh/cookbook/harmony-textbook-abc-examples.md): 和声教程中讲终止式的一章：谱例是写在Markdown中的ABC代码块，按栏宽刻印成谱，其中最长的一例像段落一样从一页接排到下一页。 · 难度 2 (中级) · 教科书
- [No. 122 · 带训点的汉文和它的训读](https://postext.dev/zh/cookbook/kanbun-kundoku.md): 按日本读本的排法排三则《论语》和一首唐人绝句：返り点和送り仮名用朱色，每段之下是假名书写的训读文。 · 难度 2 (中级) · 教科书
