# Fotos que crescem até encher uma coluna curta

> Cada foto marca uma área segura; o equilíbrio das colunas pode cortar fora dela para alongar a foto, e a coluna que tinha ficado curta termina rente.

- Versão HTML: https://postext.dev/pt/cookbook/photos-fill-short-columns
- Receita Nº 116 · Figuras e imagens · Nível 2 (Intermediário) · Saídas: Canvas, PDF
- Gêneros: Revistas e fanzines
- Requer postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · testada com 1.19.1, postext-pdf 1.19.1 em 2026-10-06
- Páginas: [1](https://postext.dev/cookbook/photos-fill-short-columns/en/p01.webp?v=94e02cba), [2](https://postext.dev/cookbook/photos-fill-short-columns/en/p02.webp?v=94e02cba), [3](https://postext.dev/cookbook/photos-fill-short-columns/en/p03.webp?v=94e02cba), [2](https://postext.dev/cookbook/photos-fill-short-columns/en/p05.webp?v=94e02cba), [3](https://postext.dev/cookbook/photos-fill-short-columns/en/p06.webp?v=94e02cba)
- PDF: https://postext.dev/cookbook/photos-fill-short-columns/en/photos-fill-short-columns.pdf?v=94e02cba
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=photos-fill-short-columns&lang=en (.postext: https://postext.dev/cookbook/photos-fill-short-columns/en/photos-fill-short-columns.postext)
- Última atualização: 2026-10-05
- Outros idiomas: [en](https://postext.dev/en/cookbook/photos-fill-short-columns.md), [es](https://postext.dev/es/cookbook/photos-fill-short-columns.md), [ca](https://postext.dev/ca/cookbook/photos-fill-short-columns.md), [zh](https://postext.dev/zh/cookbook/photos-fill-short-columns.md), [ja](https://postext.dev/ja/cookbook/photos-fill-short-columns.md), [ar](https://postext.dev/ar/cookbook/photos-fill-short-columns.md)

## Em poucas palavras

Diga à diagramação qual parte de cada foto precisa aparecer sempre. Quando faltam algumas linhas numa coluna, a foto fica mais alta em vez de deixar um buraco.

## O que você vai compor

Três páginas de uma revista do litoral: uma reportagem sobre os ofícios de uma cidadezinha de estuário, com uma foto sangrada na abertura e cinco fotos nas colunas. O pen compõe a reportagem duas vezes a partir do mesmo texto. Na primeira composição, as fotos mantêm o formato dos arquivos, e na [página 2](https://postext.dev/cookbook/photos-fill-short-columns/en/p02.webp?v=94e02cba) as duas colunas terminam uma linha antes do fim da grade: a da esquerda sob o último parágrafo, a da direita acima da foto do pé. Na segunda, cada foto indica a parte do enquadramento que precisa aparecer sempre, e o motor corta fora dela: na [mesma página](https://postext.dev/cookbook/photos-fill-short-columns/en/p05.webp?v=94e02cba), a marisqueira e o remendador de redes crescem uma linha cada um, e as duas colunas terminam na última linha da grade. Ninguém precisou escolher os cortes.

**Esta receita responde a:**

- Como faço para deixar uma foto ser cortada para encher uma coluna curta sem perder o assunto dela?
- Como deixo as colunas alinhadas no pé e a última página equilibrada (justificação vertical)?
- Como decido onde vai uma figura: no alto da página, sobre as duas colunas, exatamente aqui ou na margem?

## A resposta curta

```js
// script.js, linhas 47–63
// Each photo names the rectangle that must always show, in fractions of the file:
// x and width of its width, y and height of its height. Outside it the engine may crop,
// so the photo can stand taller (sides cut, down to the area's width) or lower (top and
// bottom cut, down to its height) than the file, always as wide as its column.
const SAFE_AREAS = {
  mariscadora: { x: 0.2, y: 0.36, width: 0.34, height: 0.46 }, // Carmen, her rake and basket
  redeira: { x: 0.34, y: 0.14, width: 0.4, height: 0.66 }, // Rosa and the net in her lap
  carpintero: { x: 0.12, y: 0.2, width: 0.64, height: 0.62 }, // Manuel and the whole hull
  faro: { x: 0.34, y: 0.14, width: 0.32, height: 0.56 }, // the tower and the walker below it
  pulpeira: { x: 0.2, y: 0.08, width: 0.56, height: 0.82 }, // Lucía, the octopus, the cauldron
};
// Columns end flush on this grid by whole lines. A heading or a photo that does not fit a
// column's foot leaves lines empty there; these settings forbid the usual fixes (space above
// a heading, under a photo, or a paragraph run long), so only a photo with a safe area can
// take them, by growing a line at a time. Without safe areas the short columns stay short.
const balancing = { enabled: true, maxLinesPerHeading: 0, stretchAfterLists: false,
  stretchAfterFloats: false, looseParagraphs: false };
```

## Ingredientes

**Ensina**

- [Área segura da imagem](https://postext.dev/pt/docs/document-format.md#área-segura): A parte de uma imagem que sempre aparece; a diagramação pode cortar o que fica fora dela para deixar a figura mais alta ou mais baixa, preencher uma coluna curta ou manter uma imagem no texto junto do seu texto.
- [Equilíbrio de colunas](https://postext.dev/pt/docs/configuration.md#equilíbrio-de-colunas): Faz cada coluna terminar rente ao pé da página ajustando, nesta ordem, o espaço em torno de títulos, listas e flutuantes, depois a folga dos parágrafos e o espaçamento entre letras; também nivela a última página de cada capítulo.
- [Posicionamento de figuras](https://postext.dev/pt/docs/document-format.md#posicionamento): No alto, embaixo, automático ou aqui, em uma coluna ou na largura da página, em uma fração da largura, por recurso ou por tipo; os flutuantes de uma mesma sequência nunca ultrapassam uns aos outros.

**Também usa**

- [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)
- [Figuras exatamente aqui](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Estilo de legenda](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda)
- [Tipos de recurso personalizados](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Aberturas desenhadas](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)
- [Uma ou duas colunas](https://postext.dev/pt/docs/configuration.md#tipos-de-layout)
- [Margens espelhadas](https://postext.dev/pt/docs/configuration.md#margens-espelhadas)
- [Cabeços e fólios](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Cabeços por tipo de página](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)
- [Paleta de cores semântica](https://postext.dev/pt/docs/configuration.md#paleta-de-cores)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Páginas em um canvas](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)
- [Exportação para PDF](https://postext.dev/pt/docs/configuration.md#geração-de-pdf)
- [Figura e Tabela no seu idioma](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Faixa de capítulo em largura total](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Fontes incorporadas ao PDF](https://postext.dev/pt/docs/configuration.md#por-que-um-provedor-de-fontes)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration.md#texto-do-corpo), [`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), [`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)

**API**

- [`buildDocument`](https://postext.dev/pt/docs/configuration.md#compilar-um-documento), [`clearMeasurementCache`](https://postext.dev/pt/docs/configuration.md#cache-de-medidas), [`decompressWoff2`](https://postext.dev/pt/docs/configuration.md#provedor-de-fontes-no-navegador-fontsource--woff2), [`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), [`renderToPdf`](https://postext.dev/pt/docs/configuration.md#geração-de-pdf)

**Tipos**

- Newsreader (OFL-1.1), Archivo (OFL-1.1), Archivo Narrow (OFL-1.1)

## Preparo

### 1 · Marque o que precisa ficar, e nada mais

O código é [a resposta curta](https://postext.dev/pt/cookbook/photos-fill-short-columns.md#a-resposta-curta) logo acima. Uma área segura são quatro frações da imagem, medidas a partir do canto superior esquerdo. O motor pode mostrar a foto em qualquer formato entre o arquivo inteiro e esse retângulo, sempre na largura da coluna: mais alta cortando as laterais, mais baixa cortando em cima e embaixo. O que fica fora da área é cortado em proporção às margens de cada lado, então Rosa, à direita do centro no cais, continua à direita do centro quando as laterais são cortadas.

### 2 · Deixe o equilíbrio compensar a sobra só com as fotos

A mesma região configura o equilíbrio. Os recursos de costume dele acrescentam branco: uma linha acima de um título, ar embaixo de uma foto, um parágrafo com uma linha a mais. Aqui eles estão desligados, para que a composição simples mostre cada coluna curta como ela fica. Uma foto com área segura é testada primeiro de qualquer forma, antes de qualquer um desses recursos, porque uma foto mais alta não abre buraco no texto.

### 3 · A área segura pertence ao recurso

```js
// script.js, linhas 211–245
const FILES = { estuario: [1700, 1133] }; // px, declared as they are; the rest 1600 × 1067
// Caption and alt text of each photo, one block per photo: content.figures.<lang>.md.
const figureTexts = String.raw`mariscadora
Carmen Lago sorts cockles on her plot of the estuary at low water.
A woman in green boots kneels on wet sand beside a rake and a mesh basket.

redeira
Rosa Doval mends a purse-seine panel on the quay.
A woman on a stool sews a green net spread around her; fishing boats behind.

carpintero
Manuel Barreiro planes the planking of the gamela.
An old man planes the side of a half-built wooden boat inside a shed.

faro
Arnela point: the lighthouse and the coastal path.
A white lighthouse on granite rocks; a walker with a red backpack below it.

pulpeira
Lucía Fraga lifts an octopus out of the copper cauldron.
A woman in an apron lifts a boiled octopus from a steaming copper pot.
`;
const CAPTIONS = Object.fromEntries(figureTexts.trim().split(/\n\s*\n/)
  .map((block) => block.split('\n').map((line) => line.trim()))
  .map(([id, caption, alt]) => [id, [caption, alt]]));
const PLACEMENT = { pulpeira: { position: 'here' } }; // the rest float to the first free slot
const photo = (id, safe) => {
  const [w, h] = FILES[id] ?? [1600, 1067];
  const [caption, alt] = CAPTIONS[id] ?? [];
  return { id, typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h },
    ...(caption && { caption, altText: alt, placement: PLACEMENT[id] ?? { position: 'auto' } }),
    ...(safe && SAFE_AREAS[id] && { safeArea: SAFE_AREAS[id] }) };
};
const resources = (safe) => ['estuario', ...Object.keys(CAPTIONS)].map((id) => photo(id, safe));
```

`safeArea` fica no recurso, ao lado do arquivo, da legenda e do posicionamento, então a foto a mantém onde quer que flutue e em qualquer documento que a use. O pen a passa ou a omite, e essa é a única diferença entre as duas composições. O estuário da abertura é desenhado pelo estilo de título, não flutua, então não precisa de área segura.

### 4 · Componha as duas e compare

```js
// script.js, linhas 256–258
const build = (safe) => buildWithFonts(() =>
  buildDocument({ markdown, resources: resources(safe) }, config()), markdown);
const docs = { plain: await build(false), safe: await build(true) };
```

Os dois documentos saem de uma mesma configuração e de um mesmo texto Markdown. Só as páginas que continuam são preenchidas até o pé: a [página 3](https://postext.dev/cookbook/photos-fill-short-columns/en/p03.webp?v=94e02cba) fecha a reportagem, e numa página de fechamento as colunas só precisam terminar na mesma altura uma da outra, o que aqui já acontece, então nenhuma foto cresce ali. O botão do PDF compõe a versão que estiver na mesa, e o PDF recorta cada foto na moldura do mesmo jeito.

## 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/photos-fill-short-columns

### script.js

```js
// ═══ Postext Cookbook · Nº 116 · Photos that grow to fill a short column ═══════════
// https://postext.dev/en/cookbook/photos-fill-short-columns
// Code: MIT · Text: original (CC BY 4.0) · Photos: diffusion models
// Fonts: Newsreader, Archivo, Archivo Narrow (SIL OFL 1.1) · Needs postext ≥ 1.16.1
//
// A magazine feature set twice. Without safe areas some columns end short; with them the
// engine crops each photo outside its area to set it taller, and the photos fill those lines.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('es' | 'en')
const RECIPE = 'photos-fill-short-columns';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: six named colours, the accents taken from the photos: sea slate, net green
const palette = {
  ink: '#1d2326', // text: a cold near-black
  sea: '#2f5d73', // the accent: kicker, caption labels, folios, references
  net: '#3f6b55', // the second colour: the opener's rule
  rule: '#c9d1d3', // hairlines
  muted: '#5f6a6e', // running heads, credits, the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': pointed at the accent, no default blue shows.
  { id: 'main-color', name: 'sea (defaults)', value: { hex: palette.sea, model: 'hex' } },
];
// #endregion
const [TEXT, DISPLAY, LABEL] = ['Newsreader', 'Archivo', 'Archivo Narrow'];
const LEAD = 13.6; // body leading in pt: the grid the photos grow by
const [PAGE_W, PAGE_H, TOP, BOTTOM, INNER, OUTER, GUTTER] = [225, 297, 22, 22, 18, 16, 7]; // mm
const PHOTO_H = 150; // the opener's bleed photo: 225 × 150 mm, the shape of the file
const at = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(size && { size }) });
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  align: 'left', overflow: 'wrap', ...extra }); // wrap, not '…' (gotcha: overflow-ellipsis-default)
const caps = (size) => ({ fontWeight: 700, textTransform: 'uppercase',
  letterSpacing: pt(size * 0.18) });

// #region answer: a safe area per photo, and balancing that may only grow pictures
// Each photo names the rectangle that must always show, in fractions of the file:
// x and width of its width, y and height of its height. Outside it the engine may crop,
// so the photo can stand taller (sides cut, down to the area's width) or lower (top and
// bottom cut, down to its height) than the file, always as wide as its column.
const SAFE_AREAS = {
  mariscadora: { x: 0.2, y: 0.36, width: 0.34, height: 0.46 }, // Carmen, her rake and basket
  redeira: { x: 0.34, y: 0.14, width: 0.4, height: 0.66 }, // Rosa and the net in her lap
  carpintero: { x: 0.12, y: 0.2, width: 0.64, height: 0.62 }, // Manuel and the whole hull
  faro: { x: 0.34, y: 0.14, width: 0.32, height: 0.56 }, // the tower and the walker below it
  pulpeira: { x: 0.2, y: 0.08, width: 0.56, height: 0.82 }, // Lucía, the octopus, the cauldron
};
// Columns end flush on this grid by whole lines. A heading or a photo that does not fit a
// column's foot leaves lines empty there; these settings forbid the usual fixes (space above
// a heading, under a photo, or a paragraph run long), so only a photo with a safe area can
// take them, by growing a line at a time. Without safe areas the short columns stay short.
const balancing = { enabled: true, maxLinesPerHeading: 0, stretchAfterLists: false,
  stretchAfterFloats: false, looseParagraphs: false };
// #endregion

// #region opener: the estuary across the head of the page, then kicker, title and standfirst
const opener = {
  enabled: true,
  // Images reserve no height (gotcha: opener-image-no-reserve): minHeight keeps the text
  // under the photo, the kicker, the title and the standfirst.
  minHeight: mm(188),
  slot: { elements: [
    { kind: 'image', id: 'photo', resourceId: 'estuario', // bleeds off the top and both sides
      placement: at('page', 'top-left', 0, 0, { width: mm(PAGE_W), height: mm(PHOTO_H) }) },
    text('kicker', '{attr.kicker}', LABEL, 8.5, 'sea', at('container', 'top-left', 0,
      PHOTO_H - TOP + 9), caps(8.5)),
    text('title', '{titleText}', DISPLAY, 34, 'ink', at('#kicker', 'below', 0, 2.5,
      { width: mm(150), height: 'auto' }), { fontWeight: 800, lineHeight: 1.04 }),
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(2), color: col('net'),
      placement: at('#title', 'below', 0, 4, { width: mm(16) }) },
    text('lead', '{attr.lead}', TEXT, 11.2, 'ink', at('#rule', 'below', 0, 4,
      { width: mm(150), height: 'auto' }), { italic: true, lineHeight: 1.36, hyphenate: true }),
  ] },
};
// #endregion

// #region furniture: magazine and issue on the verso, the feature's title on the recto
const HEAD_Y = 12; // mm from the top edge
const head = (id, content, parity, edge, x, extra) => text(id, content, LABEL, 7.6, 'muted',
  at('page', edge, x, HEAD_Y), { ...caps(7.6), fontWeight: 600, parity, pages: 'body',
    overflow: 'ellipsis', ...extra });
const folio = (id, parity, edge, x, extra) => text(id, '{pageNumber}', DISPLAY, 8.5, 'sea',
  at('page', edge, x, HEAD_Y), { fontWeight: 800, parity, pages: 'body', ...extra });
const header = { elements: [
  folio('verso-folio', 'even', 'top-left', OUTER),
  head('verso-title', '{title} · {subtitle}', 'even', 'top-left', OUTER + 8),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8), { align: 'right' }),
  folio('recto-folio', 'odd', 'top-right', -OUTER, { align: 'right' }),
] };
const footer = { elements: [] }; // the opener carries no folio: its photo bleeds off the head
// #endregion

const types = () => defaultResourceTypes(LANG).map((type) => ({ ...type,
  numberingTemplate: '{n}', resetOn: 'never' })); // 'Figura 3', not '1.3', in a one-article issue

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-gb', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  resourceTypes: types(), // "Figura" in Spanish (gotcha: resource-types-locale)
  colorPalette,
  page: { width: mm(PAGE_W), height: mm(PAGE_H), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
      mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(9.8), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('sea'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: {
    fontFamily: DISPLAY, fontWeight: 800, color: col('ink'),
    balancing,
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, fontSize: pt(34), span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
      { level: 2, fontSize: pt(12), lineHeight: pt(LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) }, // one grid line above, none below
    ],
  },
  captionStyle: { fontFamily: LABEL, fontSize: pt(8), color: col('ink'), gap: mm(2),
    labelBold: true, labelColor: col('sea'), descriptionItalic: false,
    note: { color: col('muted') } },
  paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.2), lineHeight: pt(10),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Tides"
subtitle: "A coastal magazine · Issue 14"
---

# People of the low tide {kicker="Working the sea · Arnela estuary" lead="Twice a day the water drains out of the estuary and leaves a kilometre of sand behind. That is when the shellfish gatherers work; on the quay, the net menders sew up what the sea tore the night before. We spent a week in October with five trades that keep the tide's time."}

Arnela has fourteen hundred people, a harbour with eleven inshore boats and an estuary that all but empties at low water. From the coast road you can see the whole village: white houses packed onto the hillside, the church at the top and, at the bottom, the fish market under its corrugated roof. What you cannot see from up there is the timetable. Nobody in Arnela arranges to meet at five or at six; they meet "at half tide" or "when it's out", and the tide table the fishermen's guild pins up each month beside the market door counts for more than any clock.

This week low water falls at a quarter past seven in the morning and half past seven in the evening. An hour before, the sand is already full of people.

## On foot at low water

Carmen Lago started gathering shellfish at fourteen, alongside her mother, and she is sixty-one now. She works with a long-handled rake, a bucket and a mesh basket, always on the same plot of the estuary, the one the guild assigns each season to her group of nine women (:ref{id="mariscadora" case="lower"}). She drags the rake against the current, crouches, sorts out by hand the cockles that fall short of the minimum size and puts them back in the hole they came from.

"The small ones stay," she says. "Take them today and there's nothing in March."

Each gatherer may take a fixed daily quota per species, which the guild reviews according to the state of the beds. In October Carmen's is three kilos of carpet-shell clams and eight of cockles. Anything over the quota goes back into the sand. At nine, as the water starts to cover the first pools, the group climbs the slope to the market with their buckets. There the catch is washed, weighed gatherer by gatherer and graded by size before the auction at eleven.

The work does not end on the sand. Two days a week the group "sows": they move young clams from the patches where they crowd together to the ones that have emptied, and clear the sand of weed and of the starfish that eat the young. Nobody is paid for those hours. "It's like watering the vegetable patch," says Carmen, who still remembers the winters when there was nothing to take.

## The eleven o'clock auction

Arnela's fish market is a long shed with a concrete floor that is never dry. By a quarter to eleven the morning's crates are lined up on the belt: the gatherers' shellfish, sorted by species and size, and the inshore boats' fish, line-caught hake, horse mackerel, the odd turbot. Each crate carries a label with its weight, its zone and the name of whoever brought it in.

The auction runs downwards. An electronic board starts at a high price and drops ten cents at a time; the first buyer to press a handset takes the crate at that price. Three wholesalers from the district buy here, two restaurants in the village and a processing plant that packs clams for the whole province. In October a kilo of carpet-shell clams fetches around twenty-six euros and a kilo of cockles about seven. On a good morning two tonnes are sold in forty minutes.

Marisa Couto has kept the market's books for nineteen years. She notes every sale on a sheet, with the buyer and the price, and at the end of the month pays out each gatherer's and each boat's share, less the guild's commission. "We used to shout," she remembers. "Now all you hear is the board beeping, and by one o'clock the floor's been hosed down."

## The net menders on the quay

At the end of the quay, beside the market's stacked crates, Rosa Doval works on a low stool with a green net spread around her like a rug (:ref{id="redeira" case="lower"}). The purse seiners came in before dawn with sardines and with three torn panels; a rock split one of them, and the hole is big enough for a person to climb through.

Rosa sews with a plastic needle as long as her hand, loaded with twine, and a small wooden gauge that sets the size of each mesh. She trims the frayed edges back to whole meshes, counts the ones missing and knots the patch in mesh by mesh. A large hole takes her a morning. A new panel, two to three hundred metres of net, takes four women most of a month.

Seven net menders are left in Arnela. They organised twelve years ago, when the boat owners began sending their nets to workshops inland, and now they are paid by the hour rather than by the patch. The youngest is twenty-eight and learned on a course run by the guild; the oldest is eighty-one and only sews on fine days. Rosa prefers the quay to the shed the council lent them: "With the net in the sun you see the holes better, and I hear what every boat has brought in."

## The last boatyard on the shore

At the head of the cove, where the coastal path turns into a dirt track, a timber shed with its doors open smells of resin and fresh-cut pine. It is Manuel Barreiro's boatyard, the last of the five the estuary once had. Manuel is seventy-four and works with his nephew on a six-metre gamela, a flat-bottomed boat once used for gathering shellfish afloat (:ref{id="carpintero" case="lower"}).

The hull has been on the slipway since August. First comes the keel, then the oak frames, bent over a fire, and last the pine planks of the skin, fixed one by one with copper nails. Manuel works without drawings. He takes the shapes from plywood templates he inherited from his father, which hang on the back wall, each with the name of the boat it was made for pencilled on it.

In the eighties the yard launched fifteen boats a year. Now it builds two or three, nearly always for rowing clubs or for families who want their grandfather's boat back. This gamela was ordered by the residents' association, which wants to race it in the summer regattas in the village colours. Manuel reckons it will be in the water by April, if the winter lets him work with the doors open.

## The lighthouse and the path

From the boatyard the path climbs through gorse to Arnela point, where a white lighthouse with a red lantern marks the mouth of the estuary (:ref{id="faro" case="lower"}). It was automated in 1993 and nobody has lived in the keeper's house since, but Xosé Pazos, who looked after it for twenty-two years, still walks up twice a week to check the batteries and note the wind and the state of the sea in an exercise book.

The trail that leads there is part of an eleven-kilometre route around the whole estuary, beach to beach, which the district waymarked three years ago with wooden posts and yellow arrows. In October few walkers pass: a group of pensioners, couples with rucksacks and, at weekends, runners heading down to the harbour. If they ask, Pazos shows them the way down to Cantal cove without slipping on the wet rock.

In bad weather the waves break on the point and run up the rocks to the foot of the tower. Pazos keeps a photograph from 1987 in which the water reaches the door. That winter he spent three days unable to get down to the village, with the radio, a box of tins and the exercise book.

## The Sunday cauldron

On Sundays the covered market opens at nine, and by ten there is a queue at Lucía Fraga's stall. Lucía cooks octopus the way her mother did, in an eighty-litre copper cauldron over a wood fire: she "scares" it by dipping it in the boiling water three times so the skin stays on, lets it cook for about twenty minutes and lifts it out on a hook when the tip of a tentacle gives under the fork.

::resource{id="pulpeira"}

She cuts it with scissors onto wooden plates, adds coarse salt, sweet and hot paprika in equal parts and a stream of olive oil, and serves it with cachelos, potatoes boiled in the same water. A plate costs twelve euros. On summer Sundays Lucía cooks thirty octopuses; in October, fourteen or fifteen, bought on Friday at the market from the harbour's own pot boats.

By two in the afternoon the tide is low again. On the sand the first buckets are out and, on the quay, a net lies drying in the sun.

:::paragraphs{style="colophon"}
Arnela and its people are imaginary · Set in Newsreader, Archivo and Archivo Narrow (SIL Open Font License) · Text: CC BY 4.0 · Photographs: diffusion models
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region photos: one resource per file; the safe area is the only difference between builds
const FILES = { estuario: [1700, 1133] }; // px, declared as they are; the rest 1600 × 1067
// Caption and alt text of each photo, one block per photo: content.figures.<lang>.md.
const figureTexts = String.raw`mariscadora
Carmen Lago sorts cockles on her plot of the estuary at low water.
A woman in green boots kneels on wet sand beside a rake and a mesh basket.

redeira
Rosa Doval mends a purse-seine panel on the quay.
A woman on a stool sews a green net spread around her; fishing boats behind.

carpintero
Manuel Barreiro planes the planking of the gamela.
An old man planes the side of a half-built wooden boat inside a shed.

faro
Arnela point: the lighthouse and the coastal path.
A white lighthouse on granite rocks; a walker with a red backpack below it.

pulpeira
Lucía Fraga lifts an octopus out of the copper cauldron.
A woman in an apron lifts a boiled octopus from a steaming copper pot.
`;
const CAPTIONS = Object.fromEntries(figureTexts.trim().split(/\n\s*\n/)
  .map((block) => block.split('\n').map((line) => line.trim()))
  .map(([id, caption, alt]) => [id, [caption, alt]]));
const PLACEMENT = { pulpeira: { position: 'here' } }; // the rest float to the first free slot
const photo = (id, safe) => {
  const [w, h] = FILES[id] ?? [1600, 1067];
  const [caption, alt] = CAPTIONS[id] ?? [];
  return { id, typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h },
    ...(caption && { caption, altText: alt, placement: PLACEMENT[id] ?? { position: 'auto' } }),
    ...(safe && SAFE_AREAS[id] && { safeArea: SAFE_AREAS[id] }) };
};
const resources = (safe) => ['estuario', ...Object.keys(CAPTIONS)].map((id) => photo(id, safe));
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first)
  Newsreader: ['400', '400i', '600'], Archivo: ['800'], 'Archivo Narrow': ['600', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await Promise.all(resources(false).map((r) => loadImage(r.bitmap.fileId, asset(r.bitmap.fileId))));
// #region builds: the same text, config and photos, without and then with the safe areas
const build = (safe) => buildWithFonts(() =>
  buildDocument({ markdown, resources: resources(safe) }, config()), markdown);
const docs = { plain: await build(false), safe: await build(true) };
// #endregion
const title = t({ en: 'Photos that grow to fill a short column',
  es: 'Fotos que crecen hasta llenar la columna' });
// Two buttons put either build on the desk; the PDF follows the one shown.
const LABELS = { plain: t({ en: 'Without safe areas', es: 'Sin zona segura' }),
  safe: t({ en: 'With safe areas', es: 'Con zona segura' }) };
const switches = Object.keys(docs).map((key) => Object.assign(document.createElement('button'),
  { type: 'button', value: key, textContent: LABELS[key] }));
const show = (key) => {
  showPages(docs[key], { title });
  for (const b of switches) b.ariaPressed = String(b.value === key);
  document.querySelectorAll('#pt-actions a, [data-postext-pdf]').forEach((old) => old.remove());
  offerPdf(() => renderToPdf(docs[key], { fontProvider: fontsourceProvider,
    resourceBytes: imageBytes }), `${RECIPE}-${key}.pdf`);
};
for (const b of switches) b.addEventListener('click', () => show(b.value));
show('safe');
document.getElementById('pt-actions').prepend(...switches);
document.head.insertAdjacentHTML('beforeend',
  '<style>#pt-actions [aria-pressed=true] { text-decoration: underline }</style>');

// ─── 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 · pdf v2 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType: the nearest weight the
 *  family ships, upright if it has no italic; latin, then what the face's
 *  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 more = kitSubsetsFor(text, meta);
  const files = await Promise.all(['latin', ...more].map(async (subset) => {
    const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
  }));
  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 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

### Mantenha uma foto em linha junto do texto

Uma foto com `position: 'here'` e área segura é cortada em cima e embaixo quando fica um pouco alta demais para o espaço que sobra na coluna, em vez de passar para a coluna seguinte.

```diff
-const PLACEMENT = { pulpeira: { position: 'here' } }; // the rest float to the first free slot
+const PLACEMENT = { pulpeira: { position: 'here' }, faro: { position: 'here' } };
```

### Devolva ao equilíbrio os outros recursos

Com os valores padrão, as fotos continuam vindo primeiro, e os títulos e os flutuantes ficam com o que a área segura de uma foto não consegue cobrir.

```diff
-const balancing = { enabled: true, maxLinesPerHeading: 0, stretchAfterLists: false,
-  stretchAfterFloats: false, looseParagraphs: false };
+const balancing = { enabled: true };
```

## 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.
- **As imagens de uma abertura nunca contam para a altura que ela reserva.** No postext 1.4.1, um título com design avançado mede a altura que reserva sem as imagens: textos, fios e caixas contam, mesmo quando ancorados na página, mas uma imagem, como uma ilustração sangrada no alto da página, não reserva nada, então o texto pode começar por cima dela. Defina com minHeight onde o texto deve começar.
- **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.
- **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.

- Uma área segura que cobre a imagem inteira, ou que tenha um lado menor que 2 % dela, é ignorada: a foto aparece inteira, como se não tivesse área segura.
- Só crescem os flutuantes de coluna e as fotos em linha. Uma foto flutuante na largura das duas colunas mantém o formato, porque crescer empurraria de uma vez todas as colunas embaixo dela.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: The feature, the captions and the colophon, in Spanish and English; Arnela and its people are imaginary: Postext Cookbook, CC-BY-4.0
- Imagens: The estuary at low water at dawn, with shellfish gatherers: Generated With Diffusion Models, original
- Imagens: A shellfish gatherer kneeling on the wet sand: Generated With Diffusion Models, original
- Imagens: A net mender on the quay: Generated With Diffusion Models, original
- Imagens: A shipwright planing a wooden boat in his shed: Generated With Diffusion Models, original
- Imagens: A lighthouse on a granite point and a walker below it: Generated With Diffusion Models, original
- Imagens: A cook lifting an octopus from a copper cauldron at a market: Generated With Diffusion Models, original
- Tipos: Newsreader (OFL-1.1), Archivo (OFL-1.1), Archivo Narrow (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 009 · Figuras que flutuam até onde você as cita](https://postext.dev/pt/cookbook/figures-float-where-cited.md): Capítulo em duas colunas com sete figuras numeradas: seis flutuam do primeiro :ref até o primeiro espaço que a posição permite; uma fica onde ::resource a põe. · Nível 3 (Avançado) · Livros didáticos
- [Nº 135 · Reportagem de revista num grid de três colunas](https://postext.dev/pt/cookbook/magazine-three-column-feature.md): layoutType 'multiple' divide a página em três colunas; placement.columns põe fotos e uma citação flutuante sobre duas delas, ou sobre as três. · Nível 3 (Avançado) · Revistas e fanzines
- [Nº 051 · Zine riso em duas cores: a arte numa cor especial](https://postext.dev/pt/cookbook/riso-zine-single-ink.md): Um zine de quatro páginas para risografia de dois tambores: os desenhos coloridos saem em tons de rosa, recoloridos pelo pen no canvas e por renderToPdf no PDF. · Nível 3 (Avançado) · Revistas e fanzines
