# Uma família de boxes de livro didático com código de cores

> Seis tipos de boxe de livro didático em três cores, diferenciados por faixa com ícone, selo, aba numerada, pictogramas ou um indicador fora da moldura.

- Versão HTML: https://postext.dev/pt/cookbook/textbook-box-family
- Receita Nº 008 · Boxes e notas · Nível 2 (Intermediário) · Saídas: Canvas
- Gêneros: Livros didáticos
- Requer postext ≥ 1.4.1 · testada com 1.19.1 em 2026-10-06
- Páginas: [27](https://postext.dev/cookbook/textbook-box-family/en/p01.webp?v=bc5684d7), [28](https://postext.dev/cookbook/textbook-box-family/en/p02.webp?v=bc5684d7), [29](https://postext.dev/cookbook/textbook-box-family/en/p03.webp?v=bc5684d7)
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=textbook-box-family&lang=en (.postext: https://postext.dev/cookbook/textbook-box-family/en/textbook-box-family.postext)
- Última atualização: 2026-09-26
- Outros idiomas: [en](https://postext.dev/en/cookbook/textbook-box-family.md), [es](https://postext.dev/es/cookbook/textbook-box-family.md), [ca](https://postext.dev/ca/cookbook/textbook-box-family.md), [zh](https://postext.dev/zh/cookbook/textbook-box-family.md), [ja](https://postext.dev/ja/cookbook/textbook-box-family.md), [ar](https://postext.dev/ar/cookbook/textbook-box-family.md)

## Em poucas palavras

Um capítulo de um livro de biologia com seis tipos de boxes, como avisos e notas de laboratório. Cada tipo tem cor e símbolo próprios, para o aluno distinguir um do outro num relance.

## O que você vai compor

O capítulo 2 de *Living Matter*, um livro didático de biologia do ensino médio numa página de 210 × 280 mm, abre sob o desenho de uma célula cortada pelo canto superior da página, com o número do capítulo no núcleo. Seguem seis tipos de boxe em três cores, cada um com um recurso gráfico próprio, para que o aluno distinga um objetivo de um aviso antes de lê-los. O magenta marca os objetivos de aprendizagem, sobre uma faixa com um alvo, e a autoavaliação, que tem um quadradinho com visto fora da moldura. O verde marca a dica de estudo, ao lado de um fio fino encimado por um selo com uma lâmpada, e o boxe de laboratório com os seus três pictogramas de segurança. O aviso é âmbar, com um selo de alerta no canto externo. O BOX 2.1 é lavanda, sob uma aba preta e um frasco. Os termos em negrito assumem a cor do seu boxe, magenta no BOX 2.1.

**Esta receita responde a:**

- Como faço boxes de objetivos, dicas e avisos com ícone, faixa, cantos arredondados ou uma aba numerada?
- Como acrescento uma aba numerada (“BOXE 1-1”), um ícone de canto ou um ícone na margem com um fio?
- Como dou cor aos termos-chave (em negrito ou itálico) no texto ou dentro dos boxes?

## A resposta curta

```js
// script.js, linhas 40–61
// box(id, hue, device) is the shared base: the hue colours the title, bullets and key terms.
const TAB = 5, BADGE = 7.4; // in mm: the tab's height (and offset), the warning badge's size
const calloutStyles = [
  // The first style is also the one a missing or misspelt type falls back to.
  box('objectives', 'band', { title: t({ en: 'In this chapter', es: 'En este capítulo' }),
    background: col('tintBand'), stripe: { enabled: true, width: mm(7.5), color: col('band') },
    icon: icon('target', 5) }), // on a side stripe (left by default) the icon is centred on it
  box('feature', 'band', { background: col('mist'),
    titleStyle: { fontFamily: 'Lexend', fontSize: pt(10.5), color: col('ink'), gap: mm(1.6) },
    // Printed only where the fence has label="…": the tab, the flask beside it, a rule to them.
    label: { fontFamily: LABEL, fontSize: pt(8), color: col('paper'), background: col('ink'),
      position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default)
      height: mm(TAB), offset: mm(TAB), paddingX: mm(2.3), // offset = height: on the top edge
      icon: { resourceId: 'flask', width: mm(4.4), gap: mm(1.2) },
      rule: { enabled: true, color: col('ink'), width: pt(1.2) } } }),
  box('warning', 'warnInk', { background: col('tintWarn'), borderRadius: mm(1.4),
    border: { enabled: true, color: col('warn'), width: pt(0.75) },
    // The badge hangs half past the outer corner: sides of half badge + GAP align the title.
    padding: { top: mm(2.8), right: mm(BADGE / 2 + GAP), bottom: mm(3.2),
      left: mm(BADGE / 2 + GAP) },
    icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }),
]; // straight into config(), followed by the 'more' styles
```

## Ingredientes

**Ensina**

- [Boxes](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe): Estilos de boxe com nome para notas, dicas e advertências: fundo, borda, raio, faixa, título e tipografia própria para o texto e as listas.
- [Abas numeradas nos boxes](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe): Uma aba de rótulo na borda do boxe (“BOXE 1-1”) tirada do atributo label da cerca do bloco.
- [Ícones e selos de canto nos boxes](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe): Um glifo ou uma imagem ao lado do conteúdo de um boxe, pendurado no canto superior ou disposto como uma faixa larga de pictogramas.

**Também usa**

- [Coluna de marca ao lado do boxe](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe)
- [Colunas dentro de um boxe](https://postext.dev/pt/docs/document-format.md#columns)
- [Boxes na largura da página](https://postext.dev/pt/docs/configuration.md#o-contêiner-callout)
- [Boxes flutuantes](https://postext.dev/pt/docs/configuration.md#o-contêiner-callout)
- [Negrito, itálico e suas cores](https://postext.dev/pt/docs/configuration.md#texto-do-corpo)
- [Paleta de cores semântica](https://postext.dev/pt/docs/configuration.md#paleta-de-cores)
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Faixa de capítulo em largura total](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Imagens nos designs de página](https://postext.dev/pt/docs/configuration.md#elementos-de-imagem)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Quebras de linha nos títulos](https://postext.dev/pt/docs/document-format.md#quebras-de-linha-em-títulos)
- [Cabeços e fólios](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Margens espelhadas](https://postext.dev/pt/docs/configuration.md#margens-espelhadas)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format.md#recursos)
- [Citações que posicionam as figuras](https://postext.dev/pt/docs/document-format.md#referência-em-linha-a-forma-principal)
- [Posicionamento de figuras](https://postext.dev/pt/docs/document-format.md#posicionamento)
- [Páginas em um canvas](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)
- [Títulos numerados](https://postext.dev/pt/docs/configuration.md#sobrescritas-por-nível)
- [Figuras exatamente aqui](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Figura e Tabela no seu idioma](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Cabeços por tipo de página](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Tipos de recurso personalizados](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration.md#texto-do-corpo), [`calloutStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe), [`captionStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda), [`colorPalette`](https://postext.dev/pt/docs/configuration.md#paleta-de-cores), [`footer`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`header`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`headings`](https://postext.dev/pt/docs/configuration.md#títulos), [`layout`](https://postext.dev/pt/docs/configuration.md#diagramação), [`locale`](https://postext.dev/pt/docs/configuration.md#hifenização), [`orderedLists`](https://postext.dev/pt/docs/configuration.md#listas-numeradas), [`page`](https://postext.dev/pt/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo), [`resourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso), [`unorderedLists`](https://postext.dev/pt/docs/configuration.md#listas-com-marcadores)

**API**

- [`buildDocument`](https://postext.dev/pt/docs/configuration.md#compilar-um-documento), [`clearMeasurementCache`](https://postext.dev/pt/docs/configuration.md#cache-de-medidas), [`defaultResourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso), [`registerResourceImage`](https://postext.dev/pt/docs/architecture.md#superfície-da-api), [`renderPageToCanvas`](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)

**Tipos**

- Noto Serif (OFL-1.1), Lexend (OFL-1.1), Barlow Semi Condensed (OFL-1.1)

## Preparo

### 1 · Dar uma cor a cada tipo de boxe

```js
// script.js, linhas 13–21
const palette = { ink: '#1f2430', paper: '#ffffff', // text; type on stripes and tabs
  band: '#ab2e78', tintBand: '#fae3ef', // the chapter's magenta: opener, objectives, self-check
  tip: '#25734a', tintTip: '#e2f0e7', // study tips and lab work
  warn: '#e39422', tintWarn: '#fcefd8', warnInk: '#94540a', // frame and badge; tint; its type
  mist: '#e8e3f0', rule: '#dccfd8', muted: '#675e66' }; // feature box; lines in drawings; heads
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the band, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
```

Seis tipos de boxe dividem três matizes: magenta para os objetivos e a autoavaliação, verde para a dica de estudo e o laboratório, âmbar para o aviso. O BOX 2.1 tem fundo lavanda e usa o magenta nos termos-chave. Cada matiz tem uma versão clara para os fundos, e toda configuração de cor está ligada a uma entrada da paleta, então mudar um matiz é editar um valor. Um texto de corpo tão pequeno precisa de contraste de 4,5:1. O magenta sobre o lavanda chega a 4,9:1, mas o âmbar sobre o fundo do aviso fica em 2,2:1, por isso o aviso guarda o âmbar para a moldura e o selo e compõe o título e os termos-chave em `warnInk`, um âmbar mais escuro, com 5,2:1.

### 2 · Compartilhar todo o resto

```js
// script.js, linhas 27–36
const box = (id, hue, device) => ({ id, marginTop: pt(LEAD), marginBottom: pt(LEAD / 2),
  padding: { top: mm(2.8), right: mm(3.4), bottom: mm(3.2), left: mm(3.4) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), color: col(hue), // bold by default
    textTransform: 'uppercase', letterSpacing: pt(1.4), gap: mm(GAP) },
  body: { fontFamily: LABEL, fontSize: pt(9.6), lineHeight: pt(12.6), color: col('ink'),
    boldColor: col(hue), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  lists: { color: col(hue), gap: mm(2.2), itemSpacing: pt(2.4) }, // bullets and numbers
  ...device }); // the kind's title, fill and device: its spread wins over the base
// An SVG or bitmap resource, fitted into a square of that size (width × size with `width`).
const icon = (id, size, more) => ({ kind: 'resource', resourceId: id, size: mm(size), ...more });
```

`box()` reúne as configurações que todos os boxes compartilham: margens, preenchimento, letra do título e do texto, espaçamento das listas. O argumento `hue` colore o título, os marcadores e números e os trechos em negrito. `body.boldColor` deixa **fluid mosaic model** em magenta nos objetivos e **cell wall** em âmbar-acastanhado no aviso; o negrito no texto corrido continua em tinta ([tipografia dentro de um boxe](https://postext.dev/pt/docs/configuration.md#tipografia-dentro-de-um-boxe)). Declarar `calloutStyles` substitui o único estilo que o motor traz de fábrica, um boxe cinza chamado `note`. Um bloco cujo `type` falta ou está escrito errado assume o primeiro estilo, por isso a lista começa pelos objetivos, que todo capítulo tem.

### 3 · Faixa, aba e selo

O código é a [resposta curta](https://postext.dev/pt/cookbook/textbook-box-family.md#a-resposta-curta) acima. A faixa dos objetivos tem 7,5 mm de largura e o alvo 5 mm, então o ícone fica dentro da cor com folga dos dois lados. O `offset` da aba é igual à sua `height`, o que apoia a aba na borda superior do boxe, longe do título. `'top-left'` é o canto externo desta página par; o frasco vem depois da aba, e o fio corre pela borda superior do frasco até o canto oposto. `position: 'corner'` pendura o selo do aviso com metade da largura para fora da borda, e `cornerSide: 'outer'` o manda para o lado do corte, a esquerda nesta página par. Desse lado, o motor recua o título para desviar do selo, a menos que o preenchimento já cubra metade do selo mais o espaço do título; por isso o aviso tem preenchimento de `BADGE / 2 + GAP` nos dois lados, e o título se alinha com o texto. Só este boxe tem `borderRadius`. Num boxe com faixa, a faixa é pintada à parte, como uma barra de cantos retos, e os cantos desse lado ficariam retos ([estilos de boxe](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe)).

### 4 · Mais três recursos gráficos

```js
// script.js, linhas 65–78
const BULB = 6.4; // the study tip's badge in mm: the text clears it
const moreStyles = [
  box('tip', 'tip', { title: t({ en: 'Study tip', es: 'Para recordar' }),
    backgroundEnabled: false, stripe: { enabled: true, width: pt(2.5), color: col('tip') },
    // A stripe narrower than its icon: the badge sits on it at the top and hides it there.
    padding: { top: mm(0.6), right: mm(0), bottom: mm(0.6), left: mm(BULB / 2 + 2) },
    icon: icon('bulb', BULB) }),
  box('safety', 'tip', { background: col('tintTip'), marginTop: pt(4), // under its heading
    icon: icon('safety', 7, { width: mm(24.5), align: 'center' }) }), // 3 pictograms, 1 image
  box('check', 'band', { background: col('tintBand'),
    // Outside the frame: [marker][rule][gap][box], the rule as tall as the box.
    marker: { kind: 'resource', resourceId: 'check', size: mm(7), align: 'top', gap: mm(3),
      rule: { enabled: true, color: col('band'), width: pt(0.75) } } }),
];
```

A faixa da dica de estudo tem 2,5 pt de largura contra os 6,4 mm do selo da lâmpada, então ela sai como um fio fino com o selo por cima da ponta de cima; um preenchimento à esquerda de meio selo mais 2 mm mantém o texto longe da lâmpada. `icon.width` encaixa uma imagem numa caixa larga em vez de num quadrado, então os três pictogramas de segurança, desenhados como uma só imagem de 24,5 mm, formam a coluna de ícone do boxe de laboratório, centralizada em relação ao texto. O indicador da autoavaliação fica fora da moldura, na ordem indicador, fio, espaço, boxe. O quadradinho sobre um fio de 0,75 pt a distingue da dica de estudo, cujo selo é redondo. O bloco acrescenta `span="page" placement="bottom"` para que a autoavaliação flutue até o pé da [página 29](https://postext.dev/cookbook/textbook-box-family/en/p03.webp?v=bc5684d7).

### 5 · Os ícones são recursos

```js
// script.js, linhas 240–265
const svgResource = (id, width, height, extra) => ({ id, typeId: 'figure', kind: 'svg',
  svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0, ...extra });
const pageTop = { position: 'top', span: 'page' }; // a 'top' float opens the page after its :ref
const resources = [
  // Uncited, so never placed as figures: the box styles and the opener use them by id.
  ...['target', 'bulb', 'caution', 'check'].map((id) => svgResource(id, 240, 240)),
  svgResource('flask', 200, 240), svgResource('safety', 760, 240), svgResource('cell', 2000, 2000),
  svgResource('mosaic', 3480, 1240, { placement: pageTop, caption: t({
    en: 'The fluid mosaic: magenta phospholipids, green proteins, an amber carrier with its '
      + 'glucose, grey cholesterol, amber sugars.', // one line: the page is wide
    es: 'El mosaico fluido: fosfolípidos magenta, proteínas verdes, transportadora ámbar con su '
      + 'glucosa, colesterol gris, azúcares ámbar.' }),
    altText: t({ en: 'A cell membrane in section', es: 'Una membrana celular en sección' }) }),
  svgResource('fusion', 1800, 500, { placement: { position: 'here' }, caption: t({
    en: 'Mouse proteins in green, human proteins in magenta (the red dye): the two cells, the '
      + 'hybrid just after fusion and the same hybrid 40 minutes later.',
    es: 'En verde, las proteínas de ratón; en magenta, las humanas (el colorante rojo): las dos '
      + 'células, el híbrido recién fusionado y el mismo híbrido 40 minutos después.' }),
    altText: t({ en: 'Two cells fusing into a hybrid', es: 'Dos células que se fusionan' }) }),
  svgResource('osmosis', 3480, 860, { placement: pageTop, caption: t({
    en: 'Red blood cells in a hypertonic, an isotonic and a hypotonic solution. Arrows show the '
      + 'net flow of water.',
    es: 'Glóbulos rojos en una disolución hipertónica, una isotónica y una hipotónica. Las flechas '
      + 'indican el flujo neto de agua.' }),
    altText: t({ en: 'Blood cells in three solutions', es: 'Glóbulos rojos en tres medios' }) }),
];
```

Todos os ícones, o frasco ao lado da aba e a célula da abertura são recursos SVG declarados com o conteúdo e registrados pelo id de arquivo. Um estilo os chama pelo id de recurso, e como o texto nunca os cita, nenhum deles vira uma figura numerada. Glifos como ✓ ou ☞ seriam mais curtos de escrever, mas os arquivos latin do Fontsource que as páginas carregam não os têm: o canvas os pegaria emprestados de uma fonte do sistema, e um PDF os descartaria. Os desenhos também não levam palavras, porque um SVG desenhado como imagem não pode usar as fontes da página; as legendas é que dão o nome das cores.

### 6 · Abrir o capítulo com uma célula

```js
// script.js, linhas 82–101
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  overflow: 'wrap', align: 'left', ...extra }); // gotcha: overflow-ellipsis-default
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, size: { width: mm(width) } });
const chapter = t({ en: 'Chapter {chapterNumber}', es: 'Capítulo {chapterNumber}' });
const opener = { enabled: true, minHeight: mm(66), slot: { elements: [
  { kind: 'image', id: 'cell', resourceId: 'cell', placement: { anchor: { to: 'page',
    edge: 'top-right' }, offset: { x: mm(58), y: mm(-71) }, size: { width: mm(150) } } },
  text('numeral', '{chapterNumber}', 'Lexend', 118, 'paper', { anchor: { to: 'page',
    edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(14) } },
    { fontWeight: 800, lineHeight: 1, align: 'right' }),
  text('kicker', `${chapter} · {attr.unit}`, LABEL, 9, 'band', { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(3) } },
    { fontWeight: 700, letterSpacing: pt(1.8), textTransform: 'uppercase' }),
  text('title', '{titleText}', 'Lexend', 34, 'ink', below('kicker', 2.5, 112),
    { fontWeight: 700, lineHeight: 1.04 }),
  text('lead', '{attr.lead}', 'Noto Serif', 10.5, 'ink', below('title', 5, 98),
    { italic: true, lineHeight: 1.42 }),
] } };
```

A célula é um elemento de imagem ancorado no canto superior direito da página e empurrado para além dele, de modo que aparece sobretudo o quarto inferior esquerdo, com o numeral no núcleo. Os dois ficam dentro dos 66 mm que `minHeight` reserva para a abertura. O antetítulo e a linha fina vêm da linha do título, `# Cells and \\ Membranes {unit="The cell" lead="…"}`, e o `\\` quebra o título só na abertura.

## A receita completa

Um único arquivo, composto a partir da pasta da receita com o texto de exemplo e o kit comum das Receitas já incluídos; ele monta a própria página. Para executá-lo, coloque-o em um `<script type="module">` de uma página vazia ou cole-o no painel JS de um pen novo do CodePen (como módulo). Ele importa o postext do esm.sh, então não há nada para instalar nem compilar.

- Pasta da receita: https://github.com/drnachio/postext/tree/main/cookbook/textbook-box-family

### script.js

```js
// ═══ Postext Cookbook · Nº 008 · A family of textbook boxes ═══════════════════════
// https://postext.dev/en/cookbook/textbook-box-family
// Code: MIT · Text: original (CC BY 4.0) · Drawings and icons: generated in code (CC BY 4.0)
// Fonts: Noto Serif, Lexend, Barlow Semi Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes } from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: three hues for six kinds of box; everything links to an entry
const palette = { ink: '#1f2430', paper: '#ffffff', // text; type on stripes and tabs
  band: '#ab2e78', tintBand: '#fae3ef', // the chapter's magenta: opener, objectives, self-check
  tip: '#25734a', tintTip: '#e2f0e7', // study tips and lab work
  warn: '#e39422', tintWarn: '#fcefd8', warnInk: '#94540a', // frame and badge; tint; its type
  mist: '#e8e3f0', rule: '#dccfd8', muted: '#675e66' }; // feature box; lines in drawings; heads
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the band, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
// Label face (box text and titles, tabs, captions, heads); leading in pt; outer margin in mm
const LABEL = 'Barlow Semi Condensed', LEAD = 13.4, OUTER = 16, GAP = 1.5; // GAP: title gap, mm

// #region base: what every box shares; each kind brings its colour and its one device
const box = (id, hue, device) => ({ id, marginTop: pt(LEAD), marginBottom: pt(LEAD / 2),
  padding: { top: mm(2.8), right: mm(3.4), bottom: mm(3.2), left: mm(3.4) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), color: col(hue), // bold by default
    textTransform: 'uppercase', letterSpacing: pt(1.4), gap: mm(GAP) },
  body: { fontFamily: LABEL, fontSize: pt(9.6), lineHeight: pt(12.6), color: col('ink'),
    boldColor: col(hue), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  lists: { color: col(hue), gap: mm(2.2), itemSpacing: pt(2.4) }, // bullets and numbers
  ...device }); // the kind's title, fill and device: its spread wins over the base
// An SVG or bitmap resource, fitted into a square of that size (width × size with `width`).
const icon = (id, size, more) => ({ kind: 'resource', resourceId: id, size: mm(size), ...more });
// #endregion

// #region answer: three devices: an icon on a wide stripe, a numbered tab, an outer badge
// box(id, hue, device) is the shared base: the hue colours the title, bullets and key terms.
const TAB = 5, BADGE = 7.4; // in mm: the tab's height (and offset), the warning badge's size
const calloutStyles = [
  // The first style is also the one a missing or misspelt type falls back to.
  box('objectives', 'band', { title: t({ en: 'In this chapter', es: 'En este capítulo' }),
    background: col('tintBand'), stripe: { enabled: true, width: mm(7.5), color: col('band') },
    icon: icon('target', 5) }), // on a side stripe (left by default) the icon is centred on it
  box('feature', 'band', { background: col('mist'),
    titleStyle: { fontFamily: 'Lexend', fontSize: pt(10.5), color: col('ink'), gap: mm(1.6) },
    // Printed only where the fence has label="…": the tab, the flask beside it, a rule to them.
    label: { fontFamily: LABEL, fontSize: pt(8), color: col('paper'), background: col('ink'),
      position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default)
      height: mm(TAB), offset: mm(TAB), paddingX: mm(2.3), // offset = height: on the top edge
      icon: { resourceId: 'flask', width: mm(4.4), gap: mm(1.2) },
      rule: { enabled: true, color: col('ink'), width: pt(1.2) } } }),
  box('warning', 'warnInk', { background: col('tintWarn'), borderRadius: mm(1.4),
    border: { enabled: true, color: col('warn'), width: pt(0.75) },
    // The badge hangs half past the outer corner: sides of half badge + GAP align the title.
    padding: { top: mm(2.8), right: mm(BADGE / 2 + GAP), bottom: mm(3.2),
      left: mm(BADGE / 2 + GAP) },
    icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }),
]; // straight into config(), followed by the 'more' styles
// #endregion

// #region more: a badge threaded on a thin rule, a strip of pictograms, a marker outside
const BULB = 6.4; // the study tip's badge in mm: the text clears it
const moreStyles = [
  box('tip', 'tip', { title: t({ en: 'Study tip', es: 'Para recordar' }),
    backgroundEnabled: false, stripe: { enabled: true, width: pt(2.5), color: col('tip') },
    // A stripe narrower than its icon: the badge sits on it at the top and hides it there.
    padding: { top: mm(0.6), right: mm(0), bottom: mm(0.6), left: mm(BULB / 2 + 2) },
    icon: icon('bulb', BULB) }),
  box('safety', 'tip', { background: col('tintTip'), marginTop: pt(4), // under its heading
    icon: icon('safety', 7, { width: mm(24.5), align: 'center' }) }), // 3 pictograms, 1 image
  box('check', 'band', { background: col('tintBand'),
    // Outside the frame: [marker][rule][gap][box], the rule as tall as the box.
    marker: { kind: 'resource', resourceId: 'check', size: mm(7), align: 'top', gap: mm(3),
      rule: { enabled: true, color: col('band'), width: pt(0.75) } } }),
];
// #endregion

// #region opener: a cell cut by the corner of the page, the chapter number inside it
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  overflow: 'wrap', align: 'left', ...extra }); // gotcha: overflow-ellipsis-default
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, size: { width: mm(width) } });
const chapter = t({ en: 'Chapter {chapterNumber}', es: 'Capítulo {chapterNumber}' });
const opener = { enabled: true, minHeight: mm(66), slot: { elements: [
  { kind: 'image', id: 'cell', resourceId: 'cell', placement: { anchor: { to: 'page',
    edge: 'top-right' }, offset: { x: mm(58), y: mm(-71) }, size: { width: mm(150) } } },
  text('numeral', '{chapterNumber}', 'Lexend', 118, 'paper', { anchor: { to: 'page',
    edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(14) } },
    { fontWeight: 800, lineHeight: 1, align: 'right' }),
  text('kicker', `${chapter} · {attr.unit}`, LABEL, 9, 'band', { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(3) } },
    { fontWeight: 700, letterSpacing: pt(1.8), textTransform: 'uppercase' }),
  text('title', '{titleText}', 'Lexend', 34, 'ink', below('kicker', 2.5, 112),
    { fontWeight: 700, lineHeight: 1.04 }),
  text('lead', '{attr.lead}', 'Noto Serif', 10.5, 'ink', below('title', 5, 98),
    { italic: true, lineHeight: 1.42 }),
] } };
// #endregion

// Heads 12.8 mm below the trim (the larger folio 0.4 mm higher), titles 8.5 mm in from folios.
const head = (id, content, parity, edge, x, extra, y = 12.8) => ({ kind: 'text', id, content,
  parity, pages: 'body', fontFamily: LABEL, fontSize: pt(7.8), fontWeight: 600,
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } } });
const folio = { fontFamily: 'Lexend', fontSize: pt(9), fontWeight: 700, color: col('band') };
const header = { elements: [ // folios on the fore-edge, never on the opener
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio, 12.4),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8.5),
  head('recto-title', `${chapter} · {chapterTitle}`, 'odd', 'top-right', -(OUTER + 8.5)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio, 12.4),
] }; // the opener's folio drops to the foot:
const footer = { elements: [{ ...head('drop', '{pageNumber}', 'all', 'bottom', 0, folio, -12),
  pages: 'opener' }] };

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales)
  resourceTypes: defaultResourceTypes(LANG), // "Figura" (gotcha: resource-types-locale)
  colorPalette, header, footer,
  page: { width: mm(210), height: mm(280), dpi: 150, margins: { top: mm(24), bottom: mm(22),
    left: mm(20), right: mm(OUTER), mirror: true } }, // left is the inner margin
  layout: { layoutType: 'double', gutterWidth: mm(8) },
  bodyText: { fontFamily: 'Noto Serif', fontSize: pt(9.4), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('band'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false }, // hyphens: default
  headings: { fontFamily: 'Lexend', color: col('band'), levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
    { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}',
      marginTop: pt(LEAD * 1.4), marginBottom: pt(LEAD * 0.6) }, // 3 lines, close to its text
    { level: 3, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('tip'),
      marginTop: pt(LEAD), marginBottom: pt(0) }, // a line clear of the text, on its box
  ] },
  // In 1.4.1 a box's lists.color reaches its numbers only if it differs from these bullets'.
  unorderedLists: { color: col('ink'), marginTop: pt(0), marginBottom: pt(0) },
  orderedLists: { fontFamily: 'Lexend', color: col('tip'), marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [...calloutStyles, ...moreStyles],
  captionStyle: { fontFamily: LABEL, fontSize: pt(8.8), color: col('ink'),
    labelColor: col('band'), gap: mm(1.8) },
  paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.6), lineHeight: pt(10),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Living Matter"
subtitle: "Biology for upper secondary school"
---

# Cells and \\ Membranes {unit="The cell" lead="Every living cell is wrapped in a film ten thousand times thinner than a sheet of paper. Oxygen passes through it freely; salts and sugars need a protein to cross."}

A cell is a crowded, watery workshop. Its enzymes need salts, sugars and building blocks close at hand, and its waste has to leave before it builds up. The **plasma membrane**, a layer about eight nanometres thick, separates that workshop from the world outside. In this chapter you will see what the membrane is made of and why it behaves more like a liquid than a wall. Some substances cross it at no cost to the cell; others cross only when the cell spends energy.

:::callout{type="objectives"}
- Use the **fluid mosaic model** to describe the plasma membrane.
- Explain how **diffusion** and **osmosis** move substances without energy.
- Predict how a cell changes in a hypertonic, an isotonic and a hypotonic solution.
- Compare **active transport** with passive transport.
:::

## A boundary with doors

Membranes are built mostly from **phospholipids**. Each molecule has a head that is attracted to water and two fatty tails that avoid it. In water, phospholipids arrange themselves so that the tails are hidden: they form a double layer, the **lipid bilayer**, with the heads facing the watery fluid on both sides and the tails meeting in the middle (:ref{id="mosaic"}).

Because its core is oily, the bilayer lets small, neutral molecules such as oxygen and carbon dioxide slip straight across. Ions and larger polar molecules, such as glucose, are turned away. Water, although polar, is small enough to trickle through slowly on its own, and many cells speed it up with water channels, the aquaporins. Letting some substances through and not others is called **selective permeability**. It lets a cell keep an inside that differs from its surroundings, and it is why ions and sugars cross through proteins set in the membrane.

:::callout{type="tip"}
Read the name of the model as a description. **Fluid** means that the lipids drift sideways and swap places with their neighbours millions of times a second, and **mosaic** means that proteins are set into the layer like tiles in a floor.
:::

## The fluid mosaic

In 1972 S. Jonathan Singer and Garth Nicolson proposed the model that is still used today. The bilayer is the fluid base, and proteins float in it: some span the whole membrane, others rest on one of its faces. **Channel proteins** form water-filled pores for particular ions, and **carrier proteins** change shape to move a molecule from one side to the other. Chains of sugars fixed to proteins and lipids face the outside of the cell and work as identity tags that other cells recognise.

Cholesterol, tucked between the phospholipid tails, acts as a buffer. When it is warm, cholesterol restrains the tails and keeps the membrane from becoming runny; when it is cold, it keeps them from packing tightly and turning stiff. Living things tune their membranes to their temperature: bacteria grown in the cold build lipids with more kinks in their tails, which keeps the layer fluid, and many plants do the same as winter comes, as do the fish that live in the icy seas around Antarctica.

:::callout{type="feature" label="BOX 2.1" title="Watching proteins drift" span="page"}
:::columns{count=2 breaks="2"}
::resource{id="fusion"}

In 1970 Larry Frye and Michael Edidin fused a mouse cell with a human cell. They had tagged the mouse membrane proteins with a green fluorescent dye and the human ones with a red dye. At first each colour kept to its own half of the hybrid cell. Forty minutes later, at 37 °C, the colours were completely mixed (:ref{id="fusion"}). Kept cold, the hybrids mixed far more slowly. The proteins were **drifting** on their own through a fluid layer that the cold made stiffer.
:::
:::

:::callout{type="warning" title="Membrane or wall?"}
Plants, fungi and most bacteria also have a **cell wall** outside the membrane. The wall gives the cell its shape and keeps it from bursting, but it lets almost everything through. Only the membrane is selectively permeable.
:::

The lipids and many of the proteins in a membrane keep moving, yet the layer holds together, because wherever a lipid drifts, its tails stay hidden from the water. For the same reason, a small hole in a bilayer closes up again on its own.

## Crossing the membrane

Particles in a solution never stop moving. **Diffusion** is their net movement from where they are more concentrated to where they are less, until they are evenly spread. It needs no energy from the cell, because the particles’ own motion does the work: oxygen diffuses into a cell that keeps using it up, and carbon dioxide diffuses out.

**Osmosis** is the diffusion of water across a selectively permeable membrane. If the solution outside a cell holds more dissolved particles than the cytoplasm, it is **hypertonic**, and the cell loses water and shrinks; if it holds fewer, it is **hypotonic**, and water floods in; if the two are equal, it is **isotonic**, and the cell keeps its size (:ref{id="osmosis"}). Red blood cells in pure water swell until they burst. Plant cells do not burst: the wall holds them firm, which is why a watered plant stands upright and a dry one wilts.

Diffusion is quick over short distances and slow over long ones. An oxygen molecule crosses a cell ten micrometres across in about a fortieth of a second, yet it would need some seven hours to diffuse one centimetre through water. That is one reason why cells stay small, and why large animals need lungs, gills and blood to carry oxygen the rest of the way.

Transport down the gradient through a channel or a carrier protein is called **facilitated diffusion**, and it is still passive: the protein opens a way, but the cell spends no energy on it. To move a substance uphill, from low concentration to high, a cell has to spend energy, usually as ATP, and that is **active transport**. The sodium–potassium pump in your nerve cells pushes three sodium ions out and two potassium ions in for every ATP it uses, and pumps like it take a large share of a resting cell’s energy.

:::callout{type="check" title="Check yourself" span="page" placement="bottom"}
:::columns{count=2 breaks="4"}
1. Why can oxygen cross the bilayer but glucose cannot?
2. What does **fluid** mean in the fluid mosaic model?
3. A slice of cucumber is left in salty water. Use **osmosis** to predict what happens to its cells.
4. Why does the sodium–potassium pump count as **active transport** and not as diffusion?
5. Could a cell with a rigid membrane use **endocytosis** to take in a bacterium? Explain.
:::
:::

## Moving in bulk

Some cargo is far too large for any channel or carrier. A white blood cell swallows a whole bacterium by wrapping its membrane around it and pinching off a bubble, a **vesicle**, into the cytoplasm: this is **endocytosis**. Cells drink the same way: in pinocytosis the membrane folds in around a droplet of the fluid outside, with whatever is dissolved in it.

The reverse, **exocytosis**, carries material out. Vesicles filled with hormones, digestive enzymes or the mucus that lines your airways fuse with the plasma membrane and empty their contents outside. Most signals that a nerve cell passes to the next leave it the same way: vesicles release messenger molecules, the neurotransmitters, into the narrow gap between the two cells. Both processes cost energy, and both work only because the bilayer is fluid enough to break and reseal without leaking.

Membranes do not stop at the surface of the cell. Inside it, the same bilayer wraps the nucleus, the mitochondria and a maze of inner compartments, each keeping its own chemistry apart from the rest. In Chapter 3 you will follow a protein that the cell exports, from the ribosome that makes it, through the endoplasmic reticulum and the Golgi apparatus, to the vesicle that releases it outside.

### Try it: osmosis in a potato

:::callout{type="safety" title="Lab safety"}
Wear safety goggles and gloves, and always cut away from your fingers.
:::

1. Cut two potato strips of the same size, about five centimetres long, and weigh them.
2. Leave one in tap water and the other in strongly salted water for thirty minutes.
3. Dry both strips, weigh them again and bend them.
4. Explain the change in mass and in stiffness with what you know about osmosis.

:::paragraphs{style="colophon"}
**Living Matter** is a textbook invented for the Postext Cookbook. Text and drawings: original, CC BY 4.0. Set in Noto Serif, Lexend and Barlow Semi Condensed (SIL Open Font License).
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region icons: icons are resources too: declared with the content, registered by file id
const svgResource = (id, width, height, extra) => ({ id, typeId: 'figure', kind: 'svg',
  svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0, ...extra });
const pageTop = { position: 'top', span: 'page' }; // a 'top' float opens the page after its :ref
const resources = [
  // Uncited, so never placed as figures: the box styles and the opener use them by id.
  ...['target', 'bulb', 'caution', 'check'].map((id) => svgResource(id, 240, 240)),
  svgResource('flask', 200, 240), svgResource('safety', 760, 240), svgResource('cell', 2000, 2000),
  svgResource('mosaic', 3480, 1240, { placement: pageTop, caption: t({
    en: 'The fluid mosaic: magenta phospholipids, green proteins, an amber carrier with its '
      + 'glucose, grey cholesterol, amber sugars.', // one line: the page is wide
    es: 'El mosaico fluido: fosfolípidos magenta, proteínas verdes, transportadora ámbar con su '
      + 'glucosa, colesterol gris, azúcares ámbar.' }),
    altText: t({ en: 'A cell membrane in section', es: 'Una membrana celular en sección' }) }),
  svgResource('fusion', 1800, 500, { placement: { position: 'here' }, caption: t({
    en: 'Mouse proteins in green, human proteins in magenta (the red dye): the two cells, the '
      + 'hybrid just after fusion and the same hybrid 40 minutes later.',
    es: 'En verde, las proteínas de ratón; en magenta, las humanas (el colorante rojo): las dos '
      + 'células, el híbrido recién fusionado y el mismo híbrido 40 minutos después.' }),
    altText: t({ en: 'Two cells fusing into a hybrid', es: 'Dos células que se fusionan' }) }),
  svgResource('osmosis', 3480, 860, { placement: pageTop, caption: t({
    en: 'Red blood cells in a hypertonic, an isotonic and a hypotonic solution. Arrows show the '
      + 'net flow of water.',
    es: 'Glóbulos rojos en una disolución hipertónica, una isotónica y una hipotónica. Las flechas '
      + 'indican el flujo neto de agua.' }),
    altText: t({ en: 'Blood cells in three solutions', es: 'Glóbulos rojos en tres medios' }) }),
];
// #endregion

// #region art: the icons and the drawings, in the palette's colours (seeded)
// No words in them: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts).
function rng(seed) { // Mulberry32: the same drawing on every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let x = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
    return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
  };
}
const n = (v) => +v.toFixed(2);
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const dot = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" `
  + `fill="${fill}"${extra}/>`;
const line = (d, stroke, width, extra = '') => `<path d="${d}" fill="none" stroke="${stroke}" `
  + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`;
const shape = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`;
const capsule = (x, y, w, h, fill, turn = 0, extra = '') => `<rect x="${n(x - w / 2)}" `
  + `y="${n(y - h / 2)}" width="${n(w)}" height="${n(h)}" rx="${n(Math.min(w, h) / 2)}" `
  + `fill="${fill}" transform="rotate(${n(turn)} ${n(x)} ${n(y)})"${extra}/>`;
const arrow = (x, y, len, turn, fill) => shape(`M${x} ${y - 0.9}h${len - 4}v-1.9l4 2.8-4 2.8`
  + `v-1.9H${x}Z`, fill, ` transform="rotate(${turn} ${x} ${y})"`); // a path, never a marker

function target() { // white rings on the magenta stripe
  return svg(24, 24, `<g fill="none" stroke="${palette.paper}" stroke-width="2.3">`
    + `<circle cx="12" cy="12" r="9.8"/><circle cx="12" cy="12" r="5.3"/></g>`
    + dot(12, 12, 1.9, palette.paper));
}
function bulb() {
  return svg(24, 24, dot(12, 12, 12, palette.tip) + shape('M12 4.4a5.6 5.6 0 0 0-3.3 10.1c.7.5 1 '
    + '1.1 1 1.9v.6h4.6v-.6c0-.8.3-1.4 1-1.9A5.6 5.6 0 0 0 12 4.4Z', palette.paper)
    + capsule(12, 18.6, 4.6, 1.3, palette.paper) + capsule(12, 20.3, 3, 1.2, palette.paper));
}
function flask() {
  const body = 'M8 2v7L2.4 19.4A2.1 2.1 0 0 0 4.3 22.5h11.4a2.1 2.1 0 0 0 1.9-3.1L12 9V2';
  return svg(20, 24, shape(`${body}Z`, palette.paper) + shape('M5.3 14h9.4l3 5.4a1.1 1.1 0 0 1-1 '
    + '1.6H3.3a1.1 1.1 0 0 1-1-1.6Z', palette.band) + dot(8.6, 17.4, 1.1, palette.paper)
    + dot(11.8, 18.7, 0.8, palette.paper) + line(body, palette.ink, 1.7)
    + line('M6.4 2h7.2', palette.ink, 1.7));
}
function caution() { // ink on amber: white on amber would fail contrast
  return svg(24, 24, dot(12, 12, 11, palette.warn, ` stroke="${palette.paper}" stroke-width="2"`)
    + capsule(12, 10, 2.8, 9.2, palette.ink) + dot(12, 17.6, 1.7, palette.ink));
}
function check() { // a ticked tile: square where the study tip's badge is round
  return svg(24, 24, `<rect width="24" height="24" rx="5.5" fill="${palette.band}"/>`
    + line('M6.3 12.6l3.8 3.8 7.6-8', palette.paper, 2.9));
}
function safety() { // goggles, a glove and a blade: three mandatory-action discs
  const goggles = `<g fill="none" stroke="${palette.paper}" stroke-width="1.5">`
    + '<rect x="4.3" y="9" width="6.6" height="5.6" rx="2.4"/>'
    + '<rect x="13.1" y="9" width="6.6" height="5.6" rx="2.4"/></g>'
    + line('M10.9 11.4q1.1-1 2.2 0M2.6 11.6h1.7M19.7 11.6h1.7', palette.paper, 1.4);
  const glove = shape('M32.4 20.5v-6.6l-2.3-2.7a1.2 1.2 0 0 1 1.8-1.6l1.4 1.5V6.2a1.1 1.1 0 0 1 '
    + '2.2 0v5.2V4.9a1.1 1.1 0 0 1 2.2 0v6.5V5.5a1.1 1.1 0 0 1 2.2 0v6.2V7a1.1 1.1 0 0 1 2.2 0v8.6'
    + 'l-1.2 4.9Z', palette.paper);
  const blade = `<g transform="rotate(-45 64 12)">${capsule(64, 16.3, 3.6, 9, palette.paper)}`
    + shape('M62.2 11.4V6.3L64 3.2l1.8 3.1v5.1Z', palette.paper) + '</g>';
  return svg(76, 24, [12, 38, 64].map((x) => dot(x, 12, 11.4, palette.ink)).join('')
    + goggles + glove + blade);
}
function blob(cx, cy, rx, ry, r, fill, extra = '', square = 2.6) { // a soft, uneven shape
  const pts = Array.from({ length: 16 }, (_, i) => {
    const [c, sn] = [Math.cos((i * Math.PI) / 8), Math.sin((i * Math.PI) / 8)];
    const k = 1 + (r() - 0.5) * 0.12;
    const f = (v) => Math.sign(v) * Math.abs(v) ** (2 / square); // squarer than an ellipse
    return [cx + f(c) * rx * k, cy + f(sn) * ry * k];
  });
  const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2].map(n);
  const d = pts.map((p, i) => `Q${p.map(n)} ${mid(p, pts[(i + 1) % 16])}`).join('');
  return shape(`M${mid(pts[15], pts[0])}${d}Z`, fill, extra);
}
const shine = (cx, cy, rx, ry, r) => blob(cx - rx * 0.28, cy - ry * 0.34, rx * 0.46, ry * 0.4,
  r, palette.paper, ' fill-opacity=".22"');
const pale = ` stroke="${palette.tip}" stroke-width="1.6"`; // a light green protein, outlined
const sugars = (x, y, r, count = 5) => { // a chain of sugars, branched once
  const sugar = (sx, sy) => dot(sx, sy, 2.1, palette.warn,
    ` stroke="${palette.paper}" stroke-width=".7"`);
  let out = '';
  for (let k = 0; k < count; k++, y -= 4.3, x += (r() - 0.5) * 4) {
    out += sugar(x, y) + (k === 2 ? sugar(x + 4.3, y - 1.6) : '');
  }
  return out;
};
function cell() { // a cell drawn as a disc of bilayer: mostly its lower-left quarter shows
  let out = dot(100, 100, 71, palette.band);
  for (let a = 0; a < 360; a += 3.05) {
    const [c, s] = [Math.cos((a * Math.PI) / 180), Math.sin((a * Math.PI) / 180)];
    const at = (d) => [100 + c * d, 100 + s * d];
    const tail = (d0, d1) => line(`M${at(d0).map(n)}L${at(d1).map(n)}`, palette.rule, 0.8);
    out += tail(90.5, 84.8) + tail(75.8, 81.2) + dot(...at(92.6), 2.6, palette.band)
      + dot(...at(73.6), 2.5, palette.paper);
  }
  [106, 133, 157, 184].forEach((a, k) => { // proteins across the visible arc
    const rad = (a * Math.PI) / 180;
    const fill = k % 2 ? palette.warn : palette.tip;
    out += capsule(100 + Math.cos(rad) * 83, 100 + Math.sin(rad) * 83, 26, 7, fill, a);
  });
  return svg(200, 200, out);
}
function mosaic() { // the membrane in section, 174 × 62 mm; the outside of the cell on top
  const r = rng(5);
  const W = 348;
  const yc = (x) => 70 + 3.4 * Math.sin(x / 44);
  const proteins = [[56, 34, palette.tip, 'channel'], [148, 38, palette.warn, 'carrier'],
    [234, 27, palette.tip], [306, 32, palette.tintTip, 'pale']];
  const clear = (x) => proteins.every(([px, w]) => Math.abs(x - px) > w / 2 + 1.8);
  let out = `<rect width="${W}" height="70" fill="${palette.tintBand}"/>`
    + `<rect y="70" width="${W}" height="54" fill="${palette.mist}"/>`;
  for (let x = 4; x < W; x += 5.9) { // phospholipids: heads out, two tails in
    if (!clear(x)) continue;
    for (const s of [-1, 1]) {
      const y = yc(x) + s * 13;
      for (const dx of [-1, 1]) {
        out += line(`M${n(x + dx)} ${n(y - s * 2.4)}l${n(r() - 0.5)} ${-s * 4}`
          + `l${n(r() - 0.5)} ${-s * 4.6}`, palette.band, 0.75, ' stroke-opacity=".4"');
      }
      out += dot(x, y, 2.75, palette.band);
      if (r() < 0.13 && clear(x + 3)) { // cholesterol among the tails: the only grey rods
        out += capsule(x + 3, y - s * 7.4, 2.1, 7.2, palette.ink, 0, ' fill-opacity=".62"');
      }
    }
  }
  out += sugars(196, yc(196) - 16.5, r, 3); // a glycolipid
  for (const [px, w, fill, kind] of proteins) { // proteins across the bilayer
    const y = yc(px);
    const halves = kind === 'channel' ? [px - w / 4 - 1.3, px + w / 4 + 1.3] : [px];
    for (const x of halves) {
      const rx = kind === 'channel' ? w / 4 : w / 2;
      out += blob(x, y, rx, 21, r, fill, kind === 'pale' ? pale : '') + shine(x, y, rx, 21, r);
    }
    if (kind === 'channel') {
      out += dot(px, y - 7, 2, palette.warnInk) + dot(px, y + 4, 2, palette.warnInk);
    }
    if (kind === 'carrier') { // a glucose held in the carrier's open mouth
      out += shape(`M${px - 7} ${y - 24}L${px} ${y - 12}L${px + 7} ${y - 24}Z`, palette.tintBand)
        + shape(`M${px - 3.6} ${y - 19.5}l1.8-3.1h3.6l1.8 3.1-1.8 3.1h-3.6Z`, palette.paper,
          ` stroke="${palette.ink}" stroke-width=".8"`);
    } else out += sugars(px + (r() - 0.5) * 5, y - 22.5, r);
  }
  out += blob(100, yc(100) + 22.5, 11, 5.5, r, palette.tintTip, pale) // proteins on one face only
    + blob(270, yc(270) - 22, 8.5, 5, r, palette.tip);
  for (let x = 6; x < W; x += 3.2) { // the cytoskeleton under the membrane
    out += dot(x, 111 + 2.2 * Math.sin(x / 7), 1.4, palette.muted, ' fill-opacity=".45"');
  }
  for (let k = 0; k < 9; k++) { // oxygen outside the cell
    const [x, y] = [14 + r() * 320, 8 + r() * 26];
    for (const dx of [0, 2.6]) out += dot(x + dx, y, 1.5, palette.ink, ' fill-opacity=".5"');
  }
  return svg(W, 124, out);
}
function fusion() { // two cells, the hybrid just after fusion, the hybrid 40 minutes later
  const r = rng(3);
  const cellAt = (cx, rx, fill, nuclei, pick) => {
    let out = `<ellipse cx="${cx}" cy="25" rx="${rx}" ry="${Math.min(rx, 15)}" fill="${fill}" `
      + `stroke="${palette.ink}" stroke-width=".8"/>`
      + nuclei.map((x) => dot(x, 25, 4.2, palette.rule)).join('');
    for (let a = 0; a < 360; a += 12) {
      const rad = (a * Math.PI) / 180;
      out += dot(cx + Math.cos(rad) * rx, 25 + Math.sin(rad) * Math.min(rx, 15), 1.7,
        pick(Math.cos(rad), r()));
    }
    return out;
  };
  return svg(180, 50, cellAt(16, 13, palette.tintTip, [16], () => palette.tip)
    + cellAt(45, 13, palette.tintBand, [45], () => palette.band)
    + arrow(65, 25, 12, 0, palette.muted)
    + cellAt(104, 22, palette.paper, [96, 112], (c) => (c < 0 ? palette.tip : palette.band))
    + arrow(132, 25, 12, 0, palette.muted)
    + cellAt(163, 15.5, palette.paper, [158, 168], (c, k) => (k < 0.5 ? palette.tip
      : palette.band)));
}
function osmosis() { // three solutions: water leaves, stays even, floods in
  const r = rng(9);
  const panel = (x, solutes, body, flow) => {
    let out = `<rect x="${x}" width="108" height="86" rx="4" fill="${palette.mist}"/>`;
    for (let k = 0; k < solutes; k++) {
      const [px, py] = [x + 5 + r() * 98, 5 + r() * 76];
      if (Math.hypot(px - x - 54, py - 43) > 30) out += dot(px, py, 1.5, palette.muted);
    }
    for (let k = 0; k < 6; k++) { // arrows: the net flow of water, out (+1) or in (-1)
      const rad = (k * Math.PI) / 3 + 0.5;
      if (flow === 0 && k % 3) continue;
      const d = flow < 0 || (flow === 0 && k) ? 40 : 27;
      out += arrow(x + 54 + Math.cos(rad) * d, 43 + Math.sin(rad) * d, 10,
        (rad * 180) / Math.PI + (d > 30 ? 180 : 0), palette.tip);
    }
    return out + body;
  };
  const at = (k, d) => [54 + Math.cos((k * Math.PI) / 11) * d, 43 + Math.sin((k * Math.PI) / 11)
    * d].map(n);
  const crenated = `M${at(0, 13)}${Array.from({ length: 22 }, (_, k) => (k % 2 ? ''
    : `Q${at(k + 1, 19)} ${at(k + 2, 13)}`)).join('')}Z`; // bumps: control points outside
  const disc = (x, rr) => dot(x + 54, 43, rr, palette.band)
    + dot(x + 54, 43, rr * 0.45, palette.tintBand);
  return svg(348, 86, panel(0, 70, shape(crenated, palette.band), 1)
    + panel(120, 34, disc(120, 17), 0) + panel(240, 8, disc(240, 23), -1));
}
const drawings = { target, bulb, flask, caution, safety, check, cell, mosaic, fusion, osmosis };
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Noto Serif': ['400', '400i', '700'], Lexend: ['700', '800'], // text, display,
  'Barlow Semi Condensed': ['400', '600', '700'] }; // labels (gotcha: fonts-first)

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await Promise.all([loadFonts(FONTS, markdown),
  ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))]);
// Folio 27 is odd like page 1, always a recto: parity follows the page (gotcha: parity-page1-recto)
const continuation = { pageNumbering: { startAt: 27 },
  headings: { h1: 1, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 2
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'Living Matter, chapter 2', es: 'Materia viva, capítulo 2' }) });

// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook
// Postext measures with the loaded faces and caches the widths: load every face
// before the first build, from Fontsource, the files the PDF embeds too.

/** 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.
 *  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',
    greek: 'U+0370-03FF',
  };
  const jobs = [];
  let added = 0;
  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 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 = `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` and loads any face the pages use that FONTS missed (a regular
 *  one with a warning), then clears the measurement cache and builds 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. */
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' };
}

/** 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, '-'); }

/** 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 v1 ── 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. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Variações

### Pôr um boxe nas duas colunas

`span="page"` num bloco estende qualquer tipo de boxe pelas duas colunas; o BOX 2.1 também aninha um grupo `:::columns` para pôr a figura ao lado do texto.

```diff
-:::callout{type="warning" title="Membrane or wall?"}
+:::callout{type="warning" title="Membrane or wall?" span="page"}
```

### Pendurar o selo do lado da lombada

`'inner'` leva o selo para o lado da lombada, à esquerda numa página ímpar e à direita numa par; deixe espaço desse lado para a metade que fica fora do boxe.

```diff
-    icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }),
+    icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'inner' }) }),
```

### Pôr a aba no canto direito

`'top-right'`, o padrão, espelha o rótulo inteiro: a aba vai para o canto direito, o frasco para a esquerda dela, e o fio vem da esquerda.

```diff
-      position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default)
+      position: 'top-right',
```

## Erros comuns

- **Qualquer objeto headings desativa a quebra de página do H1.** Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração.
- **Texto alinhado à esquerda pode isolar a pontuação ao lado de um negrito ou de um :ref.** No postext 1.4.1, o texto que não é justificado (corpo de boxes, parágrafos em bandeira) pode quebrar a linha entre um trecho em negrito ou itálico, ou um :ref, e a pontuação colada nele: um ponto final pode abrir a linha seguinte, e o “(” antes de uma referência pode fechar a linha de cima. Texto justificado nunca quebra aí. Revise os boxes de cada edição e reescreva a frase onde isso acontecer, para que o trecho fique no meio da linha.
- **Um espaço não separável ainda quebra a linha.** No postext 1.4.1, o algoritmo de quebra de linha trata U+00A0 como um espaço comum, então 0,08 %, 2,006 s ou seção 2 podem ficar em duas linhas. Junte os dois elementos (0,08%) ou reescreva a frase.
- **Os arquivos do Fontsource cobrem letras, não símbolos.** O kit incorpora o arquivo latin do Fontsource de cada fonte e, quando o texto usa esses caracteres, o arquivo latin-ext para č, ł, † e o arquivo greek para α, χ (em uma família que o tenha: Lora e Gelasio não têm), na tela e no PDF. Símbolos como →, ≈, ✓ e ★ não estão em nenhum desses arquivos e somem no PDF: desenhe-os, componha-os como matemática ou escolha uma fonte cujos arquivos os tenham.
- **O texto dentro de um SVG <img> não pode usar fontes web.** Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda.
- **Um flutuante 'top' nunca cai na página que o cita.** Um flutuante nunca fica acima da própria referência, então um flutuante 'top' na largura da página citado na página N abre a página N+1. Cite-o antes, ou use a posição 'auto' ou 'bottom', que podem ocupar o pé da página que o cita.
- **:::columns só funciona dentro de um boxe e nunca se divide.** :::columns é ignorado fora de um boxe, e um boxe que se divide nunca corta dentro de um grupo de colunas. O atributo breaks conta blocos filhos, e um boxe aninhado conta como um.
- **Traduza Figura e Tabela com defaultResourceTypes(locale).** O locale da configuração define a hifenização, não as legendas: sem resourceTypes, os tipos embutidos dizem Figure e Table, em inglês. Passe resourceTypes: defaultResourceTypes('es') para o espanhol; para qualquer outro idioma, escreva você mesmo os nomes em resourceTypes.
- **Só 8 idiomas têm hifenização, com o código exato.** A hifenização existe para en-us, es, fr, de, it, pt, ca e nl, com o código exato: 'es-ES' ou qualquer outro idioma passa sem aviso para o inglês americano.
- **Carregue todas as fontes antes do layout.** O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada.
- **O excesso de texto de design é 'ellipsis-end' por padrão.** Um elemento de texto de design que não cabe na sua largura termina em reticências por padrão. Use overflow: 'wrap' nos títulos que devem passar para mais linhas.
- **A página 1 é ímpar: planeje com os números físicos.** A página 1 fica à direita e a 2 é a primeira página par, então planeje as páginas duplas com os números físicos de página: uma abertura em página par fica de frente para a ímpar que vem depois dela.
- **A configuração fica em cache pela identidade: crie um objeto novo.** O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config().
- **Aviso de diagramação: Tipo de boxe desconhecido** (`unknownCalloutType`). Um :::callout cita um tipo que calloutStyles não define, então ele assume o primeiro estilo sem avisar. Solução: Defina o estilo ou corrija o atributo type. ([Documentação](https://postext.dev/pt/docs/configuration.md#o-contêiner-callout))

- A aba só é impressa quando o bloco tem `label="BOX 2.1"` e o estilo tem um objeto `label`. Um estilo sem ele ignora o atributo.
- O Postext não numera os boxes. O rótulo é o texto literal do bloco, então você digita cada número, BOX 2.1, BOX 2.2, à mão.
- Um selo de canto fica com metade da largura para fora da lateral do boxe, sobre a margem do lado externo da página. Na outra coluna da mesma página, ele invade a medianiz, então mantenha os boxes com selo na coluna externa ou deixe espaço para eles.
- Um boxe só recolore os marcadores e números das listas quando o seu `lists.color` difere de `unorderedLists.color`. Se os dois forem o mesmo magenta, uma lista numerada dentro do boxe assume `orderedLists.color`, e é por isso que este documento põe os seus próprios marcadores em tinta.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipos: Noto Serif (OFL-1.1), Lexend (OFL-1.1), Barlow Semi Condensed (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 050 · Manual de produto com avisos de segurança](https://postext.dev/pt/cookbook/product-manual-warnings.md): O manual de uma chaleira em alemão: os boxes WARNUNG e VORSICHT levam o triângulo numa faixa da cor de alerta, e as legendas dizem Abbildung e Tabelle. · Nível 2 (Intermediário) · Manuais, guias e obras de referência
- [Nº 021 · Boxes que se dividem, flutuam e ficam fixos](https://postext.dev/pt/cookbook/boxes-split-float-pin.md): Uma ficha de laboratório em que o boxe do procedimento se divide em duas colunas, a folha de dados vai para o alto da página seguinte e um selo fica fixo no pé. · Nível 3 (Avançado) · Cadernos de exercícios, Livros didáticos
- [Nº 022 · Atividade com caixas de resposta e banco de palavras](https://postext.dev/pt/cookbook/worksheet-answer-boxes.md): Uma folha de ciências de quatro páginas: caixas de resposta brancas em cartões verde-claros, 2 mm abaixo de cada pergunta e fora da grade, e lacunas em chips. · Nível 2 (Intermediário) · Cadernos de exercícios
