Zum Hauptinhalt springen
Rezept Nummer 45

Rezepte · Kapitel 3 · Überschriften und Auftakte

Marginalüberschriften, hängende Nummern, eingebundene Titel

Eine Wettbewerbsauslobung mit Abschnittstiteln in einer Randspalte auf den Grundlinien des Textes, hängenden Unternummern und roten Anfangsbegriffen.

Auf dieser Seite

S. 1 von 5

  • Englisches Beispiel: noch keine deutsche Ausgabe
  • Endformat 210 × 297 mm
  • Anderthalb Spalten, 7 mm Spaltenabstand
  • Mona Sans 10/14
  • DM Mono
  • Noto Serif Display
  • 5 Seiten
  • Stufe
  • Postext 1.31.0
  • In 66 ms gesetzt
  • 261 Zeilen Code

Kurz gesagt

Die Ausschreibung eines Stadtrats für Entwürfe, die eine alte Fischmarkthalle in eine Bibliothek verwandeln. Die Abschnittstitel stehen in einem breiten linken Rand, auf der Höhe der ersten Zeile des Textes daneben.

Was Sie setzen

Fünf A4-Seiten einer Architekturwettbewerbsauslobung, in der der Rat von Puerto Lince, einem fiktiven spanischen Hafenort, Entwürfe sucht, um seine Fischmarkthalle von 1931 in die Stadtbibliothek zu verwandeln. Das Layout folgt Schweizer Gemeindeberichten. Eine Randspalte von 50 mm läuft links über jede Seite, und jeder Abschnitt beginnt mit einer roten Nummer und einer schwarzen Linie über den ganzen Satzspiegel. Der Titel des Abschnitts steht in der Randspalte in Noto Serif Display, auf der Höhe der ersten Textzeile. Unterabschnittsnummern hängen in roter DM Mono in den Spaltenabstand, sodass jeder Unterabschnittstitel die linke Kante des Textes hält. Die Beurteilungskriterien in Abschnitt 4 beginnen mit fetten Begriffen in Rot. Seite 1 setzt einen Titel in 58 pt über einen Lageplan, ein Plan des Grundstücks steht auf Seite 2 in der Randspalte, und ein Aufriss der Kaifront eröffnet Seite 3.

Dieses Rezept beantwortet

  • Wie nummeriere ich Überschriften (1, 1.1, 1.1.1) und gestalte jede Ebene anders?
  • Wie setze ich ein Layout mit anderthalb Spalten, mit breiter Textspalte und schmaler Seitenspalte?
  • Wie bestimme ich, wo eine Abbildung steht: oben auf der Seite, über beide Spalten, genau hier oder am Rand?
  • Wie färbe ich Schlüsselbegriffe (fett oder kursiv) im Text oder in Kästen ein?

Die kurze Antwort

script.js · Zeilen 56–81im vollständigen Code
// The heading stays in the flow and keeps its number, but its design has no {titleText}:
// it prints the number in the channel and a rule from there to the column's right edge,
// both reserve: false, or the box below would keep a float gap under them (a line lower).
const [NUMBER, RULE] = [8.5, 0.75]; // pt: the DM Mono number, whose figures are 0.7 em tall
// Centre the rule on the figures: their baseline is 0.8 of the line down, less 0.35 em.
const RULE_Y = pt(NUMBER * (0.8 - 0.7 / 2) - RULE / 2);
const h2 = { level: 2, numberingTemplate: '{2}', lineHeight: pt(LEAD), // one grid line
  marginTop: pt(2 * LEAD), marginBottom: pt(0), advancedDesign: { enabled: true, slot: {
    elements: [
      { kind: 'text', id: 'number', content: '{number}', fontFamily: LABEL, fontWeight: 500,
        fontSize: pt(NUMBER), lineHeight: 1, color: col('signal'), reserve: false,
        placement: at('container', 'top-left', mm(-HANG)) },
      { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(RULE), reserve: false,
        color: col('ink'), placement: { ...at('#number', 'right-of', mm(2), RULE_Y),
          size: { width: 'fill' } } },
    ] } } };
// The title goes in a box fenced right after the heading: span="side" stands it in the
// channel on the grid line where the text resumes (gotcha: side-box-starts-at-fence).
// Attributes at the end of the line stay with the heading.
const sideHeads = (md) => md.replace(/^## (.+?)(\s*\{[^}]*\})?$/gm,
  (line, title) => `${line}\n\n:::callout{type="sidehead" span="side"}\n${title}\n:::`);
// The box has no background and no padding, and it takes the text's leading, so each
// line of the title sits on a baseline of the text beside it.
const sidehead = { id: 'sidehead', backgroundEnabled: false,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  body: { fontFamily: DISPLAY, fontSize: pt(13.5), lineHeight: pt(LEAD) } };

Zutaten

Schrift
Mona Sans, Noto Serif Display, DM Mono (SIL OFL 1.1)
Dateien
Keine: Jedes Bild wird im Code gezeichnet

Zubereitung

#1 · Den Abschnittstitel aus einem Kasten in der Randspalte drucken

Der Code steht oben in der kurzen Antwort. Eine Überschrift reserviert Platz bis zum untersten Element ihrer Gestaltung; ein Titel, der in der Randspalte auf der Höhe der ersten Zeile des Abschnitts gezeichnet wird, schöbe den Text also eine Zeile nach unten, bei einem zweizeiligen Titel zwei. Die Gestaltung der Überschrift zeichnet daher nur Nummer und Linie, und sideHeads() kopiert den Text jedes ## Title in einen Direktivenblock :::callout direkt nach der Überschrift. Mit span="side" verlässt dieser Kasten den Fluss und steht in der Randspalte auf der Rasterlinie, auf der der Text weiterläuft (Kastenformate). Nummer und Linie setzen reserve: false: Sonst hielte ein Kasten in der Randspalte unter den dort stehenden Überschriftenelementen einen Gleitobjektabstand von einer Zeile ein, und der Titel fiele auf die zweite Textzeile. Kästen und Grundtext setzen die Grundlinie einer Zeile beide bei 0,8 der Zeilenhöhe, sodass ein Kasten mit dem Zeilenabstand von 14 pt des Textes jede Zeile eines zweizeiligen Titels auf eine Grundlinie des Absatzes daneben stellt. Das Markdown bleibt ein schlichtes ## Schedule of accommodation. Da die Gestaltung der Überschrift kein {titleText} druckt, listet die Sandbox die Warnung Überschriftentitel nicht referenziert, obwohl der Kasten den Titel druckt.

Seite 2: Nummer und Linie stammen aus der Überschrift, der Titel aus dem Kasten in der Randspalte, auf der Höhe der ersten Textzeile. Der Grundstücksplan stapelt sich darunter.

#2 · Die Randspalte für Titel und Abbildungen freihalten

script.js · Zeilen 46–52im vollständigen Code
const layout = {
  layoutType: 'oneAndHalf',
  sideColumnPercent: (SIDE / CONTENT) * 100, // 50 of the 178 mm between the margins
  sideColumnSide: 'left', // on every page: the brief is printed on one side of the sheet
  sideColumnRole: 'floats', // no text in the channel: what span: 'side' sends, side captions
  gutterWidth: mm(GUTTER),
};

sideColumnRole: 'floats' hält den Text aus der Randspalte heraus (Layouttypen). Nach dem Auftakt enthält die Randspalte nur die sechs Abschnittstitel und den Grundstücksplan, beide mit span: 'side' dorthin geschickt, und die Legenden der Tabellen 1 und 2. Die Seitenspalte steht standardmäßig 'right', und eine einseitig gedruckte Auslobung hält sie auf jeder Seite auf derselben Seite. sideColumnSide: 'left' setzt sie nach links, wo jeder Abschnittstitel vor seinem Text gelesen wird.

#3 · Die Nummern hängen lassen und die kleinsten Titel einbinden

script.js · Zeilen 85–102im vollständigen Code
// The title keeps the text's left edge; the number, right-aligned in a 12 mm box, ends
// 2 mm short of it. The box needs that fixed width: an 'auto' width is clamped to the
// column and would shrink to nothing out here.
const H3 = { size: 10.5, number: 9 }; // pt; two boxes one grid line tall share a baseline
const h3 = { level: 3, numberingTemplate: '{2}.{3}', fontSize: pt(H3.size),
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0),
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontWeight: 600,
      fontSize: pt(H3.size), lineHeight: LEAD / H3.size, color: col('ink'), align: 'left',
      overflow: 'wrap', placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
    { kind: 'text', id: 'number', content: '{number}', fontFamily: LABEL, fontWeight: 500,
      fontSize: pt(H3.number), lineHeight: LEAD / H3.number, color: col('signal'),
      align: 'right', placement: { ...at('#title', 'left-of', mm(-2)),
        size: { width: mm(12) } } },
  ] } } };
// Level 4 is not a heading: '**Accessibility.** A wheelchair user…' opens its paragraph.
// No other paragraph has bold; table cells keep tableStyle's ink, so Total stays black.
const runIn = { boldColor: col('signal') }; // spread into bodyText

Der Unterabschnittstitel beginnt an der linken Kante der Spalte. Seine Nummer ist 'left-of' des Titels verankert, rechtsbündig in einem Kasten von 12 mm, der 2 mm vor dem Text endet, sodass Nummern wie 2.1 und 3.3 in den Spaltenabstand hängen und jeder Titel mit dem Text darunter fluchtet (Platzierung von Elementen). Der Kasten braucht eine feste Breite. Eine Breite 'auto' wird auf die Spalte beschränkt. Draußen im Spaltenabstand bleibt dann gar keine Breite, und die Nummer wird nicht gezeichnet. Ebene 2 druckt '{2}' und Ebene 3 '{2}.{3}', denn mit {1} begänne jede Nummer mit der 1 der einzigen H1 der Auslobung. Die Kriterien in Abschnitt 4 sind eine vierte Ebene ohne Überschrift. Jeder Absatz beginnt mit einem fetten Begriff, und bodyText.boldColor druckt ihn in Rot (Grundtext). Kein anderer Absatz enthält Fett, und Tabellenzellen nehmen ihre Farbe aus tableStyle, sodass die fette Summenzeile von Tabelle 1 schwarz bleibt.

#4 · Jeder Abbildung und Tabelle ihre eigene Platzierung geben

script.js · Zeilen 333–367im vollständigen Code
// The plot plan stands in the channel; the elevation crosses channel and column at the
// head of the next page; the table of areas floats to the head of the text column, with
// its caption beside it in the channel; the timetable sits where ::resource puts it, and
// its caption takes the channel too (captionSide serves an inline embed from 1.28).
const PLAN_W = 44; // mm: the plot plan, 88 m wide at 1:2000
const resource = (id, typeId, rest) => ({ id, typeId, createdAt: 0, updatedAt: 0, ...rest });
const drawing = (id, width, height, rest) => resource(id, 'figure', { kind: 'svg',
  svg: { fileId: `${id}.svg`, width, height }, ...rest });
const resources = [
  drawing('parcela', PLAN_W * 10, 400, {
    placement: { span: 'side', width: PLAN_W / SIDE }, // 44 mm of the channel's 50
    caption: t({ en: 'The plot, 1:2000. Market hall and weighbridge hut in grey; six tamarisks '
      + 'along the north edge. Red arrows: possible entrances; grey arrow: service.',
    es: 'La parcela, 1:2000. Lonja y caseta de básculas, en gris; seis tamarindos en el '
      + 'borde norte. Flechas rojas: accesos posibles; flecha gris: servicio.' }),
    altText: t({ en: 'Plan of the plot with the hall, the hut, six trees and three entrances.',
      es: 'Planta de la parcela con la lonja, la caseta, seis árboles y tres accesos.' }) }),
  drawing('fachada', 1780, 300, { placement: { position: 'top', span: 'page' },
    caption: t({ en: 'Quay front, 1:300. In red, the four frames at the east end, where the '
      + 'reinforcement has corroded.',
    es: 'Fachada al muelle, 1:300. En rojo, los cuatro pórticos del extremo este, con '
      + 'las armaduras corroídas.' }),
    altText: t({ en: 'Elevation of the hall: thirteen vaults, eleven arches, four frames in red.',
      es: 'Alzado de la lonja: trece bóvedas, once arcos y cuatro pórticos en rojo.' }) }),
  resource('programa', 'table', { kind: 'table', table: programme,
    placement: { position: 'top', captionSide: true }, caption: t({
      en: 'Schedule of net floor areas.', es: 'Programa de superficies útiles por zonas.' }) }),
  resource('calendario', 'table', { kind: 'table', table: calendar,
    placement: { position: 'here', captionSide: true },
    caption: t({ en: 'Competition timetable.', es: 'Calendario del concurso.' }) }),
  // No :ref cites the site plan: only the opener's image element draws it.
  drawing('situacion', MAP.w * 10, MAP.h * 10, { altText: t({
    en: 'Site plan of the Poniente quay with the market hall’s plot in red.',
    es: 'Plano de situación del muelle de Poniente con la parcela de la lonja en rojo.' }) }),
];

Der Grundstücksplan geht mit span: 'side' in die Randspalte und stapelt sich unter dem Marginaltitel von Abschnitt 2 (Platzierung). width: PLAN_W / SIDE macht ihn 44 mm breit, womit seine 88 Meter genau im Maßstab 1:2000 stehen. Der Fassadenaufriss ist ein Gleitobjekt 'top' über die ganze Seitenbreite. Er wird auf Seite 2 zitiert und eröffnet Seite 3, da ein Gleitobjekt nie über seinem Verweis platziert wird. Die Tabelle der Flächen, ebenfalls auf Seite 2 zitiert, gleitet auf Seite 3 an den Kopf der Textspalte, unter den Aufriss. captionSide: true stellt ihre Legende in die Randspalte, auf die Höhe der Tabellenoberkante. Der Zeitplan steht dort, wo ::resource ihn hinsetzt, am Ende der Auslobung, und captionSide: true stellt auch seine Legende in die Randspalte, neben Abschnitt 6.3.

#5 · Die Auslobung auf dem Lageplan eröffnen

script.js · Zeilen 106–131im vollständigen Code
const MAP = { y: 68.5, w: CONTENT, h: 52 }; // mm: from the top of the text block
const display = { fontFamily: DISPLAY, fontWeight: 300, color: col('ink'), align: 'left',
  overflow: 'wrap' };
const opener = { enabled: true,
  // The plan's foot falls between two grid lines. A floor on the next one leaves the rule
  // of section 1 as far from its text as the rule of every later section.
  minHeight: mm(GRID * Math.ceil((MAP.y + MAP.h) / GRID)),
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, color: col('signal'),
      placement: at('container', 'top-left', mm(HANG)) },
    { kind: 'text', id: 'series', content: '{subtitle}', ...label,
      placement: { ...at('container', 'top-left'), size: { width: mm(SIDE) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...display, fontSize: pt(58),
      lineHeight: 0.96, placement: { ...at('#kicker', 'below', mm(0), mm(4.5)),
        size: { width: mm(MAIN) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', ...display, italic: true,
      fontSize: pt(14), lineHeight: 1.3, placement: { ...at('#title', 'below', mm(0), mm(5)),
        size: { width: mm(MAIN) } } },
    { kind: 'text', id: 'legend', content: '{attr.map}', fontFamily: TEXT, fontSize: pt(7.5),
      lineHeight: 1.4, color: col('muted'), align: 'left', overflow: 'wrap',
      placement: { ...at('#lead', 'align-top', mm(-HANG), pt(2)),
        size: { width: mm(SIDE - 6) } } },
    { kind: 'image', id: 'map', resourceId: 'situacion', // across the channel and the column
      placement: { ...at('container', 'top-left', mm(0), mm(MAP.y)),
        size: { width: mm(MAP.w) } } },
  ] } };

Mit span: 'page' ist der Container des Auftakts der ganze Satzspiegel, Randspalte eingeschlossen (Span und erweiterte Gestaltung). Dachzeile und Titel stehen 57 mm weit innen, an der linken Kante der Textspalte, und der Plan läuft über Randspalte und Textspalte. Ohne diese Angabe ist der Container die Textspalte, jedes Element rückt 57 mm nach rechts, und Titel, Vorspann und Plan laufen über die rechte Kante der Seite hinaus. Die Gestaltung reserviert Platz bis zum Fuß des Plans, der zwischen zwei Rasterlinien fällt. Ein auf die nächste Rasterlinie aufgerundetes minHeight lässt die Linie von Abschnitt 1 so weit von ihrem Text entfernt wie die Linie jedes späteren Abschnitts. Dachzeile, Vorspann und Kartenlegende sind Attribute der Zeile # Competition brief.

Das ganze Rezept

Sandbox
// ═══ Postext Cookbook · Nº 045 · Side heads, hanging numbers and run-in heads ═══════
// https://postext.dev/en/cookbook/side-heads-hanging-numbers
// Code: MIT · Text: original (CC BY 4.0) · Drawings: made in code (MIT)
// Fonts: Mona Sans, Noto Serif Display, DM Mono (SIL OFL 1.1) · Needs postext ≥ 1.28.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, defaultResourceTypes,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'side-heads-hanging-numbers';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // a Swiss municipal report: black, white and one signal red
  ink: '#17171a', // text and rules
  signal: '#d42a1f', // section numbers, run-in heads, the plot on the plans (5.1:1)
  tint: '#f9dcd7', // the plot's ground on the plans
  sea: '#d8e2e8', // water on the plans
  stone: '#cacad0', // built ground on the plans
  rule: '#c3c3ca', // hairlines under the running head and in tables
  muted: '#5e5e67', // running heads, legends, the colophon (6.4:1)
  paper: '#ffffff',
};
// Every colour is linked to its palette entry by id.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// Defaults this config does not restate link to 'main-color', so it points at the accent.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.signal })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Mona Sans', 'Noto Serif Display', 'DM Mono'];
const TRIM = { width: 210, height: 297 }; // mm: A4, like every document of the competition
const [TOP, LEFT, RIGHT] = [26, 16, 16]; // mm; not mirrored: the brief prints one-sided
const LEAD = 14; // pt: the body leading, the pitch of the baseline grid
const LINES = 50; // grid lines in the text block
const PT = 25.4 / 72; // mm in a point
const GRID = LEAD * PT; // mm: one line of the grid
const CONTENT = TRIM.width - LEFT - RIGHT; // 178 mm
const [SIDE, GUTTER] = [50, 7]; // mm: the margin channel on the left, and the gap after it
const MAIN = CONTENT - SIDE - GUTTER; // 121 mm: the text column
const HANG = SIDE + GUTTER; // mm from the channel's left edge to the text's left edge
const at = (to, edge, x = mm(0), y = mm(0)) => ({ anchor: { to, edge }, offset: { x, y } });
// Kickers, running heads and folios: tracked capitals in the mono.
const LABEL_PT = 7.5;
const label = { fontFamily: LABEL, fontWeight: 500, fontSize: pt(LABEL_PT), lineHeight: 1.35,
  letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted'), align: 'left' };

// #region channel: one text column, and on its left a channel for boxes and figures only
const layout = {
  layoutType: 'oneAndHalf',
  sideColumnPercent: (SIDE / CONTENT) * 100, // 50 of the 178 mm between the margins
  sideColumnSide: 'left', // on every page: the brief is printed on one side of the sheet
  sideColumnRole: 'floats', // no text in the channel: what span: 'side' sends, side captions
  gutterWidth: mm(GUTTER),
};
// #endregion

// #region answer: side heads: the heading draws a number and a rule, a box prints the title
// The heading stays in the flow and keeps its number, but its design has no {titleText}:
// it prints the number in the channel and a rule from there to the column's right edge,
// both reserve: false, or the box below would keep a float gap under them (a line lower).
const [NUMBER, RULE] = [8.5, 0.75]; // pt: the DM Mono number, whose figures are 0.7 em tall
// Centre the rule on the figures: their baseline is 0.8 of the line down, less 0.35 em.
const RULE_Y = pt(NUMBER * (0.8 - 0.7 / 2) - RULE / 2);
const h2 = { level: 2, numberingTemplate: '{2}', lineHeight: pt(LEAD), // one grid line
  marginTop: pt(2 * LEAD), marginBottom: pt(0), advancedDesign: { enabled: true, slot: {
    elements: [
      { kind: 'text', id: 'number', content: '{number}', fontFamily: LABEL, fontWeight: 500,
        fontSize: pt(NUMBER), lineHeight: 1, color: col('signal'), reserve: false,
        placement: at('container', 'top-left', mm(-HANG)) },
      { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(RULE), reserve: false,
        color: col('ink'), placement: { ...at('#number', 'right-of', mm(2), RULE_Y),
          size: { width: 'fill' } } },
    ] } } };
// The title goes in a box fenced right after the heading: span="side" stands it in the
// channel on the grid line where the text resumes (gotcha: side-box-starts-at-fence).
// Attributes at the end of the line stay with the heading.
const sideHeads = (md) => md.replace(/^## (.+?)(\s*\{[^}]*\})?$/gm,
  (line, title) => `${line}\n\n:::callout{type="sidehead" span="side"}\n${title}\n:::`);
// The box has no background and no padding, and it takes the text's leading, so each
// line of the title sits on a baseline of the text beside it.
const sidehead = { id: 'sidehead', backgroundEnabled: false,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  body: { fontFamily: DISPLAY, fontSize: pt(13.5), lineHeight: pt(LEAD) } };
// #endregion

// #region lower: level 3 hangs its number in the gutter; level 4 runs into its paragraph
// The title keeps the text's left edge; the number, right-aligned in a 12 mm box, ends
// 2 mm short of it. The box needs that fixed width: an 'auto' width is clamped to the
// column and would shrink to nothing out here.
const H3 = { size: 10.5, number: 9 }; // pt; two boxes one grid line tall share a baseline
const h3 = { level: 3, numberingTemplate: '{2}.{3}', fontSize: pt(H3.size),
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0),
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontWeight: 600,
      fontSize: pt(H3.size), lineHeight: LEAD / H3.size, color: col('ink'), align: 'left',
      overflow: 'wrap', placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
    { kind: 'text', id: 'number', content: '{number}', fontFamily: LABEL, fontWeight: 500,
      fontSize: pt(H3.number), lineHeight: LEAD / H3.number, color: col('signal'),
      align: 'right', placement: { ...at('#title', 'left-of', mm(-2)),
        size: { width: mm(12) } } },
  ] } } };
// Level 4 is not a heading: '**Accessibility.** A wheelchair user…' opens its paragraph.
// No other paragraph has bold; table cells keep tableStyle's ink, so Total stays black.
const runIn = { boldColor: col('signal') }; // spread into bodyText
// #endregion

// #region opener: the brief's first page: kicker, title, lead and the site plan
const MAP = { y: 68.5, w: CONTENT, h: 52 }; // mm: from the top of the text block
const display = { fontFamily: DISPLAY, fontWeight: 300, color: col('ink'), align: 'left',
  overflow: 'wrap' };
const opener = { enabled: true,
  // The plan's foot falls between two grid lines. A floor on the next one leaves the rule
  // of section 1 as far from its text as the rule of every later section.
  minHeight: mm(GRID * Math.ceil((MAP.y + MAP.h) / GRID)),
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, color: col('signal'),
      placement: at('container', 'top-left', mm(HANG)) },
    { kind: 'text', id: 'series', content: '{subtitle}', ...label,
      placement: { ...at('container', 'top-left'), size: { width: mm(SIDE) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...display, fontSize: pt(58),
      lineHeight: 0.96, placement: { ...at('#kicker', 'below', mm(0), mm(4.5)),
        size: { width: mm(MAIN) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', ...display, italic: true,
      fontSize: pt(14), lineHeight: 1.3, placement: { ...at('#title', 'below', mm(0), mm(5)),
        size: { width: mm(MAIN) } } },
    { kind: 'text', id: 'legend', content: '{attr.map}', fontFamily: TEXT, fontSize: pt(7.5),
      lineHeight: 1.4, color: col('muted'), align: 'left', overflow: 'wrap',
      placement: { ...at('#lead', 'align-top', mm(-HANG), pt(2)),
        size: { width: mm(SIDE - 6) } } },
    { kind: 'image', id: 'map', resourceId: 'situacion', // across the channel and the column
      placement: { ...at('container', 'top-left', mm(0), mm(MAP.y)),
        size: { width: mm(MAP.w) } } },
  ] } };
// #endregion

// #region furniture: letterhead and folio, the same on every page of a one-sided brief
const [HEAD_Y, FOOT_Y] = [13, 11]; // mm from the top and from the foot of the sheet
const FILE_NO = 'BML-2026/04'; // the council's file number, also in the opener's kicker
const header = { elements: [
  // The council's name in two lines, the second on the running head's baseline.
  { kind: 'text', id: 'city', content: '{author}', ...label, color: col('ink'),
    overflow: 'wrap', placement: { ...at('page', 'top-left', mm(LEFT),
      mm(HEAD_Y - LABEL_PT * label.lineHeight * PT)), size: { width: mm(40) } } },
  { kind: 'text', id: 'book', content: '{title}', ...label,
    placement: at('page', 'top-left', mm(LEFT + HANG), mm(HEAD_Y)) },
  { kind: 'text', id: 'folio', content: t({ en: 'Page {pageNumber} of {totalPages}',
    es: 'Página {pageNumber} de {totalPages}' }), ...label,
  placement: at('page', 'top-right', mm(-RIGHT), mm(HEAD_Y)) },
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: { ...at('page', 'top-left', mm(LEFT), mm(HEAD_Y + 5)),
      size: { width: mm(CONTENT) } } },
] };
const footer = { elements: [
  { kind: 'text', id: 'file', content: `{chapterTitle} · ${FILE_NO}`, ...label,
    placement: at('page', 'bottom-left', mm(LEFT + HANG), mm(-FOOT_Y)) },
] };
// #endregion

const config = () => ({
  locale: t({ en: 'en-gb', es: 'es' }), // each edition is hyphenated in its own language
  // Figure / Table, Figura / Tabla, counted through the whole brief: 1, 2…
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}' })),
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * GRID), left: mm(LEFT),
      right: mm(RIGHT), mirror: false } },
  layout,
  bodyText: { fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'),
    ...runIn, boldFontWeight: 600, referenceColor: col('ink'), referenceBold: false,
    italicColor: col('ink'), // an italic would otherwise take main-color, the red
    // Report texture: ragged right, no indent, a blank line between paragraphs.
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  // The hidden text of levels 2 and 3 asks for this weight, and 600 is loaded already.
  headings: { fontFamily: TEXT, fontWeight: 600,
    // Balancing would add lines above heads to fill short pages: three blank lines over
    // some side heads instead of two. Off, the white above every head is the same.
    balancing: { enabled: false }, levels: [
    // parity 'any': a brief opens the next page, whichever side it falls on.
    // span: 'page' gives the opener the whole text block, channel included, as container.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
      advancedDesign: opener },
    h2, h3,
  ] },
  paragraphStyles: [{ id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10.5),
    color: col('muted') }],
  calloutStyles: [sidehead],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('ink'), headerColor: col('paper'), headerFontSize: pt(8),
    bodyFontSize: pt(8.6), cellPadding: mm(1.4) },
  captionStyle: { fontSize: pt(8), labelColor: col('signal'), gap: mm(2) },
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown-Beispiel · 108 Zeilen · content.en.mdtitle: "New Municipal Library" subtitle: "Design competition" author: "Puerto Lince Town Council" --- # Competition brief {kicker="File BML-2026/04" lead="An open, anonymous competition in two stages to turn the fish market of 1931 into the town’s library." map="Site plan at 1:3000, with the fish market’s plot in red and the built town in grey."} ## The competition Puerto Lince Town Council is holding a design competition, judged by a jury, to select the architectural proposal for the new Municipal Library, which will occupy the former fish market on the Poniente quay. The present library has operated since 1987 on the ground floor of the Casa de Cultura: 610 m² for a town of 24,300 inhabitants, with no room to grow. The competition is governed by this brief and by Spain’s Law 9/2017 on Public Sector Contracts. It is open to architects qualified to practise in a Member State of the European Union, entering alone or as the lead of a team. ### Scope of the commission The winning team will prepare the outline and detailed designs, the health and safety study and the activity licence application, and will supervise the works. The fee, set at €412,000 plus VAT, also covers the design of the fixed furniture and the signage. ### Budget and timescale The maximum construction budget, which includes the landscaping of the plot, is €4,850,000 excluding VAT. Entries that exceed it by more than 10% will be disqualified. The works will take about twenty months, and the library should open in 2030. ## The site The fish market stands on the town’s front to the old harbour, between the fishermen’s quarter and the extension of 1905. It is the only building in the old harbour from before 1936 that is still standing, and the Council bought it from the Port Authority in 2022. ### The plot The plot is a rectangle of 70 by 45 metres, 3,150 m² (:ref{id="parcela" case="lower"}). It is bounded to the south by the Paseo del Muelle, to the east by Calle de la Aduana and to the north by the Plaza de las Redes, which the local plan intends to pedestrianise in 2028. To the west stands the weighbridge hut, owned by the Council, which may be demolished or brought into the scheme. Outside the hall, 1,300 m² may be built on, in no more than two storeys and 9.5 m to the cornice. No new building may project beyond the line of the quay front. ### The 1931 fish market The fish market is a reinforced-concrete hall of 52 by 24 metres, roofed by thirteen barrel vaults spanning four metres that rest on portal frames. It was built by the Harbour Works Board, and fish was auctioned in it until 2009, when the auction moved to the new port. The municipal heritage list protects it at grade 2. The structure, the vaults and the quay front with its eleven round arches must be kept (:ref{id="fachada" case="lower"}). The structural survey of 2025 found corroded reinforcement in the four frames at the east end; their repair is included in the budget, and each entry will explain how it proposes to carry it out without dismantling any of the vaults. ### Access and trees The main entrance may be from the square or from the promenade, but deliveries will be made from Calle de la Aduana. The six tamarisks along the north edge, some forty years old, are to be kept. ## Schedule of accommodation The areas in the table are net internal areas (:ref{id="programa" style="full" case="lower"}). Each zone may vary by 10% either way, provided the total does not change. The hall will house the reading and meeting rooms; anything that needs enclosure, services or noise control may go in the new building. ### Public areas The main reading room will hold the adult lending collection, some 38,000 items, with ninety reader seats. Periodicals and the local history collection will share an adjoining room, with twelve study places and a self-service scanner. The children’s library will have its own entrance from outside and a storytelling corner for twenty-five children. The young people’s room and the multi-purpose hall must be able to open outside library hours, with the rest of the building closed. ### Staff areas Cataloguing, the offices and the closed stack add up to 260 m². These areas must connect with the loading bay without crossing the public rooms. The stack will have mobile shelving; its floor slab will be designed for an imposed load of 12 kN/m² and will be at street level. ### Outdoor spaces Between the hall and the new building, an open-air reading courtyard of at least 180 m² is required, sheltered from the north wind, which in winter gusts here at over 60 km/h. ## Design criteria The jury will assess the entries against the following criteria, listed in descending order of weight. **The market hall.** Work on the hall must be recognisable as new and reversible. Floors that cut across the vaults will not be accepted; mezzanines that stand clear and leave the frames in full view will. **Accessibility.** A wheelchair user must reach every part of the building unaided, courtyard and mezzanines included. No ramp may exceed 6%. **Energy and comfort.** Entries will say how the hall is ventilated and shaded from the sun, since its vaults cannot be insulated on the inside. Credit will be given for heating and cooling controlled room by room. **Maintenance.** The fish market is 18 metres from the water, and salt spray reaches it all year. New materials will be chosen for those conditions, and the report will include a plan for cleaning roofs and gutters. **Cost.** The jury will check that the cost estimate keeps within the maximum set in section 1.2. ## Submission of entries The competition is held in two stages. All documents are to be delivered on paper to the Council’s general registry, and as PDF files to the procurement platform, before 14:00 on the date set. ### Stage one Each team will submit two A1 boards in portrait format, mounted on lightweight rigid board, showing the general idea, the ground-floor plan at 1:200 and a section through the hall. They will be accompanied by a report of no more than four A4 pages; the jury will not read any pages beyond that limit. Models are not accepted at this stage. ### Stage two The jury will shortlist five entries, whose authors will develop their idea on three A1 boards, with a ten-page report and a cost estimate broken down by element. Each shortlisted team will receive €6,000 on delivery of the complete documents. ### Anonymity The boards and the report will carry only a motto of up to five words, repeated on the sealed envelope that holds the team’s details. Any entry bearing names, logos or references by which its authors could be identified will be excluded. The envelopes will be opened at a public session, once the jury has signed its decision. ## Jury and timetable The jury will be constituted before the deadline for questions, and its membership will be published on the Council’s buyer profile. Its deliberations will be confidential, and the record of the decision will give written reasons for each prize and each commendation. ### Members of the jury The jury will have seven voting members: the councillor for Culture, who will chair it; the municipal architect; the director of the library; three architects, two of them nominated by the professional association; and a librarian from the regional library service. A procurement officer will act as secretary, with the right to speak but not to vote. ### Prizes The first prize carries the award of the contract for the design and the supervision of the works. The second prize will receive €12,000 and the third, €8,000. There are also two commendations of €4,000 each, which the jury may decide not to award. ### Timetable The dates in the table will not change (:ref{id="calendario" style="full" case="lower"}). Questions are to be sent in writing, and the answers will be published without naming whoever asked. ::resource{id="calendario"} :::paragraphs{style="colophon"} Sample document: Puerto Lince, its fish market and this competition are fictional. Set in Mona Sans, Noto Serif Display and DM Mono (SIL Open Font License). Original text, CC BY 4.0. :::
`; // content.<lang>.md, inlined by the Cookbook // The two tables: every column after the first is right-aligned, its header too. const table = (columnWidths, rows) => ({ model: { headerRowCount: 1, columnWidths, rows: rows.map((cells, r) => cells.map((content, i) => ({ content, isHeader: r === 0, align: i > 0 ? 'right' : 'left' }))) } }); const AREAS = [120, 560, 240, 140, 150, 160, 100, 140, 120, 270]; // m²: 2,000 in all const programme = table([3, 1], [[t({ en: 'Area', es: 'Zona' }), 'm²'], ...t({ en: ['Reception and lending desk', 'Main reading room', 'Children’s library', 'Young people’s room', 'Periodicals and local history', 'Multi-purpose hall', 'Training rooms (2)', 'Cataloguing and offices', 'Closed stack', 'Toilets, stores and plant'], es: ['Acogida y préstamo', 'Sala general', 'Sala infantil', 'Espacio joven', 'Hemeroteca y fondo local', 'Sala polivalente', 'Aulas de formación (2)', 'Proceso técnico y dirección', 'Depósito cerrado', 'Aseos, almacenes e instalaciones'], }).map((zone, i) => [zone, `${AREAS[i]}`]), ['**Total**', t({ en: '**2,000**', es: '**2.000**' })]]); const calendar = table([3, 2], t({ en: [['Milestone', 'Date'], ['Notice published', '15 October 2026'], ['Questions close', '13 November 2026'], ['Stage one submission', '11 January 2027, 14:00'], ['Five teams shortlisted', '5 February 2027'], ['Stage two submission', '22 April 2027, 14:00'], ['Jury’s decision', '20 May 2027']], es: [['Hito', 'Fecha'], ['Publicación del anuncio', '15 de octubre de 2026'], ['Fin del plazo de consultas', '13 de noviembre de 2026'], ['Entrega de la primera fase', '11 de enero de 2027, 14.00 h'], ['Selección de cinco equipos', '5 de febrero de 2027'], ['Entrega de la segunda fase', '22 de abril de 2027, 14.00 h'], ['Fallo del jurado', '20 de mayo de 2027']], })); // #region resources: four placements: the channel, across the page, a column top, here // The plot plan stands in the channel; the elevation crosses channel and column at the // head of the next page; the table of areas floats to the head of the text column, with // its caption beside it in the channel; the timetable sits where ::resource puts it, and // its caption takes the channel too (captionSide serves an inline embed from 1.28). const PLAN_W = 44; // mm: the plot plan, 88 m wide at 1:2000 const resource = (id, typeId, rest) => ({ id, typeId, createdAt: 0, updatedAt: 0, ...rest }); const drawing = (id, width, height, rest) => resource(id, 'figure', { kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, ...rest }); const resources = [ drawing('parcela', PLAN_W * 10, 400, { placement: { span: 'side', width: PLAN_W / SIDE }, // 44 mm of the channel's 50 caption: t({ en: 'The plot, 1:2000. Market hall and weighbridge hut in grey; six tamarisks ' + 'along the north edge. Red arrows: possible entrances; grey arrow: service.', es: 'La parcela, 1:2000. Lonja y caseta de básculas, en gris; seis tamarindos en el ' + 'borde norte. Flechas rojas: accesos posibles; flecha gris: servicio.' }), altText: t({ en: 'Plan of the plot with the hall, the hut, six trees and three entrances.', es: 'Planta de la parcela con la lonja, la caseta, seis árboles y tres accesos.' }) }), drawing('fachada', 1780, 300, { placement: { position: 'top', span: 'page' }, caption: t({ en: 'Quay front, 1:300. In red, the four frames at the east end, where the ' + 'reinforcement has corroded.', es: 'Fachada al muelle, 1:300. En rojo, los cuatro pórticos del extremo este, con ' + 'las armaduras corroídas.' }), altText: t({ en: 'Elevation of the hall: thirteen vaults, eleven arches, four frames in red.', es: 'Alzado de la lonja: trece bóvedas, once arcos y cuatro pórticos en rojo.' }) }), resource('programa', 'table', { kind: 'table', table: programme, placement: { position: 'top', captionSide: true }, caption: t({ en: 'Schedule of net floor areas.', es: 'Programa de superficies útiles por zonas.' }) }), resource('calendario', 'table', { kind: 'table', table: calendar, placement: { position: 'here', captionSide: true }, caption: t({ en: 'Competition timetable.', es: 'Calendario del concurso.' }) }), // No :ref cites the site plan: only the opener's image element draws it. drawing('situacion', MAP.w * 10, MAP.h * 10, { altText: t({ en: 'Site plan of the Poniente quay with the market hall’s plot in red.', es: 'Plano de situación del muelle de Poniente con la parcela de la lonja en rojo.' }) }), ]; // #endregion // #region art: the three drawings, made in code with a seeded PRNG const rng = (seed) => () => { // Mulberry32: the same town on every run 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 n = (v) => (+v).toFixed(2); const svg = (w, h, body, view = `0 0 ${w} ${h}`) => '<svg xmlns="http://www.w3.org/2000/svg" ' + `width="${w * 10}" height="${h * 10}" viewBox="${view}">${body}</svg>`; const rect = (x, y, w, h, fill, extra = '') => `<rect x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(h)}" fill="${fill}" ${extra}/>`; const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${r}" fill="${fill}" ${extra}/>`; const path = (d, extra) => `<path d="${d}" ${extra}/>`; const stroke = (color, width) => `fill="none" stroke="${color}" stroke-width="${width}"`; const through = (points) => `M${points.map(([x, y]) => `${n(x)} ${n(y)}`).join(' L')}`; const arrow = (x, y, angle, color) => path('M0 -4 L0 3 M-1.6 1.2 L0 3.6 L1.6 1.2 Z', `transform="translate(${x} ${y}) rotate(${angle}) scale(1.3)" fill="${color}" ` + `stroke="${color}" stroke-width="0.7"`); const north = (x, y, s) => path(`M${x} ${y} L${x + 0.3 * s} ${y + s} L${x} ${y + 0.8 * s} ` + `L${x - 0.3 * s} ${y + s} Z`, `fill="${palette.ink}"`); // Site plan, 1:3000 (1 mm = 3 m): the old quarter, the 1905 ensanche, the plaza, the plot. function situacionSvg() { const { w, h } = MAP; const P = palette; const rand = rng(45); const m = (metres) => metres / 3; // mm on the plan const QUAY = h - 10; // the water's edge const PLOT = { x: 94, y: QUAY - m(18) - m(45), w: m(70), h: m(45) }; // behind the promenade const EAST = PLOT.x + PLOT.w; // the calle de la Aduana starts here // The sea: a beach on the west, then the quay of the old harbour and the fishing pier. let body = path(`M0 ${h - 5} C14 ${h - 5.5} 30 ${QUAY + 1} 42 ${QUAY} L${w} ${QUAY} V${h} H0 Z`, `fill="${P.sea}"`); body += rect(150, QUAY - 1, 7, h - QUAY + 1, P.stone); body += path(`M${w} ${h - 3.5} L161 ${h - 2.5}`, stroke(P.stone, 2.2)); for (let i = 0; i < 7; i++) { // boats moored along the quay body += rect(90 + i * 8 + rand() * 3, QUAY + 1.2, 1.3, 3 + rand(), P.rule, 'rx="0.6"'); } // The old quarter: one built mass cut by lanes that wander, and the old road to the port. const lane = (points, width) => path(through(points), `${stroke(P.paper, width)} stroke-linejoin="round" stroke-linecap="round"`); body += rect(-1, -1, EAST + 1, QUAY - m(18) + 1, P.stone); for (let x = 2; x < EAST; x += 7 + rand() * 5) { // lanes down to the sea const points = []; for (let y = -2; y <= QUAY - 5; y += 6) points.push([x + (rand() - 0.5) * 3.2, y]); body += lane(points, rand() < 0.25 ? 1.9 : 1); } for (let y = 4; y < QUAY - 10; y += 6 + rand() * 3.5) { // lanes along the coast const points = []; for (let x = -2; x <= EAST + 2; x += 8) points.push([x, y + (rand() - 0.5) * 2.4]); body += lane(points, rand() < 0.2 ? 1.7 : 0.9); } body += lane([[6, -2], [34, 12], [58, QUAY - 7]], 2.6); body += rect(PLOT.x - 4, 8, PLOT.w + 4, QUAY - m(18) - 8, P.paper); // the plaza de las Redes // The ensanche: chamfered blocks built round a courtyard. const [c, bw, bh] = [2, 13, 9]; for (let x = EAST + 5; x < w; x += bw + 2.8) { for (let y = -4; y < QUAY - 16; y += bh + 2.6) { body += path(`${through([[x + c, y], [x + bw - c, y], [x + bw, y + c], [x + bw, y + bh - c], [x + bw - c, y + bh], [x + c, y + bh], [x, y + bh - c], [x, y + c]])} Z`, `fill="${P.stone}"`) + rect(x + 3.2, y + 3, bw - 6.4, bh - 6, P.paper, 'fill-opacity="0.55"'); } } for (let x = 6; x < w - 4; x += 8.6) body += circle(x, QUAY - m(9), 0.9, P.rule); // promenade for (let i = 0; i < 10; i++) { body += circle(PLOT.x + (i % 5) * 5, 11 + Math.floor(i / 5) * 4.2, 0.9, P.rule); } // The plot, the lonja along its south edge and the weighbridge hut: as in figure 1. body += rect(PLOT.x, PLOT.y, PLOT.w, PLOT.h, P.tint, `stroke="${P.signal}" stroke-width="0.5"`); body += rect(PLOT.x + m(9), PLOT.y + m(21), m(52), m(24), P.signal) + rect(PLOT.x + m(1.5), PLOT.y + m(29), m(5.5), m(8), P.stone); body += north(86, h - 7, 5.5); body += rect(44, h - 3.4, m(100), 0.8, P.ink) + rect(44, h - 3.4, m(50), 0.8, P.paper, `stroke="${P.ink}" stroke-width="0.2"`); // 100 m return svg(w, h, body); } // The plot, 1:2000 (1 mm = 2 m), drawn in metres: 88 m across the figure's 44 mm. function parcelaSvg() { const P = palette; let body = rect(-9, 63, 88, 3, P.sea); // the harbour, past the 18 m promenade body += rect(0, 0, 70, 45, P.tint, `stroke="${P.signal}" stroke-width="0.9"`); body += rect(9, 21, 52, 24, P.stone); // the lonja for (let x = 13; x < 61; x += 4) body += path(`M${x} 21 V45`, stroke(P.paper, 0.35)); body += rect(1.5, 29, 5.5, 8, P.stone); // the weighbridge hut for (let i = 0; i < 6; i++) { // the six tamarinds body += circle(9 + i * 10.4, 6.5, 3.1, 'none', `stroke="${P.muted}" stroke-width="0.5"`) + circle(9 + i * 10.4, 6.5, 0.6, P.muted); } body += arrow(35, -7, 0, P.signal) + arrow(35, 52.5, 180, P.signal) // the entrances + arrow(75.5, 14, 90, P.muted); // service body += north(-6, -12, 6); return svg(PLAN_W, 40, body, '-9 -14 88 80'); } // The quay front, 1:300 (1 m = 3.33 mm), drawn in metres: 13 vaults on 14 porticos. function fachadaSvg() { const P = palette; const [VIEW_W, VIEW_H, SKY] = [CONTENT * 0.3, 9, 7.4]; // m; SKY: ground to the top edge const X = (x) => n(x + (VIEW_W - 52) / 2); const Y = (y) => n(SKY - y); // y up from the ground const CORNICE = 6.2; const R = (4 + 0.9 * 0.9) / (2 * 0.9); // radius of a 4 m vault that rises 0.9 m let roof = `M${X(0)} ${Y(0)} V${Y(CORNICE)}`; for (let i = 1; i <= 13; i++) roof += ` A${R} ${R} 0 0 1 ${X(4 * i)} ${Y(CORNICE)}`; let body = path(`${roof} V${Y(0)} Z`, `fill="${P.paper}" stroke="${P.ink}" stroke-width="0.08"`); body += path(`M${X(0)} ${Y(CORNICE - 0.35)} H${X(52)} M${X(0)} ${Y(0.5)} H${X(52)}`, stroke(P.ink, 0.04)); for (let i = 0; i < 13; i++) { // eleven arches; a square door in each end bay const cx = 4 * i + 2; body += i === 0 || i === 12 ? rect(+X(cx - 1.1), +Y(3.6), 2.2, 3.6, P.stone) : path(`M${X(cx - 1.2)} ${Y(0.5)} V${Y(3.4)} A1.2 1.2 0 0 1 ${X(cx + 1.2)} ${Y(3.4)} ` + `V${Y(0.5)} Z`, `fill="${P.stone}"`); } for (let i = 0; i <= 13; i++) { // the porticos' pilasters; the four eastern ones in red const x = Math.min(Math.max(4 * i - 0.25, 0), 51.5); body += rect(+X(x), +Y(CORNICE - 0.35), 0.5, CORNICE - 0.35, i >= 10 ? P.signal : P.paper, `stroke="${P.ink}" stroke-width="0.04"`); } body += path(`M0 ${Y(0)} H${VIEW_W}`, stroke(P.ink, 0.12)); // the quay body += rect(+X(0), +Y(-0.9), 5, 0.22, P.ink) // 10 m + rect(+X(5), +Y(-0.9), 5, 0.22, P.paper, `stroke="${P.ink}" stroke-width="0.04"`); return svg(CONTENT, VIEW_H / 0.3, body, `0 0 ${VIEW_W} ${VIEW_H}`); } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Mona Sans': ['400', '600'], 'Noto Serif Display': ['300', '300i', '400'], 'DM Mono': ['500'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const source = sideHeads(markdown); await loadSvg('situacion.svg', situacionSvg()); await loadSvg('parcela.svg', parcelaSvg()); await loadSvg('fachada.svg', fachadaSvg()); const doc = await buildDocumentWithFonts({ markdown: source, resources }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'Competition brief', es: 'Bases del concurso' }) });
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

#Nur die Textspalte mit einer Linie versehen

Verankern Sie die Linie am eigenen Kasten der Überschrift statt an der Nummer, und sie läuft von der linken Kante des Textes bis zur rechten Kante der Spalte, und nur Nummer und Titel stehen in der Randspalte.

-        color: col('ink'), placement: { ...at('#number', 'right-of', mm(2), RULE_Y),
+        color: col('ink'), placement: { ...at('container', 'top-left', mm(0), RULE_Y),

Häufige Fehler

Häufiger Fehler

Ein Seitenkasten beginnt auf Höhe des Blocks nach seiner Einfassung

In postext 1.4.1 steht ein Kasten mit span: 'side' in der Seitenspalte auf der Höhe, die der Text an seiner Einfassung erreicht hat, auf der nächsten Rasterlinie und unter einem bereits vorhandenen Kasten. Setzen Sie eine Glosse direkt vor den Absatz, den sie erläutert: Steht sie danach, beginnt sie neben dem nächsten Absatz. Ein Kasten, der über den Fuß der Spalte hinauslaufen würde, rutscht nach oben, bis sein Fuß auf dem Fuß der Spalte sitzt, soweit der Kasten darüber es zulässt; passt er dann immer noch nicht, wartet er auf die Seitenspalte der nächsten Seite. Marginalien →

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 →

Sandbox-Prüfung · headingAdvancedWithoutTitleText

Überschriftentitel nicht gedruckt

Ursache. Eine erweiterte Gestaltung hat kein Element, das {titleText} druckt, die Worte der Überschrift erscheinen daher nie.

Lösung. Fügen Sie ein Textelement mit {titleText} hinzu, sofern die Überschrift nicht unsichtbar sein soll. Doku →

  • Eine Randabbildung nimmt den Kopf der Randspalte auf der Seite ein, die sie zitiert, und ein Marginaltitel, dessen Direktivenblock danach steht, stapelt sich unter der Abbildung, unterhalb des Beginns seines eigenen Abschnitts. Zitieren Sie Randabbildungen nach dem Abschnittstitel ihrer Seite, wie Abschnitt 2 den Grundstücksplan zitiert.
  • Eine Inline-Einbettung (position: 'here') übernimmt captionSide ab postext 1.28: Die Legende des Zeitplans steht auf Seite 5 in der Randspalte, wie die der Flächentabelle auf Seite 3. Die Legende braucht neben der Tabelle eine freie Randspalte. Wo dort bereits ein Marginaltitel oder eine Randabbildung steht, bleibt sie unter der Tabelle.

Nachweise

Text
Eigener Text, CC BY 4.0
Schriften
Mona Sans (SIL OFL 1.1) · Noto Serif Display (SIL OFL 1.1) · DM Mono (SIL OFL 1.1)
Sandbox