Zum Hauptinhalt springen
Rezept Nummer 4

Rezepte · Kapitel 3 · Überschriften und Auftakte

Magazinreportage: vom Fotoauftakt bis zum Schlusszeichen

Ein Auftakt mit advancedDesign setzt ein randabfallendes Foto und Dachzeile, Titel, Vorspann und Autorenzeile der Überschrift; dann zwei Kästen, ein Chip.

S. 57 · 1 von 4

  • Englisches Beispiel: noch keine deutsche Ausgabe
  • Endformat 225 × 297 mm
  • 2 Spalten, 6 mm Spaltenabstand
  • Literata 9,6/13,4
  • Instrument Sans
  • Instrument Serif
  • 4 Seiten
  • Stufe
  • Postext 1.31.0
  • In 261 ms gesetzt
  • 240 Zeilen Code

Kurz gesagt

Eine Reportage aus einem Naturmagazin, die mit einem großen Foto oben auf der Seite beginnt. Sie zeigt, wie Titel, kurze Einleitung, Autor und Bildnachweis gesetzt werden, dann Seitenkästen und ein kleines Schlusszeichen.

Was Sie setzen

Der Reportageteil von Boreal, der Winterausgabe eines Naturmagazins, auf einer Seite von 225 × 297 mm. Die Reportage beginnt unter einem Bergsee, der randabfallend über den oberen Teil der Seite läuft. Unter dem Bild steht eine gesperrte Dachzeile über einem zweizeiligen Titel, rechts daneben ein kursiver Vorspann und darunter die Autorenzeile; der Nachweis des Fotografen läuft unter dem Foto. Der Titel ist der Text der Markdown-Überschrift, und Dachzeile, Vorspann, Autorenzeile und Bildnachweis sind ihre Attribute. Die Reportage läuft über eine Doppelseite in zwei Spalten im Blocksatz weiter. Die linke Seite hat ein Zitat mit hängendem Anführungszeichen und einen Infokasten am Kopf der rechten Spalte; die rechte ein Band aus Kiefernwald oben und einen dunklen Zahlenkasten am Fuß. Ein kleines Quadrat beendet die Reportage. Auf der Rückseite verwendet ein Bestimmungsführer den Auftakt erneut, mit einer Zeichnung und einer rostroten Dachzeile.

Dieses Rezept beantwortet

  • Wie füge ich einem Auftakt eine Autorenzeile, einen Vorspann oder einen Einstieg mit Initiale hinzu?
  • Wie gebe ich jedem Kapitel ein eigenes Foto, eine eigene Farbe oder eine eigene Auftaktvariante?
  • Wie setze ich ein Motto, eine Widmung, eine Unterschrift oder ein herausgestelltes Zitat mit großem Anführungszeichen?

Die kurze Antwort

script.js · Zeilen 40–77im vollständigen Code
const TOP = 22; // top margin in mm: an opener's container starts here
const sans = { fontFamily: 'Instrument Sans', fontWeight: 600, textTransform: 'uppercase' };
const HEAD = 118; // mm: the headline's measure; the standfirst takes the rest of the line
// Empty padding paints nothing but counts: the story starts on the first grid line at least
// 5 mm under the lower of the headline and the byline, however many lines each one runs to.
const air = { padding: { bottom: mm(5) } };
const at = (id, edge, x, y, width) => ({ anchor: { to: id, edge },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = (resourceId, depth) => ({ // depth: how far down the page the picture bleeds
  enabled: true, // no minHeight: the story starts under the headline and byline (see air)
  slot: {
    elements: [ // the photo is an element, not a float: no float reaches the trim
      { kind: 'image', id: 'photo', resourceId, placement: { anchor: { to: 'bleed',
        edge: 'top-left' }, size: { width: 'fill', height: mm(depth) } } },
      // The photo hangs from the page's top edge, the words from the container, TOP mm lower:
      // depth − TOP is the photo's foot, so the credit sits 2 mm under it and the kicker 9 mm.
      { kind: 'text', id: 'credit', content: '{attr.credit}', ...sans, fontWeight: 500,
        fontSize: pt(6.5), letterSpacing: pt(0.6), color: col('muted'), align: 'right',
        placement: at('container', 'top-right', 0, depth - TOP + 2) },
      { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...sans, fontSize: pt(8.5),
        letterSpacing: pt(1.7), color: col('lake'), align: 'left',
        placement: at('container', 'top-left', 0, depth - TOP + 9) },
      { kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Instrument Serif',
        fontSize: pt(58), color: col('ink'), align: 'left', box: air, // wraps: a heading design
        lineHeight: 0.94, // tight: the two lines of the headline nearly touch
        placement: at('#kicker', 'below', 0, 2.5, HEAD) },
      { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', italic: true,
        fontFamily: 'Instrument Serif', fontSize: pt(13.5), lineHeight: 1.22, // a multiple
        color: col('ink'), align: 'left',
        placement: at('#headline', 'right-of', 7, 3.2) }, // wraps at the container's edge
      { kind: 'text', id: 'byline', content: '{attr.byline}', ...sans, fontSize: pt(7.5),
        letterSpacing: pt(1.3), color: col('ink'), align: 'left', box: air,
        placement: at('#standfirst', 'below', 0, 3.5) },
    ],
  },
}); // hook-up: headings.levels[0] = { level: 1, span: 'page', breakBefore, advancedDesign:
// opener('lake', 160) }. {titleText} prints the heading's text, {attr.<key>} its <key>="…":
// # The Lake That Keeps Time {kicker="…" standfirst="…" byline="…" credit="…"}

Zutaten

Schrift
Literata, Instrument Serif, Instrument Sans (SIL OFL 1.1)
Dateien
  • lake-2000.jpg
  • thaw-2000.jpg
  • Clouds mirrored in a mountain lake (the opener photograph) (Ales Krivec, CC0 1.0)
  • Walk in a thawing forest (the photo band) (Hannah Donze, CC0 1.0)

Zubereitung

#1 · Den Auftakt aus der Überschriftenzeile bauen

Der Code steht in der kurzen Antwort oben. Jedes {attr.<key>} druckt den Wert <key>="…" aus der #-Zeile der Reportage, und {titleText} druckt die Überschrift selbst (Attribute von Überschriften), sodass jede Reportage ihre Wörter in ihrem eigenen Markdown behält und alle Überschriften der Ebene 1 eine Gestaltung teilen. Das Foto hängt von der Oberkante der Seite, und der Container beginnt 22 mm tiefer, also ist depth - TOP die Unterkante des Fotos, vom Container aus gemessen; der Bildnachweis sitzt 2 mm unter dieser Linie oben rechts und die Dachzeile 9 mm darunter oben links. Der Titel steht below der Dachzeile, auf die Satzbreite HEAD von 118 mm gesetzt. Der Vorspann steht right-of dem Titel ohne eigene Breite, also bricht er an der Kante des Containers um, und die Autorenzeile steht below dem Vorspann, sodass ein längerer Vorspann die Autorenzeile nach unten schiebt, statt in sie hineinzulaufen. Die Ebene hat keine minHeight. Titel und Autorenzeile enden jeweils in einem leeren Innenabstand von 5 mm, der zur Höhe des Auftakts zählt, sodass die Reportage auf der ersten Rasterlinie beginnt, die mindestens 5 mm unter dem tiefer endenden der beiden liegt, gleich wie viele Zeilen jeder umfasst.

#2 · Die Bilder einmal deklarieren, mit ihren echten Pixeln

script.js · Zeilen 205–236im vollständigen Code
// The pictures are not numbered: their own type, with an empty caption prefix and template,
// keeps a figure number off the pine wood's caption.
const photoType = { id: 'photo', name: t({ en: 'Photograph', es: 'Fotografía' }),
  shortLabel: t({ en: 'photo', es: 'foto' }), captionPrefix: '', numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal' };
const PX = 10; // the drawing's pixels per mm
const resources = [
  { id: 'lake', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0, // never cited:
    // the opener fits it inside its box, so the JPEG is cropped to the box, 225 × 160 mm
    bitmap: { fileId: 'lake-2000.jpg', format: 'jpeg', width: 2000, height: 1422 },
    altText: t({ en: 'A still mountain lake mirroring clouds between autumn slopes.',
      es: 'Un lago de montaña en calma que refleja las nubes entre laderas otoñales.' }) },
  { id: 'thaw', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    // Pixels at the page's 150 dpi (gotcha: bitmap-print-size): 2000 px make 339 mm, so the
    // band shrinks to the 195 mm measure. At 300 dpi it would print 169 mm wide.
    bitmap: { fileId: 'thaw-2000.jpg', format: 'jpeg', width: 2000, height: 944 },
    // A top float opens the page after its ::resource line (gotcha: top-float-next-page):
    // the line sits on the verso, so the band heads the recto.
    placement: { position: 'top', span: 'page' },
    caption: t({ en: 'Early March in the pine wood above the shore: the snow goes first where '
      + 'the sun reaches the ground, weeks before the ice lets go of the lake.',
    es: 'Principios de marzo en el pinar sobre la orilla: la nieve se retira primero donde el '
      + 'sol llega al suelo, semanas antes de que el hielo suelte el lago.' }),
    note: t({ en: 'Photograph: Hannah Donze, CC0, via Wikimedia Commons',
      es: 'Fotografía: Hannah Donze, CC0, vía Wikimedia Commons' }),
    altText: t({ en: 'A walker on a snowy path between tall pines.',
      es: 'Un caminante en un sendero nevado entre pinos altos.' }) },
  { id: 'ice-art', typeId: 'photo', kind: 'svg', createdAt: 0, updatedAt: 0, // drawn below
    svg: { fileId: 'ice-art.svg', width: TRIM * PX, height: ART * PX },
    altText: t({ en: 'A lake in section: snow, white ice, black ice and water under a low sun.',
      es: 'Un lago en sección: nieve, hielo blanco, hielo negro y agua bajo un sol bajo.' }) },
];

Ein Gestaltungselement nennt sein Bild über die Ressourcen-ID, also wird das Foto des Auftakts wie jede Abbildung deklariert, aber nie zitiert: Nur der Auftakt zeichnet es, bis an das Endformat, das kein Gleitobjekt erreicht. Bitmaps werden mit den 150 dpi der Seite gemessen, also ergeben die 2000 px des Kiefernwalds 339 mm und schrumpfen auf die Satzbreite von 195 mm, im Druck etwa 260 dpi. Die Bilder erhalten einen eigenen Typ mit leerem Legendenpräfix und leerer Nummernvorlage, sodass die Bildunterschrift des Kiefernwalds keine Abbildungsnummer trägt. Das Band ist ein Gleitobjekt top über die ganze Seitenbreite, und ein oben gleitendes Objekt beginnt auf der Seite nach der, die es zitiert. Seine Zeile ::resource steht auf der linken Seite, nach dem Absatz über 1944, also steht das Band am Kopf der rechten Seite.

#3 · Das Anführungszeichen in den Rand hängen

script.js · Zeilen 112–130im vollständigen Code
// The glyph is centred in an icon square that the box keeps as a column, size + gap wide,
// left of the text. A negative gap pulls the text back over the square's empty right side,
// and a left padding of −(size + gap) moves that column out into the margin, so the text
// starts on the column's edge and the mark hangs outside it. The frame stays on the column
// (a background would stop short of the mark). « sits lower and runs wider than “, so the
// Spanish mark is set smaller.
const MARK = t({ en: { glyph: '“', size: 50, column: 34 }, // pt; the column is 12 mm
  es: { glyph: '«', size: 28, column: 22.5 } });
// The paddings are optical: once the next paragraph snaps to the grid, the quote has
// the same air above and below it, in both languages.
const quote = { id: 'pullquote', backgroundEnabled: false, marginTop: pt(LEAD),
  marginBottom: pt(0), padding: { top: pt(8), right: pt(0), bottom: pt(7),
    left: pt(-MARK.column) }, // negative: the icon column starts out in the margin
  icon: { kind: 'glyph', glyph: MARK.glyph, fontFamily: 'Instrument Serif',
    size: pt(MARK.size), color: col('lake') },
  titleStyle: { gap: pt(MARK.column - MARK.size) }, // negative too: size + gap = column
  body: { fontFamily: 'Instrument Serif', fontSize: pt(19), lineHeight: pt(1.5 * LEAD),
    textAlign: 'left', hyphenation: false, color: col('lake'), // display type: no hyphens
    italicColor: col('lake'), firstLineIndent: pt(0) } };

Ein Glyphen-Icon nimmt eine eigene Spalte neben dem Text ein, was das Zitat einrücken würde. Der negative Abstand schiebt den Text zurück über die leere rechte Seite des Glyphenquadrats. Ein negativer linker Innenabstand, so breit wie der Rest dieser Spalte (Größe + Abstand, 34 pt), verschiebt die Spalte dann in den Rand, und der Text beginnt wieder an der Spaltenkante. Der Rahmen des Kastens bleibt auf der Spalte, also endete ein Hintergrund vor dem Zeichen. Das Quadrat beginnt 12 mm außerhalb, und das darin zentrierte Zeichen hängt etwa 6 mm in den Außenrand der linken Seite von 14 mm. Die spanische Ausgabe setzt ihr « kleiner, weil ein Guillemet tiefer sitzt und breiter läuft als ein Anführungszeichen.

#4 · Die Kästen an einen Spaltenkopf und an den Seitenfuß gleiten lassen

script.js · Zeilen 134–157im vollständigen Code
// Floated boxes keep one body line from the text, so they need no margins of their own.
const glance = { id: 'glance', placement: 'top', // floats to the next column head: no hole
  backgroundEnabled: false, // one device, the stripe; the text keeps the column's edges
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('lake') },
  padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
    color: col('lake'), gap: mm(2) },
  body: { fontFamily: 'Instrument Sans', fontSize: pt(8.6), lineHeight: pt(12.2),
    textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } }; // ink from bodyText
const numbers = { id: 'numbers', span: 'page', placement: 'bottom', // floats to a page foot
  background: col('ink'), columnGap: mm(8),
  padding: { top: mm(5), right: mm(6), bottom: mm(5.5), left: mm(6) },
  titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
    color: col('ice'), gap: mm(1) },
  body: { fontFamily: 'Instrument Sans', fontSize: pt(9), lineHeight: pt(12.5),
    textAlign: 'left', hyphenation: false, color: col('ice'), firstLineIndent: pt(0) } };
// The panel's figures are level-4 headings (#### 31), a level the story never uses.
const figures = { level: 4, fontSize: pt(40), lineHeight: pt(40), color: col('ember'),
  marginBottom: pt(4) };
// The end mark is a chip of one space, :chip[ ]{style="end"}: an empty box. Its lengths are
// in its own ems: paddingX makes the width, and the height is its font size's band (0.8
// ascent + 0.25 descent). It is ink, like the text it closes, in the story and in the guide.
const endMark = { id: 'end', background: col('ink'), borderWidth: pt(0), borderRadius: pt(0),
  fontSize: em(0.62), paddingX: em(0.525), paddingY: em(0), gap: em(0.8) }; // 1.05 em square

Mit placement: 'top' gleitet der Infokasten: Er verlässt den Textfluss und nimmt den nächsten freien Spaltenkopf, sodass der Text bis zum Fuß der Spalte weiterläuft, statt eine Lücke zu lassen, wo der Kasten nicht passte. Der Zahlenkasten gleitet in die andere Richtung, an den Fuß der Seite, und reicht über beide Spalten; seine drei Zahlen sind Überschriften der Ebene 4, einer Ebene, die die Reportage nie verwendet, platziert durch breaks="3,5", das untergeordnete Blöcke zählt, nicht Zeilen. Das Schlusszeichen ist ein Chip ohne sichtbaren Text. Sein paddingX gibt dem Quadrat seine Breite und die Schriftgröße des Chips seine Höhe, beide 1,05 em in der eigenen Größe des Chips.

#5 · Kolumnentitel, die Ausgabe und Reportage nennen

script.js · Zeilen 81–108im vollständigen Code
const FOLIO_PT = 8.5; // the folio's size in pt
const LABEL_PT = 7.5; // the label's size in pt
const SQUARE = 2.1; // mm: the lake square's side, the folio's cap height
const label = { ...sans, fontSize: pt(LABEL_PT), letterSpacing: pt(1.3), color: col('muted') };
const folio = { fontFamily: 'Instrument Sans', fontWeight: 700, fontSize: pt(FOLIO_PT),
  color: col('ink') };
// A design text's baseline sits 0.8 down its line box, 1.2 × its size (the default lineHeight).
const baseline = (size) => size * 1.2 * 0.8 * 25.4 / 72; // mm from its box's top, size in pt
const HEAD_Y = 12; // mm from the top edge to the folio's box, inside the 22 mm top margin
const LINE = HEAD_Y + baseline(FOLIO_PT); // the heads' one baseline, from the top edge
const pin = (edge, x, y = HEAD_Y) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) } }); // in the margin (gotcha: header-paints-over-text)
const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left
const header = { elements: sides.flatMap(([parity, edge, s]) => [
  { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
    placement: pin(`top-${edge}`, s * OUTER) },
  { kind: 'box', id: `square-${parity}`, style: { backgroundColor: col('lake') },
    placement: { ...pin(`top-${edge}`, s * (OUTER + 7.5), LINE - SQUARE), // on the line
      size: { width: mm(SQUARE), height: mm(SQUARE) } } },
  // The smaller label's baseline sits higher in its box (0.34 mm at 7.5 and 8.5 pt), so its
  // box goes that much lower: folio, square and label share one baseline at any size.
  { kind: 'text', id: `head-${parity}`, ...label,
    content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}',
    placement: pin(`top-${edge}`, s * (OUTER + 11.5), LINE - baseline(LABEL_PT)) },
].map((element) => ({ ...element, parity, pages: 'body' }))) }; // no running heads on openers
const footer = { elements: sides.map(([parity, edge, s]) => ({ kind: 'text', parity,
  id: `drop-folio-${parity}`, ...folio, content: '{pageNumber}', pages: 'opener', align: edge,
  placement: pin(`bottom-${edge}`, s * OUTER, -11) })) }; // an opener's only folio, at the foot

Kopfelemente malen über die Seite und reservieren keinen Platz, also ist jedes Stück im oberen Rand mit Abständen in Millimetern an der Seite verankert. Die linke Seite nennt Magazin und Ausgabe aus dem Frontmatter, die rechte den Titel der Reportage, und pages: 'body' hält beide von den Auftaktseiten fern, die nur eine Pagina tragen, am Fuß. Die Grundlinie eines Gestaltungstextes sitzt bei 0,8 der Höhe seines Zeilenkastens, der das 1,2-Fache seiner Größe misst, also setzt baseline() das Etikett von 7,5 pt 0,34 mm unter die Pagina von 8,5 pt und stellt das Quadrat auf dieselbe Zeile. Ändern Sie eine der beiden Größen, teilen die drei Stücke weiterhin eine Grundlinie.

#6 · Den Auftakt für den nächsten Beitrag wiederverwenden

script.js · Zeilen 161–164im vollständigen Code
const ART = 126; // mm: the drawing bleeds less far down the page than the photograph
// On its pages, 'lake' turns rust in the opener, the headings, the boxes and the chips.
const guide = { id: 'guide', advancedDesign: opener('ice-art', ART),
  palette: { lake: palette.rust } };

Der Auftakt ist eine Funktion, also erhält der Bestimmungsführer dieselbe Gestaltung mit eigenem Bild und eigener Tiefe über ein Überschriftenformat, # Five Kinds of Ice {style="guide" kicker="Field guide" …}. Die Zeichnung läuft 126 statt 160 mm randabfallend die Seite hinab; Bildnachweis und Dachzeile werden von depth - TOP aus gemessen, und die übrigen Wörter hängen an der Dachzeile, also rücken sie alle 34 mm nach oben, und der Text des Führers beginnt höher. Die Palette des Formats ordnet lake auf diesen Seiten Rostrot zu, sodass die Dachzeile und jeder mit lake verknüpfte Zwischentitel oder Kasten ohne Farbargument für opener() rostrot wird; ein mit lake verknüpfter Chip würde ebenfalls rostrot, und das Schlusszeichen ist in beiden Beiträgen schwarz.

Das ganze Rezept

Sandbox
// ═══ Postext Cookbook · Nº 004 · Magazine feature: photo opener to end mark ═══════
// https://postext.dev/en/cookbook/magazine-feature-opener
// Code: MIT · Text: original (CC BY 4.0) · Photos: Ales Krivec, Hannah Donze (CC0)
// Fonts: Literata, Instrument Serif, Instrument Sans (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// A nature feature from a winter issue. The level-1 heading carries its kicker, standfirst,
// byline and photo credit as attributes, and one opener design lays them out under a bleed
// photograph; the story runs on with a pull quote, a fact box, a photo band, a numbers panel
// and an end mark, and the next item reuses the opener with a drawing in place of the photo.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'magazine-feature-opener';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Every colour below is linked to this palette by id.
const palette = {
  ink: '#15191c', // text: a blue-black
  lake: '#2d6a7d', // the accent: kickers, crossheads, the quote, the fact box's stripe
  ember: '#c8773d', // the panel's figures: 5.2:1 on ink (only 3.4:1 on paper)
  rust: '#9a5a2e', // the field guide's accent, swapped in for 'lake' by its heading style
  ice: '#dbe8ec', // the type on the dark panel and the drawing's sky
  rule: '#c7cdd1', // the drawing's far ridge and air bubbles
  muted: '#66707a', // running heads, credits, the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'lake (defaults)', value: { hex: palette.lake, model: 'hex' } },
];
const TRIM = 225; // page width in mm, shared with the drawing
const INNER = 16; // inner margin in mm
const OUTER = 14; // outer margin in mm: folios and running heads align to it
const LEAD = 13.4; // body leading in pt: the baseline grid

// #region answer: a bleed photo, then the heading's kicker, headline, standfirst, byline and credit
const TOP = 22; // top margin in mm: an opener's container starts here
const sans = { fontFamily: 'Instrument Sans', fontWeight: 600, textTransform: 'uppercase' };
const HEAD = 118; // mm: the headline's measure; the standfirst takes the rest of the line
// Empty padding paints nothing but counts: the story starts on the first grid line at least
// 5 mm under the lower of the headline and the byline, however many lines each one runs to.
const air = { padding: { bottom: mm(5) } };
const at = (id, edge, x, y, width) => ({ anchor: { to: id, edge },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = (resourceId, depth) => ({ // depth: how far down the page the picture bleeds
  enabled: true, // no minHeight: the story starts under the headline and byline (see air)
  slot: {
    elements: [ // the photo is an element, not a float: no float reaches the trim
      { kind: 'image', id: 'photo', resourceId, placement: { anchor: { to: 'bleed',
        edge: 'top-left' }, size: { width: 'fill', height: mm(depth) } } },
      // The photo hangs from the page's top edge, the words from the container, TOP mm lower:
      // depth − TOP is the photo's foot, so the credit sits 2 mm under it and the kicker 9 mm.
      { kind: 'text', id: 'credit', content: '{attr.credit}', ...sans, fontWeight: 500,
        fontSize: pt(6.5), letterSpacing: pt(0.6), color: col('muted'), align: 'right',
        placement: at('container', 'top-right', 0, depth - TOP + 2) },
      { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...sans, fontSize: pt(8.5),
        letterSpacing: pt(1.7), color: col('lake'), align: 'left',
        placement: at('container', 'top-left', 0, depth - TOP + 9) },
      { kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Instrument Serif',
        fontSize: pt(58), color: col('ink'), align: 'left', box: air, // wraps: a heading design
        lineHeight: 0.94, // tight: the two lines of the headline nearly touch
        placement: at('#kicker', 'below', 0, 2.5, HEAD) },
      { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', italic: true,
        fontFamily: 'Instrument Serif', fontSize: pt(13.5), lineHeight: 1.22, // a multiple
        color: col('ink'), align: 'left',
        placement: at('#headline', 'right-of', 7, 3.2) }, // wraps at the container's edge
      { kind: 'text', id: 'byline', content: '{attr.byline}', ...sans, fontSize: pt(7.5),
        letterSpacing: pt(1.3), color: col('ink'), align: 'left', box: air,
        placement: at('#standfirst', 'below', 0, 3.5) },
    ],
  },
}); // hook-up: headings.levels[0] = { level: 1, span: 'page', breakBefore, advancedDesign:
// opener('lake', 160) }. {titleText} prints the heading's text, {attr.<key>} its <key>="…":
// # The Lake That Keeps Time {kicker="…" standfirst="…" byline="…" credit="…"}
// #endregion

// #region heads: magazine and issue on the verso, the story on the recto, a lake square
const FOLIO_PT = 8.5; // the folio's size in pt
const LABEL_PT = 7.5; // the label's size in pt
const SQUARE = 2.1; // mm: the lake square's side, the folio's cap height
const label = { ...sans, fontSize: pt(LABEL_PT), letterSpacing: pt(1.3), color: col('muted') };
const folio = { fontFamily: 'Instrument Sans', fontWeight: 700, fontSize: pt(FOLIO_PT),
  color: col('ink') };
// A design text's baseline sits 0.8 down its line box, 1.2 × its size (the default lineHeight).
const baseline = (size) => size * 1.2 * 0.8 * 25.4 / 72; // mm from its box's top, size in pt
const HEAD_Y = 12; // mm from the top edge to the folio's box, inside the 22 mm top margin
const LINE = HEAD_Y + baseline(FOLIO_PT); // the heads' one baseline, from the top edge
const pin = (edge, x, y = HEAD_Y) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) } }); // in the margin (gotcha: header-paints-over-text)
const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left
const header = { elements: sides.flatMap(([parity, edge, s]) => [
  { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
    placement: pin(`top-${edge}`, s * OUTER) },
  { kind: 'box', id: `square-${parity}`, style: { backgroundColor: col('lake') },
    placement: { ...pin(`top-${edge}`, s * (OUTER + 7.5), LINE - SQUARE), // on the line
      size: { width: mm(SQUARE), height: mm(SQUARE) } } },
  // The smaller label's baseline sits higher in its box (0.34 mm at 7.5 and 8.5 pt), so its
  // box goes that much lower: folio, square and label share one baseline at any size.
  { kind: 'text', id: `head-${parity}`, ...label,
    content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}',
    placement: pin(`top-${edge}`, s * (OUTER + 11.5), LINE - baseline(LABEL_PT)) },
].map((element) => ({ ...element, parity, pages: 'body' }))) }; // no running heads on openers
const footer = { elements: sides.map(([parity, edge, s]) => ({ kind: 'text', parity,
  id: `drop-folio-${parity}`, ...folio, content: '{pageNumber}', pages: 'opener', align: edge,
  placement: pin(`bottom-${edge}`, s * OUTER, -11) })) }; // an opener's only folio, at the foot
// #endregion

// #region quote: a pull quote whose mark hangs in the margin, outside the text's edge
// The glyph is centred in an icon square that the box keeps as a column, size + gap wide,
// left of the text. A negative gap pulls the text back over the square's empty right side,
// and a left padding of −(size + gap) moves that column out into the margin, so the text
// starts on the column's edge and the mark hangs outside it. The frame stays on the column
// (a background would stop short of the mark). « sits lower and runs wider than “, so the
// Spanish mark is set smaller.
const MARK = t({ en: { glyph: '“', size: 50, column: 34 }, // pt; the column is 12 mm
  es: { glyph: '«', size: 28, column: 22.5 } });
// The paddings are optical: once the next paragraph snaps to the grid, the quote has
// the same air above and below it, in both languages.
const quote = { id: 'pullquote', backgroundEnabled: false, marginTop: pt(LEAD),
  marginBottom: pt(0), padding: { top: pt(8), right: pt(0), bottom: pt(7),
    left: pt(-MARK.column) }, // negative: the icon column starts out in the margin
  icon: { kind: 'glyph', glyph: MARK.glyph, fontFamily: 'Instrument Serif',
    size: pt(MARK.size), color: col('lake') },
  titleStyle: { gap: pt(MARK.column - MARK.size) }, // negative too: size + gap = column
  body: { fontFamily: 'Instrument Serif', fontSize: pt(19), lineHeight: pt(1.5 * LEAD),
    textAlign: 'left', hyphenation: false, color: col('lake'), // display type: no hyphens
    italicColor: col('lake'), firstLineIndent: pt(0) } };
// #endregion

// #region boxes: a fact box at a column head, a dark panel at the page foot, the end mark
// Floated boxes keep one body line from the text, so they need no margins of their own.
const glance = { id: 'glance', placement: 'top', // floats to the next column head: no hole
  backgroundEnabled: false, // one device, the stripe; the text keeps the column's edges
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('lake') },
  padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
    color: col('lake'), gap: mm(2) },
  body: { fontFamily: 'Instrument Sans', fontSize: pt(8.6), lineHeight: pt(12.2),
    textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } }; // ink from bodyText
const numbers = { id: 'numbers', span: 'page', placement: 'bottom', // floats to a page foot
  background: col('ink'), columnGap: mm(8),
  padding: { top: mm(5), right: mm(6), bottom: mm(5.5), left: mm(6) },
  titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
    color: col('ice'), gap: mm(1) },
  body: { fontFamily: 'Instrument Sans', fontSize: pt(9), lineHeight: pt(12.5),
    textAlign: 'left', hyphenation: false, color: col('ice'), firstLineIndent: pt(0) } };
// The panel's figures are level-4 headings (#### 31), a level the story never uses.
const figures = { level: 4, fontSize: pt(40), lineHeight: pt(40), color: col('ember'),
  marginBottom: pt(4) };
// The end mark is a chip of one space, :chip[ ]{style="end"}: an empty box. Its lengths are
// in its own ems: paddingX makes the width, and the height is its font size's band (0.8
// ascent + 0.25 descent). It is ink, like the text it closes, in the story and in the guide.
const endMark = { id: 'end', background: col('ink'), borderWidth: pt(0), borderRadius: pt(0),
  fontSize: em(0.62), paddingX: em(0.525), paddingY: em(0), gap: em(0.8) }; // 1.05 em square
// #endregion

// #region guide: the next item reuses the opener with its own picture, depth and accent
const ART = 126; // mm: the drawing bleeds less far down the page than the photograph
// On its pages, 'lake' turns rust in the opener, the headings, the boxes and the chips.
const guide = { id: 'guide', advancedDesign: opener('ice-art', ART),
  palette: { lake: palette.rust } };
// #endregion

const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }), // each edition's hyphenation patterns
  resourceTypes: [photoType], // one unnumbered type for every picture (see the resources)
  colorPalette,
  page: { width: mm(TRIM), height: mm(297), margins: { top: mm(TOP), bottom: mm(20),
    left: mm(INNER), right: mm(OUTER), mirror: true }, // a magazine trim; left is the inner side
    dpi: 150 }, // the layout's pixels per inch, which bitmaps are measured in (see 'thaw')
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { fontFamily: 'Literata', fontSize: pt(9.6), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(3.5), indentAfterHeading: false,
    minWordSpacing: 0.78, // a space never shrinks below 78 % (the default allows 60 %)
    runtMinCharacters: 40 }, // 40 spaces' width, about 20 letters: no one-word last lines
  // Hyphenation, optimal line breaking and widow control are on by default.
  headings: {
    fontFamily: 'Instrument Serif', fontWeight: 400, color: col('ink'),
    levels: [
      // 'any' lets the next item open on the following page, recto or verso.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginBottom: pt(0), advancedDesign: opener('lake', 160) },
      { level: 2, fontSize: pt(15), lineHeight: pt(LEAD), italic: true, color: col('lake'),
        marginTop: pt(LEAD), marginBottom: pt(0) }, // crossheads, one grid line above
      figures,
    ],
  },
  headingStyles: [guide],
  calloutStyles: [quote, glance, numbers],
  chipStyles: [endMark],
  captionStyle: { fontFamily: 'Instrument Sans', fontSize: pt(7.6), gap: mm(2), // ink: bodyText's
    note: { fontSize: pt(6.5), color: col('muted'), gap: mm(0.6) } },
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Instrument Sans', fontSize: pt(6.6),
    lineHeight: pt(9.4), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0),
    marginTop: pt(2 * LEAD) }],
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// #region resources: two photographs and a drawing, declared once, by their real pixels
// The pictures are not numbered: their own type, with an empty caption prefix and template,
// keeps a figure number off the pine wood's caption.
const photoType = { id: 'photo', name: t({ en: 'Photograph', es: 'Fotografía' }),
  shortLabel: t({ en: 'photo', es: 'foto' }), captionPrefix: '', numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal' };
const PX = 10; // the drawing's pixels per mm
const resources = [
  { id: 'lake', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0, // never cited:
    // the opener fits it inside its box, so the JPEG is cropped to the box, 225 × 160 mm
    bitmap: { fileId: 'lake-2000.jpg', format: 'jpeg', width: 2000, height: 1422 },
    altText: t({ en: 'A still mountain lake mirroring clouds between autumn slopes.',
      es: 'Un lago de montaña en calma que refleja las nubes entre laderas otoñales.' }) },
  { id: 'thaw', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    // Pixels at the page's 150 dpi (gotcha: bitmap-print-size): 2000 px make 339 mm, so the
    // band shrinks to the 195 mm measure. At 300 dpi it would print 169 mm wide.
    bitmap: { fileId: 'thaw-2000.jpg', format: 'jpeg', width: 2000, height: 944 },
    // A top float opens the page after its ::resource line (gotcha: top-float-next-page):
    // the line sits on the verso, so the band heads the recto.
    placement: { position: 'top', span: 'page' },
    caption: t({ en: 'Early March in the pine wood above the shore: the snow goes first where '
      + 'the sun reaches the ground, weeks before the ice lets go of the lake.',
    es: 'Principios de marzo en el pinar sobre la orilla: la nieve se retira primero donde el '
      + 'sol llega al suelo, semanas antes de que el hielo suelte el lago.' }),
    note: t({ en: 'Photograph: Hannah Donze, CC0, via Wikimedia Commons',
      es: 'Fotografía: Hannah Donze, CC0, vía Wikimedia Commons' }),
    altText: t({ en: 'A walker on a snowy path between tall pines.',
      es: 'Un caminante en un sendero nevado entre pinos altos.' }) },
  { id: 'ice-art', typeId: 'photo', kind: 'svg', createdAt: 0, updatedAt: 0, // drawn below
    svg: { fileId: 'ice-art.svg', width: TRIM * PX, height: ART * PX },
    altText: t({ en: 'A lake in section: snow, white ice, black ice and water under a low sun.',
      es: 'Un lago en sección: nieve, hielo blanco, hielo negro y agua bajo un sol bajo.' }) },
];
// #endregion

const markdown = String.raw`---
Markdown-Beispiel · 107 Zeilen · content.en.mdtitle: "Boreal" subtitle: "Winter 2026" --- # The Lake That Keeps Time {kicker="Climate · Field report" standfirst="For a hundred and fifteen winters, one family has written down the day their lake froze and the day it let go. Their notebooks are now one of the longest climate records in the mountains." byline="Words by Ingrid Solberg" credit="Late October, before the freeze · Photograph: Ales Krivec, CC0"} The notebook lives in a biscuit tin on the kitchen dresser, between the box of matches and the parish calendar. It is the fourth of its kind. The first three are in the regional archive now, wrapped in acid-free paper, but Hanna Brenner still prefers the tin. “The archive asked for this one as well,” she says, lifting the lid. “I told them we are still using it.” Her great-grandfather ran the ferry across the lake from 1906, and he began the record for a practical reason: a man who rows passengers over water needs to know when the water will carry a sledge instead. On the ninth of December 1911 he wrote, in pencil, *Frozen. All of it.* On the fourth of April he noted that the ice had gone out overnight, with a noise “like a door slammed somewhere in the mountains”. Every winter since, someone in the family has kept up the record, one line in pencil for every year. One hundred and fifteen lines in pencil do not look like much. Printed out, they fit on two sheets of paper. But the nearest weather station, in the next valley, opened only in 1931, and few lakes anywhere have an ice record as long as this one. The oldest, on Lake Suwa in Japan, was begun by Shinto priests in 1443 and notes the day a ridge of ice forms across the lake. ## A thermometer with a lid “A lake is a thermometer that you read once a year,” says Vera Lind, a limnologist who has spent six winters on the ice here. “The air changes from hour to hour. The lake averages all of that. When it freezes and when it thaws tells you what the whole season was like.” The reason lies in an odd habit of water. Most liquids grow denser as they cool. Fresh water does too, but only down to about 4 °C; below that it becomes lighter again, which is why a lake freezes from the top down; ice, lighter still, floats on it. In autumn, as the surface chills, the cold water sinks and warmer water rises to take its place. The lake turns over, again and again, for weeks. Only when the whole column has reached 4 °C can the surface cool further without sinking, and only then, on a still, clear night, can it skin over. :::callout{type="pullquote"} *A person stood on the same jetty and looked at the same water.* ::: That is why the freeze comes late and all at once. Hanna remembers standing on the jetty as a girl and watching the last open water close in the middle of the lake “like a pupil shrinking in the light”. By morning her father was walking out with an axe to measure the thickness. Ten centimetres will hold a person, the family rule went; twenty will hold the sledge; thirty, a horse. ## What the ice is made of The first ice to form is black ice, grown straight down from the water beneath it and so clear that you can see stones on the bottom through a hand’s width of it. It is the strongest ice a lake makes. Snow brings a second kind. A heavy fall presses the sheet down until water seeps up through the cracks and soaks the snow into slush, which freezes into white ice, cloudy with trapped air and barely half as strong. Lind drills through both every week of the winter. The cores come up banded like a tree trunk, and she reads them the same way: a thick black layer means a cold, dry December; a stack of white bands means storm after storm. Under the ice the lake goes on living. The water at the bottom stays close to 4 °C all winter. The trout slow down but keep feeding. Light still passes through clear ice, and algae grow in the green gloom beneath it, which surprised the first scientists who went looking. :::callout{type="glance" title="At a glance"} **Altitude** 1,540 m above sea level **Area** 3.2 km^2^, deepest point 63 m **Record kept since** the winter of 1911–12 **Ice cover, 1911–1960** 118 days a year on average **Ice cover, 1991–2025** 87 days a year on average **Winters with no ice** 2007 and 2020 ::: ## Counting the days For the first fifty years of the record, the lake stayed frozen for an average of 118 days a winter. Since 1991 the average has been 87. The freeze now comes about a fortnight later than it did in the ferryman’s time, and the ice goes out more than two weeks earlier. The change has not been smooth. Some winters of the 1960s, and again the winter of 2010, were as long as any in the notebooks. But the open winters are new. In 2007 and again in 2020 the lake never froze across at all, and the family wrote a single word for the year: *open*. The winter of 1944 nearly went unrecorded. The ferryman’s son was away at the war, and the dates for that year are in another hand, small and upright, with a note in the margin: *kept by his mother*. She had written them on the back of a ration card and copied them in when he came home. Lind has checked them against the weather station in the next valley. They are, she says, as good as the rest. ::resource{id="thaw"} Lind is careful about what one lake can tell us. A single record is a local story: the valley has its own winds, and the lake its own depth and shape. But the notebooks agree with hundreds of other lakes across the northern hemisphere, from Finland to Japan, where the ice seasons have shortened by weeks over the past century. “I trust it because the method never changed,” she says. “A person stood on the same jetty and looked at the same water.” She has added instruments of her own. A chain of temperature loggers hangs from a buoy over the deepest part of the lake, reading every fifteen minutes from the surface to the floor, and a camera on the church tower photographs the ice at noon each day. But when she sets them beside the notebooks, the dates hold up: the family has never been more than a day or two from what the loggers record. ## Ice-out The ice rarely leaves quietly. Through March the sun works on it from above and warmer streams from below, and the black ice rots into long vertical crystals, candle ice, that chime against each other when the wind moves them. Then a warm rain or a south wind breaks the sheet, and within a day or two the surface is open. The old fishermen claimed they could hear it happen from the village. The date matters to far more than the ferry. The spring bloom of algae, which feeds all the other life in the lake, starts when the ice breaks up and sunlight pours into the water. Perch spawn in the shallows and midges hatch on a timetable that the ice sets. When ice-out moves earlier, those timetables can drift apart, and a young fish may hatch into water whose food has already come and gone. :::callout{type="numbers" title="In numbers"} :::columns{count=3 breaks="3,5"} #### 31 fewer days of ice each winter than in the first fifty years of the notebooks #### 115 winters recorded, in pencil, by five generations of one family #### 4 °C the temperature of the lake floor all winter, where water is densest ::: ::: There are human timetables too. The winter road across the lake, which once carried hay, timber and the doctor’s sleigh, has not been opened officially since 2014. The skating club moved its races to an artificial rink in the town. The ice fishermen still go out, but later in the season, and they carry ropes. Hanna’s grandfather remembered the other extreme. In the hard winter of 1963 the ice grew to sixty centimetres, and a baker from the next village drove his van straight across the lake to save the long road round. The entry for that year has a small drawing of the van in the margin, the only picture in all four notebooks. Hanna is not sentimental about any of this. She teaches mathematics at the valley school, and she has turned the notebooks into a lesson: every class plots the two dates of each winter and draws a line through the dots. “The children always find the trend by themselves,” she says. “I hand out graph paper and a ruler and say nothing about climate.” Some of her pupils have gone further. Two years ago a class set the notebooks beside the school’s own record of when the cherry trees in the yard came into flower, and found that both dates had moved by about the same number of days. Lind now shows their chart at conferences, with the children’s names in the corner. The record will go on. Hanna’s son, who is fourteen, has taken over the November walks to the jetty to watch for the morning when the last dark patch of water disappears. He keeps a spreadsheet now, and every night a copy goes to a server at the university, where Lind stores her logger readings. But on the day the lake freezes he does what his great-great-grandfather did in 1911 and writes the date in pencil, in the notebook in the tin. Last winter the lake froze on the twenty-first of December and opened again on the eighteenth of March: eighty-seven days, almost exactly the modern average. Hanna wrote *ordinary* beside the dates, then crossed the word out. “Ordinary for now,” she says. :chip[ ]{style="end"} # Five Kinds of Ice {style="guide" kicker="Field guide" standfirst="How to read a frozen lake before you trust it with your weight, from the clear black sheet of early winter to the rotten candles of March." byline="Text by the editors" credit="Illustration generated in code for Boreal"} **Black ice.** The first ice of the season grows straight down from calm water in long crystals that let the light through. It looks dark because you are seeing the lake beneath it. Ten centimetres of new black ice will bear a walker, and on a calm morning you can watch fish pass under your boots. **White ice.** When snow loads the sheet, water seeps up through the cracks, soaks the snow and freezes into a milky layer full of air. It is roughly half as strong as black ice, so count it at half its thickness when you judge a crossing. On a sunny afternoon it softens further. **Slush.** A heavy snowfall can push the ice below the waterline and leave a layer of wet snow on top, kept liquid under an insulating crust. It is heavy going on foot and treacherous on skis, and it is where most white ice begins. Grey patches on fresh snow are its warning sign. **Shore ice.** The ice along the edge is the first to form and the first to go. Springs, reeds and the warmth of the ground weaken it, and by March a strip of open water, the moat, often separates the sheet from the land. You can walk out on good ice and find that you cannot walk back. **Candle ice.** In spring the sun rots the black ice along the seams of its crystals. The sheet can still look solid while it has become a bundle of loose vertical rods that give way under a boot. When the surface turns grey and granular, and the rods chime in the wind, stay on the shore. Never judge ice by its colour alone. Measure it every few steps, carry a pair of ice picks round your neck, and ask the people who live by the shore. :chip[ ]{style="end"} :::paragraphs{style="colophon"} Set in Literata, Instrument Serif and Instrument Sans (SIL Open Font License) · Text: Postext Cookbook, CC BY 4.0 · Photographs: Ales Krivec and Hannah Donze, CC0, via Wikimedia Commons · The lake, the family, the scientists and the writer are fictional. :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the guide's picture, a lake in section, drawn in code with a seeded PRNG function mulberry32(seed) { 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; }; } function iceArt() { // in mm, TRIM × ART: sky, shore, snow, white ice, black ice, water const rand = mulberry32(14); const f = (id, a = 1) => `fill="${palette[id]}"${a < 1 ? ` fill-opacity="${a}"` : ''}`; const rect = (x, y, w, h, paint) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" ${paint}/>`; const ridge = (base, amp, step, paint) => { // a mountain line, closed down to the shore let d = `M0 ${base}`; for (let x = 0; x <= TRIM; x += step) d += `L${x} ${(base - rand() * amp).toFixed(1)}`; return `<path d="${d}L${TRIM} 60L0 60Z" ${paint}/>`; }; const pines = Array.from({ length: 46 }, (_, i) => { // the far shore, a row of spruces const x = i * 5 + rand() * 3; const h = 4 + rand() * 5; return `<path d="M${x.toFixed(1)} ${60 - h}l${h * 0.28} ${h}h${-h * 0.56}Z" ` + `${f('ink', 0.85)}/>`; }).join(''); const bubbles = Array.from({ length: 70 }, () => { // air trapped in the white ice const r = 0.25 + rand() * 0.6; const [cx, cy] = [(rand() * TRIM).toFixed(1), (72 + rand() * 6).toFixed(1)]; return `<circle cx="${cx}" cy="${cy}" r="${r}" ${f('paper')} stroke="${palette.rule}" ` + 'stroke-width="0.15"/>'; }).join(''); // Black ice: a solid sheet on the winter side (left) that rots into candles towards spring, // ten rods evenly spaced, each shorter and thinner than the last. const candles = Array.from({ length: 10 }, (_, i) => rect(150 + i * 7.5, 79, (2.6 - i * 0.1).toFixed(2), (12.5 - i * 0.55 - rand() * 1.5).toFixed(1), f('ink'))).join(''); const clouds = [[18, 15, 52], [98, 8, 38], [146, 21, 30]].map(([x, y, w]) => [[x, y, w], [x + w * 0.22, y - 2.4, w * 0.42]].map(([cx, cy, cw]) => `<rect x="${cx}" y="${cy}" ` + `width="${cw}" height="4.4" rx="2.2" ${f('paper', 0.7)}/>`).join('')).join(''); const fish = (x, y, s) => `<path d="M${x} ${y}c${3 * s} ${-2 * s} ${7 * s} ${-2 * s} ${9 * s} 0` + `c${-2 * s} ${2 * s} ${-6 * s} ${2 * s} ${-9 * s} 0Z` // the body, then the tail + `m0 0l${-2.5 * s} ${-1.6 * s}v${3.2 * s}Z" ${f('ink', 0.55)}/>`; return `<svg xmlns="http://www.w3.org/2000/svg" width="${TRIM * PX}" height="${ART * PX}" ` + `viewBox="0 0 ${TRIM} ${ART}">` + rect(0, 0, TRIM, ART, f('ice')) + clouds // winter sky + `<g transform="translate(0 ${ART - 112})">` // the lake keeps to the foot of the picture + `<circle cx="188" cy="24" r="9" ${f('ember', 0.9)}/>` // a low March sun + ridge(38, 16, 9, f('rule')) + ridge(48, 12, 6, f('muted', 0.55)) + pines + rect(0, 60, TRIM, 11, f('paper')) + rect(0, 60, TRIM, 11, f('ice', 0.3)) // snow + rect(0, 71, TRIM, 8, f('ice', 0.75)) + bubbles // white ice, cloudy with air + rect(0, 79, TRIM, 33, f('lake')) // the water, 4 °C at the floor + rect(0, 79, 146, 13, f('ink')) + candles + fish(60, 101, 1) + fish(128, 106, 0.8) + '</g></svg>'; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build Literata: ['400', '400i', '700'], 'Instrument Serif': ['400', '400i'], 'Instrument Sans': ['400', '500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([loadImage('lake-2000.jpg', asset('lake-2000.jpg')), loadImage('thaw-2000.jpg', asset('thaw-2000.jpg')), loadSvg('ice-art.svg', iceArt())]); const continuation = { pageNumbering: { startAt: 57 } }; // pages 57–60 of the issue const doc = await buildDocumentWithFonts({ markdown, resources, continuation }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'Magazine feature: photo opener to end mark', es: 'Reportaje de revista: de la foto de apertura al signo final' }) });
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

#Jeden Beitrag auf einer rechten Seite beginnen

Mit 'odd' rückt der Bestimmungsführer auf Seite 61, und Seite 60 bleibt leer.

- { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
+ { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },

#Den Titel auf ein Farbband setzen

Für ein Lehrbuchkapitel, in dem ein Farbband und eine große Zahl das Foto ersetzen, siehe Kapitelauftakt auf einem randabfallenden Farbband.

Häufige Fehler

Häufiger Fehler

Attributwerte: kein { oder }; einen Wert mit " in einfache Anführungszeichen setzen

Ein Attributwert endet an der schließenden geschweiften Klammer, kann also kein { oder } enthalten. Ein Wert, der ein doppeltes Anführungszeichen enthält, kommt in einfache Anführungszeichen; ein Dollarzeichen ist unproblematisch. Überschriftenattribute →

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 →

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

Elemente von Kopf- und Fußzeile malen über den Text

Elemente von Kopf- und Fußzeile werden über die Seite gemalt, und der Textbereich macht ihnen keinen Platz. Halten Sie sie innerhalb der Ränder, denn die Ränder reservieren ihren Raum. Kolumnentitel und Seitenzahlen →

Sandbox-Prüfung · bitmapTooSmall

Bild mit geringer Auflösung

Ursache. Eine Bitmap wird mehr als 1,5-mal breiter gezeichnet als ihre Pixel, sie wirkt im Druck daher unscharf.

Lösung. Liefern Sie etwa 300 dpi in der Druckgröße und geben Sie die tatsächliche Breite und Höhe der Bitmap an. Doku →

  • Ein Bildelement passt das Bild in seinen Kasten ein und beschneidet es nie. Schneiden Sie das JPEG auf die Proportionen des Kastens zu, hier 225 × 160 mm, sonst schrumpft das Foto und lässt weiße Streifen an den Rändern der Seite.
  • Das hängende Zeichen braucht einen Rand. Rückt das Zitat durch eine Änderung in eine rechte Spalte, füllt das Zeichen den Spaltenabstand von 6 mm und berührt den Text der linken Spalte: Verschieben Sie das Zitat einen Absatz nach vorn oder nach hinten.
  • Die Reportage ist so eingepasst, dass sie auf Seite 59 über dem Zahlenkasten endet. Ein paar Zeilen mehr schicken ihr Ende auf eine eigene Seite, also passen Sie den Text nach einer Änderung in beiden Sprachen neu ein.
  • runtMinCharacters: 40 hält einwortige Ausgangszeilen fern. Kann der Zeilenumbruch eine zu kurze Ausgangszeile nicht vermeiden, setzt die Engine den Absatz eine Zeile kürzer, mit engeren Wortabständen und, wenn das nicht reicht, bis zu 0,01 em weniger Laufweite. Suchen Sie nach einer Änderung einen so eng gesetzten Absatz und kürzen oder ergänzen Sie dort stattdessen ein paar Wörter; der mitgelieferte Text braucht diese Korrektur in keiner der beiden Sprachen.

Nachweise

Text
Eigener Text, CC BY 4.0
Bilder
Schriften
Literata (SIL OFL 1.1) · Instrument Serif (SIL OFL 1.1) · Instrument Sans (SIL OFL 1.1)
Sandbox