Skip to main content
Recipe number 45

Cookbook · Chapter 3 · Headings & openers

Side heads, hanging numbers and run-in heads

A competition brief whose section titles stand in a margin channel on the text's baselines, with subsection numbers hung in the gutter and run-in heads in red.

  • Spanish sample: no English edition yet
  • Trim 210 × 297 mm
  • Column and a half, 7 mm gutter
  • Mona Sans 10/14
  • DM Mono
  • Noto Serif Display
  • 5 pages
  • Level
  • Postext 1.4.1
  • Laid out in 12 ms
  • 249 lines of code

What you'll build

Five A4 pages of an architectural competition brief, in which the council of Puerto Lince, a fictional Spanish port, calls for designs to turn its 1931 fish market into the town library. The layout follows Swiss municipal reports. A 50 mm channel runs down the left of every page, and each section opens on a red number and a black rule across the whole text block. The section's title stands in the channel in Noto Serif Display, level with the first line of the text. Subsection numbers hang in the gutter in red DM Mono, so every subsection title keeps the text's left edge. The judging criteria in section 4 open on bold terms in red. Page 1 sets a 58 pt title over a site plan, a plan of the plot stands in the channel on page 2, and an elevation of the quay front opens page 3.

This recipe answers

  • How do I number headings (1, 1.1, 1.1.1) and style each level differently?
  • How do I make a column-and-a-half layout, with a wide text column and a narrow side column?
  • How do I control where a figure goes: top of page, across both columns, exactly here, or in the margin?
  • How do I colour key terms (bold or italic) in the body or inside boxes?

The short answer

script.js · lines 57–81in full 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.
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'),
        placement: at('container', 'top-left', mm(-HANG)) },
      { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(RULE),
        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) } };

Side heads: the heading draws a number and a rule, a box prints the title

Ingredients

Type
Mona Sans, Noto Serif Display, DM Mono (SIL OFL 1.1)
Assets
None: every picture is drawn in code

Method

#1 · Print the section title from a box in the channel

The code is the short answer above. A heading reserves room down to the lowest element of its design, so a title drawn in the channel level with the section's first line would push the text down a line, and two for a two-line title. The heading's design draws only the number and the rule instead, and sideHeads() copies the text of each ## Title into a :::callout fenced right after the heading. With span="side" that box leaves the flow and stands in the channel on the grid line where the text resumes (callout styles). Boxes and body text both set a line's baseline 0.8 of the line height down, so a box on the text's 14 pt leading puts each line of a two-line title on a baseline of the paragraph beside it. The Markdown stays a plain ## Programa de necesidades. Since the heading's design prints no {titleText}, the Sandbox lists the warning Heading title not referenced, although the box prints the title.

Page 2: the number and the rule come from the heading, the title from the box in the channel, level with the first line of the text. The plot plan stacks under it.

#2 · Keep the channel for heads and figures

script.js · lines 47–53in full 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' keeps the text out of the channel (layout types). After the opener, the channel holds only the six section titles and the plot plan, both sent there with span: 'side', and the caption of table 1. The side column defaults to 'right', and a brief printed on one side of the sheet keeps it on the same side of every page. sideColumnSide: 'left' puts it on the left, where each section title is read before its text.

#3 · Hang the numbers and run the smallest heads in

script.js · lines 85–102in full 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: '**Accesibilidad.** Todo el edificio…' 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

The subsection title starts on the column's left edge. Its number is anchored 'left-of' the title, right-aligned in a 12 mm box that ends 2 mm short of the text, so numbers such as 2.1 and 3.3 hang in the gutter and every title lines up with the text under it (element placement). The box needs a fixed width. An 'auto' width is clamped to the column. Out in the gutter that leaves no width at all, and the number is not drawn. Level 2 prints '{2}' and level 3 '{2}.{3}', because with {1} every number would start with the 1 of the brief's one H1. The criteria in section 4 are a fourth level set without a heading. Each paragraph opens on a bold term, and bodyText.boldColor prints it in red (body text). No other paragraph has bold, and table cells take their colour from tableStyle, so the bold Total row of table 1 stays black.

#4 · Give each figure and table its own placement

script.js · lines 326–354in full code
// The plot plan stands in the channel; the elevation crosses channel and column at the
// head of the next page; the programme floats to the head of the text column, with its
// caption beside it in the channel; the calendar sits where ::resource puts it. The
// programme floats instead of sitting 'here' because an inline table that opens a page
// keeps a pending top figure off that page (gotcha: inline-table-skips-top-float).
const PLAN_W = 44; // mm: the plot plan, 88 m wide at 1:2000
const resources = [
  { id: 'parcela', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'parcela.svg', width: PLAN_W * 10, height: 400 },
    placement: { span: 'side', width: PLAN_W / SIDE }, // 44 mm of the channel's 50
    caption: '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: 'Planta de la parcela con la lonja, la caseta, seis árboles y tres accesos.' },
  { id: 'fachada', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'fachada.svg', width: 1780, height: 300 },
    placement: { position: 'top', span: 'page' },
    caption: 'Fachada al muelle, 1:300. En rojo, los cuatro pórticos del extremo este, con '
      + 'las armaduras corroídas.',
    altText: 'Alzado de la lonja: trece bóvedas, once arcos y cuatro pórticos en rojo.' },
  { id: 'programa', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { position: 'top', captionSide: true },
    caption: 'Programa de superficies útiles por zonas.', table: programme },
  { id: 'calendario', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { position: 'here' }, caption: 'Calendario del concurso.', table: calendar },
  // No :ref cites the site plan: only the opener's image element draws it.
  { id: 'situacion', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'situacion.svg', width: MAP.w * 10, height: MAP.h * 10 },
    altText: 'Plano de situación del muelle de Poniente con la parcela de la lonja en rojo.' },
];

The plot plan goes into the channel with span: 'side' and stacks under the side head of section 2 (placement). width: PLAN_W / SIDE makes it 44 mm wide, which puts its 88 metres at exactly 1:2000. The facade elevation is a 'top' float across the page. It is cited on page 2 and opens page 3, since a float is never placed above its reference. The programme table, also cited on page 2, floats to the head of the text column on page 3; set inline, it would have opened page 3 and sent the elevation on to page 4. captionSide: true stands its caption in the channel, level with the table's top. The calendar sits where ::resource puts it, at the end of the brief.

#5 · Open the brief on the site plan

script.js · lines 106–131in full 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 is an image element, which reserves no height (gotcha:
  // opener-image-no-reserve): minHeight carries the reserve down to its foot.
  minHeight: mm(GRID * Math.ceil((MAP.y + MAP.h) / GRID)), // on a grid line
  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) } } },
  ] } };

With span: 'page' the opener's container is the whole text block, channel included (span and advanced design). The kicker and the title are placed 57 mm in, on the text column's left edge, and the plan runs across channel and column. Without it the container is the text column, every element moves 57 mm to the right, and the title, the lead and the plan run past the right edge of the page. An image element reserves no height, so minHeight sets the reserve down to the plan's foot. Rounded up to a whole grid line, it leaves the rule of section 1 as far from its text as the rule of every later section. The kicker, the lead and the map legend are attributes of the # Bases del concurso line.

The whole recipe

// ═══ 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.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @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',
};
// The hex as well as the id: design slots read only the hex (gotcha: palette-skips-designs).
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.
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'),
        placement: at('container', 'top-left', mm(-HANG)) },
      { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(RULE),
        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: '**Accesibilidad.** Todo el edificio…' 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 is an image element, which reserves no height (gotcha:
  // opener-image-no-reserve): minHeight carries the reserve down to its foot.
  minHeight: mm(GRID * Math.ceil((MAP.y + MAP.h) / GRID)), // on a grid line
  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 = () => ({ // a factory: the engine caches resolved configs per object
  // Figura / Tabla, counted through the whole brief: 1, 2… (gotcha: resource-types-locale)
  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: [
    // Any headings object drops the H1 page break: restated (gotcha: headings-drop-h1-break).
    // 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 sample · 108 lines · content.es.mdtitle: "Nueva Biblioteca Municipal" subtitle: "Concurso de proyectos" author: "Ayuntamiento de Puerto Lince" --- # Bases del concurso {kicker="Expediente BML-2026/04" lead="Concurso abierto y anónimo, en dos fases, para convertir la lonja de 1931 en la biblioteca de la ciudad." map="Plano de situación a escala 1:3000, con la parcela de la lonja en rojo y la ciudad construida en gris."} ## Objeto del concurso El Ayuntamiento de Puerto Lince convoca un concurso de proyectos con intervención de jurado para elegir la propuesta arquitectónica de la nueva Biblioteca Municipal, que ocupará la antigua lonja del pescado en el muelle de Poniente. La biblioteca actual funciona desde 1987 en la planta baja de la Casa de Cultura: 610 m² para una ciudad de 24.300 habitantes, sin posibilidad de ampliarse. El concurso se rige por estas bases y por la Ley 9/2017, de Contratos del Sector Público. Pueden participar los arquitectos con título habilitante en un Estado de la Unión Europea, solos o al frente de un equipo. ### Alcance del encargo El equipo ganador redactará el proyecto básico y el de ejecución, el estudio de seguridad y salud y el proyecto de actividad, y dirigirá la obra. Los honorarios, fijados en 412.000 € más IVA, incluyen también el diseño del mobiliario fijo y de la señalética. ### Presupuesto y plazo El presupuesto máximo de ejecución material, que incluye la urbanización de la parcela, es de 4.850.000 € sin IVA. Quedan fuera del concurso las propuestas que lo superen en más de un 10 %. La obra durará unos veinte meses, y la biblioteca debería abrir en 2030. ## El emplazamiento La lonja ocupa el frente de la ciudad hacia el puerto viejo, entre el barrio de pescadores y el ensanche de 1905. Es el único edificio del puerto viejo anterior a 1936 que sigue en pie, y el Ayuntamiento lo compró a la Autoridad Portuaria en 2022. ### La parcela La parcela es un rectángulo de 70 por 45 metros, 3.150 m² (:ref{id="parcela" case="lower"}). Linda al sur con el paseo del Muelle, al este con la calle de la Aduana y al norte con la plaza de las Redes, que el planeamiento prevé hacer peatonal en 2028. Al oeste queda la caseta de básculas, de propiedad municipal, que puede demolerse o integrarse en el conjunto. Descontada la nave, quedan 1.300 m² edificables en un máximo de dos plantas y 9,5 m de altura a cornisa. Ninguna construcción nueva puede adelantarse a la línea de la fachada del muelle. ### La lonja de 1931 La lonja es una nave de hormigón armado de 52 por 24 metros, cubierta por trece bóvedas de cañón de cuatro metros de luz que apoyan en pórticos. La construyó la Junta de Obras del Puerto, y en ella se subastó el pescado hasta 2009, cuando la subasta pasó al puerto nuevo. El catálogo municipal la protege en grado 2. Deben conservarse la estructura, las bóvedas y la fachada al muelle con sus once arcos de medio punto (:ref{id="fachada" case="lower"}). La inspección de 2025 encontró armaduras corroídas en los cuatro pórticos del extremo este; su reparación entra en el presupuesto, y cada propuesta explicará cómo piensa hacerla sin desmontar las bóvedas. ### Accesos y arbolado La entrada principal puede plantearse desde la plaza o desde el paseo, pero la carga y descarga se hará por la calle de la Aduana. Los seis tamarindos del borde norte, de unos cuarenta años, se conservarán. ## Programa de necesidades Las superficies de la tabla son útiles (:ref{id="programa" case="lower"}). Cada zona admite un 10 % de más o de menos, siempre que el total no cambie. La nave alojará las salas de lectura y de encuentro; lo que necesite cerramientos, instalaciones o control del ruido puede ir en la edificación nueva. ### Áreas públicas La sala general reunirá la colección de préstamo para adultos, unos 38.000 documentos, con noventa puestos de lectura. La hemeroteca y el fondo local compartirán una sala contigua, con doce puestos de consulta y un escáner de uso libre. La sala infantil tendrá entrada propia desde el exterior y un rincón de cuentacuentos para veinticinco niños. El espacio joven y la sala polivalente deben poder abrirse fuera del horario de la biblioteca, con el resto del edificio cerrado. ### Áreas internas El proceso técnico, la dirección y el depósito cerrado suman 260 m². Estas áreas han de comunicarse con la zona de carga sin cruzar las salas públicas. El depósito tendrá estanterías compactas; su forjado se calculará con una sobrecarga de 12 kN/m² y quedará a nivel de la calle. ### Espacios exteriores Entre la nave y la edificación nueva se pide un patio de lectura al aire libre de al menos 180 m², protegido del viento del norte, que en invierno sopla aquí con rachas de más de 60 km/h. ## Criterios de proyecto El jurado valorará las propuestas con los criterios siguientes, ordenados de mayor a menor peso. **Relación con la lonja.** Las intervenciones sobre la nave serán reconocibles y reversibles. No se admiten forjados que corten las bóvedas; sí altillos exentos que dejen ver los pórticos completos. **Accesibilidad.** Todo el edificio será accesible sin ayuda en silla de ruedas, patio y altillos incluidos. Ninguna rampa superará el 6 % de pendiente. **Energía y confort.** La propuesta dirá cómo se ventila la nave y cómo se protege del sol, porque sus bóvedas no admiten aislamiento por el interior. Se valorará que la climatización pueda regularse sala por sala. **Mantenimiento.** La lonja está a 18 metros del agua y el salitre le llega todo el año. Los materiales nuevos se elegirán para ese ambiente, y la memoria incluirá un plan de limpieza de cubiertas y canalones. **Coste.** El jurado comprobará que la estimación de coste se ajusta al máximo del apartado 1.2. ## Entrega de propuestas El concurso se desarrolla en dos fases. Toda la documentación se entrega en papel en el registro general del Ayuntamiento, y en PDF en la plataforma de contratación, antes de las 14.00 h del día fijado. ### Primera fase Cada equipo entregará dos paneles DIN A1 en vertical, sobre soporte rígido ligero, con la idea general, la planta baja a escala 1:200 y una sección por la nave. Los acompañará una memoria de cuatro páginas DIN A4 como máximo; el jurado no leerá las páginas que excedan ese límite. En esta fase no se admiten maquetas. ### Segunda fase El jurado elegirá cinco propuestas, que desarrollarán su idea en tres paneles DIN A1, una memoria de diez páginas y una estimación de coste por capítulos. Cada equipo seleccionado recibirá 6.000 € al entregar la documentación completa. ### Anonimato Los paneles y la memoria llevarán solo un lema de hasta cinco palabras, que se repetirá en el sobre cerrado con los datos del equipo. Se excluirá cualquier propuesta con nombres, logotipos o referencias que permitan reconocer a sus autores. Los sobres se abrirán en un acto público, después de que el jurado haya firmado el fallo. ## Jurado y plazos El jurado se constituirá antes de que termine el plazo de consultas, y su composición se publicará en el perfil del contratante. Sus deliberaciones serán secretas, y el acta del fallo explicará por escrito las razones de cada premio y de cada accésit. ### Composición del jurado El jurado tendrá siete miembros con voto: la concejala de Cultura, que lo presidirá; la arquitecta municipal; la directora de la biblioteca; tres arquitectos, dos de ellos designados por el colegio profesional, y una bibliotecaria del servicio regional de bibliotecas. Un técnico de contratación actuará como secretario, con voz y sin voto. ### Premios El primer premio conlleva la adjudicación del contrato de redacción del proyecto y dirección de obra. El segundo premio recibirá 12.000 € y el tercero, 8.000 €. Hay además dos accésits de 4.000 €, que el jurado puede declarar desiertos. ### Calendario Las fechas de la tabla no se moverán (:ref{id="calendario" case="lower"}). Las consultas se enviarán por escrito, y las respuestas se publicarán sin identificar a quien pregunta. ::resource{id="calendario"} :::paragraphs{style="colophon"} Documento de ejemplo: Puerto Lince, su lonja y este concurso son ficticios. Compuesto en Mona Sans, Noto Serif Display y DM Mono (SIL Open Font License). Texto original, 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 row = (cells, header = false) => cells.map((content, i) => ({ content, isHeader: header, align: i > 0 ? 'right' : 'left' })); const programme = { model: { headerRowCount: 1, columnWidths: [3, 1], rows: [ row(['Zona', 'm²'], true), row(['Acogida y préstamo', '120']), row(['Sala general', '560']), row(['Sala infantil', '240']), row(['Espacio joven', '140']), row(['Hemeroteca y fondo local', '150']), row(['Sala polivalente', '160']), row(['Aulas de formación (2)', '100']), row(['Proceso técnico y dirección', '140']), row(['Depósito cerrado', '120']), row(['Aseos, almacenes e instalaciones', '270']), row(['**Total**', '**2.000**']), ] } }; const calendar = { model: { headerRowCount: 1, columnWidths: [3, 2], rows: [ row(['Hito', 'Fecha'], true), row(['Publicación del anuncio', '15 de octubre de 2026']), row(['Fin del plazo de consultas', '13 de noviembre de 2026']), row(['Entrega de la primera fase', '11 de enero de 2027, 14.00 h']), row(['Selección de cinco equipos', '5 de febrero de 2027']), row(['Entrega de la segunda fase', '22 de abril de 2027, 14.00 h']), row(['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 programme floats to the head of the text column, with its // caption beside it in the channel; the calendar sits where ::resource puts it. The // programme floats instead of sitting 'here' because an inline table that opens a page // keeps a pending top figure off that page (gotcha: inline-table-skips-top-float). const PLAN_W = 44; // mm: the plot plan, 88 m wide at 1:2000 const resources = [ { id: 'parcela', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'parcela.svg', width: PLAN_W * 10, height: 400 }, placement: { span: 'side', width: PLAN_W / SIDE }, // 44 mm of the channel's 50 caption: '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: 'Planta de la parcela con la lonja, la caseta, seis árboles y tres accesos.' }, { id: 'fachada', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'fachada.svg', width: 1780, height: 300 }, placement: { position: 'top', span: 'page' }, caption: 'Fachada al muelle, 1:300. En rojo, los cuatro pórticos del extremo este, con ' + 'las armaduras corroídas.', altText: 'Alzado de la lonja: trece bóvedas, once arcos y cuatro pórticos en rojo.' }, { id: 'programa', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'top', captionSide: true }, caption: 'Programa de superficies útiles por zonas.', table: programme }, { id: 'calendario', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'here' }, caption: 'Calendario del concurso.', table: calendar }, // No :ref cites the site plan: only the opener's image element draws it. { id: 'situacion', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'situacion.svg', width: MAP.w * 10, height: MAP.h * 10 }, altText: '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()); await loadFonts(FONTS, source); const doc = await buildWithFonts(() => buildDocument({ markdown: source, resources }, config()), source); showPages(doc, { title: t({ en: 'Competition brief', es: 'Bases del concurso' }) });
Kit · core, fonts, viewer, images: the same in every recipe · 270 lines// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { 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', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true 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; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { 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, { 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 v1 ── 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 (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** 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 ───────────────────────────────────────────────────────────────────────

The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗

Variations

#Rule the text column only

Anchor the rule to the heading's own box instead of the number, and it runs from the text's left edge to the column's right edge, with only the number and the title in the channel.

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

Pitfalls

Pitfall

A side box starts level with the block after its fence

In postext 1.4.1 a span: 'side' box stands in the side column at the height the text has reached at its fence, on the next grid line, and under any box already there. Fence a gloss just before the paragraph it explains: fenced after it, the gloss starts beside the next paragraph. A box that would run past the column's foot slides up until its foot sits on the column's foot, as far as the box above it allows; one that still does not fit waits for the side column of the next page. Margin notes →

Pitfall

Design text overflow defaults to 'ellipsis-end'

A design text element that does not fit its width ends in an ellipsis by default. Set overflow: 'wrap' for titles that should break onto more lines. Text, rules and boxes in page designs →

Pitfall

Ragged text is never checked for runts

optimalLineBreaking, avoidRunts, runtPenalty and runtMinCharacters act on the Knuth–Plass line breaker, which postext 1.4.1 runs for justified text only. A ragged paragraph is broken line by line and can end on one short word whatever those settings say. Read the last lines of ragged text and reword a paragraph that ends on a runt. Widows, orphans and runts →

Pitfall

An opener's images never count towards the height it reserves

In postext 1.4.1 an advanced-design heading measures the height it reserves without its images: its texts, rules and boxes count, even when anchored to the page, but an image, such as a picture bled across the head of the page, reserves nothing, so the text can start on top of it. Set minHeight to where the text should begin. Designed openers →

Pitfall

A 'top' float never lands on its citing page

A float never goes above its own reference, so a page-wide 'top' float cited on page N opens page N+1. Cite it earlier, or use position 'auto' or 'bottom', which can take the foot of the citing page. Figure placement →

Pitfall

A page that opens on an inline table skips a waiting top float

In postext 1.4.1, when an inline table (position 'here', set with ::resource) moves to the head of the next page, a page-wide 'top' float cited on the page before does not take the head of that page: it waits for the page after. Float the table too (position 'top' or 'auto', cited with :ref), or cite the figure where the next page opens on text. Figure placement →

Pitfall

Text inside an SVG <img> cannot use web fonts

An SVG is drawn as an image, and an image has no access to the page's web fonts, so its labels fall back to a system face. Outline the text, embed an @font-face subset in the SVG, or move the labels to the caption. Figures and tables as resources →

Pitfall

A design text's lineHeight is a multiple, never a dimension

In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title. Text, rules and boxes in page designs →

Pitfall

A swapped palette misses design elements and the reference colour

postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build. Semantic colour palette →

Pitfall

Localise Figure/Table with defaultResourceTypes(locale)

The config's locale sets hyphenation, not captions: without resourceTypes the built-in types say Figure and Table in English. Pass resourceTypes: defaultResourceTypes('es') for Spanish; for any other language, write the names yourself in resourceTypes. Figure and Table in your language →

Pitfall

Any headings object switches off the H1 page break

By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →

Sandbox check · headingAdvancedWithoutTitleText

Heading title not printed

Why. An advanced design has no element that prints {titleText}, so the heading's own words never appear.

Fix. Add a text element with {titleText}, unless the heading is meant to be invisible. Docs →

  • A side figure takes the head of the channel on the page that cites it, and a side head fenced after it stacks under the figure, below the start of its own section. Cite side figures after the section head of their page, as section 2 cites the plot plan.
  • In postext 1.4.1 a side box fenced between a heading and its first paragraph gives that paragraph a first-line indent, even with indentAfterHeading: false. This brief has no indents, so nothing shows. With an indented texture, wrap each section's first paragraph in a :::paragraphs container whose style sets firstLineIndent: 0.
  • In postext 1.4.1 a paragraph style's spaceBetween is also added after the container's last paragraph, where it does not merge into the next heading's marginTop as body paragraph spacing does. With the criteria in such a container and one line of spaceBetween, section 5 had three blank lines above it and every other section two, so this brief colours its run-in terms with bodyText.boldColor.

Credits

Text
Original prose, CC BY 4.0
Fonts
Mona Sans (SIL OFL 1.1) · Noto Serif Display (SIL OFL 1.1) · DM Mono (SIL OFL 1.1)