# データシート：データから作る表とセル結合

> TSVで貼り付けた表をparseTSVで読み、mergeCells、setAlignment、setCellBackgroundで整えます。レジスターマップは自動でページをまたいで分割されます。

- HTML版: https://postext.dev/ja/cookbook/technical-datasheet
- レシピ No. 010 · 表 · 難易度 3 (上級) · 出力: Canvas, PDF
- ジャンル: マニュアル・ガイド・リファレンス
- 必要なもの postext ≥ 1.4.1, postext-pdf ≥ 1.4.1 · テスト環境 1.4.1, postext-pdf 1.4.1 ／テスト日 2026-09-25
- ページ: [1](https://postext.dev/cookbook/technical-datasheet/en/p01.webp?v=b0677359), [2](https://postext.dev/cookbook/technical-datasheet/en/p02.webp?v=b0677359), [3](https://postext.dev/cookbook/technical-datasheet/en/p03.webp?v=b0677359), [4](https://postext.dev/cookbook/technical-datasheet/en/p04.webp?v=b0677359)
- PDF: https://postext.dev/cookbook/technical-datasheet/en/technical-datasheet.pdf?v=b0677359
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=technical-datasheet&lang=en (.postext: https://postext.dev/cookbook/technical-datasheet/en/technical-datasheet.postext)
- 最終更新: 2026-09-25
- 他の言語: [en](https://postext.dev/en/cookbook/technical-datasheet.md), [es](https://postext.dev/es/cookbook/technical-datasheet.md), [ca](https://postext.dev/ca/cookbook/technical-datasheet.md), [zh](https://postext.dev/zh/cookbook/technical-datasheet.md), [ar](https://postext.dev/ar/cookbook/technical-datasheet.md)

## かんたんな説明

電子式の温度センサーの4ページの製品資料です。表計算のデータから表を作り、見出しを共有させ、長い表を次のページへ続ける方法を示します。

## できあがり

架空のメーカーPyxis Microdevicesの温度センサーPX-7021の、4ページのデータシートです。大半は表で、見出しには結合したセルが多く、レジスターマップは1段より長くなります。表紙ページは、品番と3つの主要な数値を載せた紫の帯で始まります。2ページ目では1つの表がページ幅にまたがります。2行の見出しはValueをMin、Typ、Maxの上に置き、パラメーターのグループは第1列で縦に結合し、パラメーターは1つおきに灰色の地を敷きます。レジスターマップは3ページ目の地で切れ、4ページ目で見出しを繰り返して続き、型番の表の横、パッケージ図の上に置かれます。PostextはMarkdownの表を解析しないので、表は表計算のデータをTSVとして貼り付けたものです。

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

- 貼り付けたデータを、結合した見出し、列幅、セルごとのそろえを持つ表にするには？
- 長い表を、見出し行を繰り返し「続く」の表示を付けて、ページをまたいで分割するには？
- 1つの文書の中で、複数の表を異なるスタイル（塗り、縞模様のセル、角丸の枠）にするには？
- PDFで図表を鮮明に保ち、テキストを選択できるようにするには（SVG、印刷用マスター）？
- 画像や表を、Markdownの![]()ではなくコードから（リソースとして）追加するには？

## 手短な答え

```js
// script.js, 行 31–69
const at = (row, column) => ({ row, col: column });
const span = (r0, c0, r1, c1) => ({ start: at(r0, c0), end: at(r1, c1) });
const C = { group: 0, param: 1, symbol: 2, conditions: 3, min: 4, max: 6, unit: 7 }; // columns
function electricalTable(tsv) {
  // In 1.4.1 parseTSV makes plain cells and leaves headerRowCount unset: the head is two rows.
  let m = Object.assign(parseTSV(tsv), { headerRowCount: 2,
    columnWidths: [22, 44, 16, 38, 15, 15, 15, 15] }); // weights: mm of the 180 mm measure
  // 'Parameter' covers two columns and two rows; 'Value' spans Min, Typ and Max. mergeCells
  // marks the covered cells hiddenBy, so no column shifts (gotcha: merged-cells-hiddenby).
  for (const range of [span(0, C.group, 1, C.param), span(0, C.symbol, 1, C.symbol),
    span(0, C.conditions, 1, C.conditions), span(0, C.min, 0, C.max),
    span(0, C.unit, 1, C.unit)]) {
    m = mergeCells(m, range); // 'Value' is centred over its three columns, the rest set left
    m = setAlignment(m, range.start, range.start.col === C.min ? 'center' : 'left', 'middle');
  }
  // Min, Typ and Max go right, over their figures; setAlignment clears a vAlign it is not given.
  for (let c = C.min; c <= C.max; c++) m = setAlignment(m, at(1, c), 'right');
  let zebra = false;
  for (let r = m.headerRowCount; r < m.rows.length; r++) {
    const row = m.rows[r];
    if (row[C.param].content) zebra = !zebra; // a parameter keeps one fill over its conditions
    for (let c = 0; c < row.length; c++) {
      // An empty cell continues the one above: a group, or a parameter and its symbol.
      if (c <= C.symbol && row[c].content) {
        let end = r;
        while (m.rows[end + 1] && !m.rows[end + 1][c].content
          && (c === C.group || !m.rows[end + 1][C.param].content)) end++;
        m = mergeCells(m, span(r, c, end, c));
      }
      // Figures flush right, as datasheets set them (no decimal tab: gap tab-stops).
      m = setAlignment(m, at(r, c), c >= C.min && c <= C.max ? 'right' : 'left', 'middle');
      // A table style has no zebra rows, so they are filled cell by cell (gotcha: no-zebra). Each
      // helper returns a new model, cheap at 20 rows; for thousands, set the cell fields directly.
      if (c === C.group) m = setCellBackground(m, at(r, c), col('tint'));
      else if (zebra) m = setCellBackground(m, at(r, c), col('zebra'));
    }
  }
  return m;
}
```

## 材料

**学べること**

- [データから作る表](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置): ヘッダー行、結合セル、列幅の比率、セルごとの行そろえ、セル内のリストを持つ表のリソースです。パイプ記法の表は解析されません。
- [ページをまたぐ表](https://postext.dev/ja/docs/configuration.md#ページより高い表): 長い表は行と行のあいだで分割され、ヘッダーが繰り返されます。キャプションには「（続き）」が付き、「続く」の表示が入ります。rowspanの途中では分割しません。

**ほかに使うもの**

- [セルの塗り](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置)
- [名前付きの表スタイル](https://postext.dev/ja/docs/configuration.md#名前付きの表スタイル)
- [表スタイル](https://postext.dev/ja/docs/configuration.md#表スタイル)
- [キャプションのスタイル](https://postext.dev/ja/docs/configuration.md#キャプションスタイル)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [上付きと下付き](https://postext.dev/ja/docs/document-format.md#インライン書式)
- [インラインのチップ](https://postext.dev/ja/docs/configuration.md#チップスタイル)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.md#リソース)
- [図の配置](https://postext.dev/ja/docs/document-format.md#配置)
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [段抜きの章見出し帯](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [ページデザインの画像](https://postext.dev/ja/docs/configuration.md#画像要素)
- [柱とノンブル](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [番号付き見出し](https://postext.dev/ja/docs/configuration.md#レベルごとの上書き)
- [囲み](https://postext.dev/ja/docs/configuration.md#囲みスタイル)
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration.md#カラーパレット)
- [PDFの書き出し](https://postext.dev/ja/docs/configuration.md#pdfの生成)
- citations
- [見出しスタイル](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [「図」と「表」を文書の言語で](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [改ページと改段](https://postext.dev/ja/docs/document-format.md#pagebreak)
- [ページの役割ごとの柱](https://postext.dev/ja/docs/configuration.md#テキスト要素)
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [PDFに埋め込むフォント](https://postext.dev/ja/docs/configuration.md#なぜフォントプロバイダーが必要か)
- [独自のリソースの種類](https://postext.dev/ja/docs/configuration.md#リソースの種類)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`calloutStyles`](https://postext.dev/ja/docs/configuration.md#囲みスタイル), [`captionStyle`](https://postext.dev/ja/docs/configuration.md#キャプションスタイル), [`chipStyles`](https://postext.dev/ja/docs/configuration.md#チップスタイル), [`colorPalette`](https://postext.dev/ja/docs/configuration.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`headingStyles`](https://postext.dev/ja/docs/configuration.md#見出しスタイル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル), [`resourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`tableStyle`](https://postext.dev/ja/docs/configuration.md#表スタイル), [`tableStyles`](https://postext.dev/ja/docs/configuration.md#名前付きの表スタイル), [`unorderedLists`](https://postext.dev/ja/docs/configuration.md#箇条書きリスト)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`decompressWoff2`](https://postext.dev/ja/docs/configuration.md#ブラウザー向けフォントプロバイダーfontsource--woff2), [`defaultResourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`mergeCells`](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置), [`parseTSV`](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する), [`renderToPdf`](https://postext.dev/ja/docs/configuration.md#pdfの生成), [`setAlignment`](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置), [`setCellBackground`](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置)

**書体**

- Fira Sans (OFL-1.1), Fira Sans Condensed (OFL-1.1), Fira Mono (OFL-1.1)

## 作り方

### 1 · データを貼り付け、表を整える

コードは前の[手短な答え](#手短な答え)にあります。postext 1.4.1の`parseTSV`は`headerRowCount`を設定しないので、2行の見出しは手で宣言します。表が分割されれば、両方の行が各部分で繰り返されます。`columnWidths`の比率は合計180で、これは版面の幅をmmで表した数なので、各比率がそのままページ上の列幅になります。`mergeCells`は覆われたセルをすべてその場に残して`hiddenBy`の印を付けるので、列はずれません。`setAlignment`はセルごとに働き、縦方向のそろえを省くと消してしまうため、ほとんどの呼び出しで`'middle'`をもう一度渡します。灰色はパラメーター単位で交互に付けるので、Accuracyの2行は同じ地色になります。行単位で交互にすると、2行目は白になってしまいます。

### 2 · 表と図はすべてリソースにする

```js
// script.js, 行 492–521
const svgResource = (id, caption, altText, [w, h], placement) => ({ id, typeId: 'figure',
  kind: 'svg', caption, altText, placement, createdAt: 0, updatedAt: 0,
  svg: { fileId: `${id}.svg`, width: w * SCALE, height: h * SCALE } }); // fitted to its slot
const table = (id, caption, model, { styleId, placement, note } = {}) => ({ id, typeId: 'table',
  kind: 'table', caption, note, placement, table: { model, styleId }, createdAt: 0, updatedAt: 0 });
// A float takes the first free slot after its first :ref; the logo is never cited, only drawn.
const resources = [
  svgResource('logo', '', 'Pyxis Microdevices', LOGO),
  svgResource('pinout', 'Pin configuration, 8-pin DFN, top view.', 'The package from above: '
    + 'pins 1 to 4 down the left side, 5 to 8 up the right.', PINOUT),
  svgResource('circuit', 'Typical application, bus address 48h.', 'The sensor with a 100 nF '
    + 'capacitor, address pins to ground, and SDA, SCL and ALERT pulled up to a host.', CIRCUIT),
  svgResource('outline', 'Package outline and land pattern, 8-pin DFN, in mm.', 'Top, bottom '
    + 'and side views of the 2 × 2 mm body, and the land pattern with its two vias.', OUTLINE,
  { position: 'bottom', span: 'page' }), // a strip across the foot of a page
  table('electrical', 'Electrical characteristics, *V*~DD~ = 1.6 V to 5.5 V and *T*~A~ = −40 °C '
    + 'to 125 °C unless noted', electricalTable(electrical), { styleId: 'electrical',
    placement: { position: 'top', span: 'page' }, note: 'Typical values at 3.3 V and 25 °C. '
      + '^1^ Tested at 25 °C and 50 °C, the rest by characterization. ^2^ Characterized, not '
      + 'tested in production. ^3^ One conversion a second, bus idle.' }),
  // No placement: these float, and only a floated table splits (gotcha: here-table-no-split).
  table('pins', 'Pin functions', groupedTable(pins, [9, 16, 10, 52]), { styleId: 'grouped',
    note: 'Types: P power, G ground, I input, O open-drain output, I/O open-drain input and '
      + 'output.' }),
  table('registers', 'Register map', groupedTable(registers, [9, 17, 11, 50]), {
    styleId: 'grouped', note: 'Reset values apply at power-on and after a general-call reset.' }),
  table('ordering', 'Order codes', Object.assign(parseTSV(ordering), { headerRowCount: 1,
    columnWidths: [23, 29, 18, 17] }), { styleId: 'ordering',
    note: 'WLCSP-4: fixed address 48h, no ALERT output.' }),
];
```

各表と図はキャプションを持つリソースで、表にはさらに注記と名前付きのスタイルが付きます。キャプションと注記にはイタリック、下付き、上付きが使えます。電気的特性の表は、キャプションで`*V*~DD~`を使って試験条件を示し、注記では`^1^`で脚注に番号を振ります。フロートは最初の`:ref`のあとの、最初の空き位置に置かれます。表1は1ページ目で引用され、ページの頭にまたがって置かれるので、2ページ目の冒頭に来ます。外形図はページの地にまたがって置かれ、ほかの表は初期設定の配置、つまり1段幅のフロートのままです。`here`に置いた表はけっして分割されません。

### 3 · 長い表は自分で分割させる

```js
// script.js, 行 73–87
function groupedTable(tsv, columnWidths) {
  let m = Object.assign(parseTSV(tsv), { headerRowCount: 1, columnWidths });
  const codes = [0, 2]; // Pin and Type, Addr. and Reset: short codes, centred, in a bare chip
  // A TSV cell holds no line break: the data writes \n, and a line opening with • is a list.
  m.rows = m.rows.map((row, r) => row.map((cell, c) => ({ ...cell,
    content: r > 0 && row[1].content && codes.includes(c) ? `:chip[${cell.content}]{style="code"}`
      : cell.content.replaceAll('\\n', '\n') })));
  for (let r = 0; r < m.rows.length; r++) {
    const last = m.rows[r].length - 1;
    if (r >= m.headerRowCount && !m.rows[r][1].content) { // a lone first cell heads a group
      m = setCellBackground(mergeCells(m, span(r, 0, r, last)), at(r, 0), col('tint'));
    } else for (const c of codes) m = setAlignment(m, at(r, c), 'center');
  }
  return m; // no split code: the engine cuts it between rows and repeats the head
}
```

ピンの表とレジスターマップは、どちらも`groupedTable`から作ります。最初のセルだけが埋まった行は表の幅いっぱいに結合され、グループ見出しとして紫の地になります。TSVのセルは改行を持てないので、データには`\n`と書き、関数がそれを改行に変えます。`•`で始まる行は、さらにリスト項目として組まれます。短いコードは`code`チップスタイルで組みます。このスタイルは地色、枠、左右のパディングを持たず、Fira Monoの0.9 emで組みます。フロートした表が、割り当てられた空きの段に収まらないとき、エンジンは行の間で表を切り、残りを次の空き位置に、繰り返した見出しの下に置いて、キャプションのあとに`(continued)`を付けます。`continuesMarker`の行「Continued on the next page」は最初の部分の下に入り、注記は最後の部分まで待ちます（[ページより高い表](/ja/docs/configuration#ページより高い表)）。

### 4 · 共通のスタイルと、表ごとの変種

```js
// script.js, 行 191–205
  tableStyle: { headerBackground: col('brand'), headerColor: col('paper'), headerFontFamily: COND,
    headerFontSize: pt(8.5), bodyFontSize: pt(8), rules: 'horizontal', borderColor: col('rule'),
    borderWidth: pt(0.5), cellPadding: mm(1.3) },
  tableStyles: [ // a resource picks one with table.styleId
    // White rules cut the fills apart and show where each merge and each group ends.
    { id: 'electrical', borderColor: col('paper'), borderWidth: pt(1.4), cellPadding: mm(1.1) },
    // Written out although it is the default: 'clip' and 'hide' cut a table taller than the page.
    { id: 'grouped', overflow: 'split', continuedSuffix: '(continued)',
      continuesMarker: 'Continued on the next page' },
    // A compact list in the condensed face, boxed by a rounded outer frame.
    { id: 'ordering', headerBackground: col('tint'), headerColor: col('brand'),
      rules: 'outer', borderRadius: mm(1.5), bodyFontFamily: COND },
  ],
  captionStyle: { fontFamily: COND, fontSize: pt(9.5), labelColor: col('brand'), gap: mm(2),
    note: { fontSize: pt(7.5), color: col('muted') } },
```

`tableStyle`はすべての表に共通する設定を持ちます。`tableStyles`の各項目は、その表が変える部分だけを設定し、リソースは`table.styleId`で1つを選びます。電気的特性の表は、灰色の細い罫を1.4 ptの白い罫に替えて地色を切り分け、各結合の終わりを見せます。型番の表は角丸の外枠とFira Sans Condensedを使い、4列を本文の1段に収めます。

### 5 · PDFでテキストのまま残るラベル

```js
// script.js, 行 525–539
// An SVG drawn as an image cannot use the page's fonts (gotcha: svg-no-webfonts): the PDF sets
// its labels as real text in the faces it embeds; the canvas copy embeds the face itself.
async function fontFace(family) { // the same TTF the PDF embeds, from the pdf kit block
  const ttf = await fontsourceProvider(family, 400, 'normal');
  const base64 = btoa(Array.from(ttf, (b) => String.fromCharCode(b)).join(''));
  return `@font-face{font-family:'${family}';src:url(data:font/ttf;base64,${base64})}`;
}
async function loadDrawing(fileId, markup, face) {
  await loadSvg(fileId, markup); // registers the plain SVG and keeps its bytes for the PDF
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
    markup.replace(/<svg[^>]*>/, (tag) => `${tag}<style>${face}</style>`))}`;
  await img.decode();
  registerResourceImage(fileId, img); // replaces the canvas copy only
}
```

SVGは画像としてキャンバスに届き、画像はページのWebフォントを使えません。そのため、そのままではピン配置図、回路図、パッケージ外形図のラベルがシステムの書体に置き換わります。PDFは素のマークアップを受け取り、各`<text>`を、埋め込んだ書体で選択可能なテキストとして組みます。キャンバス用には、各図にFira Monoのファイルを内蔵させてもう一度登録します。このファイルは、キットのpdfブロックのフォントローダーで一度だけ取得します。

### 6 · 扉、柱、フッター

```js
// script.js, 行 96–147
const place = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(size && { size }) });
const inset = (edge, y, size) => // a page corner, moved in by the side margin
  place('page', edge, edge.endsWith('left') ? MARGIN.x : -MARGIN.x, y, size);
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement, ...extra });
const caps = (size, weight = 600) => ({ fontWeight: weight, letterSpacing: pt(size * 0.16),
  textTransform: 'uppercase' });
const badge = { ...caps(7.5, 700), box: { backgroundColor: col('hazard'),
  padding: { top: pt(1.6), bottom: pt(1.4), left: pt(4), right: pt(4) } } };
const mark = (size, edge, y) => ({ kind: 'image', id: 'mark', resourceId: 'logo', // by id
  placement: inset(edge, y, { width: mm(size) }) });
const maker = (size, color, y) => text('maker', 'Pyxis Microdevices', COND, size, color,
  place('#mark', 'right-of', size / 4, y), caps(size));
const keyFigure = (n, y) => [ // {attr.k1} over its label {attr.k1l}, flush right on the band
  text(`k${n}`, `{attr.k${n}}`, COND, 22, 'paper', inset('top-right', y), { fontWeight: 600 }),
  text(`k${n}l`, `{attr.k${n}l}`, COND, 7, 'tint', inset('top-right', y + 9), caps(7))];
const BAND = 84; // mm: the violet band across the head of page 1
// The band under the top margin and 9 mm or more of white, in whole grid lines (16 here).
const OPENER_LINES = Math.ceil((BAND - MARGIN.y + 9) / (LEAD * 25.4 / 72));
const opener = {
  enabled: true, minHeight: pt(OPENER_LINES * LEAD), // so the columns under it start on the grid
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('brand') }, placement: {
      anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } },
    mark(6, 'top-left', 12), maker(8.5, 'paper', 1.7),
    text('doc', 'Datasheet {subtitle} · {publishDate}', MONO, 7.5, 'tint',
      inset('top-right', 13.6)),
    text('kicker', '{attr.kicker}', COND, 9.5, 'tint', inset('top-left', 30), caps(9.5)),
    text('title', '{titleText}', COND, 64, 'paper', place('#kicker', 'below', 0, 0.5),
      { fontWeight: 700, lineHeight: 1 }),
    // A design text that overflows its width ends in '…' (gotcha: overflow-ellipsis-default).
    text('lead', '{attr.lead}', 'Fira Sans', 12, 'paper', place('#title', 'below', 0, 2.5,
      { width: mm(108) }), { lineHeight: 1.32, align: 'left', overflow: 'wrap' }),
    text('status', 'Preliminary', COND, 7.5, 'ink', place('#lead', 'below', 0, 4.5), badge),
    ...[1, 2, 3].flatMap((n) => keyFigure(n, 15 + 15 * n)),
  ] },
};
const header = { elements: [ // body pages only: the opener has its band
  text('running-title', '{chapterTitle}', COND, 9, 'brand', inset('top-left', 10.4),
    { fontWeight: 700, pages: 'body' }),
  text('flag', 'Preliminary', COND, 7.5, 'ink', inset('top-right', 10.2),
    { ...badge, pages: 'body' }),
  { kind: 'rule', id: 'hairline', pages: 'body', thickness: pt(0.5), color: col('rule'),
    placement: inset('top-left', 15.5, { width: mm(TRIM[0] - 2 * MARGIN.x) }) },
] };
const footer = { elements: [ // every page: an image element works in a running slot too
  mark(4.5, 'bottom-left', -9), maker(7, 'ink', 1.2),
  text('folio', '{pageNumber}/{totalPages}', COND, 8, 'brand', inset('bottom-right', -9.6),
    { fontWeight: 700 }),
  text('doc', '{subtitle} ·', MONO, 7, 'muted', place('#folio', 'left-of', -1.5, 0.4)),
] };
```

品番は第1レベルの見出しで、そのデザインスロットが扉を描きます。塗り足しまで届く紫のボックス、ロゴ、そして見出しの属性から読む肩見出し、リード、3つの主要な数値です。画像要素は扉だけでなく柱やフッターのスロットでも使えるので、フッターはロゴのリソースを指定する画像要素で始まります。柱の要素は`pages: 'body'`を持つので、どれも表紙ページには刷られません。

## レシピの全体

レシピのフォルダーから合成した1つのファイルで、サンプルのテキストとレシピ集の共通キットを埋め込んであり、自分でページを組み立てます。動かすには、空のページの`<script type="module">`に入れるか、新しいCodePenのpenのJSパネルに貼り付けます（モジュールとして）。esm.shからpostextを読み込むので、インストールもビルドも要りません。

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/technical-datasheet

### script.js

```js
// ═══ Postext Cookbook · Nº 010 · Datasheet: tables from data, merged headers ════════
// https://postext.dev/en/cookbook/technical-datasheet
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Fira Sans, Fira Sans Condensed, Fira Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// The datasheet of a fictional sensor. Postext reads no pipe tables, so the tables are data:
// TSV pasted from a spreadsheet, parsed into table models, then merged, aligned and filled.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes, parseTSV, mergeCells, setAlignment, setCellBackground,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // eight named colours; every colour in the config links to one of them
  ink: '#16181d', brand: '#5a2a8a', // text; Pyxis violet: the band, table heads, numbers
  tint: '#eee6f5', zebra: '#f3f4f6', // violet wash for groups and pins; every other row
  hazard: '#f2b705', rule: '#c9ccd3', // maximum ratings and the badge; hairlines
  muted: '#5d636d', paper: '#ffffff', // running heads, notes and units; white
};
// 1.4.1 designs ignore paletteId and read the hex: col() sets both (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [ // the defaults link to 'main-color', so it is set to the brand violet
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'brand (defaults)', value: { hex: palette.brand, model: 'hex' } },
];

// #region answer: pasted TSV becomes a table: two header rows, merged cells, zebra fills
const at = (row, column) => ({ row, col: column });
const span = (r0, c0, r1, c1) => ({ start: at(r0, c0), end: at(r1, c1) });
const C = { group: 0, param: 1, symbol: 2, conditions: 3, min: 4, max: 6, unit: 7 }; // columns
function electricalTable(tsv) {
  // In 1.4.1 parseTSV makes plain cells and leaves headerRowCount unset: the head is two rows.
  let m = Object.assign(parseTSV(tsv), { headerRowCount: 2,
    columnWidths: [22, 44, 16, 38, 15, 15, 15, 15] }); // weights: mm of the 180 mm measure
  // 'Parameter' covers two columns and two rows; 'Value' spans Min, Typ and Max. mergeCells
  // marks the covered cells hiddenBy, so no column shifts (gotcha: merged-cells-hiddenby).
  for (const range of [span(0, C.group, 1, C.param), span(0, C.symbol, 1, C.symbol),
    span(0, C.conditions, 1, C.conditions), span(0, C.min, 0, C.max),
    span(0, C.unit, 1, C.unit)]) {
    m = mergeCells(m, range); // 'Value' is centred over its three columns, the rest set left
    m = setAlignment(m, range.start, range.start.col === C.min ? 'center' : 'left', 'middle');
  }
  // Min, Typ and Max go right, over their figures; setAlignment clears a vAlign it is not given.
  for (let c = C.min; c <= C.max; c++) m = setAlignment(m, at(1, c), 'right');
  let zebra = false;
  for (let r = m.headerRowCount; r < m.rows.length; r++) {
    const row = m.rows[r];
    if (row[C.param].content) zebra = !zebra; // a parameter keeps one fill over its conditions
    for (let c = 0; c < row.length; c++) {
      // An empty cell continues the one above: a group, or a parameter and its symbol.
      if (c <= C.symbol && row[c].content) {
        let end = r;
        while (m.rows[end + 1] && !m.rows[end + 1][c].content
          && (c === C.group || !m.rows[end + 1][C.param].content)) end++;
        m = mergeCells(m, span(r, c, end, c));
      }
      // Figures flush right, as datasheets set them (no decimal tab: gap tab-stops).
      m = setAlignment(m, at(r, c), c >= C.min && c <= C.max ? 'right' : 'left', 'middle');
      // A table style has no zebra rows, so they are filled cell by cell (gotcha: no-zebra). Each
      // helper returns a new model, cheap at 20 rows; for thousands, set the cell fields directly.
      if (c === C.group) m = setCellBackground(m, at(r, c), col('tint'));
      else if (zebra) m = setCellBackground(m, at(r, c), col('zebra'));
    }
  }
  return m;
}
// #endregion

// #region split: group rows, codes and lists in cells; a table taller than its slot splits
function groupedTable(tsv, columnWidths) {
  let m = Object.assign(parseTSV(tsv), { headerRowCount: 1, columnWidths });
  const codes = [0, 2]; // Pin and Type, Addr. and Reset: short codes, centred, in a bare chip
  // A TSV cell holds no line break: the data writes \n, and a line opening with • is a list.
  m.rows = m.rows.map((row, r) => row.map((cell, c) => ({ ...cell,
    content: r > 0 && row[1].content && codes.includes(c) ? `:chip[${cell.content}]{style="code"}`
      : cell.content.replaceAll('\\n', '\n') })));
  for (let r = 0; r < m.rows.length; r++) {
    const last = m.rows[r].length - 1;
    if (r >= m.headerRowCount && !m.rows[r][1].content) { // a lone first cell heads a group
      m = setCellBackground(mergeCells(m, span(r, 0, r, last)), at(r, 0), col('tint'));
    } else for (const c of codes) m = setAlignment(m, at(r, c), 'center');
  }
  return m; // no split code: the engine cuts it between rows and repeats the head
}
// #endregion

const TRIM = [216, 279]; // mm: US Letter
const MARGIN = { y: 20, x: 18 }; // mm: equal side margins, since a loose sheet has no spine
const LEAD = 13; // pt: the body leading, the baseline grid the headings and the opener keep to
const COND = 'Fira Sans Condensed', MONO = 'Fira Mono'; // the display and the label faces

// #region furniture: the opener band, a running head, and the logo in every footer
const place = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(size && { size }) });
const inset = (edge, y, size) => // a page corner, moved in by the side margin
  place('page', edge, edge.endsWith('left') ? MARGIN.x : -MARGIN.x, y, size);
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement, ...extra });
const caps = (size, weight = 600) => ({ fontWeight: weight, letterSpacing: pt(size * 0.16),
  textTransform: 'uppercase' });
const badge = { ...caps(7.5, 700), box: { backgroundColor: col('hazard'),
  padding: { top: pt(1.6), bottom: pt(1.4), left: pt(4), right: pt(4) } } };
const mark = (size, edge, y) => ({ kind: 'image', id: 'mark', resourceId: 'logo', // by id
  placement: inset(edge, y, { width: mm(size) }) });
const maker = (size, color, y) => text('maker', 'Pyxis Microdevices', COND, size, color,
  place('#mark', 'right-of', size / 4, y), caps(size));
const keyFigure = (n, y) => [ // {attr.k1} over its label {attr.k1l}, flush right on the band
  text(`k${n}`, `{attr.k${n}}`, COND, 22, 'paper', inset('top-right', y), { fontWeight: 600 }),
  text(`k${n}l`, `{attr.k${n}l}`, COND, 7, 'tint', inset('top-right', y + 9), caps(7))];
const BAND = 84; // mm: the violet band across the head of page 1
// The band under the top margin and 9 mm or more of white, in whole grid lines (16 here).
const OPENER_LINES = Math.ceil((BAND - MARGIN.y + 9) / (LEAD * 25.4 / 72));
const opener = {
  enabled: true, minHeight: pt(OPENER_LINES * LEAD), // so the columns under it start on the grid
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('brand') }, placement: {
      anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } },
    mark(6, 'top-left', 12), maker(8.5, 'paper', 1.7),
    text('doc', 'Datasheet {subtitle} · {publishDate}', MONO, 7.5, 'tint',
      inset('top-right', 13.6)),
    text('kicker', '{attr.kicker}', COND, 9.5, 'tint', inset('top-left', 30), caps(9.5)),
    text('title', '{titleText}', COND, 64, 'paper', place('#kicker', 'below', 0, 0.5),
      { fontWeight: 700, lineHeight: 1 }),
    // A design text that overflows its width ends in '…' (gotcha: overflow-ellipsis-default).
    text('lead', '{attr.lead}', 'Fira Sans', 12, 'paper', place('#title', 'below', 0, 2.5,
      { width: mm(108) }), { lineHeight: 1.32, align: 'left', overflow: 'wrap' }),
    text('status', 'Preliminary', COND, 7.5, 'ink', place('#lead', 'below', 0, 4.5), badge),
    ...[1, 2, 3].flatMap((n) => keyFigure(n, 15 + 15 * n)),
  ] },
};
const header = { elements: [ // body pages only: the opener has its band
  text('running-title', '{chapterTitle}', COND, 9, 'brand', inset('top-left', 10.4),
    { fontWeight: 700, pages: 'body' }),
  text('flag', 'Preliminary', COND, 7.5, 'ink', inset('top-right', 10.2),
    { ...badge, pages: 'body' }),
  { kind: 'rule', id: 'hairline', pages: 'body', thickness: pt(0.5), color: col('rule'),
    placement: inset('top-left', 15.5, { width: mm(TRIM[0] - 2 * MARGIN.x) }) },
] };
const footer = { elements: [ // every page: an image element works in a running slot too
  mark(4.5, 'bottom-left', -9), maker(7, 'ink', 1.2),
  text('folio', '{pageNumber}/{totalPages}', COND, 8, 'brand', inset('bottom-right', -9.6),
    { fontWeight: 700 }),
  text('doc', '{subtitle} ·', MONO, 7, 'muted', place('#folio', 'left-of', -1.5, 0.4)),
] };
// #endregion

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  // Tables and figures count 1, 2, 3 through the document; table captions sit above.
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}',
    ...(type.id === 'table' && { captionStyle: { position: 'above' } }) })),
  colorPalette, layout: { layoutType: 'double', gutterWidth: mm(6) },
  page: { width: mm(TRIM[0]), height: mm(TRIM[1]), dpi: 150, margins: { top: mm(MARGIN.y),
    bottom: mm(MARGIN.y), left: mm(MARGIN.x), right: mm(MARGIN.x) } },
  bodyText: { // ragged-right sans with space between paragraphs, as reports are set
    fontFamily: 'Fira Sans', fontSize: pt(9.3), lineHeight: pt(LEAD), color: col('ink'),
    boldFontWeight: 600, boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left',
    referenceColor: col('brand'), firstLineIndent: pt(0), paragraphSpacing: true },
  headings: { fontFamily: COND, color: col('ink'),
    levels: [ // H1 restated: any headings object drops its break (gotcha: headings-drop-h1-break)
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        advancedDesign: opener, marginBottom: pt(0) }, // the opener's minHeight sets the gap
      // Margin and line add up to whole grid lines (three, then two), so the grid adds no air.
      { level: 2, fontSize: pt(13), lineHeight: pt(2 * LEAD), color: col('brand'),
        numberingTemplate: '{2}', marginTop: pt(LEAD), marginBottom: pt(0) },
      { level: 3, fontSize: pt(10), lineHeight: pt(LEAD), fontWeight: 600,
        numberingTemplate: '{2}.{3}', marginTop: pt(LEAD), marginBottom: pt(0) },
    ] },
  headingStyles: [{ id: 'lead-in', marginTop: pt(0) }], // under the opener, level with column 2
  unorderedLists: { color: col('brand'), fontWeight: 400, gap: mm(2.4),
    marginTop: pt(0), marginBottom: pt(0), itemSpacing: pt(2) },
  paragraphStyles: [{ id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10), color: col('muted') }],
  chipStyles: [ // the first style is the default: pin names in mono on the violet wash
    { id: 'pin', fontFamily: MONO, fontSize: em(0.88), color: col('brand'),
      background: col('tint'), borderWidth: pt(0), borderRadius: pt(1.2) },
    { id: 'code', fontFamily: MONO, fontSize: em(0.9), color: col('ink'), // bare: a face change
      backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0) },
    ...[['preview', 'hazard', 'ink'], ['planned', 'zebra', 'muted'], ['active', 'brand', 'paper']]
      .map(([id, fill, ink]) => ({ id, fontFamily: COND, bold: true, color: col(ink),
        background: col(fill), borderWidth: pt(0), borderRadius: pt(1.2) })),
  ],
  calloutStyles: [{ id: 'ratings', title: 'Absolute maximum ratings', backgroundEnabled: false,
    stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('hazard') }, // no fill
    padding: { top: mm(2.2), right: mm(0), bottom: mm(0), left: mm(0) }, marginTop: pt(2),
    titleStyle: { fontSize: pt(9), ...caps(9, 700), color: col('ink') },
    body: { fontSize: pt(8.5), lineHeight: pt(12) },
    lists: { bulletChar: '–', color: col('muted') } }],
  // #region styles: a house table style, then one named variant per table, stating what differs
  tableStyle: { headerBackground: col('brand'), headerColor: col('paper'), headerFontFamily: COND,
    headerFontSize: pt(8.5), bodyFontSize: pt(8), rules: 'horizontal', borderColor: col('rule'),
    borderWidth: pt(0.5), cellPadding: mm(1.3) },
  tableStyles: [ // a resource picks one with table.styleId
    // White rules cut the fills apart and show where each merge and each group ends.
    { id: 'electrical', borderColor: col('paper'), borderWidth: pt(1.4), cellPadding: mm(1.1) },
    // Written out although it is the default: 'clip' and 'hide' cut a table taller than the page.
    { id: 'grouped', overflow: 'split', continuedSuffix: '(continued)',
      continuesMarker: 'Continued on the next page' },
    // A compact list in the condensed face, boxed by a rounded outer frame.
    { id: 'ordering', headerBackground: col('tint'), headerColor: col('brand'),
      rules: 'outer', borderRadius: mm(1.5), bodyFontFamily: COND },
  ],
  captionStyle: { fontFamily: COND, fontSize: pt(9.5), labelColor: col('brand'), gap: mm(2),
    note: { fontSize: pt(7.5), color: col('muted') } },
  // #endregion
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "PX-7021 digital temperature sensor"
subtitle: "DS-7021 · Rev. 0.3"
author: "Pyxis Microdevices"
publishDate: "September 2026"
---

# PX-7021 {kicker="Digital temperature sensor" lead="±0.1 °C accuracy from 1.4 µA, with an I²C and SMBus interface, in a 2 × 2 mm package" k1="±0.1 °C" k1l="Accuracy, −20 °C to 50 °C" k2="1.4 µA" k2l="At one reading a second" k3="2 × 2 mm" k3l="8-pin DFN package"}

## Features {style="lead-in"}

- ±0.1 °C maximum error from −20 °C to 50 °C
- ±0.3 °C maximum error from −40 °C to 125 °C
- 16-bit result, with a resolution of 0.0078 °C
- 1.4 µA at one reading a second, 0.1 µA in shutdown
- Supply from 1.6 V to 5.5 V, 5.5 V-tolerant interface
- I²C and SMBus, from 1 kHz to 1 MHz, with bus timeout
- Eight bus addresses, selected by three pins
- Alert output, in comparator or interrupt mode
- 8-pin DFN, 2 × 2 mm, with an exposed pad (:ref{id="pinout" style="full"})

## Applications

- Cold-chain loggers for vaccines and fresh food
- Wearable and home thermometry
- Battery packs, chargers and power banks
- Thermostats and building controls
- Thermal protection for processors and power stages
- Laboratory and medical instruments

:::columnbreak

## Description

The PX-7021 is a digital temperature sensor for coin-cell designs that must read within a tenth of a degree. A 16-bit converter reads an on-chip bandgap sensor, and every part is trimmed at two temperatures on the production line, so the board needs no calibration and the host no look-up table: one step of the result is 1/128 °C.

Between conversions the sensor sleeps: at one reading a second it draws 1.4 µA on average. Two limit registers drive the open-drain :chip[ALERT] output, which can wake a sleeping host when the temperature leaves a window.

:ref{id="circuit" style="full"} shows the typical circuit, with one decoupling capacitor and the bus pull-ups. With its address pins tied to ground the sensor answers at 48h.

:chip[Preview]{style="preview"} Engineering samples of the DFN versions are available now. The limits in :ref{id="electrical" style="full"} are preliminary and may change before production release.

:::pagebreak

## Specifications

:::callout{type="ratings"}
- Supply voltage, *V*~DD~ to GND: **−0.3 V to 6 V**
- SDA, SCL and ALERT to GND: **−0.3 V to 6 V**
- A0 to A2 to GND: **−0.3 V to *V*~DD~ + 0.3 V**
- Current into any pin: **±10 mA**
- Storage temperature: **−60 °C to 150 °C**
- Electrostatic discharge, human-body model: **±2 kV**
:::

Stress beyond these ratings can damage the device for good. Design to the operating conditions below.

### Recommended operating conditions

Operate the sensor from 1.6 V to 5.5 V, at ambient temperatures from −40 °C to 125 °C, with less than 50 mV of ripple on the supply. The bus pull-ups may return to any supply up to 5.5 V, so a sensor run from 1.8 V can share a bus with 5 V parts without a level shifter.

### Electrical characteristics

:ref{id="electrical" style="full"} lists the limits over the full supply and temperature range. Typical values are the mean of the characterization lots at 3.3 V and 25 °C; they are not guaranteed. Accuracy is tested on every part at 25 °C and 50 °C; values marked ² come from characterization only.

## Pin configuration and functions

:ref{id="pinout" style="full"} shows the package from above. A dot on the top face marks pin 1, and the pins count counterclockwise from it. The exposed pad under the package is the sensor's thermal path to the board: solder it to a ground pour. :ref{id="pins" style="full"} describes each pin.

## Detailed description

A host talks to the PX-7021 through eleven registers, listed with their reset values in :ref{id="registers" style="full"}. Two-byte registers are read and written most significant byte first.

### Temperature conversion

A conversion takes 10.5 ms at 16 bits and 3.1 ms at 12 bits. In continuous mode the sensor starts a new conversion at the programmed rate, from one every four seconds to eight a second, and sleeps in between; in one-shot mode it converts once, stores the result and shuts down again. The result register always holds the last complete reading, so a read never catches a conversion halfway.

The result is a signed 16-bit value in steps of 1/128 °C. Values above 7FFFh are negative: 1900h reads as 50 °C, and E700h as −50 °C.

### Serial interface

The sensor is a target on an I²C or SMBus bus, at clock rates up to 1 MHz. Tie each of A0, A1 and A2 to ground or to the supply to choose one of eight addresses, from 48h to 4Fh. In SMBus mode a clock held low for more than 30 ms resets the interface, so a host that crashes in mid-transfer cannot lock the bus. The sensor also answers the I²C general-call reset command.

### Alert output

The :chip[ALERT] pin compares every result with the limits in THIGH and TLOW. In comparator mode it stays asserted while the temperature is above THIGH and releases once it falls below TLOW, which gives a thermostat its hysteresis. In interrupt mode it asserts once per crossing, and releases when the host reads STATUS, or when the sensor answers a read of the SMBus alert response address.

## Packaging and ordering

:ref{id="outline" style="full"} draws the package and its land pattern: the DFN-8 body is 2 mm square and 0.55 mm high, with a 0.5 mm pin pitch and a 0.8 × 1.5 mm exposed pad. :ref{id="ordering" style="full"} lists the versions and their order codes. The top of each part carries its number over a date code, YWWL: the year, the work week and the lot.

Parts are rated at moisture sensitivity level 1, so they need no dry storage, and survive three reflow cycles at a peak of 260 °C. Reflow shifts the reading by less than 0.02 °C.

## Layout guidelines

The sensor measures the copper under its exposed pad. For air temperature, place it at the edge of the board, away from regulators and processors, and cut slots in the board around it so that heat from the rest of the circuit reaches it slowly. For the temperature of a surface, do the opposite: a solid pour and a row of vias carry heat from the surface to the pad. Keep the decoupling capacitor on the same side of the board as the sensor and close to its supply pin: 2 mm at most.

## Revision history

**Rev. 0.3**, September 2026: preliminary release, with limits from the first characterization lots. Adds the WLCSP-4 version and the SMBus timeout.

**Rev. 0.2**, May 2026: advance information for early customers, with typical values only.

**Rev. 0.1**, February 2026: product brief, with the target accuracy and supply current.

:::paragraphs{style="colophon"}
A work of fiction: Pyxis Microdevices and the PX-7021 are imaginary, and so are these figures; do not design with them. Set in Fira Sans, Fira Sans Condensed and Fira Mono (SIL Open Font License) · Text and drawings: original, licensed CC BY 4.0.
:::
`; // reworded so no unit starts a line (gotcha: nbsp-breaks)
const electrical = String.raw`Parameter		Symbol	Conditions	Value			Unit
				Min	Typ	Max	
Temperature sensor	Accuracy^1^	*T*~ACC~	−20 °C to 50 °C	−0.1	±0.05	0.1	°C
			−40 °C to 125 °C	−0.3	±0.1	0.3	°C
	Resolution		16-bit result		0.0078		°C
	Repeatability^2^		1 Hz, 100 readings		±0.008		°C
	Long-term drift^2^		500 h at 125 °C		0.02		°C
Power supply	Supply voltage	*V*~DD~		1.6	3.3	5.5	V
	Average supply current^3^	*I*~DD~	1 conversion per second		1.4	2.5	µA
	Supply current, converting	*I*~CONV~			120	175	µA
	Shutdown current	*I*~SD~	bus idle		0.1	0.5	µA
	Power-on reset threshold	*V*~POR~	*V*~DD~ rising		1.2	1.45	V
Conversion	Conversion time	*t*~CONV~	16-bit result		10.5	12	ms
			12-bit result		3.1	3.6	ms
	Conversion rate	*f*~CONV~	continuous mode	0.25		8	Hz
Digital inputs and outputs	High-level input voltage	*V*~IH~		0.7 *V*~DD~			V
	Low-level input voltage	*V*~IL~				0.3 *V*~DD~	V
	Low-level output voltage	*V*~OL~	3 mA sink			0.4	V
	Input leakage current	*I*~IN~		−1		1	µA
	Pin capacitance	*C*~IN~			3		pF
Serial interface	Clock frequency	*f*~SCL~	Fast-mode Plus	1		1000	kHz
	Bus timeout	*t*~TIMEOUT~	SMBus mode	25	30	35	ms
`; // the tables: TSV, pasted from a spreadsheet
const pins = String.raw`Pin	Name	Type	Description
**Power**			
8	:chip[VDD]	P	Supply, 1.6 V to 5.5 V. Decouple with 100 nF within 2 mm of the pin.
4	:chip[GND]	G	Ground.
EP	:chip[EP]	G	Exposed pad, the thermal path to the board. Solder it to ground.
**Serial interface**			
1	:chip[SDA]	I/O	Serial data, open drain, 5.5 V-tolerant.
2	:chip[SCL]	I	Serial clock, Schmitt-trigger input.
3	:chip[ALERT]	O	Alert output, open drain. Leave it open when unused.
**Address select**			
5	:chip[A0]	I	Address bit 0. Tie to GND or *V*~DD~, never leave it floating.
6	:chip[A1]	I	Address bit 1. Tie to GND or *V*~DD~.
7	:chip[A2]	I	Address bit 2. Tie to GND or *V*~DD~.
`;
const registers = String.raw`Addr.	Register	Reset	Contents
**Measurement**			
00h	:chip[TEMP]	8000h	The last complete result, read only.\n• 8000h until the first result\n• Signed, in steps of 1/128 °C\n• At 12 bits, bits 3 to 0 read zero\n• Two bytes, most significant first
04h	:chip[STATUS]	00h	Flags, read only. Reading STATUS releases ALERT in interrupt mode.\n• Bit 7, BUSY: still converting\n• Bit 6, HIGH: a result crossed THIGH\n• Bit 5, LOW: a result crossed TLOW\n• Bit 4, TRIM: the trim check failed at power-on
**Configuration**			
01h	:chip[CONFIG]	0000h	Operating mode and alert behavior.\n• Bits 15 and 14, MODE: continuous, one-shot or shutdown\n• Bit 13, RES: a 12-bit result instead of 16 bits\n• Bit 12, AVG: each result averages eight readings\n• Bits 11 and 10, FAULTS: 1, 2, 4 or 6 results beyond a limit before ALERT asserts\n• Bit 9, POL: ALERT active high\n• Bit 8, INT: interrupt mode instead of comparator mode
05h	:chip[RATE]	02h	Conversion rate in continuous mode.\n• 00h to 05h: 0.25, 0.5, 1, 2, 4 or 8 conversions a second\n• Higher values read back as 05h
06h	:chip[ONESHOT]	00h	Any write starts one conversion.\n• Shuts down again afterwards\n• Ignored in continuous mode
07h	:chip[OFFSET]	0000h	Added to every result, in the format of TEMP.\n• From −8 °C to 8 °C\n• Cleared by a power-on reset
**Limits**			
02h	:chip[TLOW]	F600h	Low limit, in the format of TEMP.\n• −20 °C after reset\n• Keep it below THIGH
03h	:chip[THIGH]	3C00h	High limit, in the format of TEMP.\n• 120 °C after reset\n• In comparator mode, ALERT releases below TLOW
**Identification**			
0Eh	:chip[SERIAL]	—	A 48-bit number unique to each part, read only.\n• Read six bytes, from 0Eh
FEh	:chip[MAKER]	5058h	Manufacturer, read only: PX in ASCII.
FFh	:chip[DEVICE]	7021h	Device, read only.\n• Bits 15 to 4: the part number, 702h\n• Bits 3 to 0: the silicon revision
`;
const ordering = String.raw`Order code	Package	Packing	Status
**PX-7021-DFN-R**	DFN-8, 2 × 2 mm	Reel of 3000	:chip[Preview]{style="preview"}
**PX-7021-DFN-T**	DFN-8, 2 × 2 mm	Cut tape, 250	:chip[Preview]{style="preview"}
**PX-7021-CSP-R**	WLCSP-4, 0.8 × 0.8 mm	Reel of 5000	:chip[Planned]{style="planned"}
**PX-7021-EVM**	Evaluation board	Box of 1	:chip[Available]{style="active"}
`;

// #region art: the logo, pinout and circuit at column width, the outline at page width, in mm
const SCALE = 10; // px per mm of the drawings' intrinsic size: the engine keeps only the ratio
// The outline is 48.3 mm tall so that the text above it ends on a whole grid line: the
// closing-page lift (EF-94) then leaves it at the foot, level with the other pages.
const LOGO = [24, 24], PINOUT = [87, 47], CIRCUIT = [87, 56], OUTLINE = [180, 48.3]; // mm
// One size family, in mm at 1:1 (1 mm = 2.83 pt): names 7.4 pt, labels and dimensions 6.8 pt,
// notes 6.2 pt, pin numbers 6 pt; all under the 9.3 pt text and the 9.5 pt captions.
const TEXT = { name: 2.6, label: 2.4, note: 2.2, pin: 2.1 };
const svg = ([w, h], body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * SCALE}" `
  + `height="${h * SCALE}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const label = (x, y, s, { anchor = 'start', color = 'ink', size = TEXT.label } = {}) =>
  `<text x="${x}" y="${y}" font-size="${size}" font-family="${MONO}" text-anchor="${anchor}" `
  + `fill="${palette[color]}">${s}</text>`; // Fira Mono 400, the label face
const logo = svg(LOGO, `<circle cx="12" cy="12" r="12" fill="${palette.brand}"/><path fill="`
  + `${palette.paper}" d="M12 3 14 10 21 12 14 14 12 21 10 14 3 12 10 10Z"/>`); // a compass star
function pinout() { // the DFN-8 from above: pins 1 to 4 down the left, 5 to 8 back up the right
  const [cx, cy] = [PINOUT[0] / 2, PINOUT[1] / 2];
  const [bw, bh, pitch] = [36, 46, 9.4]; // body and pin pitch: a diagram, not to scale
  const [left, right] = [cx - bw / 2, cx + bw / 2];
  const pins = ['SDA', 'SCL', 'ALERT', 'GND', 'A0', 'A1', 'A2', 'VDD'].map((name, i) => {
    const onLeft = i < 4;
    const y = cy + ((onLeft ? i : 7 - i) - 1.5) * pitch;
    return `<rect x="${(onLeft ? left : right) - 2.4}" y="${y - 1.5}" width="4.8" height="3" `
      + `rx="0.5" fill="${palette.brand}"/>${onLeft
        ? label(left - 4.2, y + 0.95, `${name} ${i + 1}`, { anchor: 'end', size: TEXT.name })
        : label(right + 4.2, y + 0.95, `${i + 1} ${name}`, { size: TEXT.name })}`;
  });
  // The exposed pad is under the package: from above, a hidden outline, dashed.
  return svg(PINOUT, `<rect x="${left}" y="${cy - bh / 2}" width="${bw}" height="${bh}" rx="1.6" `
    + `fill="${palette.paper}" stroke="${palette.ink}" stroke-width="0.45"/><rect x="${cx - 9}" `
    + `y="${cy - 12.5}" width="18" height="25" rx="0.6" fill="none" stroke="${palette.brand}" `
    + `stroke-width="0.35" stroke-dasharray="1.4 0.9"/><circle cx="${left + 4}" `
    + `cy="${cy - bh / 2 + 4}" r="1.3" fill="${palette.ink}"/>${pins.join('')}`
    + label(cx, cy + 0.95, 'EP', { anchor: 'middle', color: 'brand', size: TEXT.name }));
}
function circuit() { // the typical application: one capacitor, three pull-ups, address 48h
  const wire = (d, w = 0.3) => `<path d="${d}" fill="none" stroke="${palette.ink}" `
    + `stroke-width="${w}"/>`;
  const dots = (...xy) => xy.map(([x, y]) => `<circle cx="${x}" cy="${y}" r="0.6" `
    + `fill="${palette.ink}"/>`).join('');
  const box = (x, y, w, h, fill) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="0.8" `
    + `fill="${palette[fill]}" stroke="${palette.ink}" stroke-width="0.35"/>`;
  const small = { size: TEXT.note }, pin = { size: TEXT.pin, color: 'muted', anchor: 'middle' };
  const pullUps = [[59, 23], [63, 28], [67, 33]].map(([x, y]) => wire(`M${x} 6V10M${x} 15.5V${y}`)
    + `<rect x="${x - 0.8}" y="10" width="1.6" height="5.5" fill="${palette.paper}" `
    + `stroke="${palette.ink}" stroke-width="0.3"/>${dots([x, 6], [x, y])}`);
  const lines = [['SDA', 'SDA', 23, 1], ['SCL', 'SCL', 28, 2], ['ALERT', 'INT', 33, 3]]
    .map(([from, to, y, n]) => wire(`M55 ${y}H71`) + label(53.6, y + 0.8, from,
      { ...small, anchor: 'end' }) + label(72.4, y + 0.8, to, small) + label(57, y - 0.7, n, pin));
  const address = ['A0', 'A1', 'A2'].map((name, i) => wire(`M29 ${24 + 5 * i}H25`)
    + label(30.4, 24.8 + 5 * i, name, small) + label(27, 23.3 + 5 * i, 5 + i, pin));
  const g = CIRCUIT[1] - 5; // the ground rail
  return svg(CIRCUIT, wire(`M6 6H78.5V17M6 ${g}H78.5V41M12 6V27.6M12 29.6V${g}M42 6V16M42 44V${g}`
    + `M25 24V${g}`) + wire('M9.4 27.6H14.6M9.4 29.6H14.6', 0.55)
    + dots([12, 6], [12, g], [42, 6], [42, g], [25, 29], [25, 34], [25, g])
    + box(29, 16, 26, 28, 'tint') + box(71, 17, 15, 24, 'paper') + pullUps.join('')
    + lines.join('') + address.join('')
    + label(6, 4.2, 'VDD, 1.6 V to 5.5 V') + label(6, g + 3.6, 'GND')
    + label(8.4, 29.4, '100 nF', { ...small, color: 'muted', anchor: 'end' }) // left of the cap
    + label(68.8, 13.6, '4.7k', { ...small, color: 'muted' })
    + label(29, 14.3, 'PX-7021', { size: TEXT.name, color: 'brand' })
    + label(42, 19.8, 'VDD', { ...small, anchor: 'middle' }) + label(43.2, 14.4, 8, pin)
    + label(42, 42.2, 'GND', { ...small, anchor: 'middle' }) + label(43.2, 48.4, 4, pin)
    + label(27, g - 2.6, 'address 48h', { size: TEXT.pin, color: 'muted' })
    + label(78.5, 38.6, 'MCU', { size: TEXT.name, color: 'brand', anchor: 'middle' }));
}
function outline() { // the DFN-8 at 15:1, four views in a row: top, bottom, side, land pattern
  const ink = (d, w = 0.15) => `<path d="${d}" fill="none" stroke="${palette.ink}" `
    + `stroke-width="${w}"/>`;
  const fill = (d, color) => `<path d="${d}" fill="${palette[color]}"/>`;
  const rect = (x, y, w, h, color, extra = '') => `<rect x="${x}" y="${y}" width="${w}" `
    + `height="${h}" fill="${palette[color]}"${extra}/>`;
  const body = (x, y, w, h) => rect(x, y, w, h, 'paper',
    ` rx="0.6" stroke="${palette.ink}" stroke-width="0.35"`);
  const dim = { anchor: 'middle' };
  const caption = { size: TEXT.note, color: 'muted', anchor: 'middle' };
  const hDim = (x1, x2, y, s) => ink(`M${x1} ${y}H${x2}`) + fill(`M${x1} ${y}l1.3 -0.45v0.9z`
    + `M${x2} ${y}l-1.3 -0.45v0.9z`, 'ink') + label((x1 + x2) / 2, y - 0.9, s, dim);
  const vDim = (x, y1, y2) => ink(`M${x} ${y1}V${y2}`) + fill(`M${x} ${y1}l-0.45 1.3h0.9z`
    + `M${x} ${y2}l-0.45 -1.3h0.9z`, 'ink');
  const cy = 23, rows = [-11.25, -3.75, 3.75, 11.25].map((d) => cy + d); // 0.5 mm pitch
  const pads = (xs, w) => rows.map((y) => xs.map((x) => rect(x, y - 1.875, w, 3.75, 'brand'))
    .join('')).join('');
  const [t, b, s, l] = [4, 50, 94, 140]; // the left edge of each view
  const top = body(t, 8, 30, 30) + `<circle cx="${t + 3.6}" cy="11.6" r="1" fill="${palette.ink}"/>`
    + label(t + 15, 24, '7021', { size: 3.2, anchor: 'middle' }) // the marking, not a label
    + label(t + 15, 29, 'YWWL', { ...caption, size: TEXT.label })
    + ink(`M${t} 7.2V3.2M${t + 30} 7.2V3.2`)
    + hDim(t, t + 30, 4, '2.00') + label(t + 15, 45, 'TOP · MARKING', caption);
  const bottom = body(b, 8, 30, 30) + pads([b, b + 25.5], 4.5) + rect(b + 9, 11.75, 12, 22.5,
    'tint', ` rx="0.4" stroke="${palette.brand}" stroke-width="0.3"`) + label(b + 15, 24, 'EP', dim)
    + ink(`M${b - 0.6} 11.75H${b - 3.7}M${b - 0.6} 19.25H${b - 3.7}`) + vDim(b - 2.8, 11.75, 19.25)
    + label(b - 4.2, 16.4, '0.50', { ...dim, anchor: 'end' }) + label(b + 31.6, 13, 1, caption)
    + label(b + 15, 45, 'BOTTOM · EP 0.80 × 1.50', caption);
  const side = body(s, cy - 4, 30, 8.25) + rect(s, cy + 3.7, 4.5, 0.55, 'brand')
    + rect(s + 25.5, cy + 3.7, 4.5, 0.55, 'brand')
    + ink(`M${s + 30.6} ${cy - 4}H${s + 34.3}M${s + 30.6} ${cy + 4.25}H${s + 34.3}`)
    + vDim(s + 33.5, cy - 4, cy + 4.25)
    + label(s + 35.2, cy + 1, '0.55', { ...dim, anchor: 'start' })
    + label(s + 15, 45, 'SIDE', caption);
  const land = rect(l + 4.5, 8, 30, 30, 'paper', ` fill-opacity="0" stroke="${palette.muted}" `
    + 'stroke-width="0.2" stroke-dasharray="1 0.8"') + pads([l, l + 28.5], 10.5)
    + rect(l + 13.5, cy - 11.25, 12, 22.5, 'brand') + [cy - 5.5, cy + 5.5].map((y) =>
      `<circle cx="${l + 19.5}" cy="${y}" r="2.25" fill="${palette.paper}"/>`).join('') // vias
    + ink(`M${l} 9.3V3.2M${l + 39} 9.3V3.2`) + hDim(l, l + 39, 4, '2.60')
    + label(l + 19.5, 45, 'LAND PATTERN · 0.25 × 0.70', caption);
  return svg(OUTLINE, top + bottom + side + land);
}
// #endregion

// #region resources: each drawing and table is a resource; the floats go where they are cited
const svgResource = (id, caption, altText, [w, h], placement) => ({ id, typeId: 'figure',
  kind: 'svg', caption, altText, placement, createdAt: 0, updatedAt: 0,
  svg: { fileId: `${id}.svg`, width: w * SCALE, height: h * SCALE } }); // fitted to its slot
const table = (id, caption, model, { styleId, placement, note } = {}) => ({ id, typeId: 'table',
  kind: 'table', caption, note, placement, table: { model, styleId }, createdAt: 0, updatedAt: 0 });
// A float takes the first free slot after its first :ref; the logo is never cited, only drawn.
const resources = [
  svgResource('logo', '', 'Pyxis Microdevices', LOGO),
  svgResource('pinout', 'Pin configuration, 8-pin DFN, top view.', 'The package from above: '
    + 'pins 1 to 4 down the left side, 5 to 8 up the right.', PINOUT),
  svgResource('circuit', 'Typical application, bus address 48h.', 'The sensor with a 100 nF '
    + 'capacitor, address pins to ground, and SDA, SCL and ALERT pulled up to a host.', CIRCUIT),
  svgResource('outline', 'Package outline and land pattern, 8-pin DFN, in mm.', 'Top, bottom '
    + 'and side views of the 2 × 2 mm body, and the land pattern with its two vias.', OUTLINE,
  { position: 'bottom', span: 'page' }), // a strip across the foot of a page
  table('electrical', 'Electrical characteristics, *V*~DD~ = 1.6 V to 5.5 V and *T*~A~ = −40 °C '
    + 'to 125 °C unless noted', electricalTable(electrical), { styleId: 'electrical',
    placement: { position: 'top', span: 'page' }, note: 'Typical values at 3.3 V and 25 °C. '
      + '^1^ Tested at 25 °C and 50 °C, the rest by characterization. ^2^ Characterized, not '
      + 'tested in production. ^3^ One conversion a second, bus idle.' }),
  // No placement: these float, and only a floated table splits (gotcha: here-table-no-split).
  table('pins', 'Pin functions', groupedTable(pins, [9, 16, 10, 52]), { styleId: 'grouped',
    note: 'Types: P power, G ground, I input, O open-drain output, I/O open-drain input and '
      + 'output.' }),
  table('registers', 'Register map', groupedTable(registers, [9, 17, 11, 50]), {
    styleId: 'grouped', note: 'Reset values apply at power-on and after a general-call reset.' }),
  table('ordering', 'Order codes', Object.assign(parseTSV(ordering), { headerRowCount: 1,
    columnWidths: [23, 29, 18, 17] }), { styleId: 'ordering',
    note: 'WLCSP-4: fixed address 48h, no ALERT output.' }),
];
// #endregion

// #region drawing: the drawings' labels stay text, in the document's own label face
// An SVG drawn as an image cannot use the page's fonts (gotcha: svg-no-webfonts): the PDF sets
// its labels as real text in the faces it embeds; the canvas copy embeds the face itself.
async function fontFace(family) { // the same TTF the PDF embeds, from the pdf kit block
  const ttf = await fontsourceProvider(family, 400, 'normal');
  const base64 = btoa(Array.from(ttf, (b) => String.fromCharCode(b)).join(''));
  return `@font-face{font-family:'${family}';src:url(data:font/ttf;base64,${base64})}`;
}
async function loadDrawing(fileId, markup, face) {
  await loadSvg(fileId, markup); // registers the plain SVG and keeps its bytes for the PDF
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
    markup.replace(/<svg[^>]*>/, (tag) => `${tag}<style>${face}</style>`))}`;
  await img.decode();
  registerResourceImage(fileId, img); // replaces the canvas copy only
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the layout uses, loaded before the build (gotcha: fonts-first)
  'Fira Sans': ['400', '400i', '600', '600i'], // text; 600 is the bold
  'Fira Sans Condensed': ['400', '400i', '600', '700'], // display: title, heads, captions
  'Fira Mono': ['400'], // labels: pin names, codes, document number, the drawings' labels
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const allText = [markdown, electrical, pins, registers, ordering].join('\n'); // all it prints
await loadFonts(FONTS, allText);
await loadSvg('logo.svg', logo);
const drawings = { pinout: pinout(), circuit: circuit(), outline: outline() }; // by resource id
const face = await fontFace(MONO); // fetched once for the three drawings
for (const [id, markup] of Object.entries(drawings)) await loadDrawing(`${id}.svg`, markup, face);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText);
showPages(doc, { title: 'PX-7021 datasheet' });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`); // the same faces; the drawings stay vectors with real text

// ─── 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 · pdf v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

/** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new
 *  tab, since CodePen's preview frame cannot show PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

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

## アレンジ

### すべての行を灰色の罫で区切る

パレットの灰色`rule`の0.5 ptの罫が、地色の有無にかかわらずすべての行を区切ります。白い罫は、地色のない2行が接するところでは見えなくなります。

```diff
-    { id: 'electrical', borderColor: col('paper'), borderWidth: pt(1.4), cellPadding: mm(1.1) },
+    { id: 'electrical', borderColor: col('rule'), borderWidth: pt(0.5), cellPadding: mm(1.1) },
```

### 表のキャプションを帯の上に組む

表タイプのキャプションスタイルは、キャプションの後ろに帯を塗れます。ここでは墨の帯に、ラベルを警告色の黄色で組みます。

```diff
-    ...(type.id === 'table' && { captionStyle: { position: 'above' } }) })),
+    ...(type.id === 'table' && { captionStyle: { position: 'above', backgroundEnabled: true,
+      background: col('ink'), color: col('paper'), labelColor: col('hazard') } }) })),
```

## よくあるつまずき

- **結合セルにはhiddenByのプレースホルダーが要る。mergeCellsを使う.** セルは行の配列内の位置で配置されるため、結合セルが広がる位置にはhiddenByを付けたプレースホルダーのセルが必要です。HTMLのように省くと、それ以降の列がすべてずれます。結合はmergeCellsで作ってください。
- **名前付きの表スタイルには縞模様の行がない.** 表スタイルにはヘッダーの塗りと本文の塗りが1つずつあるだけで、1行おきの塗り分けはありません。1行おきの行は、セルごとに（setCellBackground）パレットにリンクした淡い色で塗ってください。
- **'here'の表は分割されない.** 段やページをまたいで分割されるのはフロートの表だけで、'here'に置いた表はまとめて移動します。長い表はフロートにするか、インラインの表を短くしてください。
- **SVGの<img>内のテキストはWebフォントを使えない.** SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。
- **Fontsourceのlatinファイルにはラテン文字以外のグリフがない.** PDFプロバイダーが埋め込むのはFontsourceのlatinファイルです。スペイン語や西欧語のテキストは収まりますが、→、≈、✓、★、ギリシャ文字、中欧語の文字は含まれず、PDFではそれらのグリフが欠けます。PDFのテキストはlatinの範囲に収めてください。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。
- **ノーブレークスペースでも改行される.** postext 1.4.1の改行処理はU+00A0を通常のスペースとして扱うため、0.08 %、2.006 s、Section 2などが2行に分かれることがあります。間を詰める（0.08%）か、文を書き換えてください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **デザインのテキストのoverflowの既定値は'ellipsis-end'.** 幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。
- **設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る.** エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。

- 分割した表の最初の部分は、下に3行以上入るとき、段の地を本文に譲ります。すでにフロートのある段では、かならず譲ります。長い表は、このレジスターマップのように、ほかのフロートのない段で始め、最初の部分が段の地まで届くように行を調整してください。
- デザインのテキスト要素は初期設定で中央そろえです。リードは幅が固定なので、品番の下にそろえるには`align: 'left'`が必要です。
- 章の最後のページでは、postext 1.4.1は本文より下に置いたページ幅のフロートを、本文の1行下まで持ち上げます。そのため、そこに置いた`bottom`の図はページの地ではなく、本文の最終行の下に来ます。4ページ目は段を埋めるように文面を調整し、外形図は上の本文がグリッド線で終わるよう、高さ48.3 mmで描いてあります。こうすると外形図には上へ動く余地がなく、ほかのページの地と同じ高さで終わります。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 書体: Fira Sans (OFL-1.1), Fira Sans Condensed (OFL-1.1), Fira Mono (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 036 · セルに絵を入れた通信販売カタログ](https://postext.dev/ja/cookbook/seed-catalogue.md): 種苗カタログの価格表をTSVから読んだ表で組み、各品種のセルに描いた種袋の絵を置き、表を見開きにまたがって分割します。 · 難易度 3 (上級) · カタログ
- [No. 050 · 安全上の注意を載せた製品の取扱説明書](https://postext.dev/ja/cookbook/product-manual-warnings.md): ドイツ語の電気ケトルの取扱説明書。WARNUNGとVORSICHTの囲みは信号色の帯に警告の三角形を載せ、図と表のラベルもドイツ語です。 · 難易度 2 (中級) · マニュアル・ガイド・リファレンス
- [No. 002 · 番号付きの数式を含む2段組みの論文](https://postext.dev/ja/cookbook/journal-article-with-maths.md): 2段組みの物理学論文。本文中の数式と7つの番号付き数式を?bundleビルドのMathJaxで組み、PDFでもベクターのまま保ちます。 · 難易度 3 (上級) · 論文・学術書
