# Datenblatt: Tabellen aus Daten, verbundene Köpfe

> Als TSV eingefügte Tabellen, mit parseTSV gelesen und mit mergeCells, setAlignment und setCellBackground geformt; eine Registertabelle, die selbst umbricht.

- HTML-Fassung: https://postext.dev/de/cookbook/technical-datasheet
- Rezept Nº 010 · Tabellen · Stufe 3 (Fortgeschritten) · Ausgaben: Canvas, PDF
- Gattungen: Handbücher und Nachschlagewerke
- Benötigt postext ≥ 1.28.0, postext-pdf ≥ 1.28.0 · getestet mit 1.31.0, postext-pdf 1.31.0 am 2026-10-13
- Seiten: [1](https://postext.dev/cookbook/technical-datasheet/en/p01.webp?v=e4d84103), [2](https://postext.dev/cookbook/technical-datasheet/en/p02.webp?v=e4d84103), [3](https://postext.dev/cookbook/technical-datasheet/en/p03.webp?v=e4d84103), [4](https://postext.dev/cookbook/technical-datasheet/en/p04.webp?v=e4d84103)
- PDF: https://postext.dev/cookbook/technical-datasheet/en/technical-datasheet.pdf?v=e4d84103
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=technical-datasheet&lang=en (.postext: https://postext.dev/cookbook/technical-datasheet/en/technical-datasheet.postext)
- Zuletzt aktualisiert: 2026-10-11
- Andere Sprachen: [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), [fr](https://postext.dev/fr/cookbook/technical-datasheet.md), [it](https://postext.dev/it/cookbook/technical-datasheet.md), [pt](https://postext.dev/pt/cookbook/technical-datasheet.md), [zh](https://postext.dev/zh/cookbook/technical-datasheet.md), [ja](https://postext.dev/ja/cookbook/technical-datasheet.md), [ar](https://postext.dev/ar/cookbook/technical-datasheet.md)

## Kurz gesagt

Ein vierseitiges Produktdatenblatt für einen elektronischen Temperatursensor. Es zeigt, wie man Tabellen aus Tabellenkalkulationsdaten baut, mit gemeinsamen Köpfen und einer langen Tabelle, die auf der nächsten Seite weiterläuft.

## Was Sie setzen

Das vierseitige Datenblatt des PX-7021, eines Temperatursensors eines erfundenen Herstellers, Pyxis Microdevices. Es besteht größtenteils aus Tabellen, mit Köpfen voller verbundener Zellen und einer Registertabelle, die länger ist als eine Spalte. Die Vorderseite beginnt mit einem violetten Band mit der Teilenummer und drei Kennzahlen. Auf Seite 2 reicht eine Tabelle über die Seitenbreite. Ihr zweizeiliger Kopf stellt Value über Min, Typ und Max; die Parametergruppen sind in der ersten Spalte nach unten verbunden, und jeder zweite Parameter ist grau getönt. Die Registertabelle bricht am Fuß von Seite 3 um und läuft auf Seite 4 mit wiederholtem Kopf weiter, neben den Bestellcodes und über der Gehäusezeichnung. Die Tabellen sind als TSV eingefügte Tabellenkalkulationsdaten, denn Postext liest keine Markdown-Tabellen.

**Dieses Rezept beantwortet:**

- Wie mache ich aus eingefügten Daten eine Tabelle mit verbundenen Köpfen, Spaltenbreiten und Zellausrichtung?
- Wie teile ich eine lange Tabelle über mehrere Seiten, mit wiederholtem Kopf und einem Fortsetzungsvermerk?
- Wie gestalte ich mehrere Tabellen in einem Dokument unterschiedlich (Füllungen, Zebrastreifen, abgerundete Rahmen)?
- Wie bleiben Diagramme im PDF scharf und mit auswählbarem Text (SVG, Druckvorlagen)?
- Wie füge ich Bilder und Tabellen im Code (Ressourcen) statt mit Markdown ![]() hinzu?

## Die kurze Antwort

```js
// script.js, Zeilen 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) {
  // headerRows marks the head, two rows here, which a split table would repeat on each part.
  let m = Object.assign(parseTSV(tsv, { headerRows: 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');
      // tableStyle.bodyAlternateBackground alternates row by row; this fill changes with the
      // parameter, so it is set cell by cell. Each helper returns a new model, cheap at 20 rows.
      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;
}
```

## Zutaten

**Zeigt**

- [Tabellen aus Daten](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text): Tabellenressourcen mit Kopfzeilen, verbundenen Zellen, Spaltenproportionen, Ausrichtung je Zelle und Listen in Zellen; Pipe-Tabellen werden nicht eingelesen.
- [Tabellen über mehrere Seiten](https://postext.dev/de/docs/configuration-resources.md#tabellen-die-höher-als-die-seite-sind): Lange Tabellen werden zwischen Zeilen geteilt, mit wiederholter Kopfzeile, „(Forts.)“ in der Überschrift und einem Hinweis „Fortsetzung“, nie innerhalb eines rowspan.

**Verwendet außerdem**

- [Zellfüllungen](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)
- [Benannte Tabellenformate](https://postext.dev/de/docs/configuration-resources.md#benannte-tabellenstile)
- [Tabellenformat](https://postext.dev/de/docs/configuration-resources.md#tabellenstil)
- [Format der Bildunterschrift](https://postext.dev/de/docs/configuration-resources.md#legendenstil)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Hoch- und Tiefstellung](https://postext.dev/de/docs/document-format.md#inline-formatierung)
- [Inline-Chips](https://postext.dev/de/docs/configuration-styles.md#chip-stile)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)
- [Platzierung von Abbildungen](https://postext.dev/de/docs/document-format.md#platzierung)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Kapitelband über die ganze Breite](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Nummerierte Überschriften](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)
- [Kästen](https://postext.dev/de/docs/configuration-styles.md#kastenstile)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [PDF-Export](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen)
- [Verweise, die Abbildungen platzieren](https://postext.dev/de/docs/document-format.md#verweis-im-text-die-hauptform)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Abbildung und Tabelle in Ihrer Sprache](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Seiten- und Spaltenumbrüche](https://postext.dev/de/docs/document-format.md#pagebreak)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Im PDF eingebettete Schriften](https://postext.dev/de/docs/configuration-programmatic-usage.md#warum-ein-font-provider)
- [Eigene Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`calloutStyles`](https://postext.dev/de/docs/configuration-styles.md#kastenstile), [`captionStyle`](https://postext.dev/de/docs/configuration-resources.md#legendenstil), [`chipStyles`](https://postext.dev/de/docs/configuration-styles.md#chip-stile), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), [`footer`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`headingStyles`](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile), [`headings`](https://postext.dev/de/docs/configuration-text.md#überschriften), [`layout`](https://postext.dev/de/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/de/docs/configuration-text.md#silbentrennung), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate), [`resourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen), [`tableStyle`](https://postext.dev/de/docs/configuration-resources.md#tabellenstil), [`tableStyles`](https://postext.dev/de/docs/configuration-resources.md#benannte-tabellenstile), [`unorderedLists`](https://postext.dev/de/docs/configuration-text.md#aufzählungen)

**APIs**

- [`buildDocumentWithFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), [`decompressWoff2`](https://postext.dev/de/docs/configuration-programmatic-usage.md#font-provider-im-browser-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen), [`inlineSvgFonts`](https://postext.dev/de/docs/configuration-resources.md#schriften-in-svg-text), [`mergeCells`](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text), [`parseTSV`](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text), [`prepareFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), [`registerResourceImage`](https://postext.dev/de/docs/architecture.md#api-oberfläche), [`renderPageToCanvas`](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern), [`renderToPdf`](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen), [`setAlignment`](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text), [`setCellBackground`](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)

**Schriften**

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

## Zubereitung

### 1 · Die Daten einfügen, dann die Tabelle formen

Der Code steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/technical-datasheet.md#die-kurze-antwort). `parseTSV(tsv, { headerRows: 2 })` markiert die beiden Kopfzeilen; würde die Tabelle geteilt, wiederholten sich beide auf jedem Teil. Die Gewichte in `columnWidths` ergeben zusammen 180, die Breite des Satzspiegels in Millimetern, sodass jedes Gewicht die Breite seiner Spalte auf der Seite ist. `mergeCells` lässt jede überdeckte Zelle an ihrem Platz und markiert sie mit `hiddenBy`, sodass sich keine Spalte verschiebt. `setAlignment` arbeitet Zelle für Zelle und löscht die senkrechte Ausrichtung, wenn Sie sie weglassen, darum übergeben die meisten Aufrufe erneut `'middle'`. Das Grau wechselt von Parameter zu Parameter, was beide Zeilen von Accuracy auf einer Füllung hält; ein Wechsel pro Zeile, wie ihn `bodyAlternateBackground` des Tabellenformats vornimmt, ließe die zweite weiß.

### 2 · Jedes Modell wird zu einer Ressource

```js
// script.js, Zeilen 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 to the first free slot after their reference and split there.
  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, { headerRows: 1 }), {
    columnWidths: [23, 29, 18, 17] }), { styleId: 'ordering',
    note: 'WLCSP-4: fixed address 48h, no ALERT output.' }),
];
```

Jede Tabelle und jede Zeichnung ist eine Ressource mit Legende, und die Tabellen fügen eine Anmerkung und ein benanntes Format hinzu. Legenden und Anmerkungen nehmen Kursive, Tief- und Hochstellungen auf: Die elektrische Tabelle nennt ihre Prüfbedingungen mit `*V*~DD~` in der Legende und nummeriert ihre Fußnoten mit `^1^` in der Anmerkung. Ein Gleitobjekt nimmt den ersten freien Platz nach seinem ersten `:ref`. Tabelle 1 wird auf Seite 1 zitiert und über den Kopf einer Seite gestellt, eröffnet also Seite 2. Die Gehäusezeichnung steht über dem Fuß einer Seite, und die übrigen Tabellen behalten die Standardplatzierung, ein Gleitobjekt in Spaltenbreite. Eine mit `here` platzierte Tabelle bleibt im Text; passt sie nicht in den Rest ihrer Spalte, wird auch sie zwischen Zeilen geteilt.

### 3 · Eine lange Tabelle selbst umbrechen lassen

```js
// script.js, Zeilen 73–87
function groupedTable(tsv, columnWidths) {
  let m = Object.assign(parseTSV(tsv, { headerRows: 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
}
```

Die Pintabelle und die Registertabelle entstehen beide aus `groupedTable`. Eine Zeile, in der nur die erste Zelle gefüllt ist, wird über die Tabelle verbunden und als Gruppenkopf violett getönt. Eine TSV-Zelle kann keinen Zeilenumbruch enthalten, darum schreiben die Daten `\n`, und die Funktion macht daraus einen; eine Zeile, die mit `•` beginnt, wird dann als Listenpunkt gesetzt. Die kurzen Codes stehen im Chip-Format `code`, das keine Füllung, keinen Rahmen und keinen seitlichen Innenabstand hat und sie in Fira Mono zu 0,9 em setzt. Passt eine gleitende Tabelle nicht in die leere Spalte, die ihr angeboten wird, schneidet die Engine sie zwischen Zeilen und setzt den Rest auf den nächsten freien Platz, unter dem wiederholten Kopf, mit `(continued)` nach der Legende. Die Zeile `continuesMarker`, „Continued on the next page“, steht unter dem ersten Teil, und die Anmerkung wartet auf den letzten ([Tabellen, die höher als die Seite sind](https://postext.dev/de/docs/configuration-resources.md#tabellen-die-höher-als-die-seite-sind)).

### 4 · Ein Hausstil, eine Variante pro Tabelle

```js
// script.js, Zeilen 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` enthält, was alle Tabellen gemeinsam haben. Jeder Eintrag in `tableStyles` setzt nur, was seine Tabellen ändern, und eine Ressource wählt einen mit `table.styleId`. Die elektrische Tabelle tauscht die grauen Haarlinien gegen weiße Linien von 1,4 pt, die ihre Füllungen voneinander trennen und zeigen, wo jede Verbindung endet. Die Tabelle der Bestellcodes bekommt einen abgerundeten Außenrahmen und Fira Sans Condensed, sodass ihre vier Spalten in eine Textspalte passen.

### 5 · Beschriftungen, die im PDF Text bleiben

```js
// script.js, Zeilen 535–540
// label() writes each <text> with font-family="Fira Mono". loadSvg runs the markup through
// inlineSvgFonts, which embeds that face in the SVG, so the canvas draws the labels in it;
// the PDF sets them as real text in the Fira Mono it embeds.
await loadSvg('logo.svg', logo);
const drawings = { pinout: pinout(), circuit: circuit(), outline: outline() }; // by resource id
for (const [id, markup] of Object.entries(drawings)) await loadSvg(`${id}.svg`, markup);
```

Die Beschriftungen der Pinbelegung, der Schaltung und der Gehäusezeichnung sind Elemente `<text>`, die Fira Mono anfordern, die Beschriftungsschrift des Dokuments. Ein SVG erreicht den Canvas als Bild, darum schickt `loadSvg` des Kits das Markup zuerst durch `inlineSvgFonts`, das die Schriften, die sein Text nennt, ins SVG einbettet; der Canvas zeichnet die Beschriftungen dann in Fira Mono. Das PDF setzt jedes `<text>` als auswählbaren Text in den eingebetteten Schriften.

### 6 · Auftakt, Kolumnentitel und Fußzeile

```js
// script.js, Zeilen 96–146
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 }),
    text('lead', '{attr.lead}', 'Fira Sans', 12, 'paper', place('#title', 'below', 0, 2.5,
      { width: mm(108) }), { lineHeight: 1.32, align: 'left' }), // wraps within its width
    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)),
] };
```

Die Teilenummer ist die Überschrift der ersten Ebene, und ihr Gestaltungsplatz zeichnet den Auftakt: einen violetten Kasten bis in den Anschnitt, das Logo sowie eine Dachzeile, einen Vorspann und drei Kennzahlen, gelesen aus den Attributen der Überschrift. Bildelemente funktionieren in mitlaufenden Plätzen genauso wie in Auftakten, darum beginnt die Fußzeile mit einem, das die Logo-Ressource nennt. Die Elemente des Kolumnentitels tragen `pages: 'body'`, sodass keines davon auf der Vorderseite erscheint.

## Das ganze Rezept

Eine Datei, aus dem Ordner des Rezepts zusammengesetzt, mit dem Beispieltext und dem gemeinsamen Baukasten der Rezepte bereits eingebettet; sie baut ihre eigene Seite auf. Zum Ausführen setzen Sie sie in ein `<script type="module">` auf einer leeren Seite oder fügen sie in das JS-Feld eines neuen CodePen-Pens ein (als Modul). Sie importiert postext von esm.sh, es gibt also nichts zu installieren oder zu bauen.

- Quellordner: 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.28.0
// 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 {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage,
  defaultResourceTypes, parseTSV, mergeCells, setAlignment, setCellBackground, inlineSvgFonts,
} 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
};
// Every colour carries its paletteId: change a value in `palette` and all its uses follow.
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) {
  // headerRows marks the head, two rows here, which a split table would repeat on each part.
  let m = Object.assign(parseTSV(tsv, { headerRows: 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');
      // tableStyle.bodyAlternateBackground alternates row by row; this fill changes with the
      // parameter, so it is set cell by cell. Each helper returns a new model, cheap at 20 rows.
      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, { headerRows: 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 }),
    text('lead', '{attr.lead}', 'Fira Sans', 12, 'paper', place('#title', 'below', 0, 2.5,
      { width: mm(108) }), { lineHeight: 1.32, align: 'left' }), // wraps within its width
    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 = () => ({ locale: 'en-us', // the datasheet is written in English
  // 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,
    hyphenateAcrossColumns: 'withinPage' }, // no compound is divided over a page
  headings: { fontFamily: COND, color: col('ink'),
    levels: [ // 'any': a loose sheet has no rectos, so the H1 asks for no blank page
      { 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.
:::
`; // content.<lang>.md, inlined by the Cookbook
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: a closing
// page lifts its page-wide floats to the text (layout.hugClosingFloats), and here none is left.
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 to the first free slot after their reference and split there.
  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, { headerRows: 1 }), {
    columnWidths: [23, 29, 18, 17] }), { styleId: 'ordering',
    note: 'WLCSP-4: fixed address 48h, no ALERT output.' }),
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the layout uses, loaded before the build
  '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 prepareFonts(allText, config(), kitFonts(FONTS));
// #region drawing: the drawings' labels stay text, in the document's own label face
// label() writes each <text> with font-family="Fira Mono". loadSvg runs the markup through
// inlineSvgFonts, which embeds that face in the SVG, so the canvas draws the labels in it;
// the PDF sets them as real text in the Fira Mono it embeds.
await loadSvg('logo.svg', logo);
const drawings = { pinout: pinout(), circuit: circuit(), outline: outline() }; // by resource id
for (const [id, markup] of Object.entries(drawings)) await loadSvg(`${id}.svg`, markup);
// #endregion
const doc = await buildDocumentWithFonts({ markdown, resources }, config(),
  { ...kitFonts(FONTS), text: 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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. 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 Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) 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],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
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, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
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?.weights.includes(weight) && meta.styles.includes(style))) 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 family, weight and style (fonts.check() would
 *  also say yes 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;
}

/** 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) {
  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 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 · pdf v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  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 text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

/** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame
 *  shows no 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 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 ───────────────────────────────────────────────────────────────────────
```

## Varianten

### Jede Zeile grau linieren

Halbpunktlinien in der grauen Palettenfarbe `rule` trennen jede Zeile, gefüllt oder nicht; die weißen verschwinden dort, wo zwei ungefüllte Zeilen aneinanderstoßen.

```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) },
```

### Tabellenlegenden auf einen Balken setzen

Der Legendenstil des Tabellentyps kann einen Balken hinter die Legende malen, hier in Schwarz mit der Bezeichnung im Warngelb.

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

## Häufige Fehler

- **Verbundene Zellen brauchen hiddenBy-Platzhalter: mergeCells verwenden.** Zellen werden nach ihrer Position im Array der Zeile gesetzt, deshalb braucht eine verbundene Zelle Platzhalterzellen mit hiddenBy, wo sie sich ausdehnt; lässt man sie weg, wie in HTML, verschiebt sich jede spätere Spalte. Erstellen Sie Verbindungen mit mergeCells. Seit postext 1.5 wird eine Tabelle mit unregelmäßigem Raster in doc.contentWarnings als raggedTableGrid gemeldet, und parseTSV(text, { headerRows }) kennzeichnet die Kopfzeilen.
- **Fontsource-Dateien decken Buchstaben ab, keine Symbole.** Das Kit bettet die latin-Datei einer Schrift von Fontsource ein, dazu ihre latin-ext-Datei für č, ł, † und ihre greek-Datei für α, χ (bei einer Familie, die eine mitliefert: Lora und Gelasio tun es nicht), wenn der Text diese Zeichen setzt, auf dem Bildschirm und im PDF. Symbole wie →, ≈, ✓ und ★ stehen in keiner dieser Dateien und fehlen im PDF: zeichnen Sie sie, setzen Sie sie als Mathematik oder wählen Sie eine Schrift, deren Dateien sie enthalten.

- Der erste Teil einer geteilten Tabelle überlässt den Fuß seiner Spalte dem Text, wenn darunter drei oder mehr Zeilen passen, und tut das immer in einer Spalte, die bereits ein Gleitobjekt enthält. Beginnen Sie eine lange Tabelle dort, wo ihre Spalte kein anderes Gleitobjekt enthält, wie die Registertabelle, und passen Sie ihre Zeilen so ein, dass der erste Teil den Fuß der Spalte erreicht.
- Textelemente der Gestaltung sind standardmäßig zentriert. Der Vorspann hat eine feste Breite und braucht darum `align: 'left'`, um unter der Teilenummer zu fluchten.
- Auf der Schlussseite eines Kapitels rückt Postext die unter dem Text gesetzten Gleitobjekte über die Seitenbreite bis auf eine Grundtextzeile darunter hoch, sodass eine Abbildung mit `bottom` dort unter der letzten Textzeile statt am Seitenfuß steht. Seite 4 ist so formuliert, dass sie ihre Spalten füllt, und die Gehäusezeichnung ist 48,3 mm hoch gezeichnet, damit der Text darüber auf einer Rasterzeile endet. Die Zeichnung hat dann keinen Platz zum Hochrücken und endet auf Höhe des Fußes der anderen Seiten. `layout.hugClosingFloats: false` schaltet das Hochrücken ab.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Schrift: Fira Sans (OFL-1.1), Fira Sans Condensed (OFL-1.1), Fira Mono (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 036 · Versandkatalog mit Bildern in Tabellenzellen](https://postext.dev/de/cookbook/seed-catalogue.md): Ein Saatgutkatalog mit einer aus TSV gelesenen Preisliste, einer gemalten Samentüte in der Zelle jeder Sorte und einer Liste, die über die Doppelseite läuft. · Stufe 3 (Fortgeschritten) · Kataloge
- [Nº 050 · Bedienungsanleitung mit Sicherheitshinweisen](https://postext.dev/de/cookbook/product-manual-warnings.md): Eine deutsche Wasserkocher-Anleitung, deren Kästen WARNUNG und VORSICHT das Warndreieck auf einem Band in Signalfarbe tragen, mit deutschen Beschriftungen. · Stufe 2 (Mittel) · Handbücher und Nachschlagewerke
- [Nº 002 · Zweispaltiger Aufsatz mit nummerierten Gleichungen](https://postext.dev/de/cookbook/journal-article-with-maths.md): Ein zweispaltiger Physikaufsatz, dessen Formeln im Text und sieben nummerierte Gleichungen MathJax setzt und die im PDF als Vektoren bleiben. · Stufe 3 (Fortgeschritten) · Fachartikel und Wissenschaft
