Zum Hauptinhalt springen
Rezept Nummer 27

Rezepte · Kapitel 1 · Seite und Raster

Titelseite einer Zeitung

Titelseite und Seite 2 eines Lokalblatts in zwei Spalten mit Linie, seitenbreite Schlagzeilen als Kästen und ein vierspaltiger Streifen Kurzmeldungen am Fuß.

S. 1 von 2

  • Englisches Beispiel: noch keine deutsche Ausgabe
  • Endformat 246 × 328 mm
  • 2 Spalten, 6 mm Spaltenabstand
  • PT Serif 9,4/12,6
  • Grenze Gotisch
  • Libre Franklin
  • 2 Seiten
  • Stufe
  • Postext 1.31.0
  • In 132 ms gesetzt
  • 232 Zeilen Code

Kurz gesagt

Titelseite und Seite 2 einer lokalen Wochenzeitung. Das Rezept zeigt, wie große Schlagzeilen über die ganze Seite laufen und wie ein Streifen mit Kurzmeldungen unten auf die Seite kommt.

Was Sie setzen

Titelseite und Seite 2 von The Elverdale Courier, einer erfundenen Wochenzeitung aus einem Tal, auf einem Bogen von 246 × 328 mm. Ein Zeitungskopf in Fraktur steht zwischen einem Wetter- und einem Preisfeld, über einer dicken und einer dünnen Linie und der Datumszeile. Der Aufmacher beginnt mit einer zweizeiligen Balkenüberschrift in Libre Franklin Black zu 56 pt über beide Spalten, einer kursiven Unterzeile und einem 222 mm breiten Fotoband und läuft dann in zwei Spalten im Blocksatz, getrennt von einer Haarlinie. Am Fuß teilen sich vier Kurzmeldungen einen getönten Streifen, eine je Spalte, jede von einem roten Chip eröffnet. Seite 2 beginnt mit einer roten Rubrikflagge und enthält den Brückenartikel mit einer im Code erzeugten Zeichnung, eine Fünftage-Wettervorhersage am Kopf der rechten Spalte, eine zweite Schlagzeile über die Seite, die Leserbriefe und eine Tabelle der Bezirksabstimmungen mit einem Balken in jeder Zeile.

Dieses Rezept beantwortet

  • Kann Postext drei oder mehr Textspalten setzen oder Text um ein Bild fließen lassen?
  • Wie setze ich mitten auf der Seite einen Kasten über beide Spalten, etwa eine dreiteilige „In Zahlen“-Leiste?
  • Wie bestimme ich, wo eine Abbildung steht: oben auf der Seite, über beide Spalten, genau hier oder am Rand?
  • Wie setze ich einen Kapiteltitel über die ganze Seitenbreite, während der Text darunter zweispaltig läuft?

Die kurze Antwort

script.js · Zeilen 82–99im vollständigen Code
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER), // the body of every page
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.5) } };
// The strip is a tinted, titled box, so its four columns are a :::columns group in a :::callout.
// The fence spans the page and floats the box to the foot, so the story fills the columns above:
//   :::callout{type="briefs" span="page" placement="bottom" title="In brief"}
//   :::columns{count=4 breaks="2,3,4"}   ← each brief opens a column (child 2, 3 and 4)
//   …four paragraphs…
//   :::
//   :::
// The style could carry span and placement too; on the fence they stay in sight in the text.
// hook-up: config() takes layout as it is and lists briefs in calloutStyles.
const briefs = { id: 'briefs',
  background: col('tint'), // one device: a tint, no stripe or border
  padding: { top: mm(3), right: mm(4), bottom: mm(4), left: mm(4) },
  columnGap: mm(GUTTER), // the inner columns keep the page's gutter
  titleStyle: { ...caps(9, 800, 'flag'), gap: mm(2) },
  body: { fontFamily: 'PT Serif', fontSize: pt(8.6), lineHeight: pt(11.4), textAlign: 'left',
    firstLineIndent: pt(0) } }; // ragged and unindented, unlike the columns

Zutaten

Schrift
Grenze Gotisch, PT Serif, Libre Franklin (SIL OFL 1.1)
Dateien
  • ridge-2400.jpg
  • Wind turbines on a snowy ridge (the front-page photograph, cropped to a band) (Jason Blackeye, CC0 1.0)
  • The bridge drawing and the bars in the ward table, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)

Zubereitung

#1 · Zeitungspapier, Druckfarbe und ein Rot

script.js · Zeilen 14–27im vollständigen Code
const palette = {
  ink: '#111315', // text and the heavy rules: a cold near-black
  paper: '#f8f5ee', // newsprint, and type reversed out of ink or red
  flag: '#a6192e', // the one accent: section flags, kickers, the Yes bars
  tint: '#ebe6db', // the In brief strip
  rule: '#9a978f', // hairlines: the column rule, table rules
  muted: '#5d5a55', // bylines, the folio line's title and date, credit notes, the imprint
};
// Every colour is linked to its palette entry by id.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [ // defaults link to 'main-color': point it at the ink, never blue
  ...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' } },
];

Die Seite ist Zeitungspapier (#f8f5ee) und die Schrift fast schwarze Druckfarbe (#111315). Das eine Rot, #a6192e, kommt auf die Rubrikflaggen, die Dachzeilen, die Chips und die Ja-Balken. Jede Farbe ist über paletteId mit ihrem Paletteneintrag verknüpft. main-color zeigt auf die Druckfarbe, sodass Fett, Kursiv, die beiden Verweise auf Seite 2 und jeder Standardwert, den die Konfiguration nicht ändert, in Druckfarbe drucken; auf seinem eigenen Wert belassen, druckten sie im Standardblau #295AA3.

#2 · Der Zeitungskopf ist die H1 der Titelseite

script.js · Zeilen 50–78im vollständigen Code
const NAME = 52; // pt: the blackletter nameplate
const EAR = 31; // mm: each ear, in the room the nameplate leaves at either side
const EAR_DROP = 1.8; // mm: measured so the ears' big figures sit on the nameplate's baseline
const AIR = 2; // mm between the lower rule and the masthead's foot, where the banner box starts
const ear = (side, lines) => lines.map(([id, content, look], i) => text(id, content,
  { ...look, align: side }, { ...(i === 0 ? at('container', `top-${side}`, 0, EAR_DROP)
    : at(`#${lines[i - 1][0]}`, 'below', 0, 1)), size: { width: mm(EAR) } }));
const serif = { fontFamily: 'PT Serif', fontSize: pt(9), italic: true, color: col('ink'),
  lineHeight: 1.25 };
const nameplate = { enabled: true, slot: { elements: [
  depth(7), // the masthead is seven grid lines deep; the rules and dateline hang from its foot
  { kind: 'rule', id: 'foot', thickness: pt(0.5), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -AIR), size: { width: 'fill' } } },
  ...[['left', '{attr.issue}'], ['center', '{publishDate}'], ['right', '{attr.area}']].map(
    ([align, content]) => text(`date-${align}`, content, { ...caps(7.5, 600), align },
      { ...at('#foot', 'above', 0, -1.4), size: { width: 'fill' } })),
  rule('thin', '#date-left', 0.5, 1.6), rule('heavy', '#thin', 2.5, 0.6), // the Oxford rule
  text('name', '{titleText}', { fontFamily: 'Grenze Gotisch', fontSize: pt(NAME),
    fontWeight: 700, lineHeight: 1, align: 'center' },
  { ...at('#heavy', 'above', 0, -1.2), size: { width: 'fill' } }), // centred between the ears
  ...ear('left', [['w-kicker', 'Weather', caps(7, 700, 'flag')],
    ['w-outlook', '{attr.outlook}', serif], ['w-temps', '{attr.temps}', franklin(15, 800)]]),
  ...ear('right', [['p-kicker', '{attr.since}', caps(7, 700, 'flag')],
    ['p-day', '{attr.day}', serif], ['p-price', '{attr.price}', franklin(15, 800)]]),
] } };
// hook-up: headingStyles gets { id: 'front', advancedDesign: nameplate, header: { elements:
// [] } }, H1 already spans the page, and the Markdown opens with
// # The Elverdale Courier {style="front" issue="…" area="…" outlook="…" temps="…" since="…"
//   day="…" price="…"}

# The Elverdale Courier nimmt das Überschriftenformat front, das den Titelkopf aus dem Text der Überschrift und sieben ihrer Attribute füllt (issue, area, outlook, temps, since, day und price); das Datum kommt aus publishDate im Frontmatter. Ein leerer Kasten von sieben Rasterzeilen Tiefe (31,1 mm) legt die Höhe des Auftakts fest. Linien, Datumszeile und Name hängen an seinem Fuß, sodass der Auftakt auf einer Rasterzeile endet.

#3 · Eine Schlagzeile über beide Spalten ist ein Kasten

script.js · Zeilen 103–116im vollständigen Code
// :::callout{type="banner" span="page" title="Transport"} ← an optional kicker, then the H2
const banner = { id: 'banner', backgroundEnabled: false,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...caps(8, 800, 'flag'), gap: mm(1.2) }, // a kicker, when the fence has a title
  marginTop: pt(0), // flush under the nameplate or the section flag
  marginBottom: pt(LEAD / 2), // half a line, then the columns start on the next grid line
  body: { fontFamily: 'PT Serif', fontSize: pt(13), lineHeight: pt(16.5), textAlign: 'left' } };
// A story that starts mid-page: the same box under a rule. The columns above it are cut level.
const story = { ...banner, id: 'story', marginTop: pt(LEAD),
  stripe: { enabled: true, side: 'top', width: pt(1), color: col('ink') }, // its one device
  padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) } };
// Inside the box the headline is an H2 in a heading style: one size for each rank of story.
const headline = (id, size, look = {}) => ({ id, fontSize: pt(size), lineHeight: pt(size * 1.02),
  marginBottom: pt(size * 0.2), ...look }); // tight leading, a fifth of it below

Eine Überschrift reicht nur als Auftakt über beide Spalten, und ein Auftakt beginnt eine neue Seite. Setzen Sie span: 'page' auf die H2 des Bezirksartikels statt auf ihren Kasten, und dieser Artikel wandert auf eine dritte Seite. Ein rahmenloser Kasten mit span="page" kann mitten auf der Seite beginnen. Auf Seite 2 werden die Spalten darüber auf gleicher Höhe abgeschnitten und setzen darunter wieder ein. Der Kasten enthält die H2 in einem von drei Überschriftenformaten (56, 30 oder 22 pt), die kursive Unterzeile und, auf der Titelseite, das Foto. Sein marginBottom von einer halben Zeile lässt die Spalten auf der nächsten Rasterzeile beginnen, auf Seite 2 3,4 mm unter der Unterzeile. Mit dem Standardabstand ist die Lücke 7,8 mm, und die Leserbriefe laufen auf eine dritte Seite.

#4 · Der vierspaltige Streifen ist ein Kasten mit Spalten

Der Code dieses Schritts steht oben in der kurzen Antwort. Der Text beider Seiten läuft in zwei Spalten (Layout-Typen). Die Kurzmeldungen brauchen vier nur am Fuß von Seite 1, unter einem Titel und auf einer Tönung, deshalb kommen sie in einen Kasten, hier einen seitenbreiten mit einer Gruppe :::columns. breaks zählt die Blöcke der Gruppe, einen je Meldung, sodass die Meldungen 2, 3 und 4 die Spalten 2, 3 und 4 eröffnen. Jede Spalte ist 49 mm breit, mit dem Spaltenabstand der Seite von 6 mm dazwischen.

:::callout{type="briefs" span="page" placement="bottom" title="In brief"}
:::columns{count=4 breaks="2,3,4"}
:chip[TRANSPORT]{style="flag"} **Bus 44 back on Sundays.** The Elverdale to Kirkby bus …
 
:chip[SCHOOLS]{style="flag"} **An orchard for Brook Lane.** Pupils at Brook Lane …
…
:::
:::

placement="bottom" lässt den Kasten an den Fuß von Seite 1 gleiten, wo er 36,9 mm einnimmt, und der Artikel füllt die 83 mm Spalten darüber. Das Aufmacherfoto bleibt ein Band über beide Spalten im Kasten der Balkenüberschrift: Der Text läuft neben einem Bild, das schmaler als seine Spalte ist, auf einer Seite davon, nie um ein Bild über zwei Spalten. Die Brückenzeichnung auf Seite 2 hat ihren Text daneben (Schritt 5).

#5 · Bilder im Textfluss, Tabellen als Gleitobjekte

script.js · Zeilen 339–375im vollständigen Code
// A caption label prints only when captionPrefix has text: '' leaves no "Figure 1".
const unnumbered = (id, name, captionStyle) => ({ id, name, shortLabel: name, captionPrefix: '',
  numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionStyle });
const resourceTypes = [unnumbered('picture', 'Picture'), // captions under the picture
  unnumbered('panel', 'Panel', { position: 'above', fontSize: pt(9.5) })]; // tables: titled above
const resource = (id, typeId, kind, body, extra) => ({ id, typeId, kind, [kind]: body,
  createdAt: 0, updatedAt: 0, ...extra });
const resources = [
  // Set inline in the page-span banner box: a 'top' float never lands above the line that
  // cites it (gotcha: top-float-next-page). It keeps the float gap above it, inside the box too.
  // 2400 px at 150 dpi is 406 mm: the photo shrinks to the box (gotcha: bitmap-print-size).
  resource('ridge', 'picture', 'bitmap', { fileId: 'ridge-2400.jpg', format: 'jpeg', width: 2400,
    height: 800 }, { placement: { position: 'here' },
    altText: 'Wind turbines on a snow-covered ridge under a bright, cloudy sky.',
    caption: '**File picture:** turbines on an upland ridge. Each of the three planned for '
      + 'Harrow Ridge would generate 2.3 megawatts.',
    note: 'Photograph: Jason Blackeye, CC0, via Wikimedia Commons' }),
  // Drawn in code (the art region) and set inline, where ::resource puts it in column 1,
  // at the column's right with the next paragraph running beside it (text wrap).
  resource('bridge', 'picture', 'svg', { fileId: 'bridge.svg', width: 1190, height: 460 },
    { placement: { position: 'here', wrap: 'right', width: 0.47 },
      altText: 'Drawing of a two-arch stone bridge between sloping river banks; under its '
        + 'central pier, a red concrete footing.',
      caption: '**The rebuilt pier.** Its new concrete footing (red) goes six metres below the '
        + 'riverbed. Both arches were relaid with their own stones.',
      note: 'Drawing: The Courier' }),
  // A page-span 'bottom' float sits under both columns of the page that cites it. On the last
  // page it follows the balanced columns, so the copy there is fitted to bring it to the foot.
  resource('wards', 'panel', 'table', { styleId: 'results', model: resultsTable() },
    { placement: { position: 'bottom', span: 'page' }, caption: '**How the wards voted**',
      note: 'Red: yes. Grey: no. The black line marks half the vote. '
        + 'Source: Elverdale Town Council.' }),
  // A column 'top' float waits for the head of the next column with room.
  resource('forecast', 'panel', 'table', { styleId: 'forecast', model: forecastTable() },
    { placement: { position: 'top', span: 'column' }, caption: '**The next five days**',
      note: 'Sunrise 6.57, sunset 19.03 on Thursday. Forecast: Wend Valley Weather Station.' }),
];

Das Foto steht im Textfluss des Schlagzeilenkastens, weil ein seitenbreites Gleitobjekt top nie über der Zeile landet, die es zitiert; als Gleitobjekt eröffnete das Foto Seite 2 (siehe „Häufige Fehler“). Auch die Brückenzeichnung steht im Textfluss, nach dem Absatz über den Pfeiler, und wrap: 'right' mit width: 0.47 setzt sie rechts in Spalte 1, 47 % der Spaltenbreite, wobei die nächsten Absätze neben ihr und dann in voller Zeilenlänge darunter laufen (Textumfluss). Bei dieser Breite ist keine Zeile neben ihr zu locker gesetzt; der Text des Artikels wurde auf die kürzere Zeichnung neu eingepasst. Die Wettervorhersage gleitet an den Kopf einer Spalte und landet oben in der rechten. Die Bezirkstabelle gleitet unter beide Spalten, wo die Spaltenlinie endet. Auf der letzten Seite steht sie direkt unter den ausgeglichenen Spalten, deshalb wurde der Text darüber so eingepasst, dass sie bis zum Fuß hinunterrückt. Beide Ressourcentypen setzen captionPrefix: '', und eine Legendenbezeichnung druckt nur, wenn das Präfix Text hat, sodass keine Legende mit „Figure 1“ beginnt.

#6 · Eine Innenseite beginnt mit einer Rubrikflagge

script.js · Zeilen 120–138im vollständigen Code
const section = { enabled: true, slot: { elements: [
  depth(2), // two grid lines: the flag sits on a 3 pt bar, 1.6 mm above the foot
  { kind: 'rule', id: 'bar', thickness: pt(3), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -1.6), size: { width: 'fill' } } },
  text('flag', '{titleText}', { ...caps(11, 800, 'paper'), box: { backgroundColor: col('flag'),
    padding: { top: mm(1.3), right: mm(3), bottom: mm(1.1), left: mm(3) } } },
  at('#bar', 'above')),
] } };
const FOLIO = 6; // mm from the top edge to the folio line; its rule 4 mm lower
const folio = (parity, side, s) => [ // s: +1 on a verso (folio on the left), −1 on a recto
  text(`n-${parity}`, '{pageNumber}', { ...franklin(9, 800), align: side },
    at('page', `top-${side}`, s * MARGIN.side, FOLIO)),
  text(`t-${parity}`, '{title} · {publishDate}', { ...caps(7, 600, 'muted'), align: side },
    at(`#n-${parity}`, s > 0 ? 'right-of' : 'left-of', s * 3, 0.9)),
].map((element) => ({ ...element, parity }));
const header = { elements: [...folio('even', 'left', 1), ...folio('odd', 'right', -1),
  { kind: 'rule', id: 'folio-rule', thickness: pt(0.5), color: col('ink'),
    placement: { ...at('page', 'top-left', MARGIN.side, FOLIO + 4), size: { width: mm(TRIM.width
      - 2 * MARGIN.side) } } }] };

# Town & Valley ist eine H1, deren breakBefore die Parität 'any' hat, sodass die Rubrik Seite 2 ohne vorangehende Vakatseite eröffnet. Ihre Gestaltung ist eine rote Flagge auf einem Balken von 3 pt, aufgehängt an einem leeren Kasten von zwei Rasterzeilen Tiefe. Die Paginazeile ist eine Kopfzeile, 6 mm unter der Oberkante der Seite verankert, mit einer Fassung für gerade und einer für ungerade Seiten. Das Format der Titelseite leert die Kopfzeile, sodass über dem Zeitungskopf keine Paginazeile druckt.

Das ganze Rezept

Sandbox
// ═══ Postext Cookbook · Nº 027 · Newspaper front page ═══════════════════════════
// https://postext.dev/en/cookbook/newspaper-front-page
// Code: MIT · Text: original (CC BY 4.0) · Photo: Jason Blackeye (CC0)
// Fonts: Grenze Gotisch, PT Serif, Libre Franklin (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'newspaper-front-page';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: newsprint, ink and one red for the section flags
const palette = {
  ink: '#111315', // text and the heavy rules: a cold near-black
  paper: '#f8f5ee', // newsprint, and type reversed out of ink or red
  flag: '#a6192e', // the one accent: section flags, kickers, the Yes bars
  tint: '#ebe6db', // the In brief strip
  rule: '#9a978f', // hairlines: the column rule, table rules
  muted: '#5d5a55', // bylines, the folio line's title and date, credit notes, the imprint
};
// Every colour is linked to its palette entry by id.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [ // defaults link to 'main-color': point it at the ink, never blue
  ...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 TRIM = { width: 246, height: 328 }; // mm: a 3 : 4 compact, smaller than a tabloid
const MARGIN = { top: 14, bottom: 15, side: 12 }; // newspaper margins: narrow, not mirrored
const GUTTER = 6; // mm between the two body columns, and between the In brief columns
const LEAD = 12.6; // body leading in pt: the baseline grid
const franklin = (size, weight, look = {}) => ({ fontFamily: 'Libre Franklin',
  fontSize: pt(size), fontWeight: weight, color: col('ink'), ...look });
const caps = (size, weight, colour = 'ink') => franklin(size, weight, { color: col(colour),
  textTransform: 'uppercase', letterSpacing: pt(size * 0.16) }); // capitals tracked 0.16 em
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 }); // design text defaults to centred
// and black, and a header's text to an ellipsis
// A rule across the slot whose bottom sits `gap` mm above the element it names.
const rule = (id, under, weight, gap = 0) => ({ kind: 'rule', id, thickness: pt(weight),
  color: col('ink'), placement: { ...at(under, 'above', 0, -gap), size: { width: 'fill' } } });
// An empty box exactly `lines` grid lines deep sets an opener's depth, and the rest hangs from
// its foot: the opener ends on a grid line, where the columns and their rule begin.
const depth = (lines) => ({ kind: 'box', id: 'depth', style: {}, placement: {
  ...at('container', 'top-left'), size: { width: 'fill', height: pt(lines * LEAD) } } });

// #region nameplate: the front page's H1 is the paper's name, between two ears
const NAME = 52; // pt: the blackletter nameplate
const EAR = 31; // mm: each ear, in the room the nameplate leaves at either side
const EAR_DROP = 1.8; // mm: measured so the ears' big figures sit on the nameplate's baseline
const AIR = 2; // mm between the lower rule and the masthead's foot, where the banner box starts
const ear = (side, lines) => lines.map(([id, content, look], i) => text(id, content,
  { ...look, align: side }, { ...(i === 0 ? at('container', `top-${side}`, 0, EAR_DROP)
    : at(`#${lines[i - 1][0]}`, 'below', 0, 1)), size: { width: mm(EAR) } }));
const serif = { fontFamily: 'PT Serif', fontSize: pt(9), italic: true, color: col('ink'),
  lineHeight: 1.25 };
const nameplate = { enabled: true, slot: { elements: [
  depth(7), // the masthead is seven grid lines deep; the rules and dateline hang from its foot
  { kind: 'rule', id: 'foot', thickness: pt(0.5), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -AIR), size: { width: 'fill' } } },
  ...[['left', '{attr.issue}'], ['center', '{publishDate}'], ['right', '{attr.area}']].map(
    ([align, content]) => text(`date-${align}`, content, { ...caps(7.5, 600), align },
      { ...at('#foot', 'above', 0, -1.4), size: { width: 'fill' } })),
  rule('thin', '#date-left', 0.5, 1.6), rule('heavy', '#thin', 2.5, 0.6), // the Oxford rule
  text('name', '{titleText}', { fontFamily: 'Grenze Gotisch', fontSize: pt(NAME),
    fontWeight: 700, lineHeight: 1, align: 'center' },
  { ...at('#heavy', 'above', 0, -1.2), size: { width: 'fill' } }), // centred between the ears
  ...ear('left', [['w-kicker', 'Weather', caps(7, 700, 'flag')],
    ['w-outlook', '{attr.outlook}', serif], ['w-temps', '{attr.temps}', franklin(15, 800)]]),
  ...ear('right', [['p-kicker', '{attr.since}', caps(7, 700, 'flag')],
    ['p-day', '{attr.day}', serif], ['p-price', '{attr.price}', franklin(15, 800)]]),
] } };
// hook-up: headingStyles gets { id: 'front', advancedDesign: nameplate, header: { elements:
// [] } }, H1 already spans the page, and the Markdown opens with
// # The Elverdale Courier {style="front" issue="…" area="…" outlook="…" temps="…" since="…"
//   day="…" price="…"}
// #endregion

// #region answer: a two-column body, and the four-up strip as a box with columns of its own
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER), // the body of every page
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.5) } };
// The strip is a tinted, titled box, so its four columns are a :::columns group in a :::callout.
// The fence spans the page and floats the box to the foot, so the story fills the columns above:
//   :::callout{type="briefs" span="page" placement="bottom" title="In brief"}
//   :::columns{count=4 breaks="2,3,4"}   ← each brief opens a column (child 2, 3 and 4)
//   …four paragraphs…
//   :::
//   :::
// The style could carry span and placement too; on the fence they stay in sight in the text.
// hook-up: config() takes layout as it is and lists briefs in calloutStyles.
const briefs = { id: 'briefs',
  background: col('tint'), // one device: a tint, no stripe or border
  padding: { top: mm(3), right: mm(4), bottom: mm(4), left: mm(4) },
  columnGap: mm(GUTTER), // the inner columns keep the page's gutter
  titleStyle: { ...caps(9, 800, 'flag'), gap: mm(2) },
  body: { fontFamily: 'PT Serif', fontSize: pt(8.6), lineHeight: pt(11.4), textAlign: 'left',
    firstLineIndent: pt(0) } }; // ragged and unindented, unlike the columns
// #endregion

// #region banner: a headline across both columns is a frameless page-span box
// :::callout{type="banner" span="page" title="Transport"} ← an optional kicker, then the H2
const banner = { id: 'banner', backgroundEnabled: false,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...caps(8, 800, 'flag'), gap: mm(1.2) }, // a kicker, when the fence has a title
  marginTop: pt(0), // flush under the nameplate or the section flag
  marginBottom: pt(LEAD / 2), // half a line, then the columns start on the next grid line
  body: { fontFamily: 'PT Serif', fontSize: pt(13), lineHeight: pt(16.5), textAlign: 'left' } };
// A story that starts mid-page: the same box under a rule. The columns above it are cut level.
const story = { ...banner, id: 'story', marginTop: pt(LEAD),
  stripe: { enabled: true, side: 'top', width: pt(1), color: col('ink') }, // its one device
  padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) } };
// Inside the box the headline is an H2 in a heading style: one size for each rank of story.
const headline = (id, size, look = {}) => ({ id, fontSize: pt(size), lineHeight: pt(size * 1.02),
  marginBottom: pt(size * 0.2), ...look }); // tight leading, a fifth of it below
// #endregion

// #region inside: an inside page opens with a section flag and carries a folio line
const section = { enabled: true, slot: { elements: [
  depth(2), // two grid lines: the flag sits on a 3 pt bar, 1.6 mm above the foot
  { kind: 'rule', id: 'bar', thickness: pt(3), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -1.6), size: { width: 'fill' } } },
  text('flag', '{titleText}', { ...caps(11, 800, 'paper'), box: { backgroundColor: col('flag'),
    padding: { top: mm(1.3), right: mm(3), bottom: mm(1.1), left: mm(3) } } },
  at('#bar', 'above')),
] } };
const FOLIO = 6; // mm from the top edge to the folio line; its rule 4 mm lower
const folio = (parity, side, s) => [ // s: +1 on a verso (folio on the left), −1 on a recto
  text(`n-${parity}`, '{pageNumber}', { ...franklin(9, 800), align: side },
    at('page', `top-${side}`, s * MARGIN.side, FOLIO)),
  text(`t-${parity}`, '{title} · {publishDate}', { ...caps(7, 600, 'muted'), align: side },
    at(`#n-${parity}`, s > 0 ? 'right-of' : 'left-of', s * 3, 0.9)),
].map((element) => ({ ...element, parity }));
const header = { elements: [...folio('even', 'left', 1), ...folio('odd', 'right', -1),
  { kind: 'rule', id: 'folio-rule', thickness: pt(0.5), color: col('ink'),
    placement: { ...at('page', 'top-left', MARGIN.side, FOLIO + 4), size: { width: mm(TRIM.width
      - 2 * MARGIN.side) } } }] };
// #endregion

const label = { fontFamily: 'Libre Franklin', fontSize: pt(8), firstLineIndent: pt(0) };
const config = () => ({
  locale: 'en-us', colorPalette, resourceTypes, // locale: the edition's language
  page: { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom),
      left: mm(MARGIN.side), right: mm(MARGIN.side) } },
  // An inline picture or drawing keeps a line of air above it, in a box too, and none below:
  // the caption's note sits on the text that follows, which keeps the copy fitted.
  layout: { ...layout, inlineResourceGap: 'above' },
  // Hyphenation stays at its defaults: on, in 'en-us'.
  // Bold, italics and :ref labels print in ink through main-color.
  bodyText: { fontFamily: 'PT Serif', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(3.5), indentAfterHeading: false,
    minWordSpacing: 0.75, maxWordSpacing: 1.6 }, // tighter than the 0.6–2 defaults
  headings: { fontFamily: 'Libre Franklin', fontWeight: 800, // in ink, through main-color
    marginBottom: pt(0), levels: [
      // parity 'any': a section opens the next page, whichever side it falls on.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        advancedDesign: section },
      { level: 2, fontSize: pt(17), lineHeight: pt(19) }, // Letters; banners restyle it
      { level: 3, fontSize: pt(10), lineHeight: pt(LEAD), fontWeight: 700, marginTop: pt(LEAD) },
    ] },
  headingStyles: [
    { id: 'front', advancedDesign: nameplate, header: { elements: [] } }, // no folio line
    headline('lead', 56, { fontWeight: 900 }), headline('wide', 30), headline('second', 22),
  ],
  calloutStyles: [banner, story, briefs],
  chipStyles: [{ id: 'flag', background: col('flag'), color: col('paper'), borderWidth: pt(0),
    borderRadius: pt(0), fontFamily: 'Libre Franklin', fontSize: em(0.875), bold: true,
    paddingX: em(0.4), paddingY: em(0.15) }],
  paragraphStyles: [{ ...label, id: 'byline', textAlign: 'left', color: col('muted') },
    { id: 'flush', firstLineIndent: pt(0) }, // a story's first paragraph, the second letter
    { ...label, id: 'jump', textAlign: 'right' }, // "…: page 2", where a story turns
    { id: 'sign', textAlign: 'right', firstLineIndent: pt(0) }, // a letter's signature
    { id: 'imprint', fontFamily: 'Libre Franklin', fontSize: pt(6.8), lineHeight: pt(9),
      textAlign: 'left', firstLineIndent: pt(0), color: col('muted'), marginTop: pt(LEAD) }],
  captionStyle: { fontFamily: 'Libre Franklin', fontSize: pt(8), gap: mm(1.6),
    note: { fontSize: pt(6.8), color: col('muted') } }, // the credit line
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: 'Libre Franklin',
    headerFontSize: pt(7.5), bodyFontFamily: 'Libre Franklin', bodyFontSize: pt(8.5),
    cellPadding: mm(1.3) },
  tableStyles: [{ id: 'results', cellPadding: mm(1) }, // tighter rows: the bars read as one chart
    { id: 'forecast', headerBackground: col('flag'), bodyFontSize: pt(8), // the weather box
      cellPadding: mm(1.2) }],
  header, footer: { elements: [] }, // newspapers put the folio at the head, if anywhere
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// #region art: the two tables as data, and the bridge and the ward bars drawn in code
const WARDS = [ // Tuesday's parish poll: yes, no (they add up to 3,904 and 2,393)
  ['Market', 942, 418], ['Riverside', 861, 402], ['St Oswald’s', 896, 471],
  ['Brook Lane', 736, 591], ['Harrow', 469, 511]];
const cell = (content, extra = {}) => ({ content, ...extra });
const head = (content, align = 'left') => cell(content, { isHeader: true, align });
const num = (n) => n.toLocaleString('en-GB');
const share = (yes, no) => `${Math.round((100 * yes) / (yes + no))}%`;
function barSvg(yes, no) { // 1000 × 34 units: yes in red from the left, no in grey, 50% marked
  const split = (1000 * yes) / (yes + no);
  return '<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="34" viewBox="0 0 1000 34">'
    + `<rect width="${split.toFixed(1)}" height="34" fill="${palette.flag}"/>`
    + `<rect x="${(split + 4).toFixed(1)}" width="${(996 - split).toFixed(1)}" height="34" `
    + `fill="${palette.rule}"/><rect x="499" y="-1" width="2" height="36" fill="${palette.ink}"/>`
    + '</svg>';
}
const TOWN = WARDS.reduce(([, y, n], [, yes, no]) => ['Whole town', y + yes, n + no], ['', 0, 0]);
const bars = [...WARDS, TOWN].map(([ward, yes, no], i) => ({ ward, yes, no,
  id: i < WARDS.length ? `bar-${i + 1}` : 'bar-town', svg: barSvg(yes, no) })); // one per row
function mulberry32(seed) { // a seeded PRNG: the same stones 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 channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16);
const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(channel(a, i) * (1 - k)
  + channel(b, i) * k).toString(16).padStart(2, '0')).join('')}`; // a towards b by k
function bridgeSvg() { // 119 × 46 mm, 1 unit = 1 mm, about 2.6 mm to the metre
  const rand = mulberry32(1791); // the year the bridge was built, as far as the Courier knows
  const f = (v) => v.toFixed(2);
  const [W, H, TOP, DECK, WATER, BED] = [119, 46, 4.5, 8, 25, 30]; // heights from the top, mm
  const stone = mix(palette.tint, palette.muted, 0.35);
  const joint = mix(palette.tint, palette.ink, 0.55);
  const arches = [[16, 57], [62, 103]]; // two spans; the central pier stands between them
  const [SPRING, CROWN] = [WATER - 7, 13]; // the arches spring 7 mm above the water
  const CTRL = 2 * CROWN - SPRING; // the Bézier control point that puts the top at CROWN
  // The banks: road level at each end, down under the outer half of each arch to the water's
  // edge, 24 mm in, and on at the same slope to the bed.
  const EDGE = 24;
  const run = ((BED - WATER) * (EDGE - 10)) / (WATER - DECK);
  const bank = [[0, DECK], [10, DECK], [EDGE, WATER], [EDGE + run, BED]];
  const profile = [...bank, ...bank.map(([x, y]) => [W - x, y]).reverse()];
  const groundAt = (x) => { // the height of the ground at x, between two profile points
    const i = profile.findIndex(([px]) => px >= x);
    if (i <= 0) return profile[Math.max(i, 0)][1];
    const [[x0, y0], [x1, y1]] = [profile[i - 1], profile[i]];
    return y0 + ((y1 - y0) * (x - x0)) / (x1 - x0);
  };
  const water = mix(palette.paper, palette.ink, 0.14);
  let out = `<rect x="0" y="${WATER}" width="${W}" height="${BED - WATER}" fill="${water}"/>`;
  out += `<path d="M${EDGE} ${WATER}H${W - EDGE}" stroke="${palette.muted}" stroke-width="0.3"/>`;
  for (let i = 0; i < 9; i++) { // ripples on the river, between the banks
    const [x, y] = [EDGE + 3 + rand() * (W - 2 * EDGE - 12),
      WATER + 1.2 + rand() * (BED - WATER - 2.4)];
    out += `<path d="M${f(x)} ${f(y)}h${f(3 + rand() * 5)}" stroke="${palette.paper}" `
      + 'stroke-width="0.4"/>';
  }
  // The masonry: parapet to riverbed, less the two arch openings (even-odd fill). The banks
  // are drawn over it, so only the stone above the ground shows.
  const opening = ([a, b]) => `M${a} ${BED}L${a} ${SPRING}Q${(a + b) / 2} ${CTRL} `
    + `${b} ${SPRING}L${b} ${BED}Z`;
  const outline = `M4 ${BED}L10 ${DECK}V${TOP}H${W - 10}V${DECK}L${W - 4} ${BED}`;
  out += `<path fill-rule="evenodd" fill="${stone}" `
    + `d="${outline}Z${arches.map(opening).join('')}"/>`;
  // Where the masonry is at height y: inside the sloping ends, outside the arch openings.
  const solid = (x, y) => {
    const end = y < DECK ? 10 : 10 - (6 * (y - DECK)) / (BED - DECK);
    if (x < end || x > W - end) return false;
    return !arches.some(([a, b]) => { // the opening's half width at y (a parabola)
      const c = (SPRING - y) / (2 * (SPRING - CTRL)); // t(1 − t) at height y
      const half = c <= 0 ? 0.5 : c >= 0.25 ? -1 : Math.sqrt(0.25 - c);
      return Math.abs(x - (a + b) / 2) <= half * (b - a);
    });
  };
  for (let y = TOP + 2.2; y < BED; y += 2.2) { // courses of stone, joints staggered
    for (let x = 4; x < W - 4; x += 0.5) {
      if (solid(x, y) && solid(x + 0.5, y)) {
        out += `<path d="M${f(x)} ${f(y)}h0.5" stroke="${joint}" stroke-width="0.2"/>`;
      }
    }
    for (let x = 4 + rand() * 4; x < W - 6; x += 3 + rand() * 4) {
      if (solid(x, y) && solid(x, y - 2.2)) {
        out += `<path d="M${f(x)} ${f(y - 2.2)}v2.2" stroke="${joint}" stroke-width="0.2"/>`;
      }
    }
  }
  for (const [a, b] of arches) { // the voussoirs: a ring of stones round each opening
    const cx = (a + b) / 2;
    for (let t = 0; t <= 1.0001; t += 1 / 14) {
      const x = a + (b - a) * t;
      const y = SPRING - 2 * t * (1 - t) * (SPRING - CTRL);
      const [dx, dy] = [x - cx, y - (WATER + 2)];
      const k = 3 / Math.hypot(dx, dy);
      out += `<path d="M${f(x)} ${f(y)}l${f(dx * k)} ${f(dy * k)}" stroke="${joint}" `
        + 'stroke-width="0.25"/>';
    }
    out += `<path d="${opening([a, b]).replace(/Z$/, '')}" fill="none" stroke="${palette.ink}" `
      + 'stroke-width="0.35"/>';
  }
  out += `<path d="${outline}" fill="none" stroke="${palette.ink}" stroke-width="0.4"/>`;
  // The ground in section: the banks and the riverbed, hatched below the profile.
  const edge = profile.map(([x, y]) => `${f(x)} ${f(y)}`).join('L');
  out += `<path d="M${edge}L${W} ${H}L0 ${H}Z" fill="${palette.paper}"/>`;
  for (let x0 = -H; x0 < W; x0 += 1.6) { // 45° hatching, cut where it leaves the ground
    let from = null;
    for (let t = 0; t <= H + 0.05; t += 0.1) {
      const [x, y] = [x0 + t, H - t];
      const inside = x >= 0 && x <= W && y > groundAt(x);
      if (inside && !from) from = [x, y];
      if (from && (!inside || t + 0.1 > H + 0.05)) {
        out += `<path d="M${f(from[0])} ${f(from[1])}L${f(x - 0.1)} ${f(y + 0.1)}" `
          + `stroke="${palette.rule}" stroke-width="0.25"/>`;
        from = null;
      }
    }
  }
  out += `<path d="M${edge}" fill="none" stroke="${palette.muted}" stroke-width="0.3"/>`
    + `<rect x="55.5" y="${BED}" width="8" height="15.6" fill="${palette.flag}"/>` // 6 m deep
    + `<path d="M9.4 ${TOP}H${W - 9.4}" stroke="${palette.ink}" stroke-width="1.2"/>`; // coping
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" `
    + `viewBox="0 0 ${W} ${H}">${out}</svg>`;
}
function resultsTable() {
  const row = (name, yes, no, bar, bold = '') => [cell(`${bold}${name}${bold}`),
    cell('', { image: { resourceId: bar } }), cell(`${bold}${num(yes)}${bold}`,
      { align: 'right' }), cell(`${bold}${num(no)}${bold}`, { align: 'right' }),
    cell(`${bold}${share(yes, no)}${bold}`, { align: 'right' })];
  return { headerRowCount: 1, columnWidths: [22, 110, 14, 14, 14], rows: [
    [head('Ward'), head('Share of the vote'), head('Yes', 'right'),
      head('No', 'right'), head('Yes share', 'right')],
    ...bars.map((b) => row(b.ward, b.yes, b.no, b.id, b.id === 'bar-town' ? '**' : ''))] };
}
function forecastTable() {
  const days = [['Thu', 'Bright spells', 16, 8, 'W 18'], ['Fri', 'Showers', 14, 9, 'W 22'],
    ['Sat', 'Heavy rain', 12, 9, 'SW 30'], ['Sun', 'Clearing', 13, 6, 'NW 20'],
    ['Mon', 'Sunny, cold start', 15, 4, 'N 8']];
  return { headerRowCount: 1, columnWidths: [10, 40, 12, 12, 16], rows: [
    [head('Day'), head('Outlook'), head('High', 'right'), head('Low', 'right'),
      head('Wind mph', 'right')],
    ...days.map(([day, sky, hi, lo, wind]) => [cell(`**${day}**`), cell(sky),
      cell(`${hi}°`, { align: 'right' }), cell(`${lo}°`, { align: 'right' }),
      cell(wind, { align: 'right' })])] };
}
// #endregion

// #region floats: the photo sits in its box, the results float to a foot, the forecast to a head
// A caption label prints only when captionPrefix has text: '' leaves no "Figure 1".
const unnumbered = (id, name, captionStyle) => ({ id, name, shortLabel: name, captionPrefix: '',
  numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionStyle });
const resourceTypes = [unnumbered('picture', 'Picture'), // captions under the picture
  unnumbered('panel', 'Panel', { position: 'above', fontSize: pt(9.5) })]; // tables: titled above
const resource = (id, typeId, kind, body, extra) => ({ id, typeId, kind, [kind]: body,
  createdAt: 0, updatedAt: 0, ...extra });
const resources = [
  // Set inline in the page-span banner box: a 'top' float never lands above the line that
  // cites it (gotcha: top-float-next-page). It keeps the float gap above it, inside the box too.
  // 2400 px at 150 dpi is 406 mm: the photo shrinks to the box (gotcha: bitmap-print-size).
  resource('ridge', 'picture', 'bitmap', { fileId: 'ridge-2400.jpg', format: 'jpeg', width: 2400,
    height: 800 }, { placement: { position: 'here' },
    altText: 'Wind turbines on a snow-covered ridge under a bright, cloudy sky.',
    caption: '**File picture:** turbines on an upland ridge. Each of the three planned for '
      + 'Harrow Ridge would generate 2.3 megawatts.',
    note: 'Photograph: Jason Blackeye, CC0, via Wikimedia Commons' }),
  // Drawn in code (the art region) and set inline, where ::resource puts it in column 1,
  // at the column's right with the next paragraph running beside it (text wrap).
  resource('bridge', 'picture', 'svg', { fileId: 'bridge.svg', width: 1190, height: 460 },
    { placement: { position: 'here', wrap: 'right', width: 0.47 },
      altText: 'Drawing of a two-arch stone bridge between sloping river banks; under its '
        + 'central pier, a red concrete footing.',
      caption: '**The rebuilt pier.** Its new concrete footing (red) goes six metres below the '
        + 'riverbed. Both arches were relaid with their own stones.',
      note: 'Drawing: The Courier' }),
  // A page-span 'bottom' float sits under both columns of the page that cites it. On the last
  // page it follows the balanced columns, so the copy there is fitted to bring it to the foot.
  resource('wards', 'panel', 'table', { styleId: 'results', model: resultsTable() },
    { placement: { position: 'bottom', span: 'page' }, caption: '**How the wards voted**',
      note: 'Red: yes. Grey: no. The black line marks half the vote. '
        + 'Source: Elverdale Town Council.' }),
  // A column 'top' float waits for the head of the next column with room.
  resource('forecast', 'panel', 'table', { styleId: 'forecast', model: forecastTable() },
    { placement: { position: 'top', span: 'column' }, caption: '**The next five days**',
      note: 'Sunrise 6.57, sunset 19.03 on Thursday. Forecast: Wend Valley Weather Station.' }),
];
// #endregion
resources.push(...bars.map((b) => resource(b.id, 'panel', 'svg', // the bars the table cells draw
  { fileId: `${b.id}.svg`, width: 1000, height: 34 },
  { altText: `${b.ward}: ${num(b.yes)} yes, ${num(b.no)} no` })));
const markdown = String.raw`---
Markdown-Beispiel · 124 Zeilen · content.en.mdtitle: "The Elverdale Courier" author: "The Elverdale Courier" publishDate: "Thursday 24 September 2026" --- # The Elverdale Courier {style="front" issue="Vol. 116 · No. 39" area="Elverdale, Harrow and the Upper Wend" outlook="Bright spells, breezy" temps="16° · 8°" since="Est. 1911" day="Every Thursday" price="90p"} :::callout{type="banner" span="page"} ## Harrow Ridge turbines win the town’s vote {style="lead"} *Elverdale backs its own wind farm by 62% on a 71% turnout. Now the cooperative must raise £9.8 million* ::resource{id="ridge"} ::: :::callout{type="briefs" span="page" placement="bottom" title="In brief"} :::columns{count=4 breaks="2,3,4"} :chip[TRANSPORT]{style="flag"} **Bus 44 back on Sundays.** The Elverdale to Kirkby bus will run every two hours on Sundays from 4 October, paid for by the county for a year. The first leaves the Market Place at 8.10. :chip[SCHOOLS]{style="flag"} **An orchard for Brook Lane.** Pupils at Brook Lane Primary planted 24 apple and pear trees on Friday, all of them varieties grown in the valley before 1900. :chip[ARTS]{style="flag"} **Book Weekend full.** Every ticket for the fourteen events of the Book Weekend, 9 to 11 October, has gone. The Saturday talks will be screened free in the Corn Exchange. :chip[COUNCIL]{style="flag"} **Bin day moves.** Recycling north of the river goes out on Tuesdays instead of Mondays, starting in the week of 5 October. New calendars come with next week’s Courier. ::: ::: :::paragraphs{style="byline"} By **Martha Quayle** · Local Democracy Reporter ::: :::paragraphs{style="flush"} Elverdale will build a wind farm of its own. In the first town-wide poll since the 1974 vote to save the Corn Exchange, 3,904 residents backed plans for three turbines on Harrow Ridge, and 2,393 voted against. ::: The count at the Corn Exchange finished shortly before midnight on Tuesday. Turnout was 71%, the highest the town clerk, Anjali Rees, can remember at any election. “People queued in the rain outside St Oswald’s,” she said. “We ran out of pencils at eleven.” The last ballot box, from Harrow, came in at twenty to ten. The scheme belongs to Wend Valley Energy, a cooperative that residents formed in 2023 so that the wind on the ridge would pay the valley rather than a distant landlord. Its three turbines of 2.3 megawatts each would supply about 6,500 homes in an average year, far more than the 4,700 in the parish. ### Who pays and who gains The cooperative must now raise £9.8 million. A share offer opens next Thursday, with a minimum stake of £50. It hopes to find £2.4 million in the valley and to borrow the rest from a community energy lender. Members would earn up to 4% a year, and £120,000 a year would go to a town fund, spent by a panel that residents elect. “Two thousand households putting in £1,200 each would do it,” said Tom Ashby, who chairs the cooperative and farms at Low Harrow. “We had 312 pledges by Wednesday lunchtime.” ### Objectors turn to the planners The Harrow Ridge Action Group, which campaigned against the scheme, says the turbines, 125 metres to the tip of the blade, will dominate the view from the Wend Way and disturb the curlews that nest on the moor. “A poll is not a planning permission,” said its secretary, Clare Whitlock. “We will make our case to the district council, and we expect to be heard.” The planning application is due in the spring, after a year of bird and wind surveys. If it succeeds, work could start in 2028, and the turbines would be turning by the autumn of 2029. The cooperative’s 640 founding members have paid £180,000 for the surveys. :::paragraphs{style="jump"} How each ward voted, and what happens next: **page 2** ::: # Town & Valley :::callout{type="banner" span="page" title="Transport"} ## Old Mill Bridge reopens after 14 months {style="wide"} *Cars, buses and the Saturday market cross the Wend again. Lorries must wait for a load test next spring* ::: :::paragraphs{style="byline"} By **Owen Pryce** ::: :::paragraphs{style="flush"} The Old Mill Bridge reopened to traffic at seven o’clock on Monday morning, fourteen months after the flood of July 2025 scoured out the footing of its central pier and cracked both of its arches. ::: The repair cost £1.9 million, most of it from the county council’s flood recovery fund. Engineers rebuilt the pier on a new concrete footing sunk six metres into the riverbed, and relaid the arches with 3,100 of their own stones, each one numbered as it came down and set back in the same place. ::resource{id="bridge"} It reopened just five days before the first storm of the autumn is due. Heavy rain is forecast for Saturday :ref{id="forecast" text="(Weather, right)"}, and the county’s engineers will be on the bridge to watch the river rise. The first vehicle across was Keith Barlow’s dairy van, which has spent more than a year on the eleven-mile detour through Kirkby. “Forty minutes each way, six days a week,” he said. “I’d rather not add it up.” The bridge is open to cars, vans and buses, and closed to goods vehicles over 7.5 tonnes. The county says heavy traffic had weakened the old structure long before the flood, and it will not let lorries back until the new pier has passed a load test next spring. That has disappointed some traders. “It’s half a bridge for us,” said Priya Chandra, who runs the builders’ merchant on Mill Lane. “Our timber still comes round by Kirkby, eleven miles each way.” For the market, it came just in time. Stallholders from the south bank have carried their stock over the footbridge for a year, and Saturday’s market will be the first in more than a year with every pitch taken. The bell will ring at eight, as it did before the flood, and the council is running a free bus from the station car park for the first four Saturdays. The county will inspect the town’s two other bridges, at Kirkby Lane and over the mill race, before the winter, and has promised to publish both reports. The first, on the Kirkby Lane bridge, is due in November, and the county’s engineers will present it at the Corn Exchange. The meeting is open to anyone who uses the bridges. :::callout{type="story" span="page" title="The wind farm vote"} ## Harrow was the one ward to say no {style="second"} ::: :::paragraphs{style="flush"} Every ward but one voted for the wind farm in Tuesday’s poll. Market gave it the biggest majority, with 69% voting yes, and Brook Lane the smallest, 736 votes to 591 :ref{id="wards" text="(Results, below)"}. ::: Harrow, the ward beneath the ridge, voted against by 511 votes to 469, on a turnout of 76%, the highest of the five wards. “Most of the people who voted no can see the ridge from their front doors,” said its councillor, Ruth Okafor, who has asked the cooperative to meet Harrow residents before the application goes in. The first meeting is on 8 October. Tom Ashby, the cooperative’s chair, said it had expected Harrow to be the hardest ward to win. He has offered to move the nearest turbine 200 metres further from the village, which would cost about 4% of its output, and will put both sites to Harrow residents at the October meeting. The poll does not bind the district council, which expects to decide the planning application in the summer of 2027. Anyone may comment on it for 21 days after it is published. :::columnbreak ## Letters **Tea at the count.** I was one of 22 volunteers who sorted 6,297 ballots in the Corn Exchange on Tuesday night. When the urn ran dry at ten, the cricket club lent us theirs, and we finished at a quarter to twelve. Thank you to the Rotary Club for the sandwiches, and to whoever left a tin of flapjacks on the returning officer’s table. :::paragraphs{style="sign"} *Margaret Oyelaran, Brook Lane* ::: :::paragraphs{style="flush"} **The view belongs to all of us.** Those of us who walk the Wend Way every week were outvoted by people who have never climbed the ridge. I counted eleven pairs of curlews up there in May. I hope the district council will remember that the moor is not the town’s to sell, whatever the count says. ::: :::paragraphs{style="sign"} *David Hurst, Low Harrow* ::: :::paragraphs{style="imprint"} An imaginary weekly. Text CC BY 4.0. Set in Grenze Gotisch, PT Serif and Libre Franklin (SIL OFL). :::
`; // content.en.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build 'PT Serif': ['400', '400i', '700'], 'Grenze Gotisch': ['700'], 'Libre Franklin': ['400', '600', '700', '800', '900'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([loadImage('ridge-2400.jpg', asset('ridge-2400.jpg')), loadSvg('bridge.svg', bridgeSvg()), ...bars.map((b) => loadSvg(`${b.id}.svg`, b.svg))]); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); showPages(doc, { title: 'Newspaper front page' });
Kit · core, fonts, viewer, images: in jedem Rezept gleich · 270 Zeilen// ─── 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 ───────────────────────────────────────────────────────────────────────

Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)

Varianten

#Die Kurzmeldungen unter dem Foto halten

Ohne placement="bottom" in content.en.md bleibt der Streifen dort, wo sein Direktivenblock steht, direkt unter dem Foto, und der Aufmacher läuft darunter bis zum Fuß der Seite.

-:::callout{type="briefs" span="page" placement="bottom" title="In brief"}
+:::callout{type="briefs" span="page" title="In brief"}

Häufige Fehler

Häufiger Fehler

Ein 'top'-Gleitobjekt kommt auf die Seite nach seinem Verweis, außer es darf die verweisende Seite eröffnen

Standardmäßig steht ein Gleitobjekt nie über seinem eigenen Verweis, sodass ein seitenbreites 'top'-Gleitobjekt, auf das auf Seite N verwiesen wird, Seite N+1 eröffnet. Setzen Sie placement.citingPage: true an der Ressource oder am defaultPlacement ihres Typs (oder layout.floatsAtCitingPage für das ganze Buch, seit postext 1.25), dann steht es stattdessen oben auf Seite N, und der Text über dem Verweis rückt darunter, sofern es in layout.maxTopFraction passt und die verweisende Zeile auf dieser Seite bleibt. Es eröffnet nie die erste Seite eines Kapitels oder eine Seite nach einem erzwungenen Umbruch: dort eröffnet es weiterhin die nächste Seite, und 'auto' oder 'bottom' können den Fuß der verweisenden Seite einnehmen. Platzierung von Abbildungen →

Häufiger Fehler

Bitmaps beziehen ihre Druckgröße aus einer Auflösung: diese angeben, nicht Pixel in Druckgröße

Eine Bitmap ohne Auflösung wird mit einem Pixel pro Layoutpixel bei der dpi des Dokuments gesetzt, sodass ein Foto mit 2400 px auf einer Seite mit 150 dpi 406 mm breit ist. Geben Sie die echten Pixel der Datei an und dem Bild seine Auflösung (bitmap.resolution: 300), oder setzen Sie layout.bitmapResolution für jede Bitmap: einen ppi-Wert oder 'file', um den in der Datei angegebenen zu lesen. Die Spalte begrenzt das Bild weiterhin. Abbildungen und Tabellen als Ressourcen →

  • Geben Sie dem Foto placement: { position: 'top', span: 'page' }, und es wandert von Seite 1 weg, um allein auf Seite 2 zu stehen. Der Artikel auf Seite 1 endet dann 92 mm über dem Streifen mit Kurzmeldungen, und die Rubrik wandert auf Seite 3.

Nachweise

Text
Eigener Text, CC BY 4.0
Bilder
  • Wind turbines on a snowy ridge (the front-page photograph, cropped to a band) · Jason Blackeye · CC0 1.0
  • The bridge drawing and the bars in the ward table, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
Schriften
Grenze Gotisch (SIL OFL 1.1) · PT Serif (SIL OFL 1.1) · Libre Franklin (SIL OFL 1.1)
Sandbox