# Una sezione economica su carta salmone

> Un Berliner di otto pagine in cui un solo stile di titolo crea le pagine Business: carta salmone, sei colonne, accento petrolio e un run :::paper per Folio.

- Versione HTML: https://postext.dev/it/cookbook/business-section-on-salmon
- Ricetta Nº 134 · Pubblicazioni complete · Livello 3 (Avanzato) · Output: Canvas
- Generi: Giornali e newsletter
- Richiede postext ≥ 1.28.0 · provata con 1.31.0 il 2026-10-13
- Pagine: [1](https://postext.dev/cookbook/business-section-on-salmon/en/p01.webp?v=20f0f265), [2](https://postext.dev/cookbook/business-section-on-salmon/en/p02.webp?v=20f0f265), [3](https://postext.dev/cookbook/business-section-on-salmon/en/p03.webp?v=20f0f265), [4](https://postext.dev/cookbook/business-section-on-salmon/en/p04.webp?v=20f0f265), [5](https://postext.dev/cookbook/business-section-on-salmon/en/p05.webp?v=20f0f265), [6](https://postext.dev/cookbook/business-section-on-salmon/en/p06.webp?v=20f0f265), [7](https://postext.dev/cookbook/business-section-on-salmon/en/p07.webp?v=20f0f265)
- Apri nella Sandbox: https://postext.dev/it/sandbox#recipe=business-section-on-salmon&lang=en (.postext: https://postext.dev/cookbook/business-section-on-salmon/en/business-section-on-salmon.postext)
- Ultimo aggiornamento: 2026-10-11
- Altre lingue: [en](https://postext.dev/en/cookbook/business-section-on-salmon.md), [es](https://postext.dev/es/cookbook/business-section-on-salmon.md), [ca](https://postext.dev/ca/cookbook/business-section-on-salmon.md), [de](https://postext.dev/de/cookbook/business-section-on-salmon.md), [fr](https://postext.dev/fr/cookbook/business-section-on-salmon.md), [pt](https://postext.dev/pt/cookbook/business-section-on-salmon.md), [zh](https://postext.dev/zh/cookbook/business-section-on-salmon.md), [ja](https://postext.dev/ja/cookbook/business-section-on-salmon.md), [ar](https://postext.dev/ar/cookbook/business-section-on-salmon.md)

## In breve

Otto pagine di un quotidiano immaginario. Le prime quattro sono di cronaca su carta grigio chiaro; le pagine economiche che seguono sono stampate su carta salmone, con colonne più strette e colori propri.

## Che cosa comporrai

Otto pagine di *The Marrowick Ledger*, un quotidiano immaginario, sul foglio Berliner da 315 × 470 mm. Le pagine da 1 a 4 portano la cronaca su carta da giornale grigio chiaro in cinque colonne separate da filetti, con una bandella di sezione rossa su ogni pagina. Dalla pagina 5 la carta cambia. La sezione Business è stampata su carta salmone, come le pagine finanziarie da ben più di un secolo: la pagina diventa #f2d3c0, la griglia si stringe a sei colonne, l’accento passa dal rosso al petrolio e una striscia con le chiusure di mercato del giorno sta sotto il titolo di sezione. Dentro ci sono una pagina dei mercati con un anno dell’indice cittadino, un grafico per settori e quattro tabelle di dati, le notizie sulle aziende e una pagina di quotazioni. Nella vista 3D di Folio i fogli si piegano uno dentro l’altro e le ultime quattro pagine escono su carta salmone.

**Questa ricetta risponde a:**

- Come stampo le pagine economiche di un giornale su carta salmone, con griglia propria, anche in 3D?
- Come stampo una copertina, un inserto o una sezione di tavole su una carta diversa dal resto del libro?
- Come compongo una pagina di giornale: cinque colonne in formato lenzuolo, un’immagine su due di esse e un riquadro di articolo su tre?
- Come divido un libro in parti o sezioni, ognuna con il suo colore e la sua pagina di parte?

## La risposta breve

```js
// script.js, righe 95–122
// page.backgroundColor links to 'paper', so the section's palette, which turns 'paper' salmon,
// repaints every page of the section: the opener and the pages its copy runs on to.
const business = {
  id: 'business',
  advancedDesign: { enabled: true, slot: { elements: flagParts } },
  // The swap: the page, flags, kickers, the folio line, chart marks and table heads follow.
  palette: { paper: palette.salmon, accent: palette.petrol, tint: '#e6bfa8' },
  // Six narrower columns, ruled as the news pages are.
  layout: { layoutType: 'multiple', columnCount: 6, gutterWidth: mm(4) },
  bodyStyle: { fontSize: pt(8.8), lineHeight: pt(LEAD) },
};
// The section front: the same style, with a bigger flag over the market close strip.
const front = () => ({ ...business, id: 'business-front', advancedDesign: { enabled: true, slot: {
  elements: [depth(10),
    text('title', 'Business', { fontFamily: 'Playfair Display', fontSize: pt(58), fontWeight: 900,
      lineHeight: 1, color: col('accent') }, at('container', 'top-left', 0, -1)),
    text('tag', 'Companies · Markets · Money', caps(9, 700, 'accent'),
      at('#title', 'right-of', 5, 9)),
    rule('top', 3, at('#title', 'below', 0, 2)),
    ...STRIP().flatMap(([name, value, change], i) => [ // the day's close, from the market data
      text(`s${i}`, name, caps(7, 700, 'muted'), at('#top', 'below', (i * WIDE) / 6, 1.8)),
      text(`v${i}`, value, sans(13, 700), at(`#s${i}`, 'below', 0, 0.6)),
      text(`c${i}`, change, sans(9.5, 600, { color: col(change[0] === '+' ? 'accent' : 'muted') }),
        at(`#v${i}`, 'right-of', 1.6, 1))]),
    rule('under', 0.5, at('#depth', 'align-bottom', 0, -1.4))] } } });
// hook-up: headingStyles lists both, and the Markdown wraps pages 5–8 in
//   :::paper{type="newsprint" shade="#faede6"}   ← the stock Folio prints them on
//   # Business {style="business-front"}  …  # Markets {style="business"}  …  :::
```

## Ingredienti

**Insegna**

- [Una carta diversa per una sequenza di pagine](https://postext.dev/it/docs/document-format.md#paper): Un contenitore :::paper stampa le pagine che contiene su una carta propria: una sezione di tavole su carta patinata, una copertina in cartoncino, qualche foglio colorato.
- [Tre o più colonne](https://postext.dev/it/docs/configuration-page-layout.md#tipi-di-impaginazione): layoutType 'multiple' divide il testo corrente in columnCount colonne uguali, da 3 a 8, per quotidiani e riviste; filetti, bilanciamento, note a piè di pagina ed elementi flottanti a tutta larghezza funzionano su tutte.
- [Quotidiano piegato (Folio)](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#visualizzatore-folio-configurazione): folio.binding.type 'folded': fogli piegati una volta e messi l’uno dentro l’altro, senza punti metallici, dorso né piatti di copertina; una sequenza :::paper con una tinta stampa una sezione su carta da giornale salmone.

**Usa anche**

- [Formati da quotidiano](https://postext.dev/it/docs/configuration-page-layout.md#formati-di-pagina-predefiniti)
- [Elementi flottanti su più colonne](https://postext.dev/it/docs/document-format.md#collocazione)
- [Stili di titolo](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Geometria della sezione](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Colori per parte](https://postext.dev/it/docs/configuration-styles.md#il-contenitore-part)
- [Colore della carta](https://postext.dev/it/docs/configuration-page-layout.md#pagina)
- [Aperture progettate](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)
- [Ancoraggio degli elementi del progetto](https://postext.dev/it/docs/configuration-page-layout.md#collocazione-degli-elementi)
- [Testatine e folio](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina)
- [Filetto tra le colonne](https://postext.dev/it/docs/configuration-page-layout.md#filetto-tra-le-colonne)
- [Riquadri a tutta larghezza](https://postext.dev/it/docs/configuration-styles.md#il-contenitore-callout)
- [Riquadri flottanti](https://postext.dev/it/docs/configuration-styles.md#il-contenitore-callout)
- [Colonne in un riquadro o nel testo](https://postext.dev/it/docs/document-format.md#columns)
- [Stili di tabella con nome](https://postext.dev/it/docs/configuration-resources.md#stili-di-tabella-con-nome)
- [Collocazione delle figure](https://postext.dev/it/docs/document-format.md#collocazione)
- [Figure proprio qui](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [Carta, rilegatura, scrivania e luce (Folio)](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#visualizzatore-folio-configurazione)
- [Riquadri](https://postext.dev/it/docs/configuration-styles.md#stili-dei-riquadri)
- [Citazioni che collocano le figure](https://postext.dev/it/docs/document-format.md#rimando-in-linea-la-forma-principale)
- [Attributi dei titoli](https://postext.dev/it/docs/document-format.md#attributi-dei-titoli)
- [Fascia di capitolo a tutta larghezza](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)
- [Stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Tipi di risorsa personalizzati](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)
- [Figure e tabelle come risorse](https://postext.dev/it/docs/document-format.md#risorse)
- [Testatine per sezione](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Spazio verticale esplicito](https://postext.dev/it/docs/document-format.md#space)

**La configurazione in breve**

- [`bodyText`](https://postext.dev/it/docs/configuration-text.md#testo-corrente), [`calloutStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-riquadri), [`captionStyle`](https://postext.dev/it/docs/configuration-resources.md#stile-delle-didascalie), [`colorPalette`](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori), `folio`, [`footer`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`header`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`headingStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli), [`headings`](https://postext.dev/it/docs/configuration-text.md#titoli), [`layout`](https://postext.dev/it/docs/configuration-page-layout.md#impaginazione), [`locale`](https://postext.dev/it/docs/configuration-text.md#sillabazione), [`page`](https://postext.dev/it/docs/configuration-page-layout.md#pagina), [`paragraphStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo), [`resourceTypes`](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa), [`tableStyle`](https://postext.dev/it/docs/configuration-resources.md#stile-delle-tabelle), [`tableStyles`](https://postext.dev/it/docs/configuration-resources.md#stili-di-tabella-con-nome)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/it/docs/configuration-programmatic-usage.md#caricare-i-font-prima-dellimpaginazione), [`inlineSvgFonts`](https://postext.dev/it/docs/configuration-resources.md#font-nel-testo-svg), [`registerResourceImage`](https://postext.dev/it/docs/architecture.md#superficie-dellapi), [`renderPageToCanvas`](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap)

**Caratteri**

- Source Serif 4 (OFL-1.1), Playfair Display (OFL-1.1), Archivo Narrow (OFL-1.1)

## Preparazione

### 1 · Due carte in una sola tavolozza

```js
// script.js, righe 17–31
const palette = {
  ink: '#16181b', // text and heavy rules
  paper: '#f6f4ef', // the page: grey-white newsprint (salmon in Business)
  accent: '#b3261e', // news red: flags, kickers, chart marks (petrol in Business)
  tint: '#e8e4da', // box fills (a deeper salmon in Business)
  rule: '#9d9a93', // hairlines
  muted: '#5c5954', // bylines, the folio line, credits
  salmon: '#f2d3c0', // the business stock, and the teaser patches on page 1
  petrol: '#0f4c5c', // the business accent
};
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' } })),
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
```

Ogni colore della configurazione è collegato a una delle otto voci della tavolozza. Le pagine di cronaca usano `paper` (#f6f4ef) per la pagina e `accent` (#b3261e) per bandelle, occhielli e segni dei grafici. Le pagine Business conservano quei collegamenti e cambiano i valori: la tavolozza della sezione imposta `paper` sul salmone, `accent` sul petrolio (#0f4c5c) e `tint` su un salmone più carico. Anche `salmon` ha una voce propria, per l’orecchia e la striscia dei richiami di pagina 1, che restano salmone sulle pagine bianche.

### 2 · Un solo stile di titolo fa di una pagina una pagina Business

Il codice è [la risposta breve](https://postext.dev/it/cookbook/business-section-on-salmon.md#la-risposta-breve) qui sopra. `# Markets {style="business"}` apre una sezione, e le sue pagine prendono `palette`, `layout` e `bodyStyle` dello stile: sei colonne con un intercolonna di 4 mm (45,2 mm l’una, contro i 54,6 mm della cronaca), testo a 8,8 pt sulla stessa interlinea di 11,4 pt. Anche il colore della pagina segue la tavolozza. `page.backgroundColor` è collegato a `paper`, e quando la tavolozza di una sezione dà a `paper` un altro valore, ogni pagina della sezione viene dipinta con quello: la pagina che il suo titolo apre e ogni pagina su cui prosegue il suo testo. Così le pagine Business diventano salmone da bordo a bordo, e il filetto tra le colonne, che il `layout` dello stile lascia come lo imposta il documento, corre tra le sei colonne come tra le cinque.

### 3 · Ogni pagina si apre con una bandella

```js
// script.js, righe 73–91
const flagParts = [
  depth(3),
  rule('bar', 3, at('#depth', 'align-bottom', 0, -1.4)),
  text('flag', '{titleText}', { ...caps(12, 700, 'paper'), box: patch('accent', pad(1.3, 3.5)) },
    at('#bar', 'above')),
];
const FOLIO = 9; // mm from the top edge
const folioLine = (parity, side, s) => [ // s: +1 on a verso (folio on the left), −1 on a recto
  text(`n-${parity}`, '{pageNumber}', { ...sans(11, 700), align: side },
    at('page', `top-${side}`, s * M.side, FOLIO)),
  text(`t-${parity}`, '{title} · {publishDate}', { ...caps(7.5, 600, 'muted'), align: side },
    at(`#n-${parity}`, s > 0 ? 'right-of' : 'left-of', s * 3, 1)),
  text(`c-${parity}`, '{chapterTitle}', { ...caps(7.5, 700, 'accent'),
    align: s > 0 ? 'right' : 'left' }, at('page', `top-${s > 0 ? 'right' : 'left'}`,
    -s * M.side, FOLIO + 1)),
].map((element) => ({ ...element, parity }));
const header = { elements: [...folioLine('even', 'left', 1), ...folioLine('odd', 'right', -1),
  { kind: 'rule', id: 'folio-rule', thickness: pt(0.5), color: col('ink'), placement: {
    ...at('page', 'top-left', M.side, FOLIO + 5), size: { width: mm(WIDE) } } }] };
```

L’H1 è un’apertura a tutta pagina con parità `breakBefore` `'any'`, quindi ogni titolo apre la pagina successiva qualunque sia il suo lato. Il suo progetto grafico è una bandella su una barra da 3 pt, profonda tre righe di griglia. La riga del folio è un’intestazione ancorata a 9 mm dal bordo superiore: numero di pagina, testata e data all’esterno, `{chapterTitle}` all’interno nel colore d’accento, che la tavolozza rende petrolio sulle pagine salmone.

### 4 · Immagini e grafici su k colonne

```js
// script.js, righe 445–474
const unnumbered = (id, name, captionStyle) => ({ id, name, shortLabel: name, captionPrefix: '',
  numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionStyle });
const resourceTypes = [unnumbered('picture', 'Picture'),
  unnumbered('panel', 'Panel', { position: 'above', fontSize: pt(9.5) })];
const res = (id, typeId, kind, body, extra) => ({ id, typeId, kind, [kind]: body,
  createdAt: 0, updatedAt: 0, ...extra });
const photo = (id, file, [w, h], placement, resolution) => res(id, 'picture', 'bitmap',
  { fileId: file, format: 'jpeg', width: w, height: h, ...(resolution && { resolution }) },
  { placement, ...CAPTIONS[id] });
const drawing = (id, [w, h], placement) => res(id, 'picture', 'svg',
  { fileId: `${id}.svg`, width: w, height: h }, { placement, ...CAPTIONS[id] });
const data = (id, model, caption, note) => res(id, 'panel', 'table', { model },
  { placement: here, caption, note });
const top = (columns) => ({ position: 'top', columns }); // a float across k columns
const here = { position: 'here' }; // in the flow: in a column, or across a box
const resources = [
  photo('barrier', 'barrier-1800.jpg', [1800, 1106], here), // in the banner box: page-wide
  photo('welders', 'apprentices-1536.jpg', [1536, 1024], top(3)),
  drawing('gate', [1130, 500], top(3)),
  photo('ferry', 'ferry-1536.jpg', [1536, 1024], top(4)),
  photo('cartoon', 'cartoon-1280.jpg', [1280, 853], top(4), 140),
  photo('cranes', 'cranes-1800.jpg', [1800, 750], here),
  drawing('throughput', [1130, 700], top(2)),
  drawing('mx40', [2910, 1000], here), drawing('sectors', [2910, 880], here),
  photo('loaves', 'bakery-1536.jpg', [1536, 1024], top(3)),
  // The tables sit where the text names them: no float.
  ...Object.entries(TABLES).map(([id, [model, caption, note]]) => data(id, model, caption, note)),
  ...SECTORS.map((_, i) => res(`prices-${i + 1}`, 'panel', 'table',
    { styleId: 'prices', model: pricesTable(i, 21) }, { placement: here })),
];
```

`top(3)` è `placement: { position: 'top', columns: 3 }`: la fotografia del cantiere navale occupa tre delle cinque colonne di cronaca, il traghetto quattro, il panificio tre delle sei colonne Business e il grafico del porto due. Un elemento flottante citato da un paragrafo nella colonna 1 prende la testa delle colonne successive ancora vuote, quindi queste immagini partono dalla colonna 2 e l’articolo scorre accanto a loro. Un’immagine del genere resta con il suo articolo solo finché nessun altro articolo viene composto sotto di lei su quella pagina: un articolo a tutta pagina collocato dopo può portare l’immagine nella propria fascia o alla pagina seguente. Per questo le pagine 2 e 3 si chiudono con una pubblicità fatta flottare al piede anziché con un altro articolo. Le fotografie che coprono tutta la pagina stanno in linea (`here`) nel riquadro del titolo, sotto il titolo stesso. Anche le tabelle stanno nel flusso, dentro riquadri a tutta pagina al piede.

### 5 · Titoli, pannelli, dati e pubblicità sono riquadri

```js
// script.js, righe 126–153
const none = pad(0, 0);
const banner = { id: 'banner', backgroundEnabled: false, padding: none,
  titleStyle: { ...caps(8.5, 700, 'accent'), gap: mm(1.2) }, // the kicker
  marginTop: pt(0), marginBottom: pt(LEAD / 2),
  body: { fontFamily: 'Source Serif 4', fontSize: pt(13), lineHeight: pt(16.5), textAlign: 'left',
    firstLineIndent: pt(0) } };
const story = { ...banner, id: 'story', marginTop: pt(LEAD), padding: { ...none, top: mm(2.5) },
  stripe: { enabled: true, side: 'top', width: pt(1), color: col('ink') } };
const boxBody = { fontFamily: 'Archivo Narrow', fontSize: pt(9.5), lineHeight: pt(LEAD),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, boldColor: col('accent') };
const briefs = { id: 'briefs', background: col('tint'), padding: pad(2.8, 3),
  titleStyle: { ...caps(9, 700, 'accent'), gap: mm(1.5) }, body: boxBody };
const teaser = { ...briefs, id: 'teaser', background: col('salmon'), columnGap: mm(4.5),
  body: { ...boxBody, fontSize: pt(10.5), lineHeight: pt(13.5) } };
const panel = { ...story, id: 'panel', marginTop: pt(0), titleStyle: briefs.titleStyle,
  stripe: { ...story.stripe, width: pt(2.5), color: col('accent') },
  body: { ...boxBody, fontFamily: 'Source Serif 4' } };
const dataBox = { ...story, id: 'data', marginTop: pt(0), columnGap: mm(4.5) };
const ad = { id: 'ad', backgroundEnabled: false, marginTop: pt(LEAD),
  border: { enabled: true, color: col('ink'), width: pt(0.75) },
  padding: { ...pad(6, 6), top: mm(3) },
  titleStyle: { ...caps(6.5, 600, 'muted'), gap: mm(4) },
  body: { ...boxBody, fontSize: pt(14), lineHeight: pt(18), boldColor: col('ink') } };
const pullQuote = { ...story, id: 'quote', columns: 2, marginTop: pt(0),
  body: { ...banner.body, fontFamily: 'Playfair Display', fontSize: pt(19), lineHeight: pt(23),
    italic: true } }; // a pull quote, floated across two columns
const headline = (id, size, look = {}) => ({ id, fontSize: pt(size), lineHeight: pt(size * 1.04),
  marginBottom: pt(size * 0.2), ...look });
```

Un titolo che attraversa la pagina è un riquadro a tutta pagina senza cornice che contiene un H2 in una di tre dimensioni, e un filetto da 1 pt sopra di esso segna l’articolo successivo. A pagina 5 il pannello *The terminal in numbers* flotta su due delle sei colonne (`columns="2"` sulla sua recinzione), e la citazione in evidenza di pagina 4 fa lo stesso tramite `columns: 2` nel suo stile. I riquadri dei dati contengono da due a quattro tabelle affiancate in un gruppo `:::columns`, e le pubblicità sono riquadri a tutta pagina fatti flottare al piede, incorniciati da un filetto da 0,75 pt.

### 6 · La carta su cui stampa Folio

Le pagine di cronaca non hanno bisogno di nulla: `folio.paper` è carta da giornale da 45 g/m², e `binding: { type: 'folded', cover: 'pages' }` inserisce i due fogli piegati uno dentro l’altro, con la pagina 1 all’esterno e la pagina 8 come retro. Le pagine Business stanno in un unico run:

```markdown
:::paper{type="newsprint" shade="#faede6"}
# Business {style="business-front"}
…
# Share prices {style="business"}
…
:::
```

Ogni pagina del run porta `page.paper` con quella tinta, e Folio la stampa su quella carta. La tinta è il colore della pagina portato al 60 % verso il bianco (README §4): la pagina piatta dipinge già #f2d3c0, e una carta salmone piena sotto di essa la tingerebbe due volte. Il canvas, l’HTML e il PDF ignorano `:::paper` e dipingono il colore della pagina, ed è per questo che la tavolozza della sezione imposta anche `paper`.

## La ricetta completa

Un unico file, composto a partire dalla cartella della ricetta con il testo di esempio e il kit comune del Ricettario già inclusi; costruisce la propria pagina. Per eseguirlo, mettilo in uno `<script type="module">` di una pagina vuota, oppure incollalo nel pannello JS di un nuovo pen di CodePen (come modulo). Importa postext da esm.sh: non c’è niente da installare né da compilare.

- Cartella della ricetta: https://github.com/drnachio/postext/tree/main/cookbook/business-section-on-salmon

### script.js

```js
// ═══ Postext Cookbook · Nº 134 · A business section on salmon newsprint ═══════════════
// https://postext.dev/en/cookbook/business-section-on-salmon
// Code: MIT · Text: original (CC BY 4.0) · Pictures: generated (CC BY 4.0)
// Fonts: Source Serif 4, Playfair Display, Archivo Narrow (SIL OFL 1.1) · Needs postext ≥ 1.28.0
//
// Eight pages: four of news in five columns, then Business on salmon paper in six.
// One heading style carries the change; a :::paper run gives Folio the salmon stock.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, inlineSvgFonts,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'business-section-on-salmon';

// ─── 1 · Design ───
// #region palette: newsprint, ink and a news red; the Business section swaps three of them
const palette = {
  ink: '#16181b', // text and heavy rules
  paper: '#f6f4ef', // the page: grey-white newsprint (salmon in Business)
  accent: '#b3261e', // news red: flags, kickers, chart marks (petrol in Business)
  tint: '#e8e4da', // box fills (a deeper salmon in Business)
  rule: '#9d9a93', // hairlines
  muted: '#5c5954', // bylines, the folio line, credits
  salmon: '#f2d3c0', // the business stock, and the teaser patches on page 1
  petrol: '#0f4c5c', // the business accent
};
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' } })),
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
// #endregion
const PAGE = { width: 315, height: 470 }; // mm: the 'berliner' preset
const M = { top: 19, bottom: 14, side: 12 }; // mm: narrow newspaper margins, not mirrored
const WIDE = PAGE.width - 2 * M.side; // the text block's width
const LEAD = 11.4; // pt: the body leading, the same in both sections so the grid holds
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const text = (id, content, look, placement) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', color: col('ink'), ...look, placement }); // no text ends in an ellipsis
const sans = (size, weight = 400, look = {}) => ({ fontFamily: 'Archivo Narrow',
  fontSize: pt(size), fontWeight: weight, ...look });
const caps = (size, weight, colour = 'ink') => sans(size, weight, { color: col(colour),
  textTransform: 'uppercase', letterSpacing: pt(size * 0.16) });
const rule = (id, weight, placement) => ({ kind: 'rule', id, thickness: pt(weight),
  color: col('ink'), placement: { ...placement, size: { width: 'fill' } } });
const box = (id, style, placement, size) => ({ kind: 'box', id, style, placement: { ...placement,
  size } }); // an empty box a whole number of grid lines deep sets an opener's depth:
const depth = (lines) => box('depth', {}, at('container', 'top-left'), { width: 'fill',
  height: pt(lines * LEAD) });
const pad = (y, x) => ({ top: mm(y), right: mm(x), bottom: mm(y), left: mm(x) });
const patch = (fill, padding = pad(1.5, 3)) => ({ backgroundColor: col(fill), padding });

// #region nameplate: page 1's H1 is the paper's name, between the ears and the date line
const nameplate = { enabled: true, slot: { elements: [
  depth(13),
  rule('foot', 0.5, at('#depth', 'align-bottom', 0, -2)),
  ...[['left', '{attr.issue}'], ['center', '{publishDate}'], ['right', '{attr.price}']].map(
    ([align, content]) => text(`date-${align}`, content, { ...caps(8, 600), align },
      { ...at('#foot', 'above', 0, -1.6), size: { width: 'fill' } })),
  rule('thin', 0.5, at('#date-left', 'above', 0, -1.8)),
  rule('heavy', 3, at('#thin', 'above', 0, -0.8)),
  text('name', '{titleText}', { fontFamily: 'Playfair Display', fontSize: pt(76), fontWeight: 900,
    lineHeight: 1, align: 'center' }, { ...at('#heavy', 'above', 0, -3), size: { width: 'fill' } }),
  text('weather', 'Weather · {attr.weather}', { ...caps(8, 700), box: patch('tint') },
    at('container', 'top-left')),
  // The right ear points to the salmon pages, on a patch of the same colour.
  text('ear', 'Business · the salmon pages, 5–8', { ...caps(8, 700), box: patch('salmon') },
    at('container', 'top-right')),
] } };
// #endregion

// #region flags: an inside page opens with a flag; the folio line runs above it
const flagParts = [
  depth(3),
  rule('bar', 3, at('#depth', 'align-bottom', 0, -1.4)),
  text('flag', '{titleText}', { ...caps(12, 700, 'paper'), box: patch('accent', pad(1.3, 3.5)) },
    at('#bar', 'above')),
];
const FOLIO = 9; // mm from the top edge
const folioLine = (parity, side, s) => [ // s: +1 on a verso (folio on the left), −1 on a recto
  text(`n-${parity}`, '{pageNumber}', { ...sans(11, 700), align: side },
    at('page', `top-${side}`, s * M.side, FOLIO)),
  text(`t-${parity}`, '{title} · {publishDate}', { ...caps(7.5, 600, 'muted'), align: side },
    at(`#n-${parity}`, s > 0 ? 'right-of' : 'left-of', s * 3, 1)),
  text(`c-${parity}`, '{chapterTitle}', { ...caps(7.5, 700, 'accent'),
    align: s > 0 ? 'right' : 'left' }, at('page', `top-${s > 0 ? 'right' : 'left'}`,
    -s * M.side, FOLIO + 1)),
].map((element) => ({ ...element, parity }));
const header = { elements: [...folioLine('even', 'left', 1), ...folioLine('odd', 'right', -1),
  { kind: 'rule', id: 'folio-rule', thickness: pt(0.5), color: col('ink'), placement: {
    ...at('page', 'top-left', M.side, FOLIO + 5), size: { width: mm(WIDE) } } }] };
// #endregion

// #region answer: one heading style turns a page into a Business page
// page.backgroundColor links to 'paper', so the section's palette, which turns 'paper' salmon,
// repaints every page of the section: the opener and the pages its copy runs on to.
const business = {
  id: 'business',
  advancedDesign: { enabled: true, slot: { elements: flagParts } },
  // The swap: the page, flags, kickers, the folio line, chart marks and table heads follow.
  palette: { paper: palette.salmon, accent: palette.petrol, tint: '#e6bfa8' },
  // Six narrower columns, ruled as the news pages are.
  layout: { layoutType: 'multiple', columnCount: 6, gutterWidth: mm(4) },
  bodyStyle: { fontSize: pt(8.8), lineHeight: pt(LEAD) },
};
// The section front: the same style, with a bigger flag over the market close strip.
const front = () => ({ ...business, id: 'business-front', advancedDesign: { enabled: true, slot: {
  elements: [depth(10),
    text('title', 'Business', { fontFamily: 'Playfair Display', fontSize: pt(58), fontWeight: 900,
      lineHeight: 1, color: col('accent') }, at('container', 'top-left', 0, -1)),
    text('tag', 'Companies · Markets · Money', caps(9, 700, 'accent'),
      at('#title', 'right-of', 5, 9)),
    rule('top', 3, at('#title', 'below', 0, 2)),
    ...STRIP().flatMap(([name, value, change], i) => [ // the day's close, from the market data
      text(`s${i}`, name, caps(7, 700, 'muted'), at('#top', 'below', (i * WIDE) / 6, 1.8)),
      text(`v${i}`, value, sans(13, 700), at(`#s${i}`, 'below', 0, 0.6)),
      text(`c${i}`, change, sans(9.5, 600, { color: col(change[0] === '+' ? 'accent' : 'muted') }),
        at(`#v${i}`, 'right-of', 1.6, 1))]),
    rule('under', 0.5, at('#depth', 'align-bottom', 0, -1.4))] } } });
// hook-up: headingStyles lists both, and the Markdown wraps pages 5–8 in
//   :::paper{type="newsprint" shade="#faede6"}   ← the stock Folio prints them on
//   # Business {style="business-front"}  …  # Markets {style="business"}  …  :::
// #endregion

// #region boxes: headlines across the page, story breaks, briefs, panels and adverts
const none = pad(0, 0);
const banner = { id: 'banner', backgroundEnabled: false, padding: none,
  titleStyle: { ...caps(8.5, 700, 'accent'), gap: mm(1.2) }, // the kicker
  marginTop: pt(0), marginBottom: pt(LEAD / 2),
  body: { fontFamily: 'Source Serif 4', fontSize: pt(13), lineHeight: pt(16.5), textAlign: 'left',
    firstLineIndent: pt(0) } };
const story = { ...banner, id: 'story', marginTop: pt(LEAD), padding: { ...none, top: mm(2.5) },
  stripe: { enabled: true, side: 'top', width: pt(1), color: col('ink') } };
const boxBody = { fontFamily: 'Archivo Narrow', fontSize: pt(9.5), lineHeight: pt(LEAD),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, boldColor: col('accent') };
const briefs = { id: 'briefs', background: col('tint'), padding: pad(2.8, 3),
  titleStyle: { ...caps(9, 700, 'accent'), gap: mm(1.5) }, body: boxBody };
const teaser = { ...briefs, id: 'teaser', background: col('salmon'), columnGap: mm(4.5),
  body: { ...boxBody, fontSize: pt(10.5), lineHeight: pt(13.5) } };
const panel = { ...story, id: 'panel', marginTop: pt(0), titleStyle: briefs.titleStyle,
  stripe: { ...story.stripe, width: pt(2.5), color: col('accent') },
  body: { ...boxBody, fontFamily: 'Source Serif 4' } };
const dataBox = { ...story, id: 'data', marginTop: pt(0), columnGap: mm(4.5) };
const ad = { id: 'ad', backgroundEnabled: false, marginTop: pt(LEAD),
  border: { enabled: true, color: col('ink'), width: pt(0.75) },
  padding: { ...pad(6, 6), top: mm(3) },
  titleStyle: { ...caps(6.5, 600, 'muted'), gap: mm(4) },
  body: { ...boxBody, fontSize: pt(14), lineHeight: pt(18), boldColor: col('ink') } };
const pullQuote = { ...story, id: 'quote', columns: 2, marginTop: pt(0),
  body: { ...banner.body, fontFamily: 'Playfair Display', fontSize: pt(19), lineHeight: pt(23),
    italic: true } }; // a pull quote, floated across two columns
const headline = (id, size, look = {}) => ({ id, fontSize: pt(size), lineHeight: pt(size * 1.04),
  marginBottom: pt(size * 0.2), ...look });
// #endregion

const config = () => ({
  locale: 'en-gb', colorPalette, resourceTypes,
  page: { sizePreset: 'berliner', dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(M.top), bottom: mm(M.bottom), left: mm(M.side), right: mm(M.side) } },
  layout: { layoutType: 'multiple', columnCount: 5, gutterWidth: mm(4.5),
    columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.5) } },
  bodyText: { fontFamily: 'Source Serif 4', fontSize: pt(9.4), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('muted'),
    textAlign: 'justify', firstLineIndent: mm(3.5), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true, maxJustifyTracking: 20,
    avoidWidows: true, avoidOrphans: true },
  headings: { fontFamily: 'Playfair Display', fontWeight: 700, color: col('ink'),
    marginBottom: pt(0), levels: [
      // A break to the next page, left or right: every page opens with its own flag.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        advancedDesign: { enabled: true, slot: { elements: flagParts } } },
      { level: 2, fontSize: pt(17), lineHeight: pt(19), marginTop: pt(LEAD) },
    ] },
  headingStyles: [
    { id: 'front', advancedDesign: nameplate, header: { elements: [] } }, // no folio line
    business, front(),
    headline('lead', 50, { fontWeight: 900 }), headline('wide', 34), headline('second', 22),
    ...[['ad', 60], ['ad-big', 80]].map(([id, size]) => headline(id, size, { fontWeight: 900,
      color: col('accent'), marginBottom: pt(4) })),
  ],
  calloutStyles: [banner, story, briefs, teaser, panel, dataBox, ad, pullQuote],
  paragraphStyles: [
    { id: 'byline', fontFamily: 'Archivo Narrow', fontSize: pt(8.5), textAlign: 'left',
      firstLineIndent: pt(0), color: col('muted') },
    { id: 'flush', firstLineIndent: pt(0) },
    { id: 'jump', fontFamily: 'Archivo Narrow', fontSize: pt(8.5), textAlign: 'right' },
    { id: 'kicker', ...sans(8.5, 700, { color: col('accent') }), textAlign: 'left',
      firstLineIndent: pt(0) },
    { id: 'imprint', fontFamily: 'Archivo Narrow', fontSize: pt(7.2), lineHeight: pt(9.5),
      textAlign: 'left', firstLineIndent: pt(0), color: col('muted'), marginTop: pt(LEAD / 2) },
  ],
  captionStyle: { fontFamily: 'Archivo Narrow', fontSize: pt(8.5), gap: mm(1.6),
    labelColor: col('accent'), note: { fontSize: pt(7), color: col('muted') } },
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: 'Archivo Narrow',
    headerFontSize: pt(8), bodyFontFamily: 'Archivo Narrow', bodyFontSize: pt(8.5),
    bodyColor: col('ink'), cellPadding: mm(1) },
  tableStyles: [{ id: 'prices', headerBackground: col('accent'), headerFontSize: pt(7),
    bodyFontSize: pt(7), cellPadding: mm(0.45) }],
  header, footer: { elements: [] },
  folio: { paper: { type: 'newsprint', grammage: 45, shade: { hex: '#fbfaf8', model: 'hex' } },
    binding: { type: 'folded', cover: 'pages' }, surface: { type: 'oak' },
    lighting: { environment: 'overcast' } },
});

// ─── 2 · Content ───
// #region art: the market data and the drawings, every figure from one source
function mulberry32(seed) { // a seeded PRNG: the same prices on every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}
const sign = (n, d = 1) => `${n < 0 ? '−' : '+'}${Math.abs(n).toFixed(d)}`;
// A table: its first `words` columns are text, flush left; the figures sit flush right.
const table = (widths, heads, rows, words = 1) => ({ headerRowCount: 1, columnWidths: widths,
  rows: [heads, ...rows].map((row, r) => row.map((c, i) => ({ content: String(c),
    align: i < words ? 'left' : 'right', ...(r ? {} : { isHeader: true }) }))) });
const INDICES = [['MX 40', '8,412.6', 66.8, 0.80, 14.1],
  ['MX All-Share', '4,516.2', 27.0, 0.60, 11.8],
  ['MX Small Cap', '6,904.3', -12.4, -0.18, 7.2], ['Europe 350', '2,118.7', 5.9, 0.28, 6.0],
  ['North America 500', '6,702.4', -8.1, -0.12, 9.4],
  ['Asia-Pacific 200', '3,281.9', 14.6, 0.45, 4.8],
  ['Nordic 120', '1,604.2', 7.3, 0.46, 8.1], ['Emerging 100', '1,148.2', 3.1, 0.27, 3.9],
  ['World 1000', '3,902.6', 4.4, 0.11, 7.7], ['Latin America 80', '2,416.0', -9.6, -0.40, 2.2],
  ['Gulf 60', '1,873.5', 6.2, 0.33, 5.1], ['MX Mid 100', '9,127.4', 22.9, 0.25, 9.8],
  ['MX Dividend 30', '3,640.1', 12.6, 0.35, 10.4]];
const CURRENCIES = [['US dollar', '1.3218', -0.20], ['Euro', '1.1634', 0.10],
  ['Japanese yen', '197.42', 0.31], ['Swiss franc', '1.1281', -0.05],
  ['Norwegian krone', '14.212', 0.22], ['Swedish krona', '14.508', 0.12],
  ['Canadian dollar', '1.8215', -0.14], ['Australian dollar', '2.0127', 0.08],
  ['Danish krone', '8.6812', 0.09], ['Polish zloty', '4.9873', 0.18],
  ['Indian rupee', '111.06', -0.25],
  ['Chinese yuan', '9.4127', -0.11], ['Hong Kong dollar', '10.281', -0.19]];
const COMMODITIES = [['Crude oil, $/barrel', '71.40', 1.10],
  ['Natural gas, p/therm', '84.60', 2.30], ['Gold, $/oz', '2,688', -0.40],
  ['Silver, $/oz', '31.42', -0.70], ['Copper, $/tonne', '9,812', 0.60],
  ['Aluminium, $/tonne', '2,604', 0.20], ['Wheat, £/tonne', '182.50', -0.30],
  ['Cocoa, $/tonne', '6,212', 1.80], ['Coffee, $/tonne', '4,890', -0.90],
  ['Sugar, $/tonne', '512.4', 0.40],
  ['Salmon, NOK/kg', '78.20', -1.20], ['Cotton, c/lb', '71.85', 0.15],
  ['Zinc, $/tonne', '2,884', 0.70]];
// Page 5's strip reads the rows page 6's tables print: one source for both.
const quote = (name, [, value, ...rest], pct = rest.at(-1)) => [name, value, `${sign(pct, 2)}%`];
const STRIP = () => [quote('MX 40', INDICES[0], INDICES[0][3]),
  quote('MX All-Share', INDICES[1], INDICES[1][3]), quote('£ / $', CURRENCIES[0]),
  quote('£ / €', CURRENCIES[1]), quote('Crude oil $', COMMODITIES[0]),
  quote('Gold $', COMMODITIES[2])];
const FORECAST = [['Tue', 'Gales easing, showers', '13°', '8°', 'W 40'],
  ['Wed', 'Bright and breezy', '14°', '7°', 'W 25'],
  ['Thu', 'Cloudy, rain later', '12°', '9°', 'SW 30'],
  ['Fri', 'Rain clearing', '13°', '6°', 'NW 22'], ['Sat', 'Sunny spells', '14°', '5°', 'N 12']];
const TIDES = [['Tue', '10.51', '5.9', '23.14', '6.1'], ['Wed', '11.32', '5.6', '23.55', '5.8'],
  ['Thu', '—', '—', '12.14', '5.4'], ['Fri', '0.37', '5.5', '12.58', '5.1'],
  ['Sat', '1.22', '5.1', '13.45', '4.8']];
const PREFIX = ['Ardley', 'Brack', 'Calder', 'Carrow', 'Wynd', 'Corran', 'Dunmere', 'Fairlie',
  'Fenner', 'Haddow', 'Holm', 'Holmside', 'Kelby', 'Quay', 'Lusk', 'Marrow', 'Orvane', 'Pellow',
  'Rennie', 'Skerra', 'Strand', 'Tessary', 'Wendholm'];
const SECTORS = [['Banks', ['Bank', 'Cap', 'Svgs', 'Trust', 'Fin']],
  ['Insurance', ['Ins', 'Life', 'Re', 'Assur']], ['Shipping', ['Lines', 'Freight', 'Shpg', 'Tugs',
    'Ports']], ['Food & drink', ['Foods', 'Bakers', 'Brew', 'Dairies', 'Fish']],
  ['Retail', ['Stores', 'Home', 'Outfit', 'Retail']], ['Engineering', ['Eng', 'Pumps', 'Cables',
    'Castings', 'Marine']], ['Energy', ['Energy', 'Power', 'Wind', 'Water']],
  ['Property', ['Ests', 'Homes', 'Land', 'Props']], ['Technology', ['Soft', 'Data', 'Sys',
    'Digital']], ['Health', ['Pharma', 'Care', 'Diag', 'Med']]];
function pricesTable(i, rows) { // one sector's share prices, seeded
  const rand = mulberry32(2026 + i);
  const names = new Set();
  while (names.size < rows) {
    const [a, b] = [PREFIX, SECTORS[i][1]].map((list) => list[Math.floor(rand() * list.length)]);
    names.add(`${a} ${b}`);
  }
  const fmt = (v) => Math.round(v).toLocaleString('en-GB');
  return table([3.6, 1.2, 1, 1.2, 1.2], [SECTORS[i][0], 'Price', '+/−', 'High', 'Low'],
    [...names].sort().map((name) => {
      const price = 40 + rand() ** 2 * 2400;
      return [name, fmt(price), sign(price * (rand() - 0.47) * 0.05, price < 200 ? 1 : 0),
        fmt(price * (1.05 + rand() * 0.3)), fmt(price * (0.7 + rand() * 0.25))];
    }));
}
// The tables of pages 3, 6, 7 and 8: [model, caption, note].
const TABLES = {
  budget: [table([3, 1.2, 1.2, 1], ['Service, £m', '2026/27', '2027/28', 'Change'],
    [['Adult social care', '412.3', '426.9', '+14.6'], ['Children’s services', '236.8', '241.0',
      '+4.2'], ['Housing', '39.5', '41.2', '+1.7'], ['Waste and recycling', '47.2', '47.9', '+0.7'],
    ['Libraries and culture', '21.6', '21.6', '0.0'],
    ['Roads and transport', '88.4', '84.9', '−3.5'],
    ['Public health', '38.2', '38.9', '+0.7'], ['Planning', '24.1', '22.0', '−2.1'],
    ['Everything else', '134.3', '115.6', '−18.7'], ['**Total**', '**1,042.4**', '**1,040.0**',
      '**−2.4**']]), '**Where the money goes**', 'Source: Marrowick City Council.'],
  forecast: [table([0.8, 3, 0.8, 0.8, 1], ['Day', 'Outlook', 'High', 'Low', 'Wind mph'], FORECAST,
    2),
    '**Weather**', 'Forecast: Marrowick Met Station.'],
  tides: [table([1, 1, 0.8, 1, 0.8], ['Day', 'High', 'm', 'High', 'm'], TIDES),
    '**Tides at Kingsquay**', 'Times BST; heights above chart datum.'],
  indices: [table([2.6, 1.3, 1, 1, 1], ['Index', 'Close', 'Chg', '%', 'Year %'],
    INDICES.map(([n, v, c, p, y]) => [n, v, sign(c), sign(p, 2), sign(y)])), '**Indices**'],
  currencies: [table([2.4, 1.2, 1], ['Per pound', 'Rate', '%'],
    CURRENCIES.map(([n, v, p]) => [n, v, sign(p, 2)])), '**Currencies**'],
  commodities: [table([2.6, 1.2, 1], ['Commodity', 'Price', '%'],
    COMMODITIES.map(([n, v, p]) => [n, v, sign(p, 2)])), '**Commodities**'],
  active: [table([2.6, 1.2, 1.2, 1], ['Most traded', 'Shares m', 'Close p', '%'], [
    ['Corvane Group', '31.0', '1,184', '+6.2'], ['Tessary Insurance', '18.4', '642', '+1.9'],
    ['Skerra Lines', '12.9', '318', '+3.1'], ['Ashby Stores', '11.2', '97.4', '−3.4'],
    ['Dunmore Bank', '9.7', '455', '+0.6'], ['Fenner Holdings', '8.3', '736', '+2.7'],
    ['Halden Bakeries', '6.1', '1,092', '+1.2'], ['Kelby Software', '5.8', '214', '+0.4'],
    ['Wendholm Water', '5.2', '871', '−0.2'], ['Calder Life', '4.9', '388', '+0.9'],
    ['Orvane Marine', '4.4', '152', '+1.5'], ['Brack Cables', '3.9', '611', '−0.8'],
    ['Saltmarsh Fish', '3.6', '86.5', '+2.4']]), '**Most traded**'],
  results: [table([2.6, 1.1, 1.1, 0.9], ['£m', 'Sales', 'Profit', 'Div p'],
    [['Halden (year)', '214.0', '19.6', '14.4'], ['Skerra Lines (half)', '58.3', '3.1', '4.2'],
      ['Kelby Software (half)', '31.7', '4.9', '2.0'], ['Fenner (year)', '96.2', '11.4', '9.8'],
      ['Ashby Stores (half)', '142.5', '−2.3', '1.0'],
      ['Calder Life (year)', '388.0', '41.6', '12.2'],
      ['Orvane Marine (half)', '44.9', '3.8', '1.6']]),
  '**Results**'],
  diary: [table([1, 2.6, 2], ['Day', 'Company', 'Event'], [['Wed', 'Ashby Stores',
    'Half-year results'], ['Wed', 'Wendholm Water', 'Trading update'], ['Thu', 'Kelby Software',
    'Annual meeting'], ['Fri', 'Dunmore Bank', 'Third-quarter update'], ['Fri', 'Calder Life',
    'Dividend paid, 6.1p'], ['Mon', 'Halden Bakeries', 'Annual meeting'],
    ['Tue', 'Tessary Insurance', 'Storm claims update']], 3), '**Company diary**'],
  savings: [table([2.6, 1], ['Account', 'Rate'], [['1-year fixed, Dunmore Bank', '4.15%'],
    ['2-year fixed, Holm BS', '4.02%'], ['Easy access, Marrow Mutual', '3.85%'],
    ['90-day notice, Kelby Savings', '3.90%'], ['Cash ISA, Fairlie Bank', '3.70%']]),
  '**Best buys**', 'Gross annual rates on £10,000.'],
};
const GENERATED = 'Picture: Generated With Diffusion Models';
const LEDGER = 'Source: Ledger Data';
const CAPTIONS = Object.fromEntries(Object.entries({
  barrier: ['**The Marrow Barrier at 9.30pm on Monday,** its gates closed against the surge. The '
    + 'river behind them rose by 30 centimetres.', 'Curved steel flood gates closed across a '
    + 'river in a storm, waves breaking on them, two engineers on a walkway.'],
  welders: ['**First week:** apprentices welding a hull section in the fabrication hall at '
    + 'Brackwater.', 'Two young welders in masks at a steel hull, an instructor watching.'],
  gate: ['**How a gate turns.** Each segment turns on a pin in the pier, from its sill on the '
    + 'riverbed to stand against the sea.', 'Diagram of two barrier gates in section, one lying '
    + 'in its sill, one turned up.', 'Drawing: The Ledger'],
  ferry: ['**The Holm Maid** leaves Skerra pier on her first crossing since May.',
    'A blue and white car ferry leaving a stone harbour on a grey day.'],
  cartoon: ['**Holding the line,** by Kit Carrow.', 'Cartoon: a flood gate drawn as a castle '
    + 'wall holds back a wave while the town drinks tea behind it; on top, an official with an '
    + 'empty piggy bank.'],
  cranes: ['**The Outer Quay at dawn.** The new berth would take ships three times the size of '
    + 'those the port handles now.', 'Container cranes unloading a ship at dawn.'],
  throughput: ['**Containers through Marrowick,** thousands of twenty-foot units a year.',
    'Bar chart of container traffic, 982,000 units in 2016 to 1,185,000 in 2026, near capacity.',
    'Source: Marrowick Harbour Board'],
  mx40: ['**The MX 40 over the past year,** daily closes.',
    'Line chart of the MX 40, from 7,370 to 8,412.6 in twelve months.', LEDGER],
  sectors: ['**Sectors on the Marrowick Exchange,** change on the day.',
    'Bar chart of ten sectors, from shipping, up 3.4%, to retail, down 2.1%.', LEDGER],
  loaves: ['**Halden’s Quayside plant** bakes 1.1 million loaves a week.',
    'Bakery workers in white coats checking loaves on a conveyor.'],
}).map(([id, [caption, altText, note = GENERATED]]) => [id, { caption, altText, note }]));
// The labels are set in Archivo Narrow: loadSvg embeds the face the root names.
const svg = (W, H, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" `
  + `height="${H * 10}" viewBox="0 0 ${W} ${H}" font-family="Archivo Narrow">${body}</svg>`;
const label = (x, y, s, content, look = '', fill = palette.ink) => `<text x="${x.toFixed(2)}" `
  + `y="${y.toFixed(2)}" font-size="${s}" fill="${fill}" ${look}>${content}</text>`;
function mx40Svg() { // a year of the MX 40: a seeded walk that ends on the close
  const rand = mulberry32(40);
  const walk = [0];
  for (let i = 1; i < 253; i++) walk.push(walk[i - 1] + rand() - 0.46);
  const level = walk.map((v) => 7370 + (v * 1042.6) / walk[252]);
  const [W, H, L, R, B] = [291, 100, 14, 18, 8]; // mm: size, left and right gutters, axis
  const x = (i) => L + (i * (W - L - R)) / 252;
  const y = (v) => H - B - ((v - 7200) * (H - B - 4)) / 1400;
  let out = '';
  for (const v of [7400, 7800, 8200, 8600]) {
    out += `<path d="M${L} ${y(v).toFixed(2)}H${W - R}" stroke="${palette.rule}" `
      + 'stroke-width="0.2"/>'
      + label(L - 1.5, y(v) + 1, 3, v.toLocaleString('en-GB'), 'text-anchor="end"');
  }
  'Oct Nov Dec Jan Feb Mar Apr May Jun Jul Aug Sep'.split(' ').forEach((m, k) => {
    out += label(x(k * 21 + 10), H - 2, 3, m, 'text-anchor="middle"');
  });
  const line = level.map((v, i) => `${i ? 'L' : 'M'}${x(i).toFixed(2)} ${y(v).toFixed(2)}`)
    .join('');
  return svg(W, H, `${out}<path d="${line}L${x(252)} ${H - B}L${L} ${H - B}Z" `
    + `fill="${palette.petrol}" fill-opacity="0.14"/><path d="${line}" fill="none" `
    + `stroke="${palette.petrol}" stroke-width="0.6"/><path d="M${L} ${H - B}H${W - R}" `
    + `stroke="${palette.ink}" stroke-width="0.35"/>`
    + label(x(252) + 1.5, y(level[252]) + 1, 3.4, '8,412.6', 'font-weight="700"'));
}
function throughputSvg() { // container traffic, thousands of TEU a year, 2016–2026
  const DATA = [982, 1004, 1031, 1047, 918, 996, 1072, 1108, 1139, 1164, 1185];
  const [W, H, B] = [113, 70, 8];
  const y = (v) => H - B - (v * (H - B - 8)) / 1300;
  let out = '';
  DATA.forEach((v, i) => {
    out += `<rect x="${3 + i * 10}" y="${y(v).toFixed(2)}" width="7" height="${(H - B - y(v))
      .toFixed(2)}" fill="${i === 10 ? palette.petrol : palette.rule}"/>`
      + label(6.5 + i * 10, H - 3, 2.8, `’${16 + i}`, 'text-anchor="middle"');
  });
  return svg(W, H, `${out}<path d="M2 ${y(1200).toFixed(2)}H111" stroke="${palette.ink}" `
    + 'stroke-width="0.4" stroke-dasharray="1.2 0.8"/>'
    + label(3, y(1200) - 1.6, 3, 'Capacity today, 1.2 million')
    + label(109.5, y(1185) + 4, 2.8, '1,185', 'text-anchor="end" font-weight="700"',
      palette.salmon));
}
function gateSvg() { // a gate in section, in its sill and turned up
  const [r, bed] = [14, 38];
  const py = bed - r * Math.SQRT1_2; // the pivot: the arc's ends sit on the riverbed
  const p = (cx, a) => `${(cx + r * Math.cos((a * Math.PI) / 180)).toFixed(2)} `
    + `${(py - r * Math.sin((a * Math.PI) / 180)).toFixed(2)}`;
  const gate = (cx, a) => `<path d="M${p(cx, a)}A${r} ${r} 0 0 0 ${p(cx, a + 90)}Z" `
    + `fill="${palette.accent}"/><path d="M${p(cx, a)}L${cx} ${py.toFixed(2)}L${p(cx, a + 90)}" `
    + `fill="none" stroke="${palette.ink}" stroke-width="0.35"/><circle cx="${cx}" `
    + `cy="${py.toFixed(2)}" r="1.4" fill="${palette.ink}"/>`;
  const water = (x, w, top) => `<rect x="${x}" y="${top}" width="${w}" height="${bed - top}" `
    + `fill="${palette.rule}" fill-opacity="0.4"/>`;
  return svg(113, 50, water(0, 54, 24) + water(59, 30, 30) + water(89, 24, 19)
    + `<rect x="0" y="${bed}" width="54" height="12" fill="${palette.muted}"/>`
    + `<rect x="59" y="${bed}" width="54" height="12" fill="${palette.muted}"/>`
    + gate(27, 225) + gate(80, -45)
    + label(1, 5, 3.2, 'Open', 'font-weight="700"')
    + label(1, 9.5, 2.8, 'The gate lies in its sill')
    + label(60, 5, 3.2, 'Closed', 'font-weight="700"')
    + label(60, 9.5, 2.8, 'Turned up through 90°')
    + label(61, 34, 2.8, 'River') + label(111, 23, 2.8, 'Sea', 'text-anchor="end"'));
}
const SECTOR_MOVES = [['Shipping', 3.4], ['Insurance', 1.6], ['Engineering', 1.1], ['Banks', 0.7],
  ['Energy', 0.5], ['Food and drink', 0.3], ['Property', -0.1], ['Technology', -0.2],
  ['Health', -0.4], ['Retail', -2.1]];
function sectorsSvg() { // each sector's move on the day
  const [W, H, Z, k] = [291, 88, 150, 30]; // mm; the zero line; mm per point
  let out = '';
  SECTOR_MOVES.forEach(([name, move], i) => {
    const y = 2 + i * 8.6;
    const [x, w] = move >= 0 ? [Z, move * k] : [Z + move * k, -move * k];
    out += `<rect x="${x.toFixed(2)}" y="${y.toFixed(1)}" width="${w.toFixed(2)}" height="6" `
      + `fill="${move >= 0 ? palette.petrol : palette.rule}"/>`
      + label(move >= 0 ? Z - 2 : Z + 2, y + 4.3, 3.6, name,
        `text-anchor="${move >= 0 ? 'end' : 'start'}"`)
      + label(move >= 0 ? x + w + 1.5 : x - 1.5, y + 4.3, 3.6, `${sign(move)}%`,
        `font-weight="700" text-anchor="${move >= 0 ? 'start' : 'end'}"`);
  });
  return svg(W, H, `${out}<path d="M${Z} 0V${H}" stroke="${palette.ink}" stroke-width="0.35"/>`);
}
// #endregion

// #region floats: pictures, charts and tables across k of the page's columns
const unnumbered = (id, name, captionStyle) => ({ id, name, shortLabel: name, captionPrefix: '',
  numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionStyle });
const resourceTypes = [unnumbered('picture', 'Picture'),
  unnumbered('panel', 'Panel', { position: 'above', fontSize: pt(9.5) })];
const res = (id, typeId, kind, body, extra) => ({ id, typeId, kind, [kind]: body,
  createdAt: 0, updatedAt: 0, ...extra });
const photo = (id, file, [w, h], placement, resolution) => res(id, 'picture', 'bitmap',
  { fileId: file, format: 'jpeg', width: w, height: h, ...(resolution && { resolution }) },
  { placement, ...CAPTIONS[id] });
const drawing = (id, [w, h], placement) => res(id, 'picture', 'svg',
  { fileId: `${id}.svg`, width: w, height: h }, { placement, ...CAPTIONS[id] });
const data = (id, model, caption, note) => res(id, 'panel', 'table', { model },
  { placement: here, caption, note });
const top = (columns) => ({ position: 'top', columns }); // a float across k columns
const here = { position: 'here' }; // in the flow: in a column, or across a box
const resources = [
  photo('barrier', 'barrier-1800.jpg', [1800, 1106], here), // in the banner box: page-wide
  photo('welders', 'apprentices-1536.jpg', [1536, 1024], top(3)),
  drawing('gate', [1130, 500], top(3)),
  photo('ferry', 'ferry-1536.jpg', [1536, 1024], top(4)),
  photo('cartoon', 'cartoon-1280.jpg', [1280, 853], top(4), 140),
  photo('cranes', 'cranes-1800.jpg', [1800, 750], here),
  drawing('throughput', [1130, 700], top(2)),
  drawing('mx40', [2910, 1000], here), drawing('sectors', [2910, 880], here),
  photo('loaves', 'bakery-1536.jpg', [1536, 1024], top(3)),
  // The tables sit where the text names them: no float.
  ...Object.entries(TABLES).map(([id, [model, caption, note]]) => data(id, model, caption, note)),
  ...SECTORS.map((_, i) => res(`prices-${i + 1}`, 'panel', 'table',
    { styleId: 'prices', model: pricesTable(i, 21) }, { placement: here })),
];
// #endregion
const markdown = String.raw`---
title: "The Marrowick Ledger"
author: "The Marrowick Ledger"
publishDate: "Tuesday 6 October 2026"
---

# The Marrowick Ledger {style="front" issue="No. 41,207 · Est. 1878" price="£1.60" weather="Gales easing · 13° · 8°"}

:::callout{type="banner" span="page" title="Storm surge"}
## Barrier holds back the Marrow’s highest tide in forty years {style="lead"}

*Nine steel gates swung shut at 9.12pm. Behind them, 14,000 homes in the Lower Town stayed dry*

::resource{id="barrier"}
:::

:::callout{type="teaser" span="page" placement="bottom"}
:::columns{count=3 breaks="2,3"}
**Business** · The salmon pages, 5–8. The harbour board backs a £640 million deep-water terminal, and the MX 40 closes at a record.

**Markets** · Page 6. Shipping and insurers lead the city’s index higher. Share prices and savings rates: page 8.

**Inside** · Weather and tides, page 3. Opinion and letters, page 4. Night trams, below.
:::
:::

:::paragraphs{style="byline"}
By **Helen Strand** · Environment Correspondent
:::

:::paragraphs{style="flush"}
The Marrow Barrier closed against a storm surge for the first time on Monday night, seven months after it opened, and held back a tide that reached 6.84 metres above chart datum at Kingsquay, the highest the harbour gauge has recorded since January 1986.
:::

Its nine gates, each 41 metres wide and weighing 820 tonnes, began to turn up from the riverbed at 8.40pm and were sealed by 9.12pm, eighty minutes before high water. By then the wind was gusting at 74 mph off Skerra Head and the surge was running 1.9 metres above the predicted tide.

Upstream of the gates the river rose by only 30 centimetres. Downstream, at Fenner’s Wharf and along the Outer Quay, water topped the old harbour wall for about an hour and flooded the yards of two warehouses. Nobody was hurt, and the fire service pumped out three cellars on Saltmarsh Road.

“This is the night it was built for,” said Ruth Carrow, who runs the barrier for the River Marrow Authority and spent the night in the control room on the north pier. “We closed on the forecast, not on the water, and that is what gave us the margin.”

Police closed the embankment road at seven and kept it shut until the gates opened again. Several hundred people watched the closure from the north bank, many of them old enough to remember the last great flood.

In 1986 a smaller surge put 1.2 metres of water into the streets of the Lower Town and caused damage the city later put at £96 million in today’s money. The barrier, which cost £310 million and took six years to build, was designed for a tide 1.5 metres higher than Monday’s.

The gates reopened at 3.05am, and the first ships went through on the morning tide. The authority expects to close the barrier four to eight times each winter, and more often as the sea rises.

:::paragraphs{style="jump"}
How the gates turn: **page 2** · Opinion: **page 4**
:::

:::callout{type="story" span="page" title="Transport"}
## Night trams will run until 2am from January {style="second"}
:::

:::paragraphs{style="flush"}
Trams on the Harbour and University lines will run until two in the morning on Fridays and Saturdays from 8 January, the transport committee agreed last night by eleven votes to three.
:::

A trial last winter carried 41,000 passengers over twelve weekends, more than twice what the council had expected, and the busiest tram of the night was usually the last. The later service will cost £1.1 million a year, paid from the city centre parking levy.

Police told the committee that assaults in the city centre fell by a tenth on the trial’s weekends. Taxi drivers opposed the plan: their association said its members take a fifth of the week’s fares in those four hours, and asked for the rank at Market Cross to be moved nearer the tram stop.

“People will leave the car at home if they can be sure of getting back,” said Councillor Imran Haddow, who chairs the committee and proposed the trial. Night buses will go on serving the outer estates, where the trams do not reach, and a review of the bus routes that feed the tram stops is due in the spring.

The first night trams will leave Market Cross at half past midnight and run every twenty minutes. Drivers will work the late turns on a voluntary rota, paid at time and a half, and their union said yesterday that enough had signed up to cover the winter. Two conductors will ride on the last tram, as they did in the trial.

Fares will be the same as by day, and passes will be valid. The last trams will wait for the 1.40 train from the south. The committee will look at Thursday nights once it has three months of figures; the University line may come first.

# City

:::callout{type="banner" span="page" title="Brackwater"}
## Shipyard takes on 64 apprentices, its biggest intake since 1991 {style="wide"}

*Four research ships and two island ferries fill the order book until 2030*

:::paragraphs{style="byline"}
By **Owen Lusk** · Industry Reporter
:::
:::

:::paragraphs{style="flush"}
Corran’s yard at Brackwater has taken on 64 apprentices this autumn, its largest intake for 35 years, as it starts work on four research vessels and a pair of ferries for the island routes :ref{id="welders" text="(picture)"}.
:::

The new starters, 19 of them women, began their four years on Monday in the fabrication hall: welders, platers, pipefitters and electricians, with six in the drawing office. Each is paid £16,400 in the first year, rising to £27,900 in the fourth, and every one who finishes is promised a job.

“Ten years ago we were taking on eight a year and losing half of them to the oil fields,” said Mairi Corran, the yard’s managing director and the fourth of her family to run it. “Now the work is here for a decade, and so is the training.”

The yard employs 1,140 people and has orders worth £780 million. The research ships, 70 metres long, will be built for a consortium of northern universities; the first steel was cut in August. The two ferries, for the Skerra and Holm crossings, are the first in the islands to run on batteries charged from the shore.

The apprentices spend their first six months in the training bay before they go out to the ships. Each is paired with a tradesman who has at least ten years at the yard, and the older hands are paid an extra £1,800 a year to take them on. Half of the yard’s present foremen started the same way.

Most of the new intake are from the city, but eleven come from the islands and will lodge in Brackwater during the week. Skerra Lines has agreed to carry them home free on Friday evenings for their first year.

In 2019 the yard had a single dredger on the slipway and had cut its workforce to 610. Since then it has won the research ships against bids from Norway and the Netherlands, and the two ferries from the island council, which wanted them built within reach of the crossings they will serve.

Skills remain the risk. The yard expects a quarter of its welders to retire within six years, and the city college has doubled its evening classes to keep up. A new training hall, paid for jointly by the yard and the regional growth fund, opens in the spring.

:::callout{type="story" span="page" title="Storm surge"}
## How the gates turn {style="second"}
:::

:::paragraphs{style="flush"}
The barrier’s gates lie flat in concrete sills on the riverbed, so ships pass over them on every ordinary tide. Each is a hollow steel segment, curved like a slice of a drum and pinned at both ends to a pier :ref{id="gate" text="(diagram)"}.
:::

To close, hydraulic arms in the piers turn the segments through 90 degrees until the curved face stands up against the sea. The turn takes about half an hour, and the control room closes the gates in pairs from the middle out, so the river is never squeezed through a single gap.

To open, the gates turn on past the vertical and back down into the sills, washing out the silt that settles in them between closures. Once a month, on a quiet neap tide, the authority closes one gate as a test.

:::callout{type="briefs" title="In brief"}
**Library hours.** Central Library opens until 8pm on Thursdays from next week, with the café and the local studies room.

**Bridge works.** One lane of Kingsquay Bridge closes overnight from 12 to 16 October while joints are replaced.

**Night market.** The Saturday market on Fish Quay stays open until 9pm from 7 November until Christmas, with 40 extra stalls.

**Fun run.** The Harbour 10K on Sunday raised £212,000 for the children’s ward at Marrowick Infirmary.
:::

:::callout{type="ad" span="page" placement="bottom" title="Advertisement"}
## Pellow & Rae {style="ad-big"}

**The autumn sale starts on Saturday**

:::space{lines=7}

Wool coats from £89 · Cashmere knitwear from £59 · Leather boots from £75

Free alterations on every coat until Christmas · Castle Wynd, open 9 till 7, late on Thursdays
:::

# Region

:::callout{type="banner" span="page" title="Council budget"}
## Libraries spared as the council finds £18 million of savings {style="wide"}

*Parking charges rise and two depots close, but all eleven branch libraries stay open*

:::paragraphs{style="byline"}
By **Priya Anand** · Local Government Correspondent
:::
:::

:::callout{type="data" span="page" placement="bottom"}
:::columns{count=3 breaks="2,3"}
::resource{id="budget"}

::resource{id="forecast"}

::resource{id="tides"}
:::
:::

:::paragraphs{style="flush"}
Marrowick City Council will keep all eleven of its branch libraries open next year, abandoning a plan to close four of them, after finding £18.2 million of savings elsewhere in a budget of £1.04 billion.
:::

The draft budget, published yesterday, raises on-street parking charges by 40p an hour in the city centre and closes two of the council’s five highways depots. Council tax rises by 4.99%, the most allowed without a local vote, adding £1.38 a week to a Band D bill.

The four libraries, at Holmside, Saltmarsh, Fenner’s Green and Kelby Rise, drew 3,100 signatures to a petition in the summer. “The libraries are where people go for the internet, for warmth and for help with forms,” said the council’s leader, Ann Fairlie. “We heard that.”

Adult social care takes the largest increase, £14.6 million, as the number of people over 85 in the city grows by about 600 a year. Spending on roads falls by 4% :ref{id="budget" text="(table, below)"}.

The opposition said the parking rise would hurt shops on the edge of the centre. The budget goes to the full council on 3 December.

:::callout{type="story" span="page" title="Islands"}
## Skerra ferry back in service after a £4 million refit {style="second"}
:::

:::paragraphs{style="flush"}
The *Holm Maid* returned to the Skerra crossing yesterday after five months in dry dock, with new engines that burn 30% less fuel and a car deck widened to take two more lorries :ref{id="ferry" text="(picture)"}.
:::

The island’s 900 residents have relied on a smaller chartered boat since May, which could not carry lorries. Shops on Skerra ran short of building materials, and the island’s school had to put off the start of its new extension until the spring.

“It has been a long summer,” said Morag Lusk, who keeps the shop at the pier. “We had cement on the mainland and builders on the island, and no way to get the one to the other.”

The first crossing, at 7.15am, was full. Skerra Lines says the winter timetable, with four sailings a day, starts on 26 October, and that the gales should ease by Wednesday.

The new engines are quieter as well as cleaner, the company says, and on the wider deck the fuel tanker and the supermarket lorry can sail on the same day.

The refit was done at Corran’s yard in Brackwater, the first time in twenty years that an island ferry has been repaired in the city rather than sent south. The yard finished a week before the date in its contract. Her crew of eleven spent the summer on the chartered boat, which Skerra Lines will keep on standby until the spring.


:::callout{type="ad" span="page" placement="bottom" title="Advertisement"}
## Skerra Lines {style="ad"}

**Four sailings a day from 26 October · Car spaces on 01632 960 260**

:::space{lines=1}

Kingsquay 7.15, 10.30, 14.00, 17.45 · Skerra 8.40, 12.15, 15.30, 19.10
:::
`; // content.en.md: pages 1–3
const businessPages = String.raw`# Opinion

:::callout{type="banner" span="page" title="The Ledger’s view"}
## The barrier worked. Now pay for the next one {style="wide"}
:::

:::paragraphs{style="flush"}
On Monday night the Marrow Barrier did what its critics said it never would. It closed on time, it held, and the Lower Town woke to wet pavements rather than flooded kitchens :ref{id="cartoon" text="(cartoon)"}. The engineers who built it, and the crew who ran it through the storm, have the city’s thanks.
:::

They have also earned a hearing. The barrier was designed to protect Marrowick until about 2070. Every forecast since it was planned has raised the sea a little higher and brought that date a little closer. The authority already says it will need to raise the flood walls downstream of the gates within fifteen years, at a cost it puts at £120 million.

Nor is the barrier the only wall the city leans on. The embankments at Saltmarsh and the pumping station at Fenner’s Green are older than the gates and were built for a lower sea. On Monday the pumps ran for eleven hours without a fault, and nobody at the authority will say how many more such nights they have in them.

That money is not in anybody’s budget. The city cannot ask the Lower Town to wait for the next 1986 before it starts saving. A small levy on the port’s cargo, which the barrier also protects, would raise £4 million a year. The board’s own figures put that at about 30p on each container, less than the port charges for a day’s storage. Ships that shelter behind the gates can help to pay for them. The harbour board should agree to the levy before the new terminal is built, not after.

:::callout{type="story" span="page" title="Tom Ellery"}
## Sixty-four reasons to be cheerful {style="second"}
:::

:::callout{type="quote" placement="top"}
“A city that gives its young people a choice about staying will still be here when the next storm comes”
:::

:::paragraphs{style="flush"}
I spent Monday morning in the fabrication hall at Brackwater, among sixty-four teenagers in new overalls, and it was the most hopeful room I have stood in for years.
:::

When I started on this paper the yard was a byword for decline. Every winter brought another round of lay-offs, and every spring another rumour that the cranes would be sold for scrap. My father welded there for thirty years; I was told, firmly and often, not to follow him.

What changed was not luck. The yard stopped bidding for work it could not win and learned to build the ships nobody else wanted to build: small, complicated and clean. Those ships need people who can think as well as weld, and the yard started training them.

The apprentices I met were careful to say they had other offers. That is the point. A city that gives its young people a choice about staying is a city that will still be here when the next storm comes.

:::callout{type="story" span="page" title="Letters"}
## Gates, parking and the libraries {style="second"}
:::

**Gates and gratitude.** I watched the barrier close from the north embankment on Monday. My street flooded in 1986 and I lost every photograph I owned. Thank you to everyone who built it. *June Pellow, Saltmarsh Road*

**Parking.** Forty pence an hour more is the difference between my customers coming into town and driving to the retail park. *Arif Malik, Castle Wynd*

**Libraries.** The petition worked. Next time the council should ask before it plans a closure, not after. *Elspeth Rae, Kelby Rise*

:::callout{type="ad" span="page" placement="bottom" title="Advertisement"}
## Marrowick Philharmonic {style="ad-big"}

**The winter season opens on Saturday 17 October with Sibelius and Elgar**

:::space{lines=3}

Twelve concerts at the Corn Hall, from £9 · Under-26s £5 · Box office open daily from 10
:::

**Trams.** Two in the morning is welcome, but my shift at the Infirmary ends at three. *Daniel Okoro, Holmside*

**The terminal.** Before the harbour board digs up the mudflats at the Outer Quay, someone should count the birds that winter there. I counted 2,400 knot on a single tide last January. *Neil Rennie, Fenner’s Green*

**Bus 44.** The Sunday buses to Holm stop at six. Anyone who works a late shift in town still needs a car, trams or no trams. *Grace Fairlie, Holm*

**The pumps.** The crew at Fenner’s Green pumping station worked through Monday night as well. They deserve a line too. *Hamish Orr, Fenner’s Green*

:::paper{type="newsprint" shade="#faede6"}

# Business {style="business-front"}

:::callout{type="banner" span="page" title="Ports"}
## Harbour board backs £640 million deep-water terminal {style="lead"}

*The Outer Quay scheme would let the largest container ships call at Marrowick for the first time*

::resource{id="cranes"}
:::

:::callout{type="ad" span="page" placement="bottom" title="Advertisement"}
## Dunmore Bank {style="ad"}

**A one-year fixed bond at 4.15% gross**

From £1,000, opened in any of our eleven branches or by telephone. Rates can change before you apply.
:::

:::callout{type="panel" placement="top" columns="2" title="The terminal in numbers"}
**£640m** the cost, shared between the board and Corvane

**16.5 m** depth of the dredged berth, against 11 m today

**2.6m** containers a year at full capacity, more than twice today’s 1.2 million

**1,400** jobs during the build, and 380 when the terminal is running
:::

:::paragraphs{style="byline"}
By **Callum Reid** · Business Editor
:::

:::paragraphs{style="flush"}
The harbour board voted yesterday to build a deep-water container terminal at the Outer Quay, the largest investment in the port since the docks were nationalised in 1948. Container traffic is already close to what the quays can handle :ref{id="throughput" text="(chart)"}.
:::

The £640 million scheme would dredge a berth 16.5 metres deep and 700 metres long, enough for ships carrying 20,000 containers. Today the port can take vessels of about a third that size, and cargo for the city’s factories is often unloaded further south and brought north by rail.

The board voted nine to two in favour. Construction would start in 2028 and the first berth would open in 2031, lifting the port’s capacity from 1.2 million to 2.6 million twenty-foot units a year.

“For thirty years we have watched the big ships sail past us,” said Fiona Stroud, the board’s chief executive. “This puts Marrowick back on the main line between the Atlantic and the Baltic.”

The board will pay for the terminal with £260 million of its own reserves and borrowing, and a £380 million share from Corvane Terminals, an operator that would run the berth under a 35-year concession. The two sides signed heads of terms last week.

Not everyone is convinced. The two members who voted against warned that the board was betting on a recovery in container trade that freight rates do not yet show, and that dredging would disturb mudflats used by wintering birds. The plan needs a harbour revision order, and a public inquiry is likely.

Shares in Corvane’s parent rose 6.2% on the news. Analysts at Holm & Partners said the concession would add about 4% to the group’s profits once the terminal was full, and raised their price target to 1,300p.

:::callout{type="story" span="page" title="Savings"}
## Marrow Mutual raises its easy-access rate for the third time this year {style="second"}
:::

:::paragraphs{style="flush"}
Savers with Marrow Mutual will earn 3.85% on its easy-access account from Monday, up from 3.6%, and has promised to hold the rate until the end of March whatever happens to the base rate in the meantime.
:::

The building society, which has 212,000 members and 14 branches between the city and the islands, said savers had put £96 million into the account since June. “We lend to first-time buyers in the valley, and their mortgages are paid for by local savings,” said its chief executive, Rhona Kelby. “We need both.”

The rise puts the Mutual at the top of the easy-access tables, a few points below the best one-year fixed bonds (page 8).

**Kelby Software** has won a £12 million contract to run the city’s new parking app for six years. **House prices** in Marrowick rose 0.7% in September, says the Northern Property Index, to an average of £214,300. **Unemployment** in the region fell to 4.1% in the three months to August, from 4.4%. **Corran’s yard** has ordered £9 million of steel for two island ferries, its largest purchase since 2011. **Brackwater Foods** opens a second smokehouse at Holm.

# Markets {style="business"}

:::callout{type="banner" span="page" title="Market report"}
## Shipping lifts the MX 40 to a record close {style="wide"}

*Insurers recover as the barrier holds; the pound slips against the dollar*

::resource{id="mx40"}

:::paragraphs{style="byline"}
By **Nadia Brennan** · Markets Reporter
:::
:::

:::callout{type="data" span="page" placement="bottom"}
:::columns{count=4 breaks="2,3,4"}
::resource{id="indices"}

::resource{id="currencies"}

::resource{id="commodities"}

::resource{id="active"}
:::
:::

:::paragraphs{style="flush"}
The MX 40 closed at a record 8,412.6 last night, up 0.8% on the day, as investors bought shipping and port stocks after the harbour board’s vote and insurers recovered from a nervous start.
:::

Corvane Group led the index with a rise of 6.2% to 1,184p. Skerra Lines gained 3.1% and the stevedoring firm Fenner Holdings 2.7%. Brokers said the terminal would add traffic to the whole of the port, not only the new berth.

Insurers had fallen in early trading on fears of storm claims, then turned higher once it was clear that the barrier had held. Tessary Insurance ended 1.9% up, having been down 2.4% at the opening.

The index has risen 14% over the past year, against 6% for the Europe 350. Fund managers say the gap reflects the city’s mix of companies: few technology stocks, many industrial firms whose order books have filled since energy prices fell last spring.

Not every sector shared the gains. Retailers slipped after the September sales figures showed a third month of falling spending on clothing, and Ashby Stores lost 3.4%. Property shares were flat.

In the currency market the pound fell 0.2% against the dollar to \$1.3218, after a speech by a central bank official was read as a hint that interest rates could be cut before Christmas. Gold slipped and crude oil rose 1.1% to \$71.40 a barrel.

Turnover on the Marrowick Exchange was 412 million shares, about a fifth above the average for the past month. Corvane was the busiest stock, with 31 million traded.

:::callout{type="story" span="page" title="Sectors"}
## Ports and insurers led, the shops lagged {style="second"}

::resource{id="sectors"}
:::

# Companies {style="business"}

:::callout{type="banner" span="page" title="Food"}
## Halden adds a night shift as supermarket orders rise {style="wide"}

*The Quayside bakery will hire 90 people and run around the clock from November*

:::paragraphs{style="byline"}
By **Callum Reid**
:::
:::

:::callout{type="ad" span="page" placement="bottom" title="Advertisement"}
## Corran Shipbuilders {style="ad"}

**Apprenticeships 2027: welding, plating, pipework, electrical and design**

:::space{lines=20}

Four years, paid from the first day, with a job at the end. Applications close on 30 November.
:::

:::callout{type="data" span="page" placement="bottom"}
:::columns{count=2 breaks="2"}
::resource{id="results"}

::resource{id="diary"}
:::
:::

:::paragraphs{style="flush"}
Halden Bakeries will add a night shift at its Quayside plant next month, hiring 90 people, after two supermarket chains moved their bread contracts for the north to the company :ref{id="loaves" text="(picture)"}.
:::

The plant bakes 1.1 million loaves a week on three tunnel ovens. With the night shift it will make 1.6 million, and the company will spend £8 million on a fourth oven next year.

Halden’s sales rose 11% to £214 million in the year to June, and its profit before tax by 18% to £19.6 million. The dividend goes up by a fifth.

“Bread is a local business, whatever the supermarkets say,” said its chief executive, Grant Halden. “A loaf baked in Marrowick at midnight is on a shelf in the Borders by six.”

Flour costs, which rose by almost half in 2023, have fallen back by a fifth, but wages and energy take a growing share of each loaf. The company says it will pay the new night staff a premium of 20%.

The new shift will run from ten at night to six in the morning, five nights a week, and Halden is recruiting through the city college and the job centre on Dock Street. About a third of the 90 posts are for engineers to keep the ovens and the slicing lines running; the rest are on the line and in dispatch, where 14 lorries leave the yard each night.

## Skerra Lines profit halves with its ferry laid up

:::paragraphs{style="flush"}
Skerra Lines, which runs the ferries to the islands, made a profit of £3.1 million in the half year to September, down from £6.4 million, after its largest boat spent five months in dry dock.
:::

The company chartered a smaller vessel for the summer at a cost of £1.9 million and lost most of its freight trade while it was away. The *Holm Maid* returned to service yesterday. Skerra Lines expects the full year to come in close to last year’s £11 million, and has kept its dividend at 4.2p.

## Tessary puts the cost of Monday’s storm at £9 million

:::paragraphs{style="flush"}
Tessary Insurance expects claims of about £9 million from Monday’s gales, mostly for roofs, fences and cars, a fraction of what it would have paid if the Lower Town had flooded.
:::

The company estimated that a repeat of the 1986 flood would have cost it £140 million. Its chief executive, Laura Dunmore, said the barrier was the reason Tessary had cut premiums in the Lower Town by 8% this year, and would cut them again if the gates kept working.

Most of the claims came from the Outer Quay and the islands, outside the barrier’s shelter. The company has 40 assessors in the city this week and says it expects to settle nine in ten claims before the end of the month.

# Share prices {style="business"}

:::callout{type="ad" span="page" placement="bottom" title="Advertisement"}
## Marrow Mutual {style="ad-big"}

**Easy access at 3.85%, held until March**

:::space{lines=15}

Owned by our 212,000 members since 1867 · 14 branches from Kingsquay to Skerra · Open an account with £1
:::

:::paragraphs{style="kicker"}
Your money
:::

## Fixed rates are falling: lock in now or wait?

:::paragraphs{style="flush"}
Savers who want a fixed rate should not wait long. The best one-year bonds have fallen from 4.6% in June to 4.15% today, as banks price in a cut in interest rates before the end of the year.
:::

Easy-access accounts still pay almost as much, and leave the money free if rates rise again. Marrow Mutual pays 3.85% on balances from £1, and the rate is guaranteed until March.

Anyone with more than the £85,000 the deposit scheme protects should spread it between banks that hold separate licences. Several of the best buys belong to one group.

::resource{id="savings"}


::resource{id="prices-1"}

::resource{id="prices-2"}

::resource{id="prices-3"}

::resource{id="prices-4"}

::resource{id="prices-5"}

::resource{id="prices-6"}

::resource{id="prices-7"}

::resource{id="prices-8"}

::resource{id="prices-9"}

::resource{id="prices-10"}

:::paragraphs{style="imprint"}
Closing prices on the Marrowick Exchange, in pence, with the day’s change and the high and low of the past 52 weeks. Source: Ledger Data. The Marrowick Ledger is an imaginary daily; its people, companies and figures are invented. Text CC BY 4.0. Set in Source Serif 4, Playfair Display and Archivo Narrow (SIL OFL).
:::

:::
`; // content.business.en.md: pages 4–8

// ─── 3 · Fonts ───
const FONTS = {
  'Source Serif 4': ['400', '400i', '700'], 'Playfair Display': ['400i', '700', '900'],
  'Archivo Narrow': ['400', '400i', '600', '700'] };

// ─── 4 · Build & show ───
const all = `${markdown}\n\n${businessPages}`;
await Promise.all([...resources.filter((r) => r.bitmap)
  .map((r) => loadImage(r.bitmap.fileId, asset(r.bitmap.fileId))),
loadSvg('gate.svg', gateSvg()), loadSvg('throughput.svg', throughputSvg()),
loadSvg('mx40.svg', mx40Svg()), loadSvg('sectors.svg', sectorsSvg())]);
const doc = await buildDocumentWithFonts({ markdown: all, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: 'The Marrowick Ledger · Business on salmon' });

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

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

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

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

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

/** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

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

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

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

## Varianti

### Cambia la carta e conserva la griglia

Uno stile di titolo con una `palette` e senza `layout` conserva le cinque colonne con filetti del documento e dipinge comunque ogni pagina della sua sezione: `{ id: 'weekend', palette: { paper: '#efe6cf' } }` mette un supplemento del fine settimana su carta da giornale color crema. Avvolgi anche le sue pagine in un run `:::paper`, con la tinta portata al 60 % verso il bianco, così Folio le stampa su una carta coordinata.

### Fai della sezione Business un inserto staccabile

Le otto pagine si piegano come due fogli, uno dentro l’altro: le pagine 1–2 e 7–8 sul foglio esterno, le 3–6 su quello interno. Folio dà a ogni foglio la carta del suo recto, quindi le pagine 7–8 escono salmone, anche se una rotativa le stamperebbe sullo stesso foglio della prima pagina. Sposta le pagine Business alle 3–6 e formeranno il foglio interno, che una rotativa può stampare su carta salmone e un lettore può estrarre intero.

## Errori frequenti

- **Un titolo a tutta larghezza apre sempre una nuova pagina.** Un titolo il cui livello o stile imposta span: 'page' viene impaginato come apertura in testa a una pagina, che abbia o no un breakBefore. Messo dopo altro testo, o dopo un’altra apertura nella stessa pagina, passa alla pagina successiva e lascia vuoto il resto di quella corrente. Un titolone su entrambe le colonne (o su entrambi i registri di una pagina verticale) sotto una testata va nella grafica dell’apertura stessa, accanto alla testata, oppure in un riquadro :::callout{span="page"} come titolo senza span, come fa newspaper-front-page con i suoi titoloni.
- **Una sequenza di carta comincia e finisce su una nuova pagina, e un foglio prende la carta del suo recto.** Il testo dentro :::paper comincia su una nuova pagina, e così anche il testo che lo segue. Un foglio ha due pagine e una sola carta, quella del suo recto, quindi una sequenza che comincia su un verso stampa quella pagina sulla carta del foglio di cui fa parte. Metti un’interruzione di pagina dove una sequenza deve cominciare su un recto.
- **Cover: 'pages' richiede un numero pari di pagine per il piatto posteriore.** Con binding.cover impostato su 'pages' la prima pagina si gira come piatto anteriore e l’ultima come piatto posteriore solo quando cade su un verso. Un numero dispari di pagine lascia il libro con la copertina anteriore e senza quella posteriore: portalo a un numero pari oppure tieni la copertina rigida (case).

## Crediti

- Ricetta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Immagini: The tidal barrier in a storm (page 1): Generated With Diffusion Models, CC-BY-4.0
- Immagini: Apprentices welding in the shipyard (page 2): Generated With Diffusion Models, CC-BY-4.0
- Immagini: The island ferry leaving Skerra pier (page 3): Generated With Diffusion Models, CC-BY-4.0
- Immagini: The editorial cartoon (page 4): Generated With Diffusion Models, CC-BY-4.0
- Immagini: Container cranes at dawn (page 5): Generated With Diffusion Models, CC-BY-4.0
- Immagini: A bakery production line (page 7): Generated With Diffusion Models, CC-BY-4.0
- Immagini: The gate diagram, the port and index charts and the market tables, drawn in code: Ignacio Ferro, CC-BY-4.0
- Caratteri: Source Serif 4 (OFL-1.1), Playfair Display (OFL-1.1), Archivo Narrow (OFL-1.1)
- Codice: MIT · Contenuto di esempio: CC-BY-4.0

## Ricette correlate

- [Nº 027 · Prima pagina di giornale](https://postext.dev/it/cookbook/newspaper-front-page.md): Prima pagina e pagina 2 di un settimanale locale su due colonne con filetti, titoli a tutta pagina come riquadri e brevi su quattro colonne flottanti al piede. · Livello 3 (Avanzato) · Giornali e newsletter
- [Nº 101 · Un campionario, ogni foglio sulla sua carta](https://postext.dev/it/cookbook/paper-swatch-book.md): Ogni foglio del campionario di un grossista di carta sta in un blocco :::paper, e postext-folio lo disegna con tinta, grammatura, superficie e rigidità. · Livello 2 (Intermedio) · Cataloghi
- [Nº 103 · Un banco di prova per le impostazioni di Folio](https://postext.dev/it/cookbook/folio-settings-bench.md): Un modulo con ogni impostazione di config.folio guida Folio con setAppearance: carta, legatura, copertine, piano, luce e inclinazione cambiano sul posto. · Livello 2 (Intermedio) · Manuali, guide e opere di consultazione
