# Um só original, edições impressa e de tela

> As duas edições saem de uma configuração: htmlViewer.overrides guarda o design escuro de tela e applyHtmlViewerOverrides o mescla antes de cada composição HTML.

- Versão HTML: https://postext.dev/pt/cookbook/print-and-screen-editions
- Receita Nº 011 · Saída e integração · Nível 3 (Avançado) · Saídas: Canvas, HTML, Controles ao vivo
- Gêneros: Revistas e fanzines
- Requer postext ≥ 1.4.1 · testada com 1.19.1 em 2026-10-06
- Páginas: [1](https://postext.dev/cookbook/print-and-screen-editions/en/p01.webp?v=59e5e740), [2](https://postext.dev/cookbook/print-and-screen-editions/en/p02.webp?v=59e5e740), [3](https://postext.dev/cookbook/print-and-screen-editions/en/p03.webp?v=59e5e740), [4](https://postext.dev/cookbook/print-and-screen-editions/en/p04.webp?v=59e5e740)
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=print-and-screen-editions&lang=en (.postext: https://postext.dev/cookbook/print-and-screen-editions/en/print-and-screen-editions.postext)
- Última atualização: 2026-09-26
- Outros idiomas: [en](https://postext.dev/en/cookbook/print-and-screen-editions.md), [es](https://postext.dev/es/cookbook/print-and-screen-editions.md), [ca](https://postext.dev/ca/cookbook/print-and-screen-editions.md), [zh](https://postext.dev/zh/cookbook/print-and-screen-editions.md), [ja](https://postext.dev/ja/cookbook/print-and-screen-editions.md), [ar](https://postext.dev/ar/cookbook/print-and-screen-editions.md)

## Em poucas palavras

A seção de meteorologia de uma revista, feita duas vezes com o mesmo texto. Mostra como um mesmo conjunto de configurações dá uma versão impressa em colunas e outra escura, numa coluna só, para ler na tela.

## O que você vai compor

*Notes on Weather*, a seção de outono de uma pequena revista, composta duas vezes a partir do mesmo Markdown. No papel, é uma edição de 225 × 297 mm: uma página divisória azul-chuva, três notas que abrem sob uma faixa clara cor de neblina, duas colunas justificadas, desenhos de um pluviômetro e de uma rosa dos ventos e uma fotografia de um vale com neblina. Na tela, é uma única coluna alinhada à esquerda em Newsreader, cinza-claro sobre quase preto, em texto HTML que o leitor pode selecionar e copiar, sem página divisória, faixas de neblina nem fólios. O painel abre no pluviômetro, reduzido à altura do painel e desenhado em cores noturnas, ao lado da página impressa em que ele fica. Role para cima para ver a abertura de Rain, ou arraste o canto do painel, e o texto é diagramado de novo no tamanho novo. O design de tela são nove chaves em `htmlViewer.overrides`, dentro da configuração da edição impressa.

**Esta receita responde a:**

- Como faço para mostrar o mesmo documento num modo de leitura HTML responsivo, com design de tela mais simples?
- Como dou à edição de tela um design mais simples que o da edição impressa, com uma só configuração?

## A resposta curta

```js
// script.js, linhas 68–97
// config() stores it as htmlViewer: { overrides: screenOverrides() }; canvas and PDF ignore it.
const screenOverrides = () => ({
  colorPalette: paletteOf(night), // arrays are replaced whole: the night values
  parts: { page: false }, // no divider page; the part still names the notes after it
  // 96 dpi: the mm and pt inherited from print render at their CSS size.
  page: { dpi: 96, margins: { top: px(40), bottom: px(40), mirror: false } },
  layout: { layoutType: 'single', // one column
    fitFiguresToPage: true }, // tall figures shrink to the pane; off by default, as in print
  bodyText: { fontSize: px(17), lineHeight: px(27), textAlign: 'left', // ragged for reading
    // A paragraph may start on the last line of a screen page. With the rule on, 1.4.1 can force
    // a paragraph taller than the pane whole into a one-line gap under a figure, and off the page.
    avoidWidows: false },
  // Heading levels merge on `level`: the print level keeps everything not restated here.
  headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false },
    marginTop: px(40), marginBottom: px(26),
    advancedDesign: { minHeight: px(0), slot: { elements: screenOpener } } }] }, // no band air
  footer: { elements: [] }, // a scrolling page runs no folios (print's header is empty already)
  captionStyle: { fontSize: px(13), gap: px(10) },
  paragraphStyles: [{ ...colophon, fontSize: px(13), lineHeight: px(20) }], // restated whole
});
// The host owns the page size: the pane's, in CSS pixels (gotcha: viewer-settings-sandbox-only).
// Wide panes get wider margins, so the measure stops at MEASURE.
const MEASURE = 470; // px: about 65 characters of Newsreader at 17 px
const MIN_SIDE = 34; // px: the side margins of a narrow pane
function screenConfig({ width, height }) {
  const merged = applyHtmlViewerOverrides(config()); // print + overrides, a fresh object
  const side = px(Math.max(MIN_SIDE, (width - MEASURE) / 2));
  return relink({ ...merged, page: { ...merged.page, width: px(width), height: px(height),
    margins: { ...merged.page.margins, left: side, right: side } } });
}
```

## Ingredientes

**Ensina**

- [Um projeto mais simples para a tela](https://postext.dev/pt/docs/configuration.md#visualizador-html): htmlViewer.overrides altera a configuração de impressão para a edição de tela e é aplicado com applyHtmlViewerOverrides.
- [Edição de leitura em HTML](https://postext.dev/pt/docs/configuration.md#integração-do-visualizador-html): renderToHtml escreve a mesma diagramação como HTML posicionado: texto selecionável, links nas citações, uma página ou várias lado a lado.

**Também usa**

- [Figuras ajustadas à página](https://postext.dev/pt/docs/configuration.md#diagramação)
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Páginas de abertura de parte](https://postext.dev/pt/docs/configuration.md#o-contêiner-part)
- [Faixa de capítulo em largura total](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Posicionamento de figuras](https://postext.dev/pt/docs/document-format.md#posicionamento)
- [Paleta de cores semântica](https://postext.dev/pt/docs/configuration.md#paleta-de-cores)
- [Cabeços por tipo de página](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)
- [Uma ou duas colunas](https://postext.dev/pt/docs/configuration.md#tipos-de-layout)
- [Cor do papel](https://postext.dev/pt/docs/configuration.md#página)
- [Páginas em um canvas](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format.md#recursos)
- [Figura e Tabela no seu idioma](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Tipos de recurso personalizados](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Citações que posicionam as figuras](https://postext.dev/pt/docs/document-format.md#referência-em-linha-a-forma-principal)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Equilíbrio de colunas](https://postext.dev/pt/docs/configuration.md#equilíbrio-de-colunas)

**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), [`htmlViewer`](https://postext.dev/pt/docs/configuration.md#visualizador-html), [`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), [`parts`](https://postext.dev/pt/docs/configuration.md#partes), [`resourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)

**API**

- [`applyHtmlViewerOverrides`](https://postext.dev/pt/docs/configuration.md#visualizador-html), [`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), [`renderToHtml`](https://postext.dev/pt/docs/configuration.md#integração-do-visualizador-html)

**Tipos**

- Newsreader (OFL-1.1), Gloock (OFL-1.1), Reddit Sans (OFL-1.1)

## Preparo

### 1 · Um conjunto de nomes de cor, dois conjuntos de valores

```js
// script.js, linhas 14–31
const day = { ink: '#1b222b', rain: '#3d6f9e', slate: '#2d3a4a', fog: '#e9edf1',
  rule: '#c8d0d8', muted: '#5f6a76', paper: '#ffffff' };
const night = { ink: '#e6e8eb', rain: '#9cc3e6', slate: '#56657a', fog: '#1b2129',
  rule: '#2e3742', muted: '#98a2ae', paper: '#111418' };
const paletteOf = (values) => // main-color: the engine's defaults follow the rain blue
  Object.entries({ ...values, 'main-color': values.rain })
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const col = (id) => ({ hex: day[id], model: 'hex', paletteId: id }); // the hex: a day fallback
// Workaround (gotcha: palette-skips-designs): 1.4.1 re-reads the palette into the text,
// table and box styles and the page background, but not into design-slot elements or
// bodyText.referenceColor, so rewrite every linked colour from the palette the config carries.
function relink(config) {
  const hex = Object.fromEntries(config.colorPalette.map(({ id, value }) => [id, value.hex]));
  const walk = (v) => (Array.isArray(v) ? v.map(walk) : !v || typeof v !== 'object' ? v
    : Object.hasOwn(hex, v.paletteId) ? { ...v, hex: hex[v.paletteId] }
      : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])));
  return walk(config);
}
```

Toda cor da configuração aponta para um id da paleta, e o hex escrito ao lado é só o valor de reserva diurno. Assim, a edição de tela passa para as cores noturnas trocando um único array. Os [overrides](https://postext.dev/pt/docs/configuration.md#visualizador-html) da [resposta curta](https://postext.dev/pt/cookbook/print-and-screen-editions.md#a-resposta-curta) mesclam objetos chave a chave e os níveis de título por `level`, e substituem inteiro qualquer outro array, inclusive `colorPalette`. `main-color` faz parte da paleta, então os valores padrão do motor que o usam também mudam. No postext 1.4.1, a paleta nova não chega aos elementos de design nem à cor das referências, por isso `relink()` reescreve cada cor vinculada a partir da paleta da configuração mesclada.

### 2 · Aberturas: uma faixa no papel, as palavras na tela

```js
// script.js, linhas 37–64
const text = (id, content, fontFamily, style) => ({ kind: 'text', id, content, fontFamily,
  color: col('ink'), align: 'left', ...style,
  overflow: 'wrap' }); // titles break onto more lines (gotcha: overflow-ellipsis-default)
const kicker = text('kicker', '{partTitle} · {attr.kicker}', 'Reddit Sans',
  { fontWeight: 600, textTransform: 'uppercase', color: col('rain') });
const title = text('title', '{titleText}', 'Gloock');
const lead = text('lead', '{attr.lead}', 'Newsreader', { italic: true, hyphenate: true });
const at = (to, edge, y, x = px(0)) => ({ anchor: { to, edge }, offset: { x, y } });
const under = (id, y, width) => ({ ...at(`#${id}`, 'below', y), size: { width } });
const band = (y, height) => ({ ...at('bleed', 'top-left', y), size: { width: 'fill', height } });
const fromTop = (y, x) => at('container', 'top-left', y, x);
// minHeight sets the reservation: the band's foot below the top margin, plus AIR. The band hangs
// from the bleed and counts too, but ends higher (gotcha: opener-reserves-anchored).
const printOpener = { enabled: true, minHeight: mm(BAND - TOP + AIR), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('fog') },
    placement: band(mm(0), mm(BAND)) },
  { kind: 'rule', id: 'horizon', direction: 'horizontal', thickness: pt(2), color: col('rain'),
    placement: band(mm(BAND - 0.7), pt(2)) }, // 2 pt is 0.7 mm: the rule ends at the band's foot
  { ...kicker, fontSize: pt(8), letterSpacing: pt(1.8), placement: fromTop(mm(12)) },
  { ...title, fontSize: pt(54), lineHeight: 1.04, placement: under('kicker', mm(2.5), mm(150)) },
  { ...lead, fontSize: pt(11.5), lineHeight: 1.36, placement: under('title', mm(4), mm(118)) },
] } };
const screenOpener = [
  { ...kicker, fontSize: px(12), letterSpacing: px(2.6), placement: fromTop(px(4)) },
  { ...title, fontSize: px(48), lineHeight: 1.05, placement: under('kicker', px(6), 'fill') },
  { ...lead, fontSize: px(18), lineHeight: 1.45, color: col('muted'),
    placement: under('title', px(10), 'fill') },
];
```

Os elementos de antetítulo, título e olho são definidos uma vez e espalhados nos dois designs, então as duas edições mostram os mesmos atributos do título: `{attr.kicker}`, `{attr.lead}` e o `{partTitle}` da parte. No papel, um título de nível 1 quebra para uma página nova e ocupa as duas colunas, e o seu design pendura uma faixa cor de neblina a partir da sangria. `minHeight` reserva a altura da faixa abaixo da margem superior, mais `AIR`, os 9 mm entre a faixa e a primeira linha. Os títulos impressos têm 54 pt porque cada um é uma palavra curta e o único texto grande da sua página. Na tela, o mesmo nível vira um título na coluna, sem faixa e sem ar. O override se mescla por `level`, então toda configuração do título que ele não repete fica como na edição impressa.

### 3 · Uma página divisória que só existe no papel

```js
// script.js, linhas 101–117
const onField = { color: col('paper'), lineHeight: 1 };
const parts = {
  margins: { top: mm(212), left: mm(24), right: mm(40) }, // the fence's list sits low
  bodyStyle: { fontSize: pt(13), lineHeight: pt(19), color: col('paper'),
    numberColor: col('paper') },
  design: { elements: [
    { kind: 'box', id: 'field', style: { backgroundColor: col('rain') },
      placement: band(mm(0), 'fill') },
    { kind: 'image', id: 'strokes', resourceId: 'rain', placement: band(mm(0), 'fill') },
    text('series', '{title}', 'Reddit Sans', { ...onField, fontWeight: 600, fontSize: pt(9),
      letterSpacing: pt(2.4), textTransform: 'uppercase', placement: fromTop(mm(30), mm(24)) }),
    text('numeral', '{number}', 'Gloock', { ...onField, fontSize: pt(190),
      placement: under('series', mm(10), mm(150)) }),
    text('name', '{titleText}', 'Gloock', { ...onField, fontSize: pt(60),
      placement: under('numeral', mm(-6), mm(150)) }),
  ] },
};
```

Na edição impressa, `:::part` abre [a página divisória azul-chuva](https://postext.dev/cookbook/print-and-screen-editions/en/p01.webp?v=59e5e740): uma caixa sangrada em todas as bordas, um elemento de imagem com traços de chuva que deixam o texto livre, o numeral e o título vindos do bloco e a lista de notas em branco. Os overrides de tela definem `parts.page: false`, então nenhuma página é aberta e a lista não é composta. A [parte](https://postext.dev/pt/docs/configuration.md#partes) continua aplicando o seu número e o seu título às notas seguintes, e o antetítulo de tela diz “Autumn · Note 1”, como o impresso.

### 4 · Desenhe cada figura duas vezes

```js
// script.js, linhas 215–248
// The valley is a photograph, one JPEG in assets/ that both editions share, at its pixels.
const photo = { kind: 'bitmap', svg: undefined,
  bitmap: { fileId: 'valley-1610.jpg', format: 'jpeg', width: 1610, height: 690 } };
const figure = (id, edition, [width, height], placement, caption, alt) => ({ id,
  typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, placement,
  svg: { fileId: `${id}-${edition}.svg`, width, height },
  caption: caption && t(caption), altText: alt && t(alt) }); // the HTML edition's <img alt>
const figures = (edition) => [
  figure('gauge', edition, [900, 1260], { position: 'auto', span: 'column' }, {
    en: 'The rain gauge in section: the funnel feeds a tube with a tenth of its area.',
    es: 'El pluviómetro en sección: el embudo vierte en un tubo con la décima parte de su área.',
  }, {
    en: 'Rain falls on a funnel set in a can sunk in the lawn; the funnel drains into a narrow '
      + 'tube, half full, beside a graduated measuring stick.',
    es: 'La lluvia cae en un embudo sobre un vaso hundido en el césped; el embudo vierte en un '
      + 'tubo estrecho, medio lleno, junto a una regla graduada.' }),
  { ...figure('valley', edition, [1610, 690], { position: 'bottom', span: 'page' }, {
    en: 'Radiation fog at dawn: cold air drains off the hills overnight and fills the valley.',
    es: 'Niebla de irradiación al amanecer: el aire frío baja de las lomas y llena el valle.',
  }, {
    en: 'A valley between wooded hills lies under layers of fog at sunrise, with a church '
      + 'tower, trees and a river showing through it, seen from a fenced bank.',
    es: 'Un valle entre lomas boscosas yace bajo la niebla al amanecer; asoman la torre de '
      + 'una iglesia, árboles y un río, vistos desde un ribazo con una cerca.' }), ...photo },
  figure('rose', edition, [900, 900], { position: 'top', span: 'column' }, {
    en: 'Where a year of morning winds came from at the garden station: west and south-west.',
    es: 'De dónde vino el viento de un año de mañanas en el jardín: del oeste y del suroeste.',
  }, {
    en: 'A wind rose of sixteen petals on three rings; the longest petals point west and '
      + 'south-west, the shortest east.',
    es: 'Una rosa de los vientos de dieciséis pétalos sobre tres anillos; los más largos '
      + 'apuntan al oeste y al suroeste, los más cortos al este.' }),
  figure('rain', 'field', [2250, 2970]), // drawn by the part design, never cited: unnumbered
];
```

Trocar a paleta não recolore um SVG, então cada desenho é feito uma vez por paleta. O vale é uma fotografia, um único JPEG que as duas edições compartilham. `figures(edition)` monta os mesmos recursos para as duas edições, cada um apontando para o arquivo da sua edição: `gauge-day.svg` no impresso, `gauge-night.svg` na tela. Ids, legendas, textos alternativos e posicionamentos não mudam, então cada `:ref` do Markdown leva à mesma figura, com o mesmo número, nas duas edições. Na edição de tela, o texto alternativo vai em cada `<img alt>`, onde um leitor de tela o encontra.

### 5 · Hospede a edição de tela

```js
// script.js, linhas 367–397
const FOLDED = 200; // px: a pane narrower or shorter than this is hidden or squeezed; skip it
if (!pane.clientHeight) { // no style.css: a height, and a corner to drag the pane smaller, but
  // not under 400 px, where 1.4.1 sets text over the opener (gotcha: opener-taller-than-column)
  pane.style.cssText = 'height:580px;min-height:400px;min-width:240px;overflow:auto;resize:both';
}
pane.style.background = night.paper; // the pane's own ground, beside the pages and the scrollbar
const shadow = pane.attachShadow({ mode: 'open' }); // the page's selectors cannot reach in, but
// inherited text properties (letter-spacing, text-transform…) can, and the lines were measured
// without them: `all: initial` on the wrapper stops them at the edition's edge.
const inShadow = `<style>:host>div{all:initial;display:block}`
  + `::selection{background:${night.rain}55}</style>`; // selected text takes the night blue
let size = '';
function showScreen() {
  const [width, height] = [pane.clientWidth, pane.clientHeight];
  if (`${width}×${height}` === size || width < FOLDED || height < FOLDED) return; // same, or folded
  const first = !size; // the first build opens on Figure 1's page, like the print proof
  size = `${width}×${height}`;
  const screenDoc = buildDocument({ markdown, resources: figures('night') },
    screenConfig({ width, height }));
  const place = pane.scrollTop / pane.scrollHeight; // the reader's place, kept across rebuilds
  shadow.innerHTML = `${inShadow}<div>${renderToHtml(screenDoc,
    { mode: 'single', padding: 0, resourceImageUrl: imageUrl })}</div>`;
  const fig = first && screenDoc.pages.find((page) => holds(page, 'gauge')); // 'single' mode
  pane.scrollTop = fig ? fig.index * height : place * pane.scrollHeight; // stacks pane-tall pages
  screenLabel.textContent =
    `${t({ en: 'Screen', es: 'Pantalla' })} · HTML · ${width} × ${height} px`;
}
showScreen();
let timer = 0; // debounced; its first call, on observe(), finds the size unchanged
new ResizeObserver(() => { clearTimeout(timer); timer = setTimeout(showScreen, 150); })
  .observe(pane);
```

Cada página de tela tem a altura do painel, então `fitFiguresToPage` reduz o pluviômetro até ele caber numa página com a legenda ([layout](https://postext.dev/pt/docs/configuration.md#diagramação)). A 96 dpi, a resolução que o CSS pressupõe, os milímetros e pontos herdados do impresso aparecem no tamanho nominal ([integrar o visualizador HTML](https://postext.dev/pt/docs/configuration.md#integração-do-visualizador-html)). O pen escreve as linhas com posição absoluta de `renderToHtml` dentro de um Shadow DOM, onde os seletores da página não as alcançam. O `all: initial` do contêiner também bloqueia propriedades herdadas como `letter-spacing`: o motor mediu as linhas sem elas, e um valor herdado faria o texto se sobrepor. As páginas HTML são transparentes, a menos que alguém as pinte, então `page.backgroundColor` tira da paleta o papel noturno. O ResizeObserver espera 150 ms e só recompõe, a partir de uma `config()` nova, quando a largura ou a altura do painel mudou; depois da recomposição, o painel volta a rolar até a mesma fração da sua altura.

## A receita completa

Os arquivos abaixo são compostos a partir da pasta da receita, com o texto de exemplo e o kit comum das Receitas já incluídos. Para executá-los como uma única página, coloque o HTML em `<body>`, o CSS em um elemento `<style>` e o script em um `<script type="module">`; ou cole cada um no painel correspondente de um pen novo do CodePen (o JS como módulo). O script 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/print-and-screen-editions

### index.html

```html
<section id="editions">
  <figure class="edition"><canvas id="proof" role="img"></canvas><figcaption></figcaption></figure>
  <figure class="edition screen"><div id="screen" role="region" tabindex="0"></div><figcaption></figcaption></figure>
</section>
<main id="pages"></main>
```

### style.css

```css
/* The reading room: the printed page and the screen edition side by side, on a fog-grey desk. */
#editions {
  display: flex; justify-content: center; align-items: center; gap: 32px;
  box-sizing: border-box; min-height: min(75vw, 100vh); padding: 28px;
  background: #dde3e9;
}
.edition { margin: 0; display: flex; flex-direction: column; gap: 14px; }
.edition figcaption {
  font: 600 11px/1 system-ui, sans-serif; letter-spacing: .16em; text-transform: uppercase;
  color: #4f5a66;
}
#proof {
  aspect-ratio: 225 / 297;
  display: block; height: min(580px, calc(100vh - 188px)); background: #fff;
  box-shadow: 0 1px 2px rgb(20 30 40 / .25), 0 20px 40px -18px rgb(20 30 40 / .5);
}
.screen { flex: 0 1 580px; min-width: 0; }
/* The pane the edition fills: its size is the page size, so resizing it re-lays the text.
   Drag its corner to try. Its background is the night paper, set by the script. At least
   400 px tall: in a shorter pane postext 1.4.1 sets the text over the opener
   (gotcha: opener-taller-than-column). */
#screen {
  height: min(580px, calc(100vh - 188px)); overflow-y: auto; scrollbar-gutter: stable;
  resize: both; min-width: 240px; min-height: 400px; max-width: 100%;
  border-radius: 10px; box-shadow: 0 0 0 7px #1c2129, 0 26px 48px -20px rgb(10 14 20 / .7);
  scrollbar-width: thin; scrollbar-color: #2e3742 transparent;
}
@media (max-width: 760px) {
  #editions { flex-direction: column; min-height: 0; padding: 24px 16px; }
  #proof { height: auto; width: min(420px, 100%); }
  .screen { flex: none; width: 100%; }
  #screen { height: 72vh; }
}
```

### script.js

```js
// ═══ Postext Cookbook · Nº 011 · One source, print and screen editions ═══════════
// https://postext.dev/en/cookbook/print-and-screen-editions
// Code: MIT · Text: original (CC BY 4.0) · Drawings: code (CC BY 4.0) · Photo: diffusion models
// Fonts: Newsreader, Gloock, Reddit Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, renderToHtml, applyHtmlViewerOverrides,
  clearMeasurementCache, registerResourceImage, defaultResourceTypes,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'print-and-screen-editions';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one set of colour ids, two sets of values: day for paper, night for screens
const day = { ink: '#1b222b', rain: '#3d6f9e', slate: '#2d3a4a', fog: '#e9edf1',
  rule: '#c8d0d8', muted: '#5f6a76', paper: '#ffffff' };
const night = { ink: '#e6e8eb', rain: '#9cc3e6', slate: '#56657a', fog: '#1b2129',
  rule: '#2e3742', muted: '#98a2ae', paper: '#111418' };
const paletteOf = (values) => // main-color: the engine's defaults follow the rain blue
  Object.entries({ ...values, 'main-color': values.rain })
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const col = (id) => ({ hex: day[id], model: 'hex', paletteId: id }); // the hex: a day fallback
// Workaround (gotcha: palette-skips-designs): 1.4.1 re-reads the palette into the text,
// table and box styles and the page background, but not into design-slot elements or
// bodyText.referenceColor, so rewrite every linked colour from the palette the config carries.
function relink(config) {
  const hex = Object.fromEntries(config.colorPalette.map(({ id, value }) => [id, value.hex]));
  const walk = (v) => (Array.isArray(v) ? v.map(walk) : !v || typeof v !== 'object' ? v
    : Object.hasOwn(hex, v.paletteId) ? { ...v, hex: hex[v.paletteId] }
      : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])));
  return walk(config);
}
// #endregion
const px = (value) => ({ value, unit: 'px' }); // screen sizes, written as CSS pixels
const [TOP, BAND, AIR] = [22, 86, 9]; // mm: top margin, the fog band's depth, air under it

// #region opener: print opens each note under a fog band; the screen keeps the words only
const text = (id, content, fontFamily, style) => ({ kind: 'text', id, content, fontFamily,
  color: col('ink'), align: 'left', ...style,
  overflow: 'wrap' }); // titles break onto more lines (gotcha: overflow-ellipsis-default)
const kicker = text('kicker', '{partTitle} · {attr.kicker}', 'Reddit Sans',
  { fontWeight: 600, textTransform: 'uppercase', color: col('rain') });
const title = text('title', '{titleText}', 'Gloock');
const lead = text('lead', '{attr.lead}', 'Newsreader', { italic: true, hyphenate: true });
const at = (to, edge, y, x = px(0)) => ({ anchor: { to, edge }, offset: { x, y } });
const under = (id, y, width) => ({ ...at(`#${id}`, 'below', y), size: { width } });
const band = (y, height) => ({ ...at('bleed', 'top-left', y), size: { width: 'fill', height } });
const fromTop = (y, x) => at('container', 'top-left', y, x);
// minHeight sets the reservation: the band's foot below the top margin, plus AIR. The band hangs
// from the bleed and counts too, but ends higher (gotcha: opener-reserves-anchored).
const printOpener = { enabled: true, minHeight: mm(BAND - TOP + AIR), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('fog') },
    placement: band(mm(0), mm(BAND)) },
  { kind: 'rule', id: 'horizon', direction: 'horizontal', thickness: pt(2), color: col('rain'),
    placement: band(mm(BAND - 0.7), pt(2)) }, // 2 pt is 0.7 mm: the rule ends at the band's foot
  { ...kicker, fontSize: pt(8), letterSpacing: pt(1.8), placement: fromTop(mm(12)) },
  { ...title, fontSize: pt(54), lineHeight: 1.04, placement: under('kicker', mm(2.5), mm(150)) },
  { ...lead, fontSize: pt(11.5), lineHeight: 1.36, placement: under('title', mm(4), mm(118)) },
] } };
const screenOpener = [
  { ...kicker, fontSize: px(12), letterSpacing: px(2.6), placement: fromTop(px(4)) },
  { ...title, fontSize: px(48), lineHeight: 1.05, placement: under('kicker', px(6), 'fill') },
  { ...lead, fontSize: px(18), lineHeight: 1.45, color: col('muted'),
    placement: under('title', px(10), 'fill') },
];
// #endregion

// #region answer: the screen edition lives in the same config, as overrides of the print one
// config() stores it as htmlViewer: { overrides: screenOverrides() }; canvas and PDF ignore it.
const screenOverrides = () => ({
  colorPalette: paletteOf(night), // arrays are replaced whole: the night values
  parts: { page: false }, // no divider page; the part still names the notes after it
  // 96 dpi: the mm and pt inherited from print render at their CSS size.
  page: { dpi: 96, margins: { top: px(40), bottom: px(40), mirror: false } },
  layout: { layoutType: 'single', // one column
    fitFiguresToPage: true }, // tall figures shrink to the pane; off by default, as in print
  bodyText: { fontSize: px(17), lineHeight: px(27), textAlign: 'left', // ragged for reading
    // A paragraph may start on the last line of a screen page. With the rule on, 1.4.1 can force
    // a paragraph taller than the pane whole into a one-line gap under a figure, and off the page.
    avoidWidows: false },
  // Heading levels merge on `level`: the print level keeps everything not restated here.
  headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false },
    marginTop: px(40), marginBottom: px(26),
    advancedDesign: { minHeight: px(0), slot: { elements: screenOpener } } }] }, // no band air
  footer: { elements: [] }, // a scrolling page runs no folios (print's header is empty already)
  captionStyle: { fontSize: px(13), gap: px(10) },
  paragraphStyles: [{ ...colophon, fontSize: px(13), lineHeight: px(20) }], // restated whole
});
// The host owns the page size: the pane's, in CSS pixels (gotcha: viewer-settings-sandbox-only).
// Wide panes get wider margins, so the measure stops at MEASURE.
const MEASURE = 470; // px: about 65 characters of Newsreader at 17 px
const MIN_SIDE = 34; // px: the side margins of a narrow pane
function screenConfig({ width, height }) {
  const merged = applyHtmlViewerOverrides(config()); // print + overrides, a fresh object
  const side = px(Math.max(MIN_SIDE, (width - MEASURE) / 2));
  return relink({ ...merged, page: { ...merged.page, width: px(width), height: px(height),
    margins: { ...merged.page.margins, left: side, right: side } } });
}
// #endregion

// #region part: the divider page, a rain field bled off every edge under the part's numeral
const onField = { color: col('paper'), lineHeight: 1 };
const parts = {
  margins: { top: mm(212), left: mm(24), right: mm(40) }, // the fence's list sits low
  bodyStyle: { fontSize: pt(13), lineHeight: pt(19), color: col('paper'),
    numberColor: col('paper') },
  design: { elements: [
    { kind: 'box', id: 'field', style: { backgroundColor: col('rain') },
      placement: band(mm(0), 'fill') },
    { kind: 'image', id: 'strokes', resourceId: 'rain', placement: band(mm(0), 'fill') },
    text('series', '{title}', 'Reddit Sans', { ...onField, fontWeight: 600, fontSize: pt(9),
      letterSpacing: pt(2.4), textTransform: 'uppercase', placement: fromTop(mm(30), mm(24)) }),
    text('numeral', '{number}', 'Gloock', { ...onField, fontSize: pt(190),
      placement: under('series', mm(10), mm(150)) }),
    text('name', '{titleText}', 'Gloock', { ...onField, fontSize: pt(60),
      placement: under('numeral', mm(-6), mm(150)) }),
  ] },
};
// #endregion

const foot = text('foot', '{pageNumber}   {title} · {partTitle}', 'Reddit Sans', {
  fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.5), textTransform: 'uppercase',
  color: col('muted'), align: 'center', placement: { ...at('container', 'top', mm(9)),
    size: { width: 'fill' } },
  pages: 'opener' }); // the notes, not the part page: each fits its opening page (a note that ran
// on would need a copy of this element with pages: 'body')
const colophon = { id: 'colophon', fontFamily: 'Reddit Sans', fontSize: pt(7.5),
  lineHeight: pt(11), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0),
  marginTop: pt(14) };

// A factory: the engine caches resolved configs per object (gotcha: config-cache-identity).
const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  // The locale does not name the figures (gotcha: resource-types-locale): "Figura" in Spanish,
  // one count for the whole issue, and a lower-case "fig." in Spanish running text.
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}',
    resetOn: 'never', ...(type.id === 'figure' && { shortLabel: t({ en: 'Fig.', es: 'fig.' }) }),
  })),
  colorPalette: paletteOf(day),
  page: { width: mm(225), height: mm(297), dpi: 150, backgroundColor: col('paper'), // dark at night
    margins: { top: mm(TOP), bottom: mm(24), left: mm(20), right: mm(16), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(7) },
  bodyText: { fontFamily: 'Newsreader', fontSize: pt(10), lineHeight: pt(14), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('rain'),
    textAlign: 'justify', // the default, stated for contrast with the screen's ragged 'left'
    firstLineIndent: mm(4.5), indentAfterHeading: false }, // hyphenation, widows: on by default
  headings: { fontFamily: 'Gloock', fontWeight: 400, color: col('ink'),
    // Off: 1.4.1 drops the column under a closing page's column float a line (here English Rain
    // under the gauge, Spanish Wind under the rose; gotcha: float-stretch-closing-page).
    balancing: { stretchAfterFloats: false }, levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
      marginTop: pt(0), marginBottom: pt(0), advancedDesign: printOpener },
  ] },
  captionStyle: { fontFamily: 'Reddit Sans', fontSize: pt(8), color: col('muted'),
    labelColor: col('rain'), gap: mm(2.4) },
  parts, paragraphStyles: [colophon], header: { elements: [] }, footer: { elements: [foot] },
  htmlViewer: { overrides: screenOverrides() }, // canvas and PDF ignore it; an HTML host applies it
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Notes on Weather"
subtitle: "Autumn"
author: "Postext Cookbook"
---

:::part{number="I" title="Autumn"}
1. Rain, and thirty-one years of the garden gauge
2. Fog, where it comes from and when it lifts
3. Wind, from the shed vane to the Beaufort scale
:::

# Rain {kicker="Note 1" lead="A copper can at the end of the garden has kept this household’s rain for thirty-one years, and its book holds over eleven thousand readings to a tenth of a millimetre."}

Rain is measured as a depth: the height the water would stand if none of it ran off, sank in or dried. A millimetre of rain is a litre on every square metre of ground, so a gardener and a hydrologist can use the same number. The instrument that counts it has hardly changed in a century and a half; :ref{id="gauge"} shows it in section. A funnel of known width catches the rain and leads it into a narrow inner tube with a tenth of the funnel’s area, so every millimetre that falls stands ten millimetres deep inside, deep enough to read to a tenth with a graduated stick. What overflows the tube in a downpour waits in the outer can and is measured afterwards.

The reading is made at eight o’clock every morning, whatever the weather, and written down before breakfast. It is dull work. Climatologists describe a place by its averages over thirty years, and our book now covers thirty-one. In that time the gauge has recorded an average of 612 millimetres a year, from 402 in the driest year to 871 in the wettest.

No gauge catches all the rain. Wind carries drops past the mouth of the funnel, so a gauge on an exposed post catches less than falls on the lawn around it, and one beside a wall or a tree catches less again. The rule is to stand it at least twice as far from any obstacle as the obstacle is tall, with its rim a foot above the grass. Ours stands alone at the far end of the lawn for that reason.

A raindrop’s shape depends on its size, and it is never the teardrop of the weather map. The smallest are perfect spheres, held round by their own surface tension. Above two millimetres or so, the air pushing up from below flattens them into something closer to a bread roll, and past five or six millimetres they are torn into smaller drops as they fall. A large drop reaches the ground at about nine metres a second, hard enough to drum on leaves and roofs, which is why a summer shower can be heard before it is felt.

The heaviest rain falls from the tallest clouds, and it seldom lasts. Long autumn rain comes instead from wide sheets of cloud along a warm front, the kind that close in by degrees through a morning: first a haze across the sun, then a grey lid, then the first drops on the window. By evening the gauge may hold ten or fifteen millimetres.

# Fog {kicker="Note 2" lead="Fog is a cloud that touches the ground. In autumn it fills the valley below the house after most clear nights, and it is usually gone by eleven."}

Meteorologists call it fog when the visibility drops below one kilometre; above that, it is mist. Either way it is made of the same stuff as a cloud: droplets of water about a hundredth of a millimetre across, so small that they hang in the air instead of falling. A cubic metre of thick fog holds less than half a gram of water, a tenth of a teaspoon, and a layer a few dozen metres deep can hide a village up to its church spire, as :ref{id="valley"} shows.

The fog of autumn mornings is usually radiation fog. On a clear, still night the ground gives up the day’s warmth to the open sky and chills the layer of air lying on it. When that air cools to its dew point, the vapour it carries condenses into droplets. Cold air is heavy, so it drains downhill and pools in hollows and river valleys, and at dawn the high ground stands in sunshine above a white, level sea. A wind of more than a few metres a second would stir the cold layer into the warmer air above it, and a cloudy night would keep the ground from cooling.

As the sun climbs it warms the ground, the ground warms the air, and the fog thins from below, often rising into a low grey ceiling before it breaks up. Sea fog is another matter. It forms when warm, moist air drifts over cold water, and because the wind that brought it keeps feeding it, it can sit on a coast for days.

Fog is white for the same reason a cloud is: its droplets scatter every colour of sunlight alike, red as much as blue.

People who live in foggy valleys learn its habits. In ours the first fog of the year comes after the first long, clear night of September. It settles earliest by the river and lingers longest behind the mill. If the church tower comes out of it before nine, the afternoon will be fine.

# Wind {kicker="Note 3" lead="We read the wind in what it moves: smoke, leaves, the sea, the washing on the line. In 1805 a naval officer gave it numbers."}

Wind is air on its way from high pressure to low. It never takes the straight road: the turning of the Earth bends it aside, so that in the northern hemisphere it circles a low anticlockwise, and near the ground friction slows it and tilts it inwards. A wind is named for where it comes from, not where it goes, so a west wind blows from the west, off the ocean, and often brings the rain. In our garden it is the commonest wind of all, as the rose in :ref{id="rose"} makes plain.

The rose is a year of mornings read from the vane on the shed roof. A vane turns its broad tail downwind, so its arrow always points into the wind, where the air is coming from. At eight each morning we note the nearest of its sixteen points, and each petal of the rose is as long as the share of mornings the wind blew from that point. Calm mornings, about one in eight, point nowhere and are left out. Checked against the gauge’s book, the vane stood between south-west and west on most wet mornings.

Francis Beaufort, an officer of the Royal Navy, wrote his scale for ships’ logs and described each force by what it did to a warship’s sails. It was later rewritten for land, and the land version still works without an anemometer. At force 2 you feel the wind on your face and the leaves rustle. At force 4 it raises dust and scraps of paper and moves small branches. At force 6 large branches swing and an umbrella is hard to hold. At force 8 twigs break off the trees and walking into it is hard work. The scale stops at force 12, hurricane force, a mean wind of 118 kilometres an hour or more.

Every valley has its own winds and its own names for them. Sailors and farmers named the winds long before anyone measured them, and the names still say where a wind comes from and what it brings: the mistral that pours down the Rhône valley, cold and dry; the föhn that warms the northern slopes of the Alps; the bora that falls on the Adriatic in winter, in gusts that can overturn a lorry; the sea breeze that sets in on a summer afternoon and dies at dusk. In Provence the mistral is said to blow for three, six or nine days.

Weather stations measure the wind ten metres above open ground and average it over ten minutes, because wind is never steady. It comes in gusts and lulls, eddies round buildings and hedges, and blows harder over the sea than over a town; a gust can be half as strong again as the average around it. That is why a forecast gives two figures, a mean speed and a gust speed, and on an open hill you plan for the second.

:::paragraphs{style="colophon"}
*Notes on Weather*, part I. Set in Newsreader, Gloock and Reddit Sans (SIL Open Font License). Text and drawings: original, CC BY 4.0. The photograph is generated.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region figures: the same resources for both editions, each pointing at its edition's drawing
// The valley is a photograph, one JPEG in assets/ that both editions share, at its pixels.
const photo = { kind: 'bitmap', svg: undefined,
  bitmap: { fileId: 'valley-1610.jpg', format: 'jpeg', width: 1610, height: 690 } };
const figure = (id, edition, [width, height], placement, caption, alt) => ({ id,
  typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, placement,
  svg: { fileId: `${id}-${edition}.svg`, width, height },
  caption: caption && t(caption), altText: alt && t(alt) }); // the HTML edition's <img alt>
const figures = (edition) => [
  figure('gauge', edition, [900, 1260], { position: 'auto', span: 'column' }, {
    en: 'The rain gauge in section: the funnel feeds a tube with a tenth of its area.',
    es: 'El pluviómetro en sección: el embudo vierte en un tubo con la décima parte de su área.',
  }, {
    en: 'Rain falls on a funnel set in a can sunk in the lawn; the funnel drains into a narrow '
      + 'tube, half full, beside a graduated measuring stick.',
    es: 'La lluvia cae en un embudo sobre un vaso hundido en el césped; el embudo vierte en un '
      + 'tubo estrecho, medio lleno, junto a una regla graduada.' }),
  { ...figure('valley', edition, [1610, 690], { position: 'bottom', span: 'page' }, {
    en: 'Radiation fog at dawn: cold air drains off the hills overnight and fills the valley.',
    es: 'Niebla de irradiación al amanecer: el aire frío baja de las lomas y llena el valle.',
  }, {
    en: 'A valley between wooded hills lies under layers of fog at sunrise, with a church '
      + 'tower, trees and a river showing through it, seen from a fenced bank.',
    es: 'Un valle entre lomas boscosas yace bajo la niebla al amanecer; asoman la torre de '
      + 'una iglesia, árboles y un río, vistos desde un ribazo con una cerca.' }), ...photo },
  figure('rose', edition, [900, 900], { position: 'top', span: 'column' }, {
    en: 'Where a year of morning winds came from at the garden station: west and south-west.',
    es: 'De dónde vino el viento de un año de mañanas en el jardín: del oeste y del suroeste.',
  }, {
    en: 'A wind rose of sixteen petals on three rings; the longest petals point west and '
      + 'south-west, the shortest east.',
    es: 'Una rosa de los vientos de dieciséis pétalos sobre tres anillos; los más largos '
      + 'apuntan al oeste y al suroeste, los más cortos al este.' }),
  figure('rain', 'field', [2250, 2970]), // drawn by the part design, never cited: unnumbered
];
// #endregion

// #region art: a rain gauge in section, a wind rose, the part page's rain
function mulberry(seed) { // a seeded PRNG: 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 svg = (w, h, body) =>
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const f1 = (n) => n.toFixed(1);
const between = (random, [a, b]) => a + random() * (b - a);
// Slanted rain strokes; `clear` lists boxes [x0, y0, x1, y1] the strokes stay out of.
const streaks = (random, n, { x, y, len, width, color, alpha, clear = [] }) =>
  Array.from({ length: n }, () => {
    const [x0, y0, l] = [between(random, x), between(random, y), between(random, len)];
    const [o, w] = [between(random, alpha).toFixed(2), between(random, width).toFixed(2)];
    const hits = clear.some(([a, b, c, d]) => x0 > a && x0 - 0.28 * l < c && y0 + l > b && y0 < d);
    return hits ? '' : `<path d="M${f1(x0)} ${f1(y0)}l${f1(-0.28 * l)} ${f1(l)}" stroke="${color}" `
      + `stroke-opacity="${o}" stroke-width="${w}" stroke-linecap="round"/>`;
  }).join('');
const shape = (tag, attrs) => `<${tag} ${Object.entries(attrs)
  .map(([k, v]) => `${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}="${v}"`).join(' ')}/>`;

function gauge(p) { // 300 × 420: rain, the funnel, the can sunk in the lawn, the tube, a stick
  const random = mulberry(7);
  const line = { stroke: p.ink, strokeWidth: 2 };
  const ticks = Array.from({ length: 23 }, (_, i) =>
    `M246 ${386 - i * 9}h${i % 5 === 0 ? 14 : 7}`).join('');
  const grass = Array.from({ length: 42 }, (_, i) => `M${f1(3 + i * 7.2 + random() * 4)} 356`
    + `l${f1((random() - 0.5) * 7)} -${f1(5 + random() * 9)}`).join('');
  return svg(300, 420, streaks(random, 44, { x: [40, 292], y: [0, 72], len: [12, 24],
    width: [1.6, 2.6], color: p.rain, alpha: [0.35, 0.95] })
    + shape('path', { d: 'M70 104H230', stroke: p.muted, strokeWidth: 1.4 })
    + shape('path', { d: 'M70 104l7 -4v8zM230 104l-7 -4v8z', fill: p.muted }) // arrowheads
    + shape('rect', { x: 0, y: 355, width: 300, height: 65, fill: p.rule, fillOpacity: 0.55 })
    + shape('path', { d: grass, stroke: p.slate, strokeWidth: 1.6, strokeLinecap: 'round' })
    + shape('rect', { x: 76, y: 128, width: 148, height: 268, rx: 5, fill: p.fog, ...line,
      strokeWidth: 2.5 })
    + shape('rect', { x: 124, y: 196, width: 52, height: 192, fill: p.paper, ...line })
    + shape('rect', { x: 126, y: 290, width: 48, height: 96, fill: p.rain, fillOpacity: 0.85 })
    + shape('path', { d: 'M70 118h160v10H70z', fill: p.slate })
    + shape('path', { d: 'M72 128h156l-72 60h-12z', fill: p.rule, ...line })
    + shape('rect', { x: 144, y: 184, width: 12, height: 16, fill: p.rule, ...line })
    + shape('rect', { x: 240, y: 176, width: 26, height: 214, fill: p.paper, ...line,
      strokeWidth: 1.6 })
    + shape('rect', { x: 241, y: 290, width: 24, height: 99, fill: p.rain, fillOpacity: 0.3 })
    + shape('path', { d: ticks, stroke: p.ink, strokeWidth: 1.2 }));
}

function rose(p) { // 300 × 300: where a year of morning winds came from, in sixteen petals
  const share = [6, 4, 3, 2, 2, 3, 4, 6, 9, 12, 19, 22, 24, 14, 9, 7]; // N first, clockwise
  const petal = (s, i) => {
    const [a, r] = [((i * 22.5 - 90) * Math.PI) / 180, s * 5.4]; // length ∝ share
    const end = (d) => `${f1(150 + r * Math.cos(a + d))} ${f1(150 + r * Math.sin(a + d))}`;
    return shape('path', { d: `M150 150L${end(-0.17)}A${f1(r)} ${f1(r)} 0 0 1 ${end(0.17)}Z`,
      fill: s > 12 ? p.rain : p.slate, fillOpacity: s > 12 ? 0.95 : 0.55 });
  };
  const rings = [40, 80, 120].map((r) =>
    shape('circle', { cx: 150, cy: 150, r, fill: 'none', stroke: p.rule, strokeWidth: 1.2 }));
  return svg(300, 300, rings.join('') + share.map(petal).join('')
    + shape('path', { d: 'M150 18V282M18 150H282', stroke: p.rule, strokeWidth: 1 })
    + shape('path', { d: 'M144 14V2L156 14V2', fill: 'none', stroke: p.ink, strokeWidth: 2 }) // N
    + shape('circle', { cx: 150, cy: 150, r: 5, fill: p.ink }));
}

async function drawFigures() { // the drawings in both palettes, the part page's rain, the photo
  const words = [[20, 26, 80, 36], [20, 100, 112, 128], [20, 206, 140, 238]]; // mm: the type
  await loadSvg('rain-field.svg', svg(225, 297, streaks(mulberry(19), 320, { x: [-10, 245],
    y: [-14, 292], len: [6, 18], width: [0.3, 0.8], color: day.paper, alpha: [0.12, 0.45],
    clear: words })));
  for (const [edition, p] of [['day', day], ['night', night]]) {
    await loadSvg(`gauge-${edition}.svg`, gauge(p));
    await loadSvg(`rose-${edition}.svg`, rose(p));
  }
  await loadImage('valley-1610.jpg', asset('valley-1610.jpg'));
}
// #endregion

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

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await drawFigures();
const doc = await buildWithFonts( // print: every page on the kit's desk, and one beside the screen
  () => buildDocument({ markdown, resources: figures('day') }, config()), markdown);
showPages(doc, { title: t({ en: 'One source, print and screen editions',
  es: 'Un solo original, ediciones impresa y de pantalla' }) });

// The two editions side by side, above the desk. index.html and style.css lay them out; pasted
// on its own, the script writes the same markup, and the screen region gives the pane a height.
if (!document.getElementById('editions')) {
  document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="editions">
  <figure class="edition"><canvas id="proof" role="img" style="width:300px"></canvas>
  <figcaption></figcaption></figure><figure class="edition screen">
  <div id="screen" role="region" tabindex="0"></div><figcaption></figcaption></figure></section>`);
}
const [proof, pane] = [document.getElementById('proof'), document.getElementById('screen')];
const [proofLabel, screenLabel] = document.querySelectorAll('#editions figcaption');
const holds = (page, id) => [...(page.floats ?? []), ...page.columns.flatMap((c) => c.blocks)]
  .some((block) => block.resourceBlock?.resource.id === id);
const note = doc.pages.find((page) => holds(page, 'gauge')) ?? doc.pages[0]; // Figure 1's page
const density = Math.min(window.devicePixelRatio || 1, 2);
renderPageToCanvas(note, doc, proof, { scale: (density * proof.clientWidth) / note.width });
const { width: trimW, height: trimH } = config().page;
proofLabel.textContent = `${t({ en: 'Print', es: 'Impresa' })} · canvas · `
  + `${trimW.value} × ${trimH.value} mm`;
proof.setAttribute('aria-label',
  `${t({ en: 'Print edition, page', es: 'Edición impresa, página' })} ${note.index + 1}`);
pane.setAttribute('aria-label', t({ en: 'Screen edition', es: 'Edición de pantalla' }));

// #region screen: the HTML edition in a Shadow DOM, laid out again when its pane resizes
const FOLDED = 200; // px: a pane narrower or shorter than this is hidden or squeezed; skip it
if (!pane.clientHeight) { // no style.css: a height, and a corner to drag the pane smaller, but
  // not under 400 px, where 1.4.1 sets text over the opener (gotcha: opener-taller-than-column)
  pane.style.cssText = 'height:580px;min-height:400px;min-width:240px;overflow:auto;resize:both';
}
pane.style.background = night.paper; // the pane's own ground, beside the pages and the scrollbar
const shadow = pane.attachShadow({ mode: 'open' }); // the page's selectors cannot reach in, but
// inherited text properties (letter-spacing, text-transform…) can, and the lines were measured
// without them: `all: initial` on the wrapper stops them at the edition's edge.
const inShadow = `<style>:host>div{all:initial;display:block}`
  + `::selection{background:${night.rain}55}</style>`; // selected text takes the night blue
let size = '';
function showScreen() {
  const [width, height] = [pane.clientWidth, pane.clientHeight];
  if (`${width}×${height}` === size || width < FOLDED || height < FOLDED) return; // same, or folded
  const first = !size; // the first build opens on Figure 1's page, like the print proof
  size = `${width}×${height}`;
  const screenDoc = buildDocument({ markdown, resources: figures('night') },
    screenConfig({ width, height }));
  const place = pane.scrollTop / pane.scrollHeight; // the reader's place, kept across rebuilds
  shadow.innerHTML = `${inShadow}<div>${renderToHtml(screenDoc,
    { mode: 'single', padding: 0, resourceImageUrl: imageUrl })}</div>`;
  const fig = first && screenDoc.pages.find((page) => holds(page, 'gauge')); // 'single' mode
  pane.scrollTop = fig ? fig.index * height : place * pane.scrollHeight; // stacks pane-tall pages
  screenLabel.textContent =
    `${t({ en: 'Screen', es: 'Pantalla' })} · HTML · ${width} × ${height} px`;
}
showScreen();
let timer = 0; // debounced; its first call, on observe(), finds the size unchanged
new ResizeObserver(() => { clearTimeout(timer); timer = setTimeout(showScreen, 150); })
  .observe(pane);
// #endregion

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

### Leia de dia

Tire a paleta noturna e use os valores diurnos para o painel e as figuras; a edição de tela passa a mostrar páginas brancas, tinta escura e os desenhos diurnos.

```diff
-  colorPalette: paletteOf(night), // arrays are replaced whole: the night values
   parts: { page: false }, // no divider page; the part still names the notes after it
@@
-pane.style.background = night.paper; // the pane's own ground, beside the pages and the scrollbar
+pane.style.background = day.paper; // the pane's own ground, beside the pages and the scrollbar
@@
-  const screenDoc = buildDocument({ markdown, resources: figures('night') },
+  const screenDoc = buildDocument({ markdown, resources: figures('day') },
```

### Dê às aberturas impressas um número de capítulo sobre uma faixa colorida

Para uma abertura impressa mais pesada, com uma faixa saturada e um número de capítulo grande, veja [Abertura de capítulo sobre uma faixa sangrada](https://postext.dev/pt/cookbook/chapter-opener-bleed-band.md); os overrides de tela continuam iguais.

## Erros comuns

- **A saída HTML não tem fio de coluna, grade nem marcas, e é transparente.** renderToHtml não desenha fio de coluna, grade de linhas de base nem marcas de corte, e as páginas são transparentes a menos que você passe um fundo, o que em uma página escura significa texto escuro sobre escuro.
- **Uma paleta trocada não chega aos elementos de design nem à cor das referências.** postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor.
- **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().
- **Algumas configurações de htmlViewer só existem no Sandbox.** htmlViewer.maxCharsPerLine, columnGap e optimalLineBreaking são lidos pelo visualizador do Sandbox, não por renderToHtml. Na sua própria página, dimensione a página você mesmo e passe mode e columnGap para renderToHtml; os ajustes são aplicados com applyHtmlViewerOverrides.
- **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.
- **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.
- **Uma abertura reserva altura até o seu elemento ancorado mais baixo.** Uma abertura com design avançado reserva a altura do seu elemento mais baixo, e os elementos ancorados à página ou à sangria abaixo do título também contam, então um ornamento no pé da página empurra o texto para a página seguinte. Mantenha esses ornamentos acima do título, passe-os para uma posição do cabeçalho ou do rodapé, ou defina a reserva com minHeight.
- **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.
- **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.
- **Um flutuante superior pode baixar a última coluna numa página de fechamento.** No postext 1.4.1, quando um capítulo ou uma matéria termina numa página que abre com um flutuante superior na largura toda e as linhas se dividem de forma desigual entre as colunas, stretchAfterFloats acrescenta uma linha em branco sob o flutuante na coluna mais curta, em vez de deixá-la terminar antes, e as duas colunas deixam de começar na mesma linha. Defina headings.balancing.stretchAfterFloats como false ou ajuste o texto a um número par de linhas.
- **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.

- Confira o fim de cada nota depois de editar. Se o último parágrafo de uma nota empurrar um rabo de duas linhas para uma página nova (o controle de viúvas mantém as duas linhas juntas), o postext 1.4.1 dá à coluna dessa página a altura de uma linha, e o canvas corta a segunda. As duas amostras foram ajustadas para que nenhuma nota termine assim.
- Em texto alinhado à esquerda, a 1.4.1 pode terminar uma linha no “(” antes de uma referência, e num painel que o leitor pode redimensionar alguma largura vai produzir essa quebra. Estas notas escrevem “as Fig. 2 shows”, nunca “(Fig. 2)”.
- Deixe um parágrafo de tela começar na última linha de uma página (`avoidWidows: false`). Com essa regra ligada, a 1.4.1 pode forçar um parágrafo mais alto que a página, inteiro, para o vão embaixo de uma figura, onde ele passa do pé da página; um painel de 240 × 460 ou 260 × 500 px mostra isso.
- Mantenha o painel com pelo menos 400 px de altura (o `min-height` do pen). Num painel estreito com menos de uns 350 px de altura, a abertura de tela fica mais alta que a área de conteúdo da página; o postext 1.4.1 então descarta a reserva inteira e compõe o primeiro parágrafo por cima do título.
- Dê um tamanho ao painel antes de diagramá-lo. Um painel sem largura, escondido ou espremido numa linha flex, daria uma página de largura zero, e é por isso que `showScreen()` ignora um painel mais estreito ou mais baixo que `FOLDED`, 200 px.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imagens: Radiation fog in a valley at dawn (Figure 2): Generated With Diffusion Models, original
- Imagens: The rain gauge, the wind rose and the part page's rain, drawn in code in both palettes: Ignacio Ferro, CC-BY-4.0
- Tipos: Newsreader (OFL-1.1), Gloock (OFL-1.1), Reddit Sans (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 054 · Mudar as cores de um documento inteiro com uma paleta](https://postext.dev/pt/cookbook/live-palette-retint.md): Cada cor da configuração leva um id de paleta, e uma só função compõe a programação de um festival em vermelho, verde-azulado, violeta ou areia. · Nível 2 (Intermediário) · Folhas avulsas e impressos efêmeros
- [Nº 056 · Editor ao vivo com a diagramação num Web Worker](https://postext.dev/pt/cookbook/web-worker-live-editor.md): Markdown ao lado de uma página de bolso: um worker criado a partir de um blob compõe com as próprias fontes, e cada tecla cancela a composição em andamento. · Nível 3 (Avançado) · Ficção, teatro e prosa literária
- [Nº 004 · Reportagem de revista: da foto de abertura ao sinal final](https://postext.dev/pt/cookbook/magazine-feature-opener.md): Uma abertura advancedDesign põe uma foto sangrada e tira do título chapéu, título, olho e assinatura; seguem dois boxes flutuantes e um chip de fecho. · Nível 3 (Avançado) · Revistas e fanzines
