# 学習用読本の色分けハイライト

> 歴史の読本で、キーワードを3色に塗り分けます。用語は緑、年代は琥珀色、人名と地名は青。3つのスタイルはmarkStylesで定義し、パレットに結び付けます。

- HTML版: https://postext.dev/ja/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)
- Sandboxで開く: https://postext.dev/ja/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), [zh](https://postext.dev/zh/cookbook/colour-coded-study-highlights.md), [ar](https://postext.dev/ar/cookbook/colour-coded-study-highlights.md)

## かんたんな説明

印刷の歴史を扱う学校向けの読み物です。用語は緑、年代は琥珀色、人や場所は青でハイライトされ、最初のページの凡例がそれぞれの色の意味を説明します。

## できあがり

学校向けの歴史読本『Readings in Early Modern History』のある単元の「読み物2」です。1450年から1550年にかけて印刷術がマインツからヨーロッパへ広がった経緯を、170 × 240 mmのページ4枚で扱います。編集者は熱心な生徒のように本文に印を付けていて、その色の約束は本全体で変わりません。用語は緑の帯、年代は琥珀色、人物と場所は淡い青の上に載ります。タイトルの下の凡例はその3色そのものを使って約束を説明し、最後のページの用語集は各項目を緑の用語で始めます。印はふつうの文字の下に敷いた帯なので、改行もハイフネーションも両端揃えも、帯がないときと同じです。

**このレシピが答える問い:**

- 学習用の文章のキーワードを、意味ごとに色を分けて（用語・年代・人名）ハイライトするには？

## 手短な答え

```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/ja/docs/document-format-inline.md#ハイライト): 教科書がキーワードに線を引くように語句の後ろに敷く色の帯。語はふつうの本文のまま行をまたぎ、帯は行ごとの部分に付きます。

**ほかに使うもの**

- [囲み](https://postext.dev/ja/docs/configuration-callouts.md#囲みスタイル)
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration-fonts-colors-viewers.md#カラーパレット)
- [デザインした章扉](https://postext.dev/ja/docs/configuration-headings.md#幅と詳細デザイン)
- [ページデザインの画像](https://postext.dev/ja/docs/configuration-headers-footers.md#画像要素)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [柱とノンブル](https://postext.dev/ja/docs/configuration-headers-footers.md#柱とノンブル)
- [ページの役割ごとの柱](https://postext.dev/ja/docs/configuration-headers-footers.md#テキスト要素)
- [見開きの余白](https://postext.dev/ja/docs/configuration-page-layout.md#見開きの余白)
- [段落スタイル](https://postext.dev/ja/docs/configuration-styles.md#段落スタイル)
- [ウィドウ、オーファン、ラント](https://postext.dev/ja/docs/configuration-page-flow.md#オーファンウィドウラント分割禁止の規則)
- [参考文献と用語集](https://postext.dev/ja/docs/configuration-styles.md#段落スタイル)
- [「図」と「表」を文書の言語で](https://postext.dev/ja/docs/configuration-resources.md#リソースの種類)
- [独自のリソースの種類](https://postext.dev/ja/docs/configuration-resources.md#リソースの種類)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format-resources.md#リソース)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration-text.md#本文), [`calloutStyles`](https://postext.dev/ja/docs/configuration-callouts.md#囲みスタイル), [`captionStyle`](https://postext.dev/ja/docs/configuration-resources.md#キャプションスタイル), [`colorPalette`](https://postext.dev/ja/docs/configuration-fonts-colors-viewers.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration-headers-footers.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration-headers-footers.md#柱とノンブル), [`headings`](https://postext.dev/ja/docs/configuration-headings.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration-page-layout.md#レイアウト), [`locale`](https://postext.dev/ja/docs/configuration-language.md#ハイフネーション), [`markStyles`](https://postext.dev/ja/docs/configuration-styles.md#ハイライトのスタイル), [`orderedLists`](https://postext.dev/ja/docs/configuration-lists-math.md#番号付きリスト), [`page`](https://postext.dev/ja/docs/configuration-page-layout.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration-styles.md#段落スタイル), [`resourceTypes`](https://postext.dev/ja/docs/configuration-resources.md#リソースの種類), [`unorderedLists`](https://postext.dev/ja/docs/configuration-lists-math.md#箇条書きリスト)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/ja/docs/configuration-programmatic-usage.md#レイアウトの前にフォントを読み込む), [`defaultResourceTypes`](https://postext.dev/ja/docs/configuration-resources.md#リソースの種類), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration-programmatic-usage.md#ページをビットマップに描画する)

**書体**

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

## 作り方

### 1 · 語の種類ごとにひとつのスタイル

コードは上の[手短な答え](https://postext.dev/ja/cookbook/colour-coded-study-highlights.md#手短な答え)にあります。`markStyles`で名前付きのスタイルを3つ宣言し、本文の各ハイライトがそのひとつを選びます。`:mark[Johann Fust]{style="name"}`、`:mark[1455]{style="date"}`、`:mark[colophon]{style="term"}`のように書きます（[ハイライト](https://postext.dev/ja/docs/document-format-inline.md#ハイライト)）。各スタイルは意味を表すので（`name`は編集時に見える名前）、Markdownにはその語に印を付けた理由が残り、何色かは残りません。スタイルの色を変えれば、本のすべての年代がいっしょに変わります。帯の端は角のままで、postext 1.52からこれが既定です（[ハイライトのスタイル](https://postext.dev/ja/docs/configuration-styles.md#ハイライトのスタイル)）。蛍光ペンの線は端が角張っていて、角を丸めた帯はチップに見えてしまいます。

### 2 · パレットの3つの淡い色

```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' } },
];
```

3本の帯は、文字色やアクセント色と同じくパレットの項目です。Sandboxでこの本を開いた人は、色見本をひとつ変えるだけで約束の色を変えられます。色は囲みの地色に近い明るさまで淡くしてあり、上の文字は12.6:1以上のコントラストを保ちます。3色が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' } } },
    ],
  },
};
```

レベル1の見出しはデザインを印刷します。絵はページに固定してあるので、上と左右の端まで裁ち落としになり、大きさは170 × 96 mm、`fit: 'cover'`で収めます。見出しラベルとリード文は見出し行の属性から取り（[見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)）、凡例はふつうの`:::callout`で、そのリストの各項目が3つのハイライトで始まります。`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/ja/docs/configuration-headers-footers.md#テキスト要素)）。扉のノンブルは下に置きます。

## レシピの全体

レシピのフォルダーから合成した1つのファイルで、サンプルのテキストとレシピ集の共通キットを埋め込んであり、自分でページを組み立てます。動かすには、空のページの`<script type="module">`に入れるか、新しいCodePenのpenの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 ───────────────────────────────────────────────────────────────────────
```

## アレンジ

### 帯の端を丸める

文字サイズの5分の1の半径にすると、フェルトペンのような柔らかい端になります。

```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
```

## よくあるつまずき

- 色だけの区別は白黒コピーで消えます。そして多くの読み物は、コピーされて教室に届きます。この用語集では用語を太字にもしています。読者がページをコピーするなら、3種類のうちひとつに色以外の目印も付けてください。
- `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/ja/cookbook/pinyin-primer.md): 香港の入門読本のモノルビ。{人之初|rén zhī chū}はピンインの音節を1字ずつ中央に載せ、Andikaで28 ptの行間に組みます。 · 難易度 2 (中級) · 教科書, ワークブックと練習問題
- [No. 163 · ABCで譜例を書く和声の教科書](https://postext.dev/ja/cookbook/harmony-textbook-abc-examples.md): 終止を扱う和声の教科書の1章です。譜例はMarkdownに書いたABCのブロックで、段の幅に合わせて浄書され、そのうち1つは2ページにわたって続きます。 · 難易度 2 (中級) · 教科書
- [No. 122 · 訓点付きの漢文と書き下し文](https://postext.dev/ja/cookbook/kanbun-kundoku.md): 『論語』の3章と唐の絶句1首を、日本の読本の組み方で組みます。返り点と送り仮名は朱、各章の下に仮名交じりの書き下し文を置きます。 · 難易度 2 (中級) · 教科書
