Zum Hauptinhalt springen
Rezept Nummer 157

Rezepte · Kapitel 7 · Abbildungen und Bilder

Ein Schulatlas, beschriftet aus dem Markdown

Karten ohne einen Buchstaben, benannt durch ::label-Zeilen an den Ankern jeder Ressource, auf zusammengesetzten Seiten und einer Doppelseite, in drei Sprachen.

Auf dieser Seite

S. 2–3 von 4

  • Englisches Beispiel: noch keine deutsche Ausgabe
  • Endformat 210 × 280 mm
  • 1 Spalte
  • Source Sans 3 10/14
  • Source Serif 4
  • 4 Seiten
  • Stufe
  • Postext 1.38.0
  • In 115 ms gesetzt
  • 249 Zeilen Code

Kurz gesagt

Drei Seiten aus einem Schulatlas eines erfundenen Landes. Die Karten sind Zeichnungen ohne Schrift; alle Namen stehen im Text, sodass eine Übersetzung die Wörter ändert und die Karten lässt, wie sie sind.

Was Sie setzen

Drei Tafeln aus einem Schulatlas von Varenia, einem Land, das für dieses Rezept erfunden wurde. Tafel 1 zeigt seine Lage auf dem Kontinent. Tafel 2 legt die physische Karte über beide Seiten, vom Lira-Meer bis zur Grenze mit Brunavia, in Höhenschichtfarben, mit drei Flüssen und der Linie eines Profils. Tafel 3 zeichnet dieses Profil quer durch das Albar-Gebirge. Jede Tafel beginnt mit einem blauen Band und endet mit einer Legende oder einem Glossar und ein paar Aufgaben für den Unterricht. Die Zeichnungen tragen keinen Buchstaben: Die 43 Namen, Höhen und Zahlen des Maßstabs sind ::label-Zeilen im Markdown, gesetzt an Ankern, die jede Karte mit sich führt. Deshalb teilen sich die spanische, die englische und die arabische Ausgabe dieselben drei Zeichnungen, und die arabische wird von rechts gelesen und rechts gebunden.

Dieses Rezept beantwortet

  • Wie setze ich die Namen einer Karte aus dem Text, damit eine Übersetzung die Wörter ändert und nicht die Zeichnung?
  • Wie lege ich eine Karte über eine Doppelseite und halte ihre Namen in einer Ausgabe von rechts nach links lesbar?

Die kurze Antwort

script.js · Zeilen 42–66im vollständigen Code
// A label takes the style its anchor names: seas and rivers in italic blue, ranges in spaced
// brown capitals, towns on a white halo. Arabic is neither slanted nor letter-spaced, and
// Naskh and Kufi set small, so the Arabic edition gets the same styles upright, unspaced and
// 15 % larger.
const [SLANT, TRACK, SIZE] = AR ? ['normal', 0, 1.15] : ['italic', 1, 1];
const halo = { color: col('paper'), width: pt(1.4) };
const style = (id, size, color, more) => ({ id, fontFamily: LABEL, fontSize: pt(size * SIZE),
  fontWeight: AR ? 500 : 400, color: col(color), ...more });
const water = (id, size, spacing, more) => style(id, size, 'water', { fontStyle: SLANT,
  textTransform: spacing ? 'uppercase' : 'none', letterSpacing: spacing * TRACK, ...more });
const caps = (id, size, color, spacing, more) => style(id, size, color, {
  fontWeight: AR ? 700 : 600, textTransform: 'uppercase', letterSpacing: spacing * TRACK,
  ...more });
const labelStyles = [
  water('ocean', 9.5, 0.3), water('sea', 7.5, 0.16), water('river', 6.8, 0, { halo }),
  caps('range', 7, 'relief', 0.32, { halo }), caps('region', 6.5, 'muted', 0.24),
  caps('country', 8.5, 'muted', 0.36), caps('nation', 12, 'ink', 0.42),
  style('capital', 7.6, 'ink', { fontWeight: 700, halo }), style('town', 7, 'ink', { halo }),
  style('peak', 6.5, 'ink', { fontWeight: AR ? 700 : 600, halo }), style('island', 6.5, 'ink'),
  style('point', 8.5, 'band', { fontWeight: 700, halo }), style('tick', 6, 'muted'),
];
// An anchor is a point of the picture in fractions of its width and height, with the side of
// the label that stands there, its turn and its style. km(win) reads a point in km off a map.
const km = (win) => (id, x, y, labelStyle, more) => ({ id, x: (x - win.x0) / (win.x1 - win.x0),
  y: (y - win.y0) / (win.y1 - win.y0), style: labelStyle, ...more });

Zutaten

Schrift
Source Sans 3, Source Serif 4, Noto Naskh Arabic, Noto Kufi Arabic (SIL OFL 1.1)
Dateien
Keine: Jedes Bild wird im Code gezeichnet

Zubereitung

#1 · Ein Ort ist ein Punkt der Zeichnung

script.js · Zeilen 600–640im vollständigen Code
const above = { verticalAlign: 'bottom' }, below = { verticalAlign: 'top' };
const r = km(WIN.relief), l = km(WIN.locator);
const ANCHORS = {
  relief: [
    r('ocean', 172, 157, 'ocean'), r('lira', 20, 52, 'sea', { rotate: -84 }),
    r('gulf', 318, 154, 'sea', { width: 0.07 }), r('nema', 22, 100, 'island', above),
    r('albar-range', 98, 108, 'range', { rotate: -33 }),
    r('gris-range', 308, 96, 'range', { rotate: -86 }),
    r('plateau', 236, 78, 'region'), r('plain', 70, 98, 'region', { width: 0.08 }),
    r('sela', 108, 62, 'river', { rotate: -4 }), r('duna', 121, 136, 'river', { rotate: -62 }),
    r('merel', 281, 92, 'river', { rotate: -47 }), r('ormo', 254, 104, 'river', above),
    ...TOWNS.map(([id, x, y]) => r(id, x, y - 2.6, id === 'almaren' ? 'capital' : 'town', above)),
    ...PEAKS.map(([id, x, y]) => r(`${id}-peak`, x, y + 2.4, 'peak', below)),
    r('ostelia', 300, 9, 'country'), r('brunavia', 390, 112, 'country', { rotate: -90 }),
    r('varenia', 250, 46, 'nation'),
    r('a', PROFILE[0][0], PROFILE[0][1] - 2, 'point', above),
    r('b', PROFILE[1][0], PROFILE[1][1] + 2, 'point', below),
    r('scale-0', SCALE.x, SCALE.y + 3, 'tick', below),
    r('scale-25', SCALE.x + 25, SCALE.y + 3, 'tick', below),
    r('scale-50', SCALE.x + 50, SCALE.y + 3, 'tick', below),
    r('north', NORTH.x, NORTH.y - 8, 'point', above),
  ],
  locator: [
    l('varenia', 205, 80, 'nation'), l('ostelia', 150, -100, 'country'),
    l('brunavia', 560, -120, 'country'), l('lira', 210, -250, 'sea', { rotate: 20 }),
    l('ocean', -180, -40, 'ocean', { width: 0.2 }), l('almaren', 259, 127 + 22, 'capital', below),
  ],
  // The section: fractions of the drawing, whose plot starts 14 mm in and runs 3 to 51 mm down.
  section: (() => {
    const [w, h] = MAP.section, x = (u) => (14 + u * (w - 14)) / w;
    const y = (m) => (h - 7 - m * (h - 10) / PROFILE_TOP) / h;
    return [
      { id: 'a', x: x(0), y: (h - 5.5) / h, style: 'point', ...below },
      { id: 'b', x: x(0.98), y: (h - 5.5) / h, style: 'point', ...below },
      ...[500, 1000, 1500, 2000].map((m) => ({ id: `m${m}`, x: 7 / w, y: y(m), style: 'tick' })),
      { id: 'sela', x: x(0.3), y: y(700), style: 'region' },
      { id: 'crest', x: x(0.47), y: y(2260), style: 'range', ...above },
      { id: 'plain', x: x(0.82), y: y(650), style: 'region' },
    ];
  })(),
};

Ein Anker ist ein Punkt in Bruchteilen der Breite und Höhe des Bildes. Er gilt also bei jeder Größe, in der der Rahmen die Karte setzt, und wenn die Umbruchstufen eine Karte verkleinern, wandern ihre Namen mit. km() rechnet die Kilometer, in denen die Zeichnung angelegt ist, in diese Bruchteile um: Der Punkt einer Stadt und ihr Name kommen beide aus TOWNS und können nicht auseinanderlaufen. Der Anker hält fest, was zum Ort gehört und nicht zur Sprache: sein Format, die Drehung eines Fluss- oder Gebirgsnamens entlang seines Verlaufs und die Seite der Beschriftung, die am Punkt steht. Stadtnamen stehen mittig über ihrem Punkt, denn align: 'start' wäre bei einem englischen Namen links und bei einem arabischen rechts.

#2 · Drei Markdown-Dateien, eine Ressource

script.js · Zeilen 644–673im vollständigen Code
const ALT = t({
  es: {
    locator: 'Mapa de situación: Varenia, en amarillo, entre Ostelia, Brunavia, el mar de Lira '
      + 'y el océano Brumoso.',
    relief: 'Mapa físico de Varenia en tintas de altitud, con la sierra Albar, los montes '
      + 'Grises, los ríos Sela, Duna y Merel y el corte A–B.',
    section: 'Perfil topográfico de A a B: el valle del Sela, la sierra Albar y la llanura '
      + 'costera.',
  },
  en: {
    locator: 'Locator map: Varenia, in yellow, between Ostelia, Brunavia, the Lira Sea and the '
      + 'Misty Ocean.',
    relief: 'Relief map of Varenia in height tints, with the Albar Range, the Grey Hills, the '
      + 'rivers Sela, Duna and Merel and the line of section A–B.',
    section: 'Section from A to B: the Sela valley, the Albar Range and the coastal plain.',
  },
  ar: {
    locator: 'خريطة الموقع: فارينيا باللون الأصفر بين أوستيليا وبرونافيا '
      + 'وبحر ليرا والمحيط الضبابي.',
    relief: 'خريطة طبيعية لفارينيا بألوان الارتفاع، فيها سلسلة ألبار '
      + 'والتلال الرمادية وأنهار سيلا ودونا وميريل وخط المقطع أ–ب.',
    section: 'مقطع تضاريسي من أ إلى ب: وادي سيلا وسلسلة ألبار '
      + 'والسهل الساحلي.',
  },
});
const resources = Object.entries(MAP).map(([id, [w, h]]) => ({ id, typeId: 'map', kind: 'svg',
  createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 },
  altText: ALT[id], anchors: ANCHORS[id] }));
const drawings = { locator: locatorMap, relief: reliefMap, section: profile };
for (const [id, draw] of Object.entries(drawings)) await loadSvg(`${id}.svg`, draw(MAP[id]));

Die Ressource hat ein SVG und eine Ankerliste für alle Ausgaben. Jede Ausgabe schreibt je Anker eine Zeile ::label[…]{at="…"} in :::figure{resource="relief" slot="map"}, sodass ::label[Calvera Plain]{at="plain"} und ::label[Llanura de Calvera]{at="plain"} an derselben Stelle stehen und bei Bedarf durch das width des Ankers auf zwei Zeilen umbrochen werden. Nur der Alternativtext ändert sich mit der Ausgabe, denn eine Ressource übernimmt keine Wörter aus dem Markdown. Ein Anker mit Format, den eine Ausgabe ohne Beschriftung lässt, meldet anchorUnlabelled; eine Beschriftung, deren at keinen Anker nennt, wird nicht gezeichnet und meldet labelAnchorUnknown.

#3 · Eine Tafel und eine Doppeltafel

script.js · Zeilen 70–103im vollständigen Code
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const kicker = { kind: 'text', id: 'unit', content: '{attr.unit}', fontFamily: LABEL,
  fontSize: pt(AR ? 10 : 8.5), fontWeight: AR ? 500 : 600, letterSpacing: pt(1.3 * TRACK),
  textTransform: 'uppercase', color: col('sky'), align: AR ? 'right' : 'left',
  // Decoration stands on the sheet: the kicker goes to the side the page starts reading from.
  placement: at('container', AR ? 'top-right' : 'top-left', 0, -11) };
const band = { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(BAND + 3) } } };
const sheet = {
  id: 'sheet', header: false, decoration: { elements: [band, kicker] },
  frames: [
    { name: 'title', takes: 'h1', placement: at('container', 'top-left', 0, -3) },
    { name: 'text', flow: true, columns: 2, columnGap: mm(7),
      placement: at('container', 'top-left', 0, BAND - PAGE.top + 7) },
    { name: 'map' },
    { name: 'key', style: 'key', width: mm(66) },
    { name: 'tasks', style: 'tasks', placement: at('#key', 'right-of', 6) },
  ],
  // A longer text pushes the map and the key down; the map is set smaller before the text is.
  groups: [{ name: 'column', kind: 'stack', frames: ['text', 'map', 'key'], gap: mm(6) }],
};
// The double sheet: one band and one map across both pages, the boxes under the map.
const spread = {
  id: 'spread', spread: true, header: false, decoration: { elements: [band, kicker] },
  frames: [
    { name: 'title', takes: 'h1', width: mm(177), placement: at('container', 'top-left', 0, -3) },
    { name: 'map', crossGutter: true, // the one frame that runs across the spine
      placement: at('container', 'top-left', 0, BAND - PAGE.top + 5) },
    { name: 'text', flow: true, columns: 2, columnGap: mm(7), width: mm(177) },
    { name: 'key', style: 'key', width: mm(72), placement: at('#map', 'below', 213, 6) },
    { name: 'tasks', style: 'tasks', placement: at('#key', 'right-of', 6) },
  ],
  groups: [{ name: 'column', kind: 'stack', frames: ['map', 'text'], gap: mm(6) }],
};

Beide Vorlagen haben dieselben fünf Rahmen, daher hat das Markdown einer Tafel auf einer Seite wie auf zweien dieselbe Form: Titel, der Text ohne Slot, Karte, Legende und Aufgaben. Auf der Doppeltafel legt spread: true beide Seiten als einen Bogen von 390 mm an, und nur der Kartenrahmen setzt crossGutter: Er läuft über den Bund, während Text und Kästen auf ihrer Seite bleiben. Text, Karte und Legende bilden eine stack-Gruppe; im Belastungstest der Prüfbank, mit allen Texten um 30 % länger, setzt die arabische Ausgabe die Lagekarte auf 87,5 %, und die Kästen darunter rücken nach oben, ohne Warnung.

#4 · Eine Palette für Seite, Höhenfarben und Legende

script.js · Zeilen 19–32im vollständigen Code
const palette = {
  ink: '#1f2a2e', paper: '#ffffff', muted: '#55646a', rule: '#c9d2d6',
  band: '#1d5f73', // the accent: the head of each sheet, box stripes, list numbers
  sky: '#d4e9f2', // the kicker on the band
  tint: '#eef2ef', // the key's box
  water: '#22597f', river: '#2c6e9b', sea: '#d4e9f2', deep: '#a9cfe0', // names and lines of water
  low: '#b5d39b', hill: '#dfe3a2', upland: '#ecd08f', mount: '#d4a46c', peak: '#a8754d',
  relief: '#6e4527', // the names of ranges and the line of the section
  land: '#ebe6da', country: '#f2c46b', border: '#8a3b52', // the locator map
};
// col(id) links a colour to its palette entry; the drawings read the same hex from `palette`.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band }) // the defaults' id
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

Die fünf Höhenschichtfarben sind Einträge der Palette. Die Zeichnung füllt ihre Schichten mit palette.low bis palette.peak, und die Legende von Tafel 2 schreibt :swatch{color="low"} mit denselben Kennungen, sodass ein neuer Hexwert Karte und Legende zugleich ändert. Die Gewässernamen in water haben auf dem flachen Meer einen Kontrast von 6:1 und auf dem tiefen von 4,5:1, der weiße Titel auf band 7,2:1.

#5 · Die arabische Ausgabe

Der Code steht in der kurzen Antwort oben. locale: 'ar' setzt den Text von rechts nach links und bindet das Buch rechts, sodass die Doppelseite auf der rechten Seite beginnt und die Rahmen dem gespiegelten Satzspiegel folgen: Titel und Text stehen rechts, die Legende rechts neben den Aufgaben. Die Karten werden nicht gespiegelt, und jeder arabische Name steht dort, wo sein Ort gezeichnet ist. Im Arabischen verlieren die Beschriftungsformate Kursive und Sperrung und werden um 15 % größer, weil das Kufi klein läuft. Dekoration und Paginierung bleiben auf dem Bogen, also wandert die Dachzeile nach rechts oben und die Seitenzahlen wechseln die Seite.

Das ganze Rezept

Sandbox
// ═══ Postext Cookbook · Nº 157 · A school atlas labelled from the Markdown ═══════
// https://postext.dev/en/cookbook/school-atlas-labelled-maps
// Code: MIT · Text: original (CC BY 4.0) · Maps: drawn in code from seed 2026 (CC BY 4.0)
// Fonts: Source Sans 3, Source Serif 4, Noto Naskh and Kufi Arabic (OFL) · Needs postext ≥ 1.38.0
//
// Three pages of a school atlas of Varenia, an invented country: a locator map, the relief map
// across a double page and a section through the mountains. The maps are drawn without a letter.
// Every name on them is a ::label in content.<lang>.md, standing at an anchor of the resource,
// so the Spanish, English and Arabic editions share the same drawings and the same anchors.
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('es' | 'en' | 'ar')
const RECIPE = 'school-atlas-labelled-maps';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the page's colours and the maps' tints, one object for both
const palette = {
  ink: '#1f2a2e', paper: '#ffffff', muted: '#55646a', rule: '#c9d2d6',
  band: '#1d5f73', // the accent: the head of each sheet, box stripes, list numbers
  sky: '#d4e9f2', // the kicker on the band
  tint: '#eef2ef', // the key's box
  water: '#22597f', river: '#2c6e9b', sea: '#d4e9f2', deep: '#a9cfe0', // names and lines of water
  low: '#b5d39b', hill: '#dfe3a2', upland: '#ecd08f', mount: '#d4a46c', peak: '#a8754d',
  relief: '#6e4527', // the names of ranges and the line of the section
  land: '#ebe6da', country: '#f2c46b', border: '#8a3b52', // the locator map
};
// col(id) links a colour to its palette entry; the drawings read the same hex from `palette`.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band }) // the defaults' id
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const AR = LANG === 'ar';
// Three voices per script: a text face, a display face and the face of the map labels.
const [TEXT, DISPLAY, LABEL] = AR ? ['Noto Naskh Arabic', 'Noto Kufi Arabic', 'Noto Kufi Arabic']
  : ['Source Sans 3', 'Source Serif 4', 'Source Sans 3'];
const PAGE = { w: 210, h: 280, top: 22, bottom: 20, inner: 18, outer: 15 }; // mm, mirrored
const BAND = 44; // mm from the trim to the foot of the band at the head of a sheet

// #region answer: label styles in the config, label fields on the anchors of a resource
// A label takes the style its anchor names: seas and rivers in italic blue, ranges in spaced
// brown capitals, towns on a white halo. Arabic is neither slanted nor letter-spaced, and
// Naskh and Kufi set small, so the Arabic edition gets the same styles upright, unspaced and
// 15 % larger.
const [SLANT, TRACK, SIZE] = AR ? ['normal', 0, 1.15] : ['italic', 1, 1];
const halo = { color: col('paper'), width: pt(1.4) };
const style = (id, size, color, more) => ({ id, fontFamily: LABEL, fontSize: pt(size * SIZE),
  fontWeight: AR ? 500 : 400, color: col(color), ...more });
const water = (id, size, spacing, more) => style(id, size, 'water', { fontStyle: SLANT,
  textTransform: spacing ? 'uppercase' : 'none', letterSpacing: spacing * TRACK, ...more });
const caps = (id, size, color, spacing, more) => style(id, size, color, {
  fontWeight: AR ? 700 : 600, textTransform: 'uppercase', letterSpacing: spacing * TRACK,
  ...more });
const labelStyles = [
  water('ocean', 9.5, 0.3), water('sea', 7.5, 0.16), water('river', 6.8, 0, { halo }),
  caps('range', 7, 'relief', 0.32, { halo }), caps('region', 6.5, 'muted', 0.24),
  caps('country', 8.5, 'muted', 0.36), caps('nation', 12, 'ink', 0.42),
  style('capital', 7.6, 'ink', { fontWeight: 700, halo }), style('town', 7, 'ink', { halo }),
  style('peak', 6.5, 'ink', { fontWeight: AR ? 700 : 600, halo }), style('island', 6.5, 'ink'),
  style('point', 8.5, 'band', { fontWeight: 700, halo }), style('tick', 6, 'muted'),
];
// An anchor is a point of the picture in fractions of its width and height, with the side of
// the label that stands there, its turn and its style. km(win) reads a point in km off a map.
const km = (win) => (id, x, y, labelStyle, more) => ({ id, x: (x - win.x0) / (win.x1 - win.x0),
  y: (y - win.y0) / (win.y1 - win.y0), style: labelStyle, ...more });
// #endregion

// #region templates: a sheet and a double sheet, each a band, a text, a map and two boxes
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const kicker = { kind: 'text', id: 'unit', content: '{attr.unit}', fontFamily: LABEL,
  fontSize: pt(AR ? 10 : 8.5), fontWeight: AR ? 500 : 600, letterSpacing: pt(1.3 * TRACK),
  textTransform: 'uppercase', color: col('sky'), align: AR ? 'right' : 'left',
  // Decoration stands on the sheet: the kicker goes to the side the page starts reading from.
  placement: at('container', AR ? 'top-right' : 'top-left', 0, -11) };
const band = { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(BAND + 3) } } };
const sheet = {
  id: 'sheet', header: false, decoration: { elements: [band, kicker] },
  frames: [
    { name: 'title', takes: 'h1', placement: at('container', 'top-left', 0, -3) },
    { name: 'text', flow: true, columns: 2, columnGap: mm(7),
      placement: at('container', 'top-left', 0, BAND - PAGE.top + 7) },
    { name: 'map' },
    { name: 'key', style: 'key', width: mm(66) },
    { name: 'tasks', style: 'tasks', placement: at('#key', 'right-of', 6) },
  ],
  // A longer text pushes the map and the key down; the map is set smaller before the text is.
  groups: [{ name: 'column', kind: 'stack', frames: ['text', 'map', 'key'], gap: mm(6) }],
};
// The double sheet: one band and one map across both pages, the boxes under the map.
const spread = {
  id: 'spread', spread: true, header: false, decoration: { elements: [band, kicker] },
  frames: [
    { name: 'title', takes: 'h1', width: mm(177), placement: at('container', 'top-left', 0, -3) },
    { name: 'map', crossGutter: true, // the one frame that runs across the spine
      placement: at('container', 'top-left', 0, BAND - PAGE.top + 5) },
    { name: 'text', flow: true, columns: 2, columnGap: mm(7), width: mm(177) },
    { name: 'key', style: 'key', width: mm(72), placement: at('#map', 'below', 213, 6) },
    { name: 'tasks', style: 'tasks', placement: at('#key', 'right-of', 6) },
  ],
  groups: [{ name: 'column', kind: 'stack', frames: ['map', 'text'], gap: mm(6) }],
};
// #endregion

// Boxes: the key on a pale blue tint, the tasks behind a stripe in the band's blue.
const boxText = { fontFamily: TEXT, fontSize: pt(AR ? 10.5 : 8.6), lineHeight: pt(AR ? 17 : 12),
  color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0),
  textAlign: 'start' }; // ragged in every script: a box's short lines are never stretched
const boxTitle = { fontFamily: LABEL, fontSize: pt(AR ? 9.5 : 8), fontWeight: 700,
  letterSpacing: pt(1.2 * TRACK), textTransform: 'uppercase', color: col('band') };
const calloutStyles = [
  { id: 'key', backgroundEnabled: true, background: col('tint'), border: { enabled: false },
    padding: { top: mm(3), right: mm(3.5), bottom: mm(3), left: mm(3.5) },
    titleStyle: boxTitle, body: boxText },
  { id: 'tasks', backgroundEnabled: false, border: { enabled: false },
    stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('band') },
    padding: { top: mm(2.5), right: mm(0), bottom: mm(0), left: mm(0) },
    titleStyle: boxTitle, body: boxText },
];

// The folio and the atlas's name at the foot, on the outer side. An Arabic book is bound on the
// right, so its rectos (odd pages) stand left of the spine and their outer edge is the left.
const foot = (parity) => {
  const left = (parity === 'even') !== AR;
  const text = (id, content, x, extra) => ({ kind: 'text', id: `${id}-${parity}`, content, parity,
    fontFamily: LABEL, fontSize: pt(AR ? 9 : 8), fontWeight: AR ? 500 : 400, color: col('muted'),
    align: left ? 'left' : 'right', ...extra,
    placement: at('page', left ? 'bottom-left' : 'bottom-right', left ? x : -x, -11) });
  return [text('folio', '{pageNumber}', PAGE.outer, { fontWeight: 700, color: col('band') }),
    text('title', '{title}', PAGE.outer + 8)];
};
const footer = { elements: [...foot('even'), ...foot('odd')] };

const config = {
  locale: t({ es: 'es', en: 'en-gb', ar: 'ar' }), // written out: Arabic sets the page right to left
  colorPalette, labelStyles, calloutStyles, footer,
  header: { elements: [] },
  pageTemplates: [sheet, spread],
  // The maps are a type of their own with no caption: their titles are the pages' headings.
  resourceTypes: [{ id: 'map', name: t({ es: 'Mapa', en: 'Map', ar: 'خريطة' }), shortLabel: '',
    numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '',
    defaultPlacement: { position: 'here' } }],
  page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150,
    margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.inner),
      right: mm(PAGE.outer), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: AR
    ? { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(20), color: col('ink'),
      boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
      textAlign: 'justify', firstLineIndent: em(1), hyphenation: { enabled: false } }
    : { fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(14), color: col('ink'),
      boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
      textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true,
      hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true,
      avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: DISPLAY, fontWeight: 700, color: col('paper'), levels: [
    { level: 1, fontSize: pt(AR ? 24 : 26), lineHeight: pt(AR ? 34 : 30),
      marginTop: pt(0), marginBottom: pt(0), breakBefore: { enabled: false } },
  ] },
  orderedLists: { numberFormat: 'arabic', color: col('band'), fontWeight: 700,
    marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [{ id: 'colophon', fontFamily: TEXT, fontSize: pt(AR ? 8.5 : 7.5),
    lineHeight: pt(AR ? 14 : 10), color: col('muted'), textAlign: 'start',
    firstLineIndent: pt(0) }],
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown-Beispiel · 128 Zeilen · content.en.mdtitle: "A School Atlas of Varenia" --- ::::page{template="sheet" unit="Plate 1 · Location"} # Varenia on the map Varenia is a country in the south of the continent, with Ostelia to the north and Brunavia to the east. The Lira Sea washes its west coast and the Misty Ocean its south coast, which the Gulf of Sora cuts into. It covers about 40,000 km² and has nearly four million people. The capital, Almaren, stands on the River Merel a few kilometres from Lake Ormo. Almost half the people live on the coast, in port towns such as Calvera, Tessa and Port Sora. This map shows where Varenia is; the next two pages show its relief and its rivers. :::figure{resource="locator" slot="map"} ::label[Varenia]{at="varenia"} ::label[Ostelia]{at="ostelia"} ::label[Brunavia]{at="brunavia"} ::label[Lira Sea]{at="lira"} ::label[Misty Ocean]{at="ocean"} ::label[Almaren]{at="almaren"} ::: :::frame{slot="key" title="Key"} :swatch{color="country"} Varenia \\ :swatch{color="land"} Other countries \\ :swatch{color="sea"} Sea and ocean \\ :swatch{color="border"} Border \\ :swatch{color="ink"} Capital ::: :::frame{slot="tasks" title="Activities"} 1. Which countries share a border with Varenia? On which side is each one? 2. Which sea washes the west coast? Which ocean washes the south coast? 3. Find the capital. Is it on the coast or inland? ::: :::: ::::page{template="spread" spread unit="Plate 2 · Relief and rivers"} # The relief and rivers of Varenia The Albar Range crosses the west of the country from south-west to north-east. It is the highest range in Varenia: Albar Peak reaches 2,565 m. In the east rise the Grey Hills, lower and more rounded. Between the two lies the Ormo Plateau, about 600 m above the sea. The rivers rise in the mountains and run down to the sea. The Sela flows west and reaches the sea at Calvera. The Duna, short and fast, runs south. The Merel, the longest, passes through Lake Ormo and flows into the Gulf of Sora. The line A–B marks the section on Plate 3. :::figure{resource="relief" slot="map"} ::label[Misty Ocean]{at="ocean"} ::label[Lira Sea]{at="lira"} ::label[Gulf of Sora]{at="gulf"} ::label[Nema Island]{at="nema"} ::label[Albar Range]{at="albar-range"} ::label[Grey Hills]{at="gris-range"} ::label[Ormo Plateau]{at="plateau"} ::label[Calvera Plain]{at="plain"} ::label[River Sela]{at="sela"} ::label[River Duna]{at="duna"} ::label[River Merel]{at="merel"} ::label[Lake Ormo]{at="ormo"} ::label[Almaren]{at="almaren"} ::label[Calvera]{at="calvera"} ::label[Tessa]{at="tessa"} ::label[Port Sora]{at="sora"} ::label[Brena]{at="brena"} ::label[Albar Peak \\ 2,565 m]{at="albar-peak"} ::label[Grey Peak \\ 1,274 m]{at="gris-peak"} ::label[Ostelia]{at="ostelia"} ::label[Brunavia]{at="brunavia"} ::label[Varenia]{at="varenia"} ::label[A]{at="a"} ::label[B]{at="b"} ::label[0]{at="scale-0"} ::label[25]{at="scale-25"} ::label[50 km]{at="scale-50"} ::label[N]{at="north"} ::: :::frame{slot="key" title="Height"} :swatch{color="peak"} Over 1,800 m \\ :swatch{color="mount"} 1,000 to 1,800 m \\ :swatch{color="upland"} 500 to 1,000 m \\ :swatch{color="hill"} 200 to 500 m \\ :swatch{color="low"} 0 to 200 m \\ :swatch{color="sea"} Sea ::: :::frame{slot="tasks" title="Activities"} 1. Which range is higher, the Albar Range or the Grey Hills? How can you tell from the colours? 2. Follow the River Merel from its source. Which lake does it pass through? Where does it reach the sea? 3. Use the scale to measure how far Calvera is from Almaren in a straight line. ::: :::: ::::page{template="sheet" unit="Plate 3 · A section"} # A section through the Albar Range A section shows the shape of the land as if it were cut along a line. This one follows the line A–B on the relief map: it starts in the hills of the north, drops into the Sela valley, climbs the Albar Range to more than 2,100 m and ends on the plain of the south coast. The line A–B is 126 km long. To make the mountains easy to see, the vertical scale is stretched about fourteen times, so the slopes in the drawing look much steeper than they are. :::paragraphs{style="colophon"} Varenia and its maps were invented for this atlas. Text and maps: CC BY 4.0. Set in Source Sans 3 and Source Serif 4 (SIL OFL). ::: :::figure{resource="section" slot="map"} ::label[A]{at="a"} ::label[B]{at="b"} ::label[500 m]{at="m500"} ::label[1,000 m]{at="m1000"} ::label[1,500 m]{at="m1500"} ::label[2,000 m]{at="m2000"} ::label[Sela valley]{at="sela"} ::label[Albar Range]{at="crest"} ::label[Coastal plain]{at="plain"} ::: :::frame{slot="key" title="Words to know"} **Range:** a line of mountains whose peaks stand in a row. **Plateau:** a high, flat stretch of land, more than 400 m above the sea. **Valley:** low land between hills or mountains, often with a river along it. **Plain:** low, nearly flat land, like the land along the south coast. ::: :::frame{slot="tasks" title="Activities"} 1. About how high is the crest of the Albar Range on this section? 2. Why is the Sela valley lower than the land around it? 3. In your exercise book, draw the section of a line from Calvera to Almaren. 4. On the relief map, what colour is the land at A and at B? Does it match the section? ::: ::::
`; // content.<lang>.md: every word of the pages, maps included // #region art: one seeded height field, drawn as the locator map, the relief map and a profile // Varenia is invented: a coast, two ranges and three rivers laid out in km (x east, y south), // warped by value noise, cut into height bands by marching squares. The drawings carry no // letters: every name, number and the N of the arrow is a ::label of the Markdown, standing at // an anchor of ANCHORS. No markers, filters or masks. const SEED = 2026; // the seed every coast and height is drawn from const MAP = { locator: [177, 118], relief: [390, 160], section: [177, 100] }; // drawings, in mm const WIN = { // the km each map shows: x east, y south relief: { x0: 0, x1: 400, y0: 0, y1: 400 * MAP.relief[1] / MAP.relief[0] }, locator: { x0: -330, x1: 770, y0: -520, y1: -520 + 1100 * MAP.locator[1] / MAP.locator[0] }, }; const TOWNS = [['almaren', 259, 127, 'capital'], ['calvera', 53, 66], ['tessa', 108, 142], ['sora', 296, 132], ['brena', 346, 92]]; const PEAKS = [['albar', 170, 63], ['gris', 325, 55]]; const PROFILE = [[92, 32], [166, 134]]; // A and B, the ends of the section const PROFILE_TOP = 2600; // m at the head of the section const SCALE = { x: 8, y: 157 }, NORTH = { x: 22, y: 140 }; // the scale bar and the arrow function mulberry32(seed) { // a seeded PRNG: never Math.random() in a recipe return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const rand = mulberry32(SEED); const LATTICE = Array.from({ length: 64 * 64 }, () => rand() * 2 - 1); const ease = (u) => u * u * (3 - 2 * u); function noise(x, y) { // value noise in −1…1 on a 64 × 64 lattice that wraps const [i, j] = [Math.floor(x), Math.floor(y)], [u, v] = [ease(x - i), ease(y - j)]; const at = (a, b) => LATTICE[((b & 63) << 6) | (a & 63)]; const top = at(i, j) + (at(i + 1, j) - at(i, j)) * u; const foot = at(i, j + 1) + (at(i + 1, j + 1) - at(i, j + 1)) * u; return top + (foot - top) * v; } const fbm = (x, y) => [1, 2, 4, 8].reduce((s, f) => s + noise(f * x, f * y) / (2 * f), 0); // Polylines in km. A range is a crest line and its height; a river runs from source to mouth. const RANGES = [ { crest: [[62, 132], [96, 108], [128, 88], [158, 70], [184, 56]], height: 2150, width: 15 }, { crest: [[318, 30], [326, 62], [322, 96], [312, 124]], height: 1050, width: 13 }, ]; const RIVERS = { sela: [[156, 78], [138, 70], [118, 66], [97, 68], [78, 62], [60, 64], [40, 62]], duna: [[126, 112], [120, 126], [112, 138], [106, 150], [101, 168]], merel: [[306, 44], [296, 62], [281, 80], [266, 96], [256, 110], [258, 124], [268, 138], [282, 150], [292, 168]], }; const LAKE = { x: 254, y: 111, rx: 7.5, ry: 4.8 }; const BORDERS = [ // Varenia and Ostelia; Varenia and Brunavia; Ostelia and Brunavia [[30, 12], [80, 6], [128, 10], [172, 18], [214, 12], [256, 24], [298, 22], [338, 34], [376, 44]], [[376, 44], [372, 76], [380, 104], [368, 132], [374, 156], [370, 190]], [[376, 44], [392, -40], [384, -170], [420, -330], [404, -520]], ]; function segDist(x, y, pts) { // the distance from (x, y) to a polyline, km let best = Infinity; for (let i = 1; i < pts.length; i++) { const [[ax, ay], [bx, by]] = [pts[i - 1], pts[i]], [dx, dy] = [bx - ax, by - ay]; const u = Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy))); best = Math.min(best, Math.hypot(ax + u * dx - x, ay + u * dy - y)); } return best; } function landness(x, y) { // km inland from the coast; negative out at sea // Far from Varenia (the window of the relief map) the coast takes larger turns. const far = Math.min(1, Math.hypot(Math.max(0, -x, x - 400), Math.max(0, -y, y - 175)) / 160); const west = x - (50 + 26 * fbm(y / 46, 3.1) + 9 * fbm(y / 11, 8.2) + far * (240 * fbm(y / 520, 7.7) - 0.45 * Math.max(0, -y - 80))); const south = 146 + 16 * fbm(x / 52, 1.3) + 6 * fbm(x / 13, 2.6) + far * (200 * fbm(x / 480, 4.4) + 0.8 * Math.max(0, x - 430)) - y; const gulf = Math.hypot((x - 312) / 1.3, y - 166) - 38 - 6 * fbm(x / 12, y / 12); const [lu, lv] = [(x - 200) * 0.94 + (y + 240) * 0.34, (y + 240) * 0.94 - (x - 200) * 0.34]; const lira = Math.hypot(lu / 2.6, lv) - 92 - 30 * fbm(x / 70, y / 70); // an inland sea, north const cape = 1 - Math.hypot((x - 90) / 22, (y - 148) / 17) + 0.3 * fbm(x / 8, y / 8); const nema = 8 - Math.hypot(x - 22, (y - 110) * 1.4) + 3 * fbm(x / 6, y / 6); return Math.max(Math.min(west, Math.max(south, cape * 12), gulf, lira), nema); } function elevation(x, y) { // metres; below 0 is sea, NaN is the lake const wx = x + 6 * fbm(x / 24, y / 24 + 5), wy = y + 6 * fbm(x / 24 + 9, y / 24); const coast = landness(wx, wy); if (coast < 0) return Math.max(-900, coast * 28); if (((x - LAKE.x) / LAKE.rx) ** 2 + ((y - LAKE.y) / LAKE.ry) ** 2 < 1) return NaN; let e = 20 + Math.min(coast, 45) * 4 + 430 * Math.exp(-(((x - 236) / 58) ** 2 + ((y - 88) / 34) ** 2)); for (const { crest, height, width } of RANGES) { const ridge = Math.exp(-((segDist(wx, wy, crest) / width) ** 2)); e += height * ridge * (0.75 + 0.5 * fbm(x / 9, y / 9)); } for (const river of Object.values(RIVERS)) { e -= 420 * Math.exp(-((segDist(x, y, river) / 6) ** 2)); } e += 170 * fbm(x / 7, y / 7); return Math.max(e, 2 + coast * 0.6); } const EDGES = { 1: ['B', 'L'], 2: ['R', 'B'], 3: ['R', 'L'], 4: ['T', 'R'], 6: ['T', 'B'], 7: ['T', 'L'], 8: ['L', 'T'], 9: ['B', 'T'], 11: ['R', 'T'], 12: ['L', 'R'], 13: ['B', 'R'], 14: ['L', 'B'], 5: ['T', 'L', 'B', 'R', 'T', 'R', 'B', 'L'], // saddles: joined, then apart 10: ['R', 'T', 'L', 'B', 'L', 'T', 'R', 'B'] }; function contours(grid, nx, ny, level) { // marching squares: closed loops above `level` const v = (i, j) => (i >= 0 && j >= 0 && i < nx && j < ny ? grid[j * nx + i] : -1e9); const at = new Map(), next = new Map(); const edge = (i, j, side) => { // T, B, L or R of cell (i, j), as a key shared by neighbours const [a, b, horizontal] = { T: [i, j, 1], B: [i, j + 1, 1], L: [i, j, 0], R: [i + 1, j, 0] }[side]; const key = `${horizontal ? 'h' : 'v'}${a},${b}`; if (!at.has(key)) { const [p, q] = horizontal ? [v(a, b), v(a + 1, b)] : [v(a, b), v(a, b + 1)]; const u = Math.max(0, Math.min(1, (level - p) / (q - p))); at.set(key, horizontal ? [a + u, b] : [a, b + u]); } return key; }; for (let j = -1; j < ny; j++) { for (let i = -1; i < nx; i++) { const corners = [v(i, j), v(i + 1, j), v(i + 1, j + 1), v(i, j + 1)]; const index = corners.reduce((n, c) => n * 2 + (c > level), 0); let sides = EDGES[index]; if (!sides) continue; const high = corners.reduce((a, b) => a + b) / 4 > level; // a saddle: which way it joins if (sides.length > 4) sides = high ? sides.slice(0, 4) : sides.slice(4); for (let k = 0; k < sides.length; k += 2) { next.set(edge(i, j, sides[k]), edge(i, j, sides[k + 1])); } } } const loops = []; for (const start of [...next.keys()]) { const loop = []; for (let k = start; next.has(k);) { loop.push(at.get(k)); const n = next.get(k); next.delete(k); k = n; } if (loop.length > 4) loops.push(loop); } return loops; } const chaikin = (loop) => loop.flatMap((p, i) => { const q = loop[(i + 1) % loop.length]; return [[0.75 * p[0] + 0.25 * q[0], 0.75 * p[1] + 0.25 * q[1]], [0.25 * p[0] + 0.75 * q[0], 0.25 * p[1] + 0.75 * q[1]]]; }); const r1 = (n) => Math.round(n * 10) / 10; // The window win = { x0, x1, y0, y1 } in km, drawn w × h units; field(x, y) gives the value. function raster(win, w, h, field, cell = 1) { const [nx, ny] = [Math.ceil(w / cell) + 1, Math.ceil(h / cell) + 1]; const grid = new Float32Array(nx * ny); for (let j = 0; j < ny; j++) { for (let i = 0; i < nx; i++) { grid[j * nx + i] = field(win.x0 + i * (win.x1 - win.x0) / (nx - 1), win.y0 + j * (win.y1 - win.y0) / (ny - 1)); } } const k = w / (win.x1 - win.x0); // units per km const X = (x) => r1((x - win.x0) * k), Y = (y) => r1((y - win.y0) * k); const above = (level, g = grid) => contours(g, nx, ny, level).map((loop) => { const p = chaikin(chaikin(loop)); return `M${p.map(([i, j]) => `${r1(i * w / (nx - 1))} ${r1(j * h / (ny - 1))}`).join('L')}Z`; }).join(''); const masked = (keep) => grid.map((value, n) => (keep(win.x0 + (n % nx) * (win.x1 - win.x0) / (nx - 1), win.y0 + Math.floor(n / nx) * (win.y1 - win.y0) / (ny - 1)) ? value : -1e9)); return { X, Y, k, w, h, above, masked, grid, nx, ny }; } const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" ` + `width="${w * 10}" height="${h * 10}">${body}</svg>`; const fill = (d, id, more = '') => (d ? `<path d="${d}" fill="${palette[id]}" ` + `fill-rule="evenodd"${more}/>` : ''); const line = (d, id, width, more = '') => `<path d="${d}" fill="none" stroke="${palette[id]}" ` + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${more}/>`; const open = (pts) => [pts[0], ...pts.slice(1).flatMap((q, i) => { // Chaikin on an open line const p = pts[i]; return [[0.75 * p[0] + 0.25 * q[0], 0.75 * p[1] + 0.25 * q[1]], [0.25 * p[0] + 0.75 * q[0], 0.25 * p[1] + 0.75 * q[1]]]; }).slice(1, -1), pts[pts.length - 1]]; const smooth = (pts) => open(open(pts)); // an open line, rounded const path = (s, pts) => `M${pts.map(([x, y]) => `${s.X(x)} ${s.Y(y)}`).join('L')}`; const landRuns = (pts) => { // the stretches of a line that run over land const runs = [[]]; for (const p of dense(pts, 3)) { if (landness(...p) > 0) runs[runs.length - 1].push(p); else if (runs[runs.length - 1].length) runs.push([]); } return runs.filter((run) => run.length > 1); }; const onLand = (pts) => { // a river line cut where it reaches the sea const out = []; for (let i = 0; i < pts.length; i++) { out.push(pts[i]); if (i && landness(...pts[i]) < 0.6) break; } return out; }; const dense = (pts, step = 1.5) => pts.slice(1).flatMap(([bx, by], i) => { const [ax, ay] = pts[i], n = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay) / step)); return Array.from({ length: n }, (_, k) => [ax + (bx - ax) * k / n, ay + (by - ay) * k / n]); }).concat([pts[pts.length - 1]]); const lakePath = (s) => { const [cx, cy, rx, ry] = [s.X(LAKE.x), s.Y(LAKE.y), LAKE.rx * s.k, LAKE.ry * s.k]; return `M${cx - rx} ${cy}a${rx} ${ry} 0 1 0 ${2 * rx} 0a${rx} ${ry} 0 1 0 ` + `${-2 * rx} 0Z`; }; const BANDS = [[0, 'low'], [200, 'hill'], [500, 'upland'], [1000, 'mount'], [1800, 'peak']]; function reliefMap([w, h]) { // the spread: hypsometric tints, rivers, borders, towns, a scale const height = (x, y) => { const e = elevation(x, y); return Number.isNaN(e) ? 30 : e; }; const s = raster(WIN.relief, w, h, height, 0.9); let out = `<rect width="${w}" height="${h}" fill="${palette.deep}"/>` + fill(s.above(-160), 'sea'); for (const [level, id] of BANDS) out += fill(s.above(level), id); // Hill shading lit from the north-west: the slopes that face away darken, the others lighten. const { nx, ny } = s; const shade = s.grid.map((e, n) => { const [i, j] = [n % nx, Math.floor(n / nx)]; if (e <= 0 || i === 0 || j === 0 || i === nx - 1 || j === ny - 1) return 0; const dx = s.grid[n + 1] - s.grid[n - 1], dy = s.grid[n + nx] - s.grid[n - nx]; return (dx + dy) / 900; // grows with the slope down to the south-east }); out += fill(s.above(0.35, shade), 'ink', ' fill-opacity="0.13"') + fill(s.above(0.9, shade), 'ink', ' fill-opacity="0.1"') + fill(s.above(0.35, shade.map((v) => -v)), 'paper', ' fill-opacity="0.22"'); out += fill(lakePath(s), 'sea') + line(lakePath(s), 'river', 0.5); for (const river of Object.values(RIVERS)) { out += line(path(s, smooth(onLand(dense(river)))), 'river', 0.9); } for (const run of BORDERS.slice(0, 2).flatMap(landRuns)) { out += line(path(s, run), 'border', 0.7, ' stroke-dasharray="2.4 1.2"'); } out += line(path(s, PROFILE), 'ink', 0.5, ' stroke-dasharray="1 1"'); const dot = (x, y, r, more = '') => `<circle cx="${s.X(x)}" cy="${s.Y(y)}" r="${r}" ` + `fill="${palette.ink}"${more}/>`; const ring = ` stroke="${palette.paper}" stroke-width="0.5"`; for (const [x, y] of PROFILE) out += dot(x, y, 1); for (const [, x, y, kind] of TOWNS) { out += kind === 'capital' ? `<rect x="${s.X(x) - 1.4}" y="${s.Y(y) - 1.4}" width="2.8" height="2.8" ` + `fill="${palette.ink}"${ring}/>` : dot(x, y, 1.2, ring); } for (const [, x, y] of PEAKS) { const [px, py] = [s.X(x), s.Y(y)]; out += `<path d="M${px - 1.5} ${py + 1.1}L${px} ${py - 1.5}L${px + 1.5} ${py + 1.1}Z" ` + `fill="${palette.ink}"/>`; } // A scale of 50 km in the south-west sea, and the north arrow above it. const [sx, sy] = [s.X(SCALE.x), s.Y(SCALE.y)], step = 25 * s.k; out += `<rect x="${sx}" y="${sy}" width="${step}" height="1.4" fill="${palette.ink}"/>` + `<rect x="${sx + step}" y="${sy}" width="${step}" height="1.4" fill="${palette.paper}" ` + `stroke="${palette.ink}" stroke-width="0.3"/>`; const [ax, ay] = [s.X(NORTH.x), s.Y(NORTH.y)]; out += `<path d="M${ax} ${ay - 7}L${ax + 2.6} ${ay + 1}L${ax} ${ay - 1}L${ax - 2.6} ${ay + 1}Z" ` + `fill="${palette.ink}"/>`; return svg(w, h, out + `<rect x="0.15" y="0.15" width="${w - 0.3}" height="${h - 0.3}" ` + `fill="none" stroke="${palette.ink}" stroke-width="0.3"/>`); // the neat line } function locatorMap([w, h]) { // Varenia among its neighbours, flat tints const s = raster(WIN.locator, w, h, landness, 1); const north = (x) => interp(BORDERS[0], x); const east = (y) => interp(BORDERS[1].map(([a, b]) => [b, a]), y); const inVarenia = (x, y) => y > north(x) && x < east(y) && x > -200; let out = `<rect width="${w}" height="${h}" fill="${palette.sea}"/>` + fill(s.above(0), 'land') + fill(s.above(0, s.masked(inVarenia)), 'country'); for (const run of BORDERS.flatMap(landRuns)) { out += line(path(s, run), 'border', 0.5, ' stroke-dasharray="1.6 0.8"'); } out += line(s.above(0), 'river', 0.3); const [, x, y] = TOWNS[0]; out += `<rect x="${s.X(x) - 1.2}" y="${s.Y(y) - 1.2}" width="2.4" height="2.4" ` + `fill="${palette.ink}"/>`; return svg(w, h, out); } function interp(pts, x) { // a polyline read as y(x); outside it, its end values if (x <= pts[0][0]) return pts[0][1]; for (let i = 1; i < pts.length; i++) { const [[ax, ay], [bx, by]] = [pts[i - 1], pts[i]]; if (x <= bx) return ay + (by - ay) * (x - ax) / (bx - ax); } return pts[pts.length - 1][1]; } function profile([w, h]) { // the section A–B: heights ×16, tinted by band, a rule each 500 m const n = 300, [[ax, ay], [bx, by]] = PROFILE, top = PROFILE_TOP, [x0, y0, y1] = [14, 3, h - 7]; const Y = (m) => r1(y1 - m * (y1 - y0) / top); const pts = Array.from({ length: n + 1 }, (_, i) => { const e = elevation(ax + (bx - ax) * i / n, ay + (by - ay) * i / n); return [r1(x0 + i * (w - x0) / n), Y(Math.max(0, Number.isNaN(e) ? 0 : e))]; }); const ground = `M${x0} ${y1}L${pts.map(([x, y]) => `${x} ${y}`).join('L')}L${w} ${y1}Z`; let out = `<defs><clipPath id="ground"><path d="${ground}"/></clipPath></defs>`; for (let m = 500; m < top; m += 500) out += line(`M${x0} ${Y(m)}H${w}`, 'rule', 0.3); out += '<g clip-path="url(#ground)">'; BANDS.forEach(([level, id], i) => { const next = BANDS[i + 1]?.[0] ?? top; out += `<rect x="${x0}" y="${Y(next)}" width="${w - x0}" height="${r1(Y(level) - Y(next))}" ` + `fill="${palette[id]}"/>`; }); const crest = line(`M${pts.map(([x, y]) => `${x} ${y}`).join('L')}`, 'relief', 0.6); return svg(w, h, `${out}</g>${crest}${line(`M${x0} ${y1}H${w}`, 'ink', 0.5)}` + line(`M${x0} ${y0}V${y1}`, 'ink', 0.4)); } // #endregion // #region anchors: the points of each map where a name stands, the same in every edition const above = { verticalAlign: 'bottom' }, below = { verticalAlign: 'top' }; const r = km(WIN.relief), l = km(WIN.locator); const ANCHORS = { relief: [ r('ocean', 172, 157, 'ocean'), r('lira', 20, 52, 'sea', { rotate: -84 }), r('gulf', 318, 154, 'sea', { width: 0.07 }), r('nema', 22, 100, 'island', above), r('albar-range', 98, 108, 'range', { rotate: -33 }), r('gris-range', 308, 96, 'range', { rotate: -86 }), r('plateau', 236, 78, 'region'), r('plain', 70, 98, 'region', { width: 0.08 }), r('sela', 108, 62, 'river', { rotate: -4 }), r('duna', 121, 136, 'river', { rotate: -62 }), r('merel', 281, 92, 'river', { rotate: -47 }), r('ormo', 254, 104, 'river', above), ...TOWNS.map(([id, x, y]) => r(id, x, y - 2.6, id === 'almaren' ? 'capital' : 'town', above)), ...PEAKS.map(([id, x, y]) => r(`${id}-peak`, x, y + 2.4, 'peak', below)), r('ostelia', 300, 9, 'country'), r('brunavia', 390, 112, 'country', { rotate: -90 }), r('varenia', 250, 46, 'nation'), r('a', PROFILE[0][0], PROFILE[0][1] - 2, 'point', above), r('b', PROFILE[1][0], PROFILE[1][1] + 2, 'point', below), r('scale-0', SCALE.x, SCALE.y + 3, 'tick', below), r('scale-25', SCALE.x + 25, SCALE.y + 3, 'tick', below), r('scale-50', SCALE.x + 50, SCALE.y + 3, 'tick', below), r('north', NORTH.x, NORTH.y - 8, 'point', above), ], locator: [ l('varenia', 205, 80, 'nation'), l('ostelia', 150, -100, 'country'), l('brunavia', 560, -120, 'country'), l('lira', 210, -250, 'sea', { rotate: 20 }), l('ocean', -180, -40, 'ocean', { width: 0.2 }), l('almaren', 259, 127 + 22, 'capital', below), ], // The section: fractions of the drawing, whose plot starts 14 mm in and runs 3 to 51 mm down. section: (() => { const [w, h] = MAP.section, x = (u) => (14 + u * (w - 14)) / w; const y = (m) => (h - 7 - m * (h - 10) / PROFILE_TOP) / h; return [ { id: 'a', x: x(0), y: (h - 5.5) / h, style: 'point', ...below }, { id: 'b', x: x(0.98), y: (h - 5.5) / h, style: 'point', ...below }, ...[500, 1000, 1500, 2000].map((m) => ({ id: `m${m}`, x: 7 / w, y: y(m), style: 'tick' })), { id: 'sela', x: x(0.3), y: y(700), style: 'region' }, { id: 'crest', x: x(0.47), y: y(2260), style: 'range', ...above }, { id: 'plain', x: x(0.82), y: y(650), style: 'region' }, ]; })(), }; // #endregion // #region resources: three drawings, their anchors and their alternative text const ALT = t({ es: { locator: 'Mapa de situación: Varenia, en amarillo, entre Ostelia, Brunavia, el mar de Lira ' + 'y el océano Brumoso.', relief: 'Mapa físico de Varenia en tintas de altitud, con la sierra Albar, los montes ' + 'Grises, los ríos Sela, Duna y Merel y el corte A–B.', section: 'Perfil topográfico de A a B: el valle del Sela, la sierra Albar y la llanura ' + 'costera.', }, en: { locator: 'Locator map: Varenia, in yellow, between Ostelia, Brunavia, the Lira Sea and the ' + 'Misty Ocean.', relief: 'Relief map of Varenia in height tints, with the Albar Range, the Grey Hills, the ' + 'rivers Sela, Duna and Merel and the line of section A–B.', section: 'Section from A to B: the Sela valley, the Albar Range and the coastal plain.', }, ar: { locator: 'خريطة الموقع: فارينيا باللون الأصفر بين أوستيليا وبرونافيا ' + 'وبحر ليرا والمحيط الضبابي.', relief: 'خريطة طبيعية لفارينيا بألوان الارتفاع، فيها سلسلة ألبار ' + 'والتلال الرمادية وأنهار سيلا ودونا وميريل وخط المقطع أ–ب.', section: 'مقطع تضاريسي من أ إلى ب: وادي سيلا وسلسلة ألبار ' + 'والسهل الساحلي.', }, }); const resources = Object.entries(MAP).map(([id, [w, h]]) => ({ id, typeId: 'map', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, altText: ALT[id], anchors: ANCHORS[id] })); const drawings = { locator: locatorMap, relief: reliefMap, section: profile }; for (const [id, draw] of Object.entries(drawings)) await loadSvg(`${id}.svg`, draw(MAP[id])); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build 'Source Sans 3': ['400', '400i', '600', '700'], // text, boxes and map labels 'Source Serif 4': ['700'], // the sheets' titles 'Noto Naskh Arabic': ['400', '700'], // the Arabic text and boxes 'Noto Kufi Arabic': ['500', '700'], // the Arabic titles and map labels }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadArabicFonts(FONTS, markdown + JSON.stringify(resources)); const doc = await withLoadedFonts(() => buildDocument({ markdown, resources }, config), { ...kitFonts(FONTS), text: markdown }); showBook(doc, { title: t({ es: 'Atlas escolar de Varenia', en: 'A school atlas of Varenia', ar: 'أطلس فارينيا المدرسي' }) });
Kit · core, fonts, viewer, images, arabic, book: in jedem Rezept gleich · 410 Zeilen// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── Kit · arabic v2 ── Arabic-script faces · postext.dev/cookbook // Fontsource ships an Arabic family as one file per subset and weight: the // `arabic` file holds the letters, the harakat, the Arabic-Indic digits, the // Arabic punctuation and the presentation forms; `latin` and `latin-ext` // hold the rest. loadFonts loads the latin files; this block adds the arabic // file of every Arabic family, for the canvas and for the PDF, which shapes // the letters with HarfBuzz from the same bytes. /** The code points of Fontsource's `arabic` subset, as its stylesheets * declare them (the same unicode-range the browser picks the file by). A * function, not a const: the kit is inlined after the recipe's top-level * awaits, and a const read before its line throws, where a function * declaration is hoisted. */ function arabicRange() { return 'U+0600-06FF,U+0750-077F,U+0870-088E,U+0890-0891,U+0897-08E1,U+08E3-08FF,' + 'U+200C-200E,U+2010-2011,U+204F,U+2E41,U+FB50-FDFF,U+FE70-FE74,U+FE76-FEFC,U+102E0-102FB,' + 'U+10E60-10E7E,U+10EC2-10EC4,U+10EFC-10EFF,U+1EE00-1EEFF'; } /** Whether code point `cp` is in the arabic file. */ function inArabicRange(cp) { inArabicRange.ranges ??= arabicRange().split(',').map((part) => { const [lo, hi = lo] = part.slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return inArabicRange.ranges.some(([lo, hi]) => cp >= lo && cp <= hi); } /** Whether Fontsource serves `family` with an `arabic` subset. Fails when * the API does not answer: an Arabic face taken for a Latin one would set * its letters in a system face. */ async function isArabicFamily(family) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); return !!meta.subsets?.includes('arabic'); } /** The arabic file of a face. */ async function arabicFileUrl(family, weight, style) { const id = fontsourceId(family); return `${await fontsourceAt(id)}files/${id}-arabic-${weight}-${style}.woff2`; } /** faces = { Amiri: ['400', '700'] }, as for loadFonts, after it: the whole * FONTS object may be passed, its families without an arabic subset are * left alone. Adds the arabic file of every listed weight of each Arabic * family (the latin files come from loadFonts) and loads it. `text` is * the sample: fails when it holds an Arabic-script character the arabic * file does not cover. List every weight the pages set in Arabic: a weight * left to the engine's later round gets the latin file only, and its Arabic letters * fall back to a system face. Resolves to the number of files loaded. */ async function loadArabicFonts(faces, text = '') { kitStatus('Loading fonts…'); let loaded = 0; try { const outside = [...new Set(text)].filter((ch) => /\p{Script=Arabic}/u.test(ch) && !inArabicRange(ch.codePointAt(0))); if (outside.length) throw new Error(`Fontsource's arabic files have no ${outside.slice(0, 12).join(' ')}`); for (const [family, specs] of Object.entries(faces)) { if (!(await isArabicFamily(family))) continue; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const face = new FontFace(family, `url(${await arabicFileUrl(family, weight, style)}) format('woff2')`, { weight: String(weight), style, unicodeRange: arabicRange() }); document.fonts.add(await face.load().catch(() => { throw new Error(`Fontsource has no arabic file for ${family} ${weight} ${style}`); })); loaded++; } } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with Arabic faces: a family with an * arabic subset gets its arabic file when its pages set Arabic letters * (`request.codePoints`), then its latin file, and its latin-ext file for * the letters beyond latin (transliteration: ā ḥ ʿ), each marked with its * unicode-range and its place in the order the screen added them (latin * and latin-ext by loadFonts, then arabic): the arabic file also has a * space and brackets, which the browser takes from latin, the file whose * range holds them, and postext-pdf follows it. Any other family goes to * fontsourceProvider (the "pdf" block). */ async function arabicPdfProvider(family, weight, style, request) { if (!(await isArabicFamily(family))) return fontsourceProvider(family, weight, style, request); const meta = await fontsourceMeta(family); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const wanted = [...(request?.codePoints ?? [])]; const id = fontsourceId(family); const subsets = !wanted.length || wanted.some(inArabicRange) ? ['arabic', 'latin'] : ['latin']; if (meta.subsets.includes('latin-ext') && wanted.some((cp) => /[Ā-˿Ḁ-ỿ]/u.test(String.fromCodePoint(cp)))) { subsets.push('latin-ext'); } const at = await fontsourceAt(id); return Promise.all(subsets.map(async (subset) => { const url = `${at}files/${id}-${subset}-${w}-${s}.woff2`; const res = await fetch(url); if (!res.ok) throw new Error(`Fontsource file ${url} (${res.status})`); const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer())); return Object.assign(bytes, { unicodeRange: subset === 'arabic' ? arabicRange() : kitRanges()[subset], order: ['latin', 'latin-ext', 'greek', 'arabic'].indexOf(subset) }); })); } // ─── Kit · book v1 ── books bound on either edge · postext.dev/cookbook // A book bound on the right (Arabic, Hebrew or Persian text, vertical // Chinese, or page.binding 'right') opens from what a Latin reader calls // the back: page 1 lies alone on the left of the spine, then [3 | 2]. /** showPages for a book bound on either edge. A right-bound book (the * document says so: doc.binding is 'right' for page.binding 'right', for * text that runs right to left and for vertical text, when the binding is * left to 'auto') lies on the desk as it opens: page 1 alone on the left * of the spine, then [3 | 2], the spine shade on each page's inner edge. * `binding` ('left' | 'right') overrides the document's. */ function showBook(docs, { binding, ...options } = {}) { const count = showPages(docs, options); const right = (binding ?? [docs].flat()[0]?.binding) === 'right'; if (!document.getElementById('pt-kit-book')) { // The pages keep direction ltr, as in a left-bound book: a canvas takes // the direction its element inherits, and under the spread's rtl a run // painted for an ltr canvas would end where the engine starts it. document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-book"> .pt-spread[dir="rtl"] canvas { direction: ltr; } .pt-spread[dir="rtl"] 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); } </style>`); } // Each pair stays [verso, recto] in the page; right to left, the verso // sits on the right. Phones stack the pages in reading order either way. for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr'; document.getElementById('pages').dataset.binding = right ? 'right' : 'left'; return count; } // ─── /Kit ───────────────────────────────────────────────────────────────────────

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

Varianten

#Einen Ort ohne Anker benennen

Eine Beschriftung kann an einem Punkt stehen, der in Prozent des Bildes angegeben ist, mit einem eigenen Format.

 :::figure{resource="relief" slot="map"}
+::label[Nema Light]{at="7% 68%" style="town"}
 ::label[Misty Ocean]{at="ocean"}

#Die Karten nummerieren und beschriften

Geben Sie dem Kartentyp einen Zähler und ein Präfix und jeder Ressource eine Bildunterschrift: siehe das Atlasblatt mit nummerierten Karten, das seine Bildunterschriften auf einen blauen Balken setzt.

Häufige Fehler

Layoutwarnung · labelAnchorUnknown

Beschriftung an einem unbekannten Anker

Ursache. Ein ::label einer :::figure nennt mit at= einen Anker, den die Ressource der Abbildung nicht hat. Die Beschriftung wird nicht auf das Bild gezeichnet; das umfließende EPUB führt sie weiter auf, und der Alternativtext des Bildes nennt sie.

Lösung. Korrigieren Sie die Anker-ID in at= (die Warnung listet die Anker der Ressource), legen Sie den Anker in der Ressource an oder setzen Sie die Beschriftung mit at="x% y%". Doku →

Layoutwarnung · anchorUnlabelled

Anker ohne Beschriftung

Ursache. Ein Anker der Ressource einer Abbildung nennt einen Beschriftungsstil, und in dieser Sprache steht dort kein ::label der Abbildung: Dieser Ausgabe fehlt das Wort, für das die Karte Platz hat. Ein Hinweis, kein Fehler; Anker ohne Stil (etwa die von Comicfiguren) werden nicht gemeldet.

Lösung. Schreiben Sie die Beschriftung in das Markdown dieser Sprache (::label[…]{at="anker"}), oder nehmen Sie dem Anker den Stil, wenn diese Ausgabe das Wort weglässt. Doku →

  • Der Alternativtext jeder Karte steht in der Ressource (ALT), eine Zeichenkette je Ausgabe: Eine Ressource übernimmt keine Wörter aus dem Markdown, daher ist er der einzige Text der Seite außerhalb von content.<lang>.md.
  • Auf einer Seite von rechts nach links folgen die Rahmen dem gespiegelten Satzspiegel, die Dekoration der Vorlage aber nicht: Platzieren Sie die Dachzeile je Ausgabe, wie es kicker tut.
  • Ein zweispaltiger Rahmen kann einen Absatz eine Zeile vor seinem Ende trennen und diese Zeile oben in die zweite Spalte stellen. Prüfen Sie die Spalten jeder Ausgabe und verschieben Sie dann einen Satz in den nächsten Absatz.

Nachweise

Text
Eigener Text, CC BY 4.0
Schriften
Source Sans 3 (SIL OFL 1.1) · Source Serif 4 (SIL OFL 1.1) · Noto Naskh Arabic (SIL OFL 1.1) · Noto Kufi Arabic (SIL OFL 1.1)
Sandbox