Aller au contenu principal
Recette numéro 45

Recettes · Chapitre 3 · Titres et ouvertures

Titres en marge, numéros suspendus et titres en ligne

Un règlement de concours aux titres de section en colonne marginale, sur les lignes de base du texte, numéros pendus dans la gouttière, titres en ligne rouges.

  • Exemple en anglais : pas encore d’édition française
  • Format 210 × 297 mm
  • Colonne et demie, Gouttière de 7 mm
  • Mona Sans 10/14
  • DM Mono
  • Noto Serif Display
  • 5 pages
  • Niveau
  • Postext 1.31.0
  • Mis en page en 66 ms
  • 261 lignes de code

En bref

L’appel à projets d’une mairie pour transformer une ancienne halle aux poissons en bibliothèque. Les titres de section se placent dans une large marge gauche, au niveau de la première ligne du texte voisin.

Ce que vous allez composer

Cinq pages A4 du règlement d’un concours d’architecture, dans lequel la municipalité de Puerto Lince, un port espagnol imaginaire, appelle à des projets pour transformer sa halle aux poissons de 1931 en bibliothèque municipale. La mise en page suit les rapports municipaux suisses. Une colonne marginale de 50 mm descend à gauche de chaque page, et chaque section s’ouvre sur un numéro rouge et un filet noir qui traverse tout le bloc de texte. Le titre de la section se tient dans la colonne marginale en Noto Serif Display, au niveau de la première ligne du texte. Les numéros de sous-section sont suspendus dans la gouttière en DM Mono rouge, si bien que chaque titre de sous-section garde le bord gauche du texte. Les critères de jugement de la section 4 s’ouvrent sur des termes en gras rouge. La page 1 compose un titre de 58 pt au-dessus d’un plan de situation, un plan de la parcelle se tient dans la colonne marginale en page 2, et une élévation de la façade sur le quai ouvre la page 3.

Cette recette répond à

  • Comment numéroter les titres (1, 1.1, 1.1.1) et styler chaque niveau différemment ?
  • Comment faire une mise en page en colonne et demie, avec une colonne de texte large et une colonne latérale étroite ?
  • Comment choisir où va une figure : en haut de page, sur les deux colonnes, exactement ici ou dans la marge ?
  • Comment colorer les termes clés (en gras ou en italique) dans le texte ou dans les encadrés ?

La réponse courte

script.js · lignes 56–81dans le code complet
// 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) } };

Ingrédients

Typographie
Mona Sans, Noto Serif Display, DM Mono (SIL OFL 1.1)
Ressources
Aucune : chaque image est dessinée en code

Préparation

#1 · Imprimer le titre de section depuis une boîte dans la colonne marginale

Le code est dans la réponse courte plus haut. Un titre réserve de la place jusqu’au plus bas élément de sa maquette : un titre dessiné dans la colonne marginale au niveau de la première ligne de la section repousserait donc le texte d’une ligne, et de deux pour un titre sur deux lignes. La maquette du titre ne dessine donc que le numéro et le filet, et sideHeads() copie le texte de chaque ## Title dans un :::callout placé juste après le titre. Avec span="side", cette boîte quitte le flux et se tient dans la colonne marginale sur la ligne de grille où le texte reprend (styles d’encadré). Le numéro et le filet règlent reserve: false : sans cela, une boîte dans la colonne marginale garde un écart de flottant, une ligne, sous les éléments de titre qui s’y trouvent, et le titre descendrait à la deuxième ligne du texte. Les boîtes comme le texte courant placent la ligne de base d’une ligne à 0,8 de sa hauteur : une boîte sur l’interlignage de 14 pt du texte pose donc chaque ligne d’un titre sur deux lignes sur une ligne de base du paragraphe voisin. Le Markdown reste un simple ## Schedule of accommodation. Comme la maquette du titre n’imprime pas de {titleText}, le Sandbox affiche l’avertissement Texte du titre non référencé, bien que la boîte imprime le titre.

Page 2 : le numéro et le filet viennent du titre, le titre de la boîte dans la colonne marginale, au niveau de la première ligne du texte. Le plan de la parcelle s’empile dessous.

#2 · Réserver la colonne marginale aux titres et aux figures

script.js · lignes 46–52dans le code complet
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' tient le texte hors de la colonne marginale (types de mise en page). Après l’ouverture, la colonne ne contient que les six titres de section et le plan de la parcelle, tous deux envoyés là avec span: 'side', et les légendes des tableaux 1 et 2. La colonne latérale est à 'right' par défaut, et un règlement imprimé au recto seulement la garde du même côté sur chaque page. sideColumnSide: 'left' la place à gauche, où chaque titre de section se lit avant son texte.

#3 · Suspendre les numéros et mettre les plus petits titres en ligne

script.js · lignes 85–102dans le code complet
// 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

Le titre de sous-section commence au bord gauche de la colonne. Son numéro est ancré 'left-of' le titre, aligné à droite dans une boîte de 12 mm qui s’arrête 2 mm avant le texte : des numéros comme 2.1 et 3.3 pendent donc dans la gouttière, et chaque titre s’aligne sur le texte qui le suit (placement des éléments). La boîte a besoin d’une largeur fixe. Une largeur 'auto' est limitée à la colonne. Dans la gouttière, cela ne laisse aucune largeur, et le numéro n’est pas dessiné. Le niveau 2 imprime '{2}' et le niveau 3 '{2}.{3}', car avec {1} chaque numéro commencerait par le 1 de l’unique H1 du règlement. Les critères de la section 4 forment un quatrième niveau composé sans titre. Chaque paragraphe s’ouvre sur un terme en gras, que bodyText.boldColor imprime en rouge (texte courant). Aucun autre paragraphe n’a de gras, et les cellules de tableau prennent leur couleur dans tableStyle : la ligne de total en gras du tableau 1 reste donc noire.

#4 · Donner à chaque figure et à chaque tableau son propre placement

script.js · lignes 333–367dans le code complet
// 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.' }) }),
];

Le plan de la parcelle va dans la colonne marginale avec span: 'side' et s’empile sous le titre marginal de la section 2 (placement). width: PLAN_W / SIDE lui donne 44 mm de large, ce qui met ses 88 mètres exactement au 1:2000. L’élévation de la façade est un flottant 'top' sur toute la largeur de la page. Elle est citée en page 2 et ouvre la page 3, car un flottant ne se place jamais au-dessus de son renvoi. Le tableau des surfaces, cité lui aussi en page 2, flotte en tête de la colonne de texte de la page 3, sous l’élévation. captionSide: true place sa légende dans la colonne marginale, au niveau du haut du tableau. Le calendrier se trouve là où le place ::resource, à la fin du règlement, et captionSide: true place aussi sa légende dans la colonne marginale, à côté de la section 6.3.

#5 · Ouvrir le règlement sur le plan de situation

script.js · lignes 106–131dans le code complet
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) } } },
  ] } };

Avec span: 'page', le conteneur de l’ouverture est tout le bloc de texte, colonne marginale comprise (largeur et maquette avancée). Le surtitre et le titre sont placés à 57 mm du bord, sur le bord gauche de la colonne de texte, et le plan traverse colonne marginale et colonne de texte. Sans cela, le conteneur est la colonne de texte, chaque élément se décale de 57 mm vers la droite, et le titre, le chapeau et le plan dépassent le bord droit de la page. La maquette réserve la place jusqu’au pied du plan, qui tombe entre deux lignes de la grille. Un minHeight arrondi à la ligne de grille suivante laisse le filet de la section 1 aussi loin de son texte que le filet de chaque section suivante. Le surtitre, le chapeau et la légende du plan sont des attributs de la ligne # Competition brief.

La recette complète

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`---
Exemple Markdown · 108 lignes · 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 : identique dans toutes les recettes · 270 lignes// ─── 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 ───────────────────────────────────────────────────────────────────────

Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)

Variantes

#Fileter la seule colonne de texte

Ancrez le filet à la boîte du titre lui-même plutôt qu’au numéro : il va alors du bord gauche du texte au bord droit de la colonne, et seuls le numéro et le titre restent dans la colonne marginale.

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

Erreurs fréquentes

Erreur fréquente

Un encadré latéral commence au niveau du bloc qui suit sa clôture

Dans postext 1.4.1, un encadré span: 'side' se place dans la colonne latérale à la hauteur atteinte par le texte à sa clôture, sur la ligne de grille suivante, et sous tout encadré déjà présent. Placez une glose juste avant le paragraphe qu’elle commente : placée après, elle commence en face du paragraphe suivant. Un encadré qui dépasserait le pied de la colonne remonte jusqu’à ce que son pied repose sur celui de la colonne, autant que le permet l’encadré au-dessus ; s’il ne tient toujours pas, il attend la colonne latérale de la page suivante. Notes marginales →

Erreur fréquente

Un flottant 'top' va à la page qui suit sa citation, sauf s’il peut ouvrir la page qui le cite

Par défaut, un flottant ne passe jamais au-dessus de son propre renvoi : un flottant 'top' sur toute la largeur cité en page N ouvre donc la page N+1. Réglez placement.citingPage: true sur la ressource ou sur le defaultPlacement de son type (ou layout.floatsAtCitingPage pour tout le livre, depuis postext 1.25) et il ouvre plutôt la page N, le texte au-dessus du renvoi descendant sous lui, quand il tient dans layout.maxTopFraction et que la ligne qui le cite reste sur cette page. Il n’ouvre jamais la première page d’un chapitre ni une page qui suit un saut forcé : là, il ouvre toujours la page suivante, et 'auto' ou 'bottom' peut prendre le pied de la page qui le cite. Placement des figures →

Vérification du Sandbox · headingAdvancedWithoutTitleText

Texte du titre non référencé

Pourquoi. Une maquette avancée n’a aucun élément qui imprime {titleText} : les mots du titre lui-même n’apparaissent jamais.

Correction. Ajoutez un élément de texte avec {titleText}, sauf si le titre doit rester invisible. Docs →

  • Une figure latérale prend la tête de la colonne marginale sur la page qui la cite, et un titre marginal placé après elle s’empile dessous, plus bas que le début de sa propre section. Citez les figures latérales après le titre de section de leur page, comme la section 2 cite le plan de la parcelle.
  • Une ressource intégrée au fil du texte (position: 'here') prend en compte captionSide depuis postext 1.28 : la légende du calendrier se tient dans la colonne marginale en page 5, comme celle du tableau des surfaces en page 3. La légende a besoin que la colonne marginale soit libre à côté du tableau. Là où un titre marginal ou une figure latérale s’y trouve déjà, elle reste sous le tableau.

Crédits

Texte
Texte original, CC BY 4.0
Polices
Mona Sans (SIL OFL 1.1) · Noto Serif Display (SIL OFL 1.1) · DM Mono (SIL OFL 1.1)
Sandbox