Zum Hauptinhalt springen
Rezept Nummer 8

Rezepte · Kapitel 6 · Kästen und Anmerkungen

Eine farbcodierte Familie von Lehrbuchkästen

Sechs Arten von Lehrbuchkästen in drei Farben, unterschieden durch Streifen mit Icon, Badge, nummerierten Reiter, Piktogrammleiste oder Marke neben dem Rahmen.

Auf dieser Seite

S. 28–29 · 2–3 von 3

  • Englisches Beispiel: noch keine deutsche Ausgabe
  • Endformat 210 × 280 mm
  • 2 Spalten, 8 mm Spaltenabstand
  • Noto Serif 9,4/13,4
  • Barlow Semi Condensed
  • Lexend
  • 3 Seiten
  • Stufe
  • Postext 1.31.0
  • In 118 ms gesetzt
  • 179 Zeilen Code

Kurz gesagt

Ein Kapitel aus einem Biologielehrbuch mit sechs Arten von Kästen, etwa Warnungen und Laborhinweisen. Jede Art hat ihre eigene Farbe und ihr eigenes Symbol, sodass Schüler sie auf einen Blick unterscheiden.

Was Sie setzen

Kapitel 2 von Living Matter, einem Biologielehrbuch für die Oberstufe auf einer Seite von 210 × 280 mm, beginnt unter der Zeichnung einer Zelle, die von der oberen Ecke der Seite angeschnitten wird, mit der Kapitelnummer in ihrem Kern. Es folgen sechs Arten von Kästen in drei Farben, jede mit einem eigenen Merkmal, sodass Schüler ein Lernziel von einer Warnung unterscheiden, bevor sie lesen. Magenta kennzeichnet die Lernziele, gesetzt auf einen Streifen mit einer Zielscheibe, und den Selbsttest, der eine abgehakte Kachel außerhalb seines Rahmens hat. Grün kennzeichnet den Lerntipp, gesetzt neben eine dünne Linie mit einem Glühbirnen-Badge obenauf, und den Laborkasten mit seinen drei Sicherheitspiktogrammen. Die Warnung ist bernsteinfarben, mit einem Warn-Badge an ihrer äußeren Ecke. BOX 2.1 ist lavendelfarben unter einem schwarzen Reiter und einem Kolben. Fette Begriffe nehmen die Farbe ihres Kastens an, in BOX 2.1 Magenta.

Dieses Rezept beantwortet

  • Wie erstelle ich Hinweis-, Tipp- oder Warnkästen mit Symbol, farbigem Streifen und abgerundeten Ecken?
  • Wie füge ich einen nummerierten Reiter („BOX 1-1“), ein Ecksymbol oder ein Randsymbol mit Linie hinzu?
  • Wie färbe ich Schlüsselbegriffe (fett oder kursiv) im Text oder in Kästen ein?

Die kurze Antwort

script.js · Zeilen 41–62im vollständigen Code
// box(id, hue, device) is the shared base: the hue colours the title, bullets and key terms.
const TAB = 5, BADGE = 7.4; // in mm: the tab's height (and offset), the warning badge's size
const calloutStyles = [
  // The first style is also the one a missing or misspelt type falls back to.
  box('objectives', 'band', { title: t({ en: 'In this chapter', es: 'En este capítulo' }),
    background: col('tintBand'), stripe: { enabled: true, width: mm(7.5), color: col('band') },
    icon: icon('target', 5) }), // on a side stripe (left by default) the icon is centred on it
  box('feature', 'band', { background: col('mist'),
    titleStyle: { fontFamily: 'Lexend', fontSize: pt(10.5), color: col('ink'), gap: mm(1.6) },
    // Printed only where the fence has label="…": the tab, the flask beside it, a rule to them.
    label: { fontFamily: LABEL, fontSize: pt(8), color: col('paper'), background: col('ink'),
      position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default)
      height: mm(TAB), offset: mm(TAB), paddingX: mm(2.3), // offset = height: on the top edge
      icon: { resourceId: 'flask', width: mm(4.4), gap: mm(1.2) },
      rule: { enabled: true, color: col('ink'), width: pt(1.2) } } }),
  box('warning', 'warnInk', { background: col('tintWarn'), borderRadius: mm(1.4),
    border: { enabled: true, color: col('warn'), width: pt(0.75) },
    // The badge hangs half past the outer corner: sides of half badge + GAP align the title.
    padding: { top: mm(2.8), right: mm(BADGE / 2 + GAP), bottom: mm(3.2),
      left: mm(BADGE / 2 + GAP) },
    icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }),
]; // straight into config(), followed by the 'more' styles

Zutaten

Schrift
Noto Serif, Lexend, Barlow Semi Condensed (SIL OFL 1.1)
Dateien
Keine: Jedes Bild wird im Code gezeichnet

Zubereitung

#1 · Jeder Kastenart eine Farbe geben

script.js · Zeilen 14–22im vollständigen Code
const palette = { ink: '#1f2430', paper: '#ffffff', // text; type on stripes and tabs
  band: '#ab2e78', tintBand: '#fae3ef', // the chapter's magenta: opener, objectives, self-check
  tip: '#25734a', tintTip: '#e2f0e7', // study tips and lab work
  warn: '#e39422', tintWarn: '#fcefd8', warnInk: '#94540a', // frame and badge; tint; its type
  mist: '#e8e3f0', rule: '#dccfd8', muted: '#675e66' }; // feature box; lines in drawings; heads
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the band, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

Sechs Kastenarten teilen sich drei Farbtöne: Magenta für Lernziele und Selbsttest, Grün für Lerntipp und Labor, Bernstein für die Warnung. BOX 2.1 hat eine lavendelfarbene Füllung und verwendet Magenta für ihre Schlüsselbegriffe. Jeder Farbton hat eine blasse Tönung für Füllungen, und jede Farbeinstellung verweist auf einen Eintrag der Palette, sodass ein anderer Farbton nur einen Wert ändert. Schrift in dieser kleinen Größe braucht einen Kontrast von 4,5:1. Magenta auf Lavendel erreicht 4,9:1, Bernstein auf der Füllung der Warnung aber nur 2,2:1, daher behält die Warnung Bernstein für Rahmen und Badge und setzt Titel und Schlüsselbegriffe in warnInk, einem dunkleren Bernstein mit 5,2:1.

#2 · Alles andere teilen

script.js · Zeilen 28–37im vollständigen Code
const box = (id, hue, device) => ({ id, marginTop: pt(LEAD), marginBottom: pt(LEAD / 2),
  padding: { top: mm(2.8), right: mm(3.4), bottom: mm(3.2), left: mm(3.4) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), color: col(hue), // bold by default
    textTransform: 'uppercase', letterSpacing: pt(1.4), gap: mm(GAP) },
  body: { fontFamily: LABEL, fontSize: pt(9.6), lineHeight: pt(12.6), color: col('ink'),
    boldColor: col(hue), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  lists: { color: col(hue), gap: mm(2.2), itemSpacing: pt(2.4) }, // bullets and numbers
  ...device }); // the kind's title, fill and device: its spread wins over the base
// An SVG or bitmap resource, fitted into a square of that size (width × size with `width`).
const icon = (id, size, more) => ({ kind: 'resource', resourceId: id, size: mm(size), ...more });

box() enthält die Einstellungen, die alle Kästen teilen: Abstände, Innenabstand, Schrift für Titel und Inhalt, Listenabstände. Sein Argument hue färbt den Titel, die Aufzählungszeichen und Nummern sowie die fetten Passagen. body.boldColor färbt fluid mosaic model in den Lernzielen magenta und cell wall in der Warnung bernsteinbraun; Fettes im laufenden Text bleibt schwarz (Typografie im Kasten). Die Angabe von calloutStyles ersetzt den einzigen Stil, den die Engine mitbringt, einen grauen Kasten namens note. Eine Direktive, deren type fehlt oder falsch geschrieben ist, nimmt den ersten Stil, daher beginnt die Liste mit den Lernzielen, die jedes Kapitel hat.

#3 · Streifen, Reiter und Badge

Den Code finden Sie oben unter Die kurze Antwort. Der Streifen der Lernziele ist 7,5 mm breit und seine Zielscheibe 5 mm, sodass das Icon mit Luft zu beiden Seiten in der Farbe sitzt. Das offset des Reiters entspricht seiner height, was den Reiter auf die Oberkante des Kastens stellt, frei vom Titel. 'top-left' ist die äußere Ecke dieser linken Seite; der Kolben folgt dem Reiter, und die Linie läuft entlang der Oberkante vom Kolben bis zur gegenüberliegenden Ecke. position: 'corner' hängt das Badge der Warnung um seine halbe Breite über den Rand hinaus, und cornerSide: 'outer' schickt es zur Seite des Außenrands, auf dieser linken Seite nach links. Auf dieser Seite rückt die Engine den Titel ein, um am Badge vorbeizukommen, sofern der Innenabstand nicht schon das halbe Badge plus den Titelabstand abdeckt; daher polstert die Warnung beide Seiten mit BADGE / 2 + GAP, und ihr Titel fluchtet mit ihrem Text. Nur dieser Kasten hat einen borderRadius: Er ist der einzige Kasten mit Rahmen, und ein Radius von 1,4 mm macht seine Ecken weicher. Ein Radius an einem Kasten mit Streifen würde die äußeren Ecken des Streifens mitrunden (Kastenstile).

#4 · Drei weitere Merkmale

script.js · Zeilen 66–79im vollständigen Code
const BULB = 6.4; // the study tip's badge in mm: the text clears it
const moreStyles = [
  box('tip', 'tip', { title: t({ en: 'Study tip', es: 'Para recordar' }),
    backgroundEnabled: false, stripe: { enabled: true, width: pt(2.5), color: col('tip') },
    // A stripe narrower than its icon: the badge sits on it at the top and hides it there.
    padding: { top: mm(0.6), right: mm(0), bottom: mm(0.6), left: mm(BULB / 2 + 2) },
    icon: icon('bulb', BULB) }),
  box('safety', 'tip', { background: col('tintTip'), marginTop: pt(4), // under its heading
    icon: icon('safety', 7, { width: mm(24.5), align: 'center' }) }), // 3 pictograms, 1 image
  box('check', 'band', { background: col('tintBand'),
    // Outside the frame: [marker][rule][gap][box], the rule as tall as the box.
    marker: { kind: 'resource', resourceId: 'check', size: mm(7), align: 'top', gap: mm(3),
      rule: { enabled: true, color: col('band'), width: pt(0.75) } } }),
];

Der Streifen des Lerntipps ist 2,5 pt breit gegenüber einem Glühbirnen-Badge von 6,4 mm, sodass er als dünne Linie mit dem Badge über ihrem oberen Ende druckt; ein linker Innenabstand von einem halben Badge plus 2 mm hält den Text von der Glühbirne fern. icon.width passt ein Bild in einen breiten statt eines quadratischen Rahmens ein, sodass die drei Sicherheitspiktogramme, als ein Bild von 24,5 mm gezeichnet, die Icon-Spalte des Laborkastens bilden, auf seinen Text zentriert. Die Marke des Selbsttests sitzt außerhalb des Rahmens, in der Reihenfolge Marke, Linie, Abstand, Kasten. Ihre quadratische Kachel auf einer Linie von 0,75 pt unterscheidet sie vom Lerntipp, dessen Badge rund ist. Die Direktive fügt span="page" placement="bottom" hinzu, um den Selbsttest an den Fuß von Seite 29 gleiten zu lassen.

#5 · Icons sind Ressourcen

script.js · Zeilen 240–265im vollständigen Code
const svgResource = (id, width, height, extra) => ({ id, typeId: 'figure', kind: 'svg',
  svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0, ...extra });
const pageTop = { position: 'top', span: 'page' }; // a 'top' float opens the page after its :ref
const resources = [
  // Uncited, so never placed as figures: the box styles and the opener use them by id.
  ...['target', 'bulb', 'caution', 'check'].map((id) => svgResource(id, 240, 240)),
  svgResource('flask', 200, 240), svgResource('safety', 760, 240), svgResource('cell', 2000, 2000),
  svgResource('mosaic', 3480, 1240, { placement: pageTop, caption: t({
    en: 'The fluid mosaic: magenta phospholipids, green proteins, an amber carrier with its '
      + 'glucose, grey cholesterol, amber sugars.', // one line: the page is wide
    es: 'El mosaico fluido: fosfolípidos magenta, proteínas verdes, transportadora ámbar con su '
      + 'glucosa, colesterol gris, azúcares ámbar.' }),
    altText: t({ en: 'A cell membrane in section', es: 'Una membrana celular en sección' }) }),
  svgResource('fusion', 1800, 500, { placement: { position: 'here' }, caption: t({
    en: 'Mouse proteins in green, human proteins in magenta (the red dye): the two cells, the '
      + 'hybrid just after fusion and the same hybrid 40 minutes later.',
    es: 'En verde, las proteínas de ratón; en magenta, las humanas (el colorante rojo): las dos '
      + 'células, el híbrido recién fusionado y el mismo híbrido 40 minutos después.' }),
    altText: t({ en: 'Two cells fusing into a hybrid', es: 'Dos células que se fusionan' }) }),
  svgResource('osmosis', 3480, 860, { placement: pageTop, caption: t({
    en: 'Red blood cells in a hypertonic, an isotonic and a hypotonic solution. Arrows show the '
      + 'net flow of water.',
    es: 'Glóbulos rojos en una disolución hipertónica, una isotónica y una hipotónica. Las flechas '
      + 'indican el flujo neto de agua.' }),
    altText: t({ en: 'Blood cells in three solutions', es: 'Glóbulos rojos en tres medios' }) }),
];

Jedes Icon, der Kolben neben dem Reiter und die Zelle des Auftakts sind SVG-Ressourcen, mit dem Inhalt angegeben und per Datei-ID registriert. Ein Stil nennt sie per Ressourcen-ID, und weil der Text sie nie zitiert, wird keine davon zu einer nummerierten Abbildung. Zeichen wie ✓ oder ☞ wären kürzer zu schreiben, aber die lateinischen Fontsource-Dateien, die die Seiten laden, haben sie nicht: Der Canvas würde sie aus einer Systemschrift borgen, und ein PDF ließe sie weg. Auch die Zeichnungen tragen keine Wörter: Die Legenden nennen die Farben, sodass eine Zeichnung beiden Ausgaben dient.

#6 · Das Kapitel mit einer Zelle eröffnen

script.js · Zeilen 83–102im vollständigen Code
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  align: 'left', ...extra }); // an opener's texts wrap by default
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, size: { width: mm(width) } });
const chapter = t({ en: 'Chapter {chapterNumber}', es: 'Capítulo {chapterNumber}' });
const opener = { enabled: true, minHeight: mm(66), slot: { elements: [
  { kind: 'image', id: 'cell', resourceId: 'cell', placement: { anchor: { to: 'page',
    edge: 'top-right' }, offset: { x: mm(58), y: mm(-71) }, size: { width: mm(150) } } },
  text('numeral', '{chapterNumber}', 'Lexend', 118, 'paper', { anchor: { to: 'page',
    edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(14) } },
    { fontWeight: 800, lineHeight: 1, align: 'right' }),
  text('kicker', `${chapter} · {attr.unit}`, LABEL, 9, 'band', { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(3) } },
    { fontWeight: 700, letterSpacing: pt(1.8), textTransform: 'uppercase' }),
  text('title', '{titleText}', 'Lexend', 34, 'ink', below('kicker', 2.5, 112),
    { fontWeight: 700, lineHeight: 1.04 }),
  text('lead', '{attr.lead}', 'Noto Serif', 10.5, 'ink', below('title', 5, 98),
    { italic: true, lineHeight: 1.42 }),
] } };

Die Zelle ist ein Bildelement, an der oberen rechten Ecke der Seite verankert und über sie hinausgeschoben, sodass vor allem ihr unteres linkes Viertel zu sehen ist, und die Ziffer sitzt in ihrem Kern. Beide bleiben innerhalb der 66 mm, die minHeight für den Auftakt reserviert. Dachzeile und Vorspann kommen aus der Zeile der Überschrift, # Cells and \\ Membranes {unit="The cell" lead="…"}, und das \\ bricht den Titel nur im Auftakt um.

Das ganze Rezept

Sandbox
// ═══ Postext Cookbook · Nº 008 · A family of textbook boxes ═══════════════════════
// https://postext.dev/en/cookbook/textbook-box-family
// Code: MIT · Text: original (CC BY 4.0) · Drawings and icons: generated in code (CC BY 4.0)
// Fonts: Noto Serif, Lexend, Barlow Semi Condensed (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'textbook-box-family';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: three hues for six kinds of box; everything links to an entry
const palette = { ink: '#1f2430', paper: '#ffffff', // text; type on stripes and tabs
  band: '#ab2e78', tintBand: '#fae3ef', // the chapter's magenta: opener, objectives, self-check
  tip: '#25734a', tintTip: '#e2f0e7', // study tips and lab work
  warn: '#e39422', tintWarn: '#fcefd8', warnInk: '#94540a', // frame and badge; tint; its type
  mist: '#e8e3f0', rule: '#dccfd8', muted: '#675e66' }; // feature box; lines in drawings; heads
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the band, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
// Label face (box text and titles, tabs, captions, heads); leading in pt; outer margin in mm
const LABEL = 'Barlow Semi Condensed', LEAD = 13.4, OUTER = 16, GAP = 1.5; // GAP: title gap, mm

// #region base: what every box shares; each kind brings its colour and its one device
const box = (id, hue, device) => ({ id, marginTop: pt(LEAD), marginBottom: pt(LEAD / 2),
  padding: { top: mm(2.8), right: mm(3.4), bottom: mm(3.2), left: mm(3.4) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), color: col(hue), // bold by default
    textTransform: 'uppercase', letterSpacing: pt(1.4), gap: mm(GAP) },
  body: { fontFamily: LABEL, fontSize: pt(9.6), lineHeight: pt(12.6), color: col('ink'),
    boldColor: col(hue), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  lists: { color: col(hue), gap: mm(2.2), itemSpacing: pt(2.4) }, // bullets and numbers
  ...device }); // the kind's title, fill and device: its spread wins over the base
// An SVG or bitmap resource, fitted into a square of that size (width × size with `width`).
const icon = (id, size, more) => ({ kind: 'resource', resourceId: id, size: mm(size), ...more });
// #endregion

// #region answer: three devices: an icon on a wide stripe, a numbered tab, an outer badge
// box(id, hue, device) is the shared base: the hue colours the title, bullets and key terms.
const TAB = 5, BADGE = 7.4; // in mm: the tab's height (and offset), the warning badge's size
const calloutStyles = [
  // The first style is also the one a missing or misspelt type falls back to.
  box('objectives', 'band', { title: t({ en: 'In this chapter', es: 'En este capítulo' }),
    background: col('tintBand'), stripe: { enabled: true, width: mm(7.5), color: col('band') },
    icon: icon('target', 5) }), // on a side stripe (left by default) the icon is centred on it
  box('feature', 'band', { background: col('mist'),
    titleStyle: { fontFamily: 'Lexend', fontSize: pt(10.5), color: col('ink'), gap: mm(1.6) },
    // Printed only where the fence has label="…": the tab, the flask beside it, a rule to them.
    label: { fontFamily: LABEL, fontSize: pt(8), color: col('paper'), background: col('ink'),
      position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default)
      height: mm(TAB), offset: mm(TAB), paddingX: mm(2.3), // offset = height: on the top edge
      icon: { resourceId: 'flask', width: mm(4.4), gap: mm(1.2) },
      rule: { enabled: true, color: col('ink'), width: pt(1.2) } } }),
  box('warning', 'warnInk', { background: col('tintWarn'), borderRadius: mm(1.4),
    border: { enabled: true, color: col('warn'), width: pt(0.75) },
    // The badge hangs half past the outer corner: sides of half badge + GAP align the title.
    padding: { top: mm(2.8), right: mm(BADGE / 2 + GAP), bottom: mm(3.2),
      left: mm(BADGE / 2 + GAP) },
    icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }),
]; // straight into config(), followed by the 'more' styles
// #endregion

// #region more: a badge threaded on a thin rule, a strip of pictograms, a marker outside
const BULB = 6.4; // the study tip's badge in mm: the text clears it
const moreStyles = [
  box('tip', 'tip', { title: t({ en: 'Study tip', es: 'Para recordar' }),
    backgroundEnabled: false, stripe: { enabled: true, width: pt(2.5), color: col('tip') },
    // A stripe narrower than its icon: the badge sits on it at the top and hides it there.
    padding: { top: mm(0.6), right: mm(0), bottom: mm(0.6), left: mm(BULB / 2 + 2) },
    icon: icon('bulb', BULB) }),
  box('safety', 'tip', { background: col('tintTip'), marginTop: pt(4), // under its heading
    icon: icon('safety', 7, { width: mm(24.5), align: 'center' }) }), // 3 pictograms, 1 image
  box('check', 'band', { background: col('tintBand'),
    // Outside the frame: [marker][rule][gap][box], the rule as tall as the box.
    marker: { kind: 'resource', resourceId: 'check', size: mm(7), align: 'top', gap: mm(3),
      rule: { enabled: true, color: col('band'), width: pt(0.75) } } }),
];
// #endregion

// #region opener: a cell cut by the corner of the page, the chapter number inside it
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  align: 'left', ...extra }); // an opener's texts wrap by default
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, size: { width: mm(width) } });
const chapter = t({ en: 'Chapter {chapterNumber}', es: 'Capítulo {chapterNumber}' });
const opener = { enabled: true, minHeight: mm(66), slot: { elements: [
  { kind: 'image', id: 'cell', resourceId: 'cell', placement: { anchor: { to: 'page',
    edge: 'top-right' }, offset: { x: mm(58), y: mm(-71) }, size: { width: mm(150) } } },
  text('numeral', '{chapterNumber}', 'Lexend', 118, 'paper', { anchor: { to: 'page',
    edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(14) } },
    { fontWeight: 800, lineHeight: 1, align: 'right' }),
  text('kicker', `${chapter} · {attr.unit}`, LABEL, 9, 'band', { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(3) } },
    { fontWeight: 700, letterSpacing: pt(1.8), textTransform: 'uppercase' }),
  text('title', '{titleText}', 'Lexend', 34, 'ink', below('kicker', 2.5, 112),
    { fontWeight: 700, lineHeight: 1.04 }),
  text('lead', '{attr.lead}', 'Noto Serif', 10.5, 'ink', below('title', 5, 98),
    { italic: true, lineHeight: 1.42 }),
] } };
// #endregion

// Heads 12.8 mm below the trim (the larger folio 0.4 mm higher), titles 8.5 mm in from folios.
const head = (id, content, parity, edge, x, extra, y = 12.8) => ({ kind: 'text', id, content,
  parity, pages: 'body', fontFamily: LABEL, fontSize: pt(7.8), fontWeight: 600,
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } } });
const folio = { fontFamily: 'Lexend', fontSize: pt(9), fontWeight: 700, color: col('band') };
const header = { elements: [ // folios on the fore-edge, never on the opener
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio, 12.4),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8.5),
  head('recto-title', `${chapter} · {chapterTitle}`, 'odd', 'top-right', -(OUTER + 8.5)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio, 12.4),
] }; // the opener's folio drops to the foot:
const footer = { elements: [{ ...head('drop', '{pageNumber}', 'all', 'bottom', 0, folio, -12),
  pages: 'opener' }] };

const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }), // hyphenation, and "Figura" in the Spanish captions
  colorPalette, header, footer,
  page: { width: mm(210), height: mm(280), dpi: 150, margins: { top: mm(24), bottom: mm(22),
    left: mm(20), right: mm(OUTER), mirror: true } }, // left is the inner margin
  layout: { layoutType: 'double', gutterWidth: mm(8) },
  bodyText: { fontFamily: 'Noto Serif', fontSize: pt(9.4), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('band'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false }, // hyphens: default
  headings: { fontFamily: 'Lexend', color: col('band'), levels: [
    // 'odd': a chapter opens on a recto, with a blank verso before it only when needed.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
    { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}',
      marginTop: pt(LEAD * 1.4), marginBottom: pt(LEAD * 0.6) }, // 3 lines, close to its text
    { level: 3, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('tip'),
      marginTop: pt(LEAD), marginBottom: pt(0) }, // a line clear of the text, on its box
  ] },
  // Every list here is in a box, whose lists.color sets its bullets and its numbers alike.
  unorderedLists: { marginTop: pt(0), marginBottom: pt(0) },
  orderedLists: { fontFamily: 'Lexend', color: col('tip'), marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [...calloutStyles, ...moreStyles],
  captionStyle: { fontFamily: LABEL, fontSize: pt(8.8), color: col('ink'),
    labelColor: col('band'), gap: mm(1.8) },
  paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.6), lineHeight: pt(10),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown-Beispiel · 87 Zeilen · content.en.mdtitle: "Living Matter" subtitle: "Biology for upper secondary school" --- # Cells and \\ Membranes {unit="The cell" lead="Every living cell is wrapped in a film ten thousand times thinner than a sheet of paper. Oxygen passes through it freely; salts and sugars need a protein to cross."} A cell is a crowded, watery workshop. Its enzymes need salts, sugars and building blocks close at hand, and its waste has to leave before it builds up. The **plasma membrane**, a layer about eight nanometres thick, separates that workshop from the world outside. In this chapter you will see what the membrane is made of and why it behaves more like a liquid than a wall. Some substances cross it at no cost to the cell; others cross only when the cell spends energy. :::callout{type="objectives"} - Use the **fluid mosaic model** to describe the plasma membrane. - Explain how **diffusion** and **osmosis** move substances without energy. - Predict how a cell changes in a hypertonic, an isotonic and a hypotonic solution. - Compare **active transport** with passive transport. ::: ## A boundary with doors Membranes are built mostly from **phospholipids**. Each molecule has a head that is attracted to water and two fatty tails that avoid it. In water, phospholipids arrange themselves so that the tails are hidden: they form a double layer, the **lipid bilayer**, with the heads facing the watery fluid on both sides and the tails meeting in the middle (:ref{id="mosaic"}). Because its core is oily, the bilayer lets small, neutral molecules such as oxygen and carbon dioxide slip straight across. Ions and larger polar molecules, such as glucose, are turned away. Water, although polar, is small enough to trickle through slowly on its own, and many cells speed it up with water channels, the aquaporins. Letting some substances through and not others is called **selective permeability**. It lets a cell keep an inside that differs from its surroundings, and it is why ions and sugars cross through proteins set in the membrane. :::callout{type="tip"} Read the name of the model as a description. **Fluid** means that the lipids drift sideways and swap places with their neighbours millions of times a second, and **mosaic** means that proteins are set into the layer like tiles in a floor. ::: ## The fluid mosaic In 1972 S. Jonathan Singer and Garth Nicolson proposed the model that is still used today. The bilayer is the fluid base, and proteins float in it: some span the whole membrane, others rest on one of its faces. **Channel proteins** form water-filled pores for particular ions, and **carrier proteins** change shape to move a molecule from one side to the other. Chains of sugars fixed to proteins and lipids face the outside of the cell and work as identity tags that other cells recognise. Cholesterol, tucked between the phospholipid tails, acts as a buffer. When it is warm, cholesterol restrains the tails and keeps the membrane from becoming runny; when it is cold, it keeps them from packing tightly and turning stiff. Living things tune their membranes to their temperature: bacteria grown in the cold build lipids with more kinks in their tails, which keeps the layer fluid, and many plants do the same as winter comes, as do the fish that live in the icy seas around Antarctica. :::callout{type="feature" label="BOX 2.1" title="Watching proteins drift" span="page"} :::columns{count=2 breaks="2"} ::resource{id="fusion"} In 1970 Larry Frye and Michael Edidin fused a mouse cell with a human cell. They had tagged the mouse membrane proteins with a green fluorescent dye and the human ones with a red dye. At first each colour kept to its own half of the hybrid cell. Forty minutes later, at 37 °C, the colours were completely mixed (:ref{id="fusion"}). Kept cold, the hybrids mixed far more slowly. The proteins were **drifting** on their own through a fluid layer that the cold made stiffer. ::: ::: :::callout{type="warning" title="Membrane or wall?"} Plants, fungi and most bacteria also have a **cell wall** outside the membrane. The wall gives the cell its shape and keeps it from bursting, but it lets almost everything through. Only the membrane is selectively permeable. ::: The lipids and many of the proteins in a membrane keep moving, yet the layer holds together, because wherever a lipid drifts, its tails stay hidden from the water. For the same reason, a small hole in a bilayer closes up again on its own. ## Crossing the membrane Particles in a solution never stop moving. **Diffusion** is their net movement from where they are more concentrated to where they are less, until they are evenly spread. It needs no energy from the cell, because the particles’ own motion does the work: oxygen diffuses into a cell that keeps using it up, and carbon dioxide diffuses out. **Osmosis** is the diffusion of water across a selectively permeable membrane. If the solution outside a cell holds more dissolved particles than the cytoplasm, it is **hypertonic**, and the cell loses water and shrinks; if it holds fewer, it is **hypotonic**, and water floods in; if the two are equal, it is **isotonic**, and the cell keeps its size (:ref{id="osmosis"}). Red blood cells in pure water swell until they burst. Plant cells do not burst: the wall holds them firm, which is why a watered plant stands upright and a dry one wilts. Diffusion is quick over short distances and slow over long ones. An oxygen molecule crosses a cell ten micrometres across in about a fortieth of a second, yet it would need some seven hours to diffuse one centimetre through water. That is one reason why cells stay small, and why large animals need lungs, gills and blood to carry oxygen the rest of the way. Transport down the gradient through a channel or a carrier protein is called **facilitated diffusion**, and it is still passive: the protein opens a way, but the cell spends no energy on it. To move a substance uphill, from low concentration to high, a cell has to spend energy, usually as ATP, and that is **active transport**. The sodium–potassium pump in your nerve cells pushes three sodium ions out and two potassium ions in for every ATP it uses, and pumps like it take a large share of a resting cell’s energy. :::callout{type="check" title="Check yourself" span="page" placement="bottom"} :::columns{count=2 breaks="4"} 1. Why can oxygen cross the bilayer but glucose cannot? 2. What does **fluid** mean in the fluid mosaic model? 3. A slice of cucumber is left in salty water. Use **osmosis** to predict what happens to its cells. 4. Why does the sodium–potassium pump count as **active transport** and not as diffusion? 5. Could a cell with a rigid membrane use **endocytosis** to take in a bacterium? Explain. ::: ::: ## Moving in bulk Some cargo is far too large for any channel or carrier. A white blood cell swallows a whole bacterium by wrapping its membrane around it and pinching off a bubble, a **vesicle**, into the cytoplasm: this is **endocytosis**. Cells drink the same way: in pinocytosis the membrane folds in around a droplet of the fluid outside, with whatever is dissolved in it. The reverse, **exocytosis**, carries material out. Vesicles filled with hormones, digestive enzymes or the mucus that lines your airways fuse with the plasma membrane and empty their contents outside. Most signals that a nerve cell passes to the next leave it the same way: vesicles release messenger molecules, the neurotransmitters, into the narrow gap between the two cells. Both processes cost energy, and both work only because the bilayer is fluid enough to break and reseal without leaking. Membranes do not stop at the surface of the cell. Inside it, the same bilayer wraps the nucleus, the mitochondria and a maze of inner compartments, each keeping its own chemistry apart from the rest. In Chapter 3 you will follow a protein that the cell exports, from the ribosome that makes it, through the endoplasmic reticulum and the Golgi apparatus, to the vesicle that releases it outside. ### Try it: osmosis in a potato :::callout{type="safety" title="Lab safety"} Wear safety goggles and gloves, and always cut away from your fingers. ::: 1. Cut two potato strips of the same size, about five centimetres long, and weigh them. 2. Leave one in tap water and the other in strongly salted water for thirty minutes. 3. Dry both strips, weigh them again and bend them. 4. Explain the change in mass and in stiffness with what you know about osmosis. :::paragraphs{style="colophon"} **Living Matter** is a textbook invented for the Postext Cookbook. Text and drawings: original, CC BY 4.0. Set in Noto Serif, Lexend and Barlow Semi Condensed (SIL Open Font License). :::
`; // content.<lang>.md, inlined by the Cookbook // #region icons: icons are resources too: declared with the content, registered by file id const svgResource = (id, width, height, extra) => ({ id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0, ...extra }); const pageTop = { position: 'top', span: 'page' }; // a 'top' float opens the page after its :ref const resources = [ // Uncited, so never placed as figures: the box styles and the opener use them by id. ...['target', 'bulb', 'caution', 'check'].map((id) => svgResource(id, 240, 240)), svgResource('flask', 200, 240), svgResource('safety', 760, 240), svgResource('cell', 2000, 2000), svgResource('mosaic', 3480, 1240, { placement: pageTop, caption: t({ en: 'The fluid mosaic: magenta phospholipids, green proteins, an amber carrier with its ' + 'glucose, grey cholesterol, amber sugars.', // one line: the page is wide es: 'El mosaico fluido: fosfolípidos magenta, proteínas verdes, transportadora ámbar con su ' + 'glucosa, colesterol gris, azúcares ámbar.' }), altText: t({ en: 'A cell membrane in section', es: 'Una membrana celular en sección' }) }), svgResource('fusion', 1800, 500, { placement: { position: 'here' }, caption: t({ en: 'Mouse proteins in green, human proteins in magenta (the red dye): the two cells, the ' + 'hybrid just after fusion and the same hybrid 40 minutes later.', es: 'En verde, las proteínas de ratón; en magenta, las humanas (el colorante rojo): las dos ' + 'células, el híbrido recién fusionado y el mismo híbrido 40 minutos después.' }), altText: t({ en: 'Two cells fusing into a hybrid', es: 'Dos células que se fusionan' }) }), svgResource('osmosis', 3480, 860, { placement: pageTop, caption: t({ en: 'Red blood cells in a hypertonic, an isotonic and a hypotonic solution. Arrows show the ' + 'net flow of water.', es: 'Glóbulos rojos en una disolución hipertónica, una isotónica y una hipotónica. Las flechas ' + 'indican el flujo neto de agua.' }), altText: t({ en: 'Blood cells in three solutions', es: 'Glóbulos rojos en tres medios' }) }), ]; // #endregion // #region art: the icons and the drawings, in the palette's colours (seeded) // No words in them: the captions name the colours, so one drawing serves both editions. function rng(seed) { // Mulberry32: the same drawing on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let x = Math.imul(seed ^ (seed >>> 15), 1 | seed); x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; return ((x ^ (x >>> 14)) >>> 0) / 4294967296; }; } const n = (v) => +v.toFixed(2); const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const dot = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" ` + `fill="${fill}"${extra}/>`; const line = (d, stroke, width, extra = '') => `<path d="${d}" fill="none" stroke="${stroke}" ` + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`; const shape = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const capsule = (x, y, w, h, fill, turn = 0, extra = '') => `<rect x="${n(x - w / 2)}" ` + `y="${n(y - h / 2)}" width="${n(w)}" height="${n(h)}" rx="${n(Math.min(w, h) / 2)}" ` + `fill="${fill}" transform="rotate(${n(turn)} ${n(x)} ${n(y)})"${extra}/>`; const arrow = (x, y, len, turn, fill) => shape(`M${x} ${y - 0.9}h${len - 4}v-1.9l4 2.8-4 2.8` + `v-1.9H${x}Z`, fill, ` transform="rotate(${turn} ${x} ${y})"`); // a path, never a marker function target() { // white rings on the magenta stripe return svg(24, 24, `<g fill="none" stroke="${palette.paper}" stroke-width="2.3">` + `<circle cx="12" cy="12" r="9.8"/><circle cx="12" cy="12" r="5.3"/></g>` + dot(12, 12, 1.9, palette.paper)); } function bulb() { return svg(24, 24, dot(12, 12, 12, palette.tip) + shape('M12 4.4a5.6 5.6 0 0 0-3.3 10.1c.7.5 1 ' + '1.1 1 1.9v.6h4.6v-.6c0-.8.3-1.4 1-1.9A5.6 5.6 0 0 0 12 4.4Z', palette.paper) + capsule(12, 18.6, 4.6, 1.3, palette.paper) + capsule(12, 20.3, 3, 1.2, palette.paper)); } function flask() { const body = 'M8 2v7L2.4 19.4A2.1 2.1 0 0 0 4.3 22.5h11.4a2.1 2.1 0 0 0 1.9-3.1L12 9V2'; return svg(20, 24, shape(`${body}Z`, palette.paper) + shape('M5.3 14h9.4l3 5.4a1.1 1.1 0 0 1-1 ' + '1.6H3.3a1.1 1.1 0 0 1-1-1.6Z', palette.band) + dot(8.6, 17.4, 1.1, palette.paper) + dot(11.8, 18.7, 0.8, palette.paper) + line(body, palette.ink, 1.7) + line('M6.4 2h7.2', palette.ink, 1.7)); } function caution() { // ink on amber: white on amber would fail contrast return svg(24, 24, dot(12, 12, 11, palette.warn, ` stroke="${palette.paper}" stroke-width="2"`) + capsule(12, 10, 2.8, 9.2, palette.ink) + dot(12, 17.6, 1.7, palette.ink)); } function check() { // a ticked tile: square where the study tip's badge is round return svg(24, 24, `<rect width="24" height="24" rx="5.5" fill="${palette.band}"/>` + line('M6.3 12.6l3.8 3.8 7.6-8', palette.paper, 2.9)); } function safety() { // goggles, a glove and a blade: three mandatory-action discs const goggles = `<g fill="none" stroke="${palette.paper}" stroke-width="1.5">` + '<rect x="4.3" y="9" width="6.6" height="5.6" rx="2.4"/>' + '<rect x="13.1" y="9" width="6.6" height="5.6" rx="2.4"/></g>' + line('M10.9 11.4q1.1-1 2.2 0M2.6 11.6h1.7M19.7 11.6h1.7', palette.paper, 1.4); const glove = shape('M32.4 20.5v-6.6l-2.3-2.7a1.2 1.2 0 0 1 1.8-1.6l1.4 1.5V6.2a1.1 1.1 0 0 1 ' + '2.2 0v5.2V4.9a1.1 1.1 0 0 1 2.2 0v6.5V5.5a1.1 1.1 0 0 1 2.2 0v6.2V7a1.1 1.1 0 0 1 2.2 0v8.6' + 'l-1.2 4.9Z', palette.paper); const blade = `<g transform="rotate(-45 64 12)">${capsule(64, 16.3, 3.6, 9, palette.paper)}` + shape('M62.2 11.4V6.3L64 3.2l1.8 3.1v5.1Z', palette.paper) + '</g>'; return svg(76, 24, [12, 38, 64].map((x) => dot(x, 12, 11.4, palette.ink)).join('') + goggles + glove + blade); } function blob(cx, cy, rx, ry, r, fill, extra = '', square = 2.6) { // a soft, uneven shape const pts = Array.from({ length: 16 }, (_, i) => { const [c, sn] = [Math.cos((i * Math.PI) / 8), Math.sin((i * Math.PI) / 8)]; const k = 1 + (r() - 0.5) * 0.12; const f = (v) => Math.sign(v) * Math.abs(v) ** (2 / square); // squarer than an ellipse return [cx + f(c) * rx * k, cy + f(sn) * ry * k]; }); const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2].map(n); const d = pts.map((p, i) => `Q${p.map(n)} ${mid(p, pts[(i + 1) % 16])}`).join(''); return shape(`M${mid(pts[15], pts[0])}${d}Z`, fill, extra); } const shine = (cx, cy, rx, ry, r) => blob(cx - rx * 0.28, cy - ry * 0.34, rx * 0.46, ry * 0.4, r, palette.paper, ' fill-opacity=".22"'); const pale = ` stroke="${palette.tip}" stroke-width="1.6"`; // a light green protein, outlined const sugars = (x, y, r, count = 5) => { // a chain of sugars, branched once const sugar = (sx, sy) => dot(sx, sy, 2.1, palette.warn, ` stroke="${palette.paper}" stroke-width=".7"`); let out = ''; for (let k = 0; k < count; k++, y -= 4.3, x += (r() - 0.5) * 4) { out += sugar(x, y) + (k === 2 ? sugar(x + 4.3, y - 1.6) : ''); } return out; }; function cell() { // a cell drawn as a disc of bilayer: mostly its lower-left quarter shows let out = dot(100, 100, 71, palette.band); for (let a = 0; a < 360; a += 3.05) { const [c, s] = [Math.cos((a * Math.PI) / 180), Math.sin((a * Math.PI) / 180)]; const at = (d) => [100 + c * d, 100 + s * d]; const tail = (d0, d1) => line(`M${at(d0).map(n)}L${at(d1).map(n)}`, palette.rule, 0.8); out += tail(90.5, 84.8) + tail(75.8, 81.2) + dot(...at(92.6), 2.6, palette.band) + dot(...at(73.6), 2.5, palette.paper); } [106, 133, 157, 184].forEach((a, k) => { // proteins across the visible arc const rad = (a * Math.PI) / 180; const fill = k % 2 ? palette.warn : palette.tip; out += capsule(100 + Math.cos(rad) * 83, 100 + Math.sin(rad) * 83, 26, 7, fill, a); }); return svg(200, 200, out); } function mosaic() { // the membrane in section, 174 × 62 mm; the outside of the cell on top const r = rng(5); const W = 348; const yc = (x) => 70 + 3.4 * Math.sin(x / 44); const proteins = [[56, 34, palette.tip, 'channel'], [148, 38, palette.warn, 'carrier'], [234, 27, palette.tip], [306, 32, palette.tintTip, 'pale']]; const clear = (x) => proteins.every(([px, w]) => Math.abs(x - px) > w / 2 + 1.8); let out = `<rect width="${W}" height="70" fill="${palette.tintBand}"/>` + `<rect y="70" width="${W}" height="54" fill="${palette.mist}"/>`; for (let x = 4; x < W; x += 5.9) { // phospholipids: heads out, two tails in if (!clear(x)) continue; for (const s of [-1, 1]) { const y = yc(x) + s * 13; for (const dx of [-1, 1]) { out += line(`M${n(x + dx)} ${n(y - s * 2.4)}l${n(r() - 0.5)} ${-s * 4}` + `l${n(r() - 0.5)} ${-s * 4.6}`, palette.band, 0.75, ' stroke-opacity=".4"'); } out += dot(x, y, 2.75, palette.band); if (r() < 0.13 && clear(x + 3)) { // cholesterol among the tails: the only grey rods out += capsule(x + 3, y - s * 7.4, 2.1, 7.2, palette.ink, 0, ' fill-opacity=".62"'); } } } out += sugars(196, yc(196) - 16.5, r, 3); // a glycolipid for (const [px, w, fill, kind] of proteins) { // proteins across the bilayer const y = yc(px); const halves = kind === 'channel' ? [px - w / 4 - 1.3, px + w / 4 + 1.3] : [px]; for (const x of halves) { const rx = kind === 'channel' ? w / 4 : w / 2; out += blob(x, y, rx, 21, r, fill, kind === 'pale' ? pale : '') + shine(x, y, rx, 21, r); } if (kind === 'channel') { out += dot(px, y - 7, 2, palette.warnInk) + dot(px, y + 4, 2, palette.warnInk); } if (kind === 'carrier') { // a glucose held in the carrier's open mouth out += shape(`M${px - 7} ${y - 24}L${px} ${y - 12}L${px + 7} ${y - 24}Z`, palette.tintBand) + shape(`M${px - 3.6} ${y - 19.5}l1.8-3.1h3.6l1.8 3.1-1.8 3.1h-3.6Z`, palette.paper, ` stroke="${palette.ink}" stroke-width=".8"`); } else out += sugars(px + (r() - 0.5) * 5, y - 22.5, r); } out += blob(100, yc(100) + 22.5, 11, 5.5, r, palette.tintTip, pale) // proteins on one face only + blob(270, yc(270) - 22, 8.5, 5, r, palette.tip); for (let x = 6; x < W; x += 3.2) { // the cytoskeleton under the membrane out += dot(x, 111 + 2.2 * Math.sin(x / 7), 1.4, palette.muted, ' fill-opacity=".45"'); } for (let k = 0; k < 9; k++) { // oxygen outside the cell const [x, y] = [14 + r() * 320, 8 + r() * 26]; for (const dx of [0, 2.6]) out += dot(x + dx, y, 1.5, palette.ink, ' fill-opacity=".5"'); } return svg(W, 124, out); } function fusion() { // two cells, the hybrid just after fusion, the hybrid 40 minutes later const r = rng(3); const cellAt = (cx, rx, fill, nuclei, pick) => { let out = `<ellipse cx="${cx}" cy="25" rx="${rx}" ry="${Math.min(rx, 15)}" fill="${fill}" ` + `stroke="${palette.ink}" stroke-width=".8"/>` + nuclei.map((x) => dot(x, 25, 4.2, palette.rule)).join(''); for (let a = 0; a < 360; a += 12) { const rad = (a * Math.PI) / 180; out += dot(cx + Math.cos(rad) * rx, 25 + Math.sin(rad) * Math.min(rx, 15), 1.7, pick(Math.cos(rad), r())); } return out; }; return svg(180, 50, cellAt(16, 13, palette.tintTip, [16], () => palette.tip) + cellAt(45, 13, palette.tintBand, [45], () => palette.band) + arrow(65, 25, 12, 0, palette.muted) + cellAt(104, 22, palette.paper, [96, 112], (c) => (c < 0 ? palette.tip : palette.band)) + arrow(132, 25, 12, 0, palette.muted) + cellAt(163, 15.5, palette.paper, [158, 168], (c, k) => (k < 0.5 ? palette.tip : palette.band))); } function osmosis() { // three solutions: water leaves, stays even, floods in const r = rng(9); const panel = (x, solutes, body, flow) => { let out = `<rect x="${x}" width="108" height="86" rx="4" fill="${palette.mist}"/>`; for (let k = 0; k < solutes; k++) { const [px, py] = [x + 5 + r() * 98, 5 + r() * 76]; if (Math.hypot(px - x - 54, py - 43) > 30) out += dot(px, py, 1.5, palette.muted); } for (let k = 0; k < 6; k++) { // arrows: the net flow of water, out (+1) or in (-1) const rad = (k * Math.PI) / 3 + 0.5; if (flow === 0 && k % 3) continue; const d = flow < 0 || (flow === 0 && k) ? 40 : 27; out += arrow(x + 54 + Math.cos(rad) * d, 43 + Math.sin(rad) * d, 10, (rad * 180) / Math.PI + (d > 30 ? 180 : 0), palette.tip); } return out + body; }; const at = (k, d) => [54 + Math.cos((k * Math.PI) / 11) * d, 43 + Math.sin((k * Math.PI) / 11) * d].map(n); const crenated = `M${at(0, 13)}${Array.from({ length: 22 }, (_, k) => (k % 2 ? '' : `Q${at(k + 1, 19)} ${at(k + 2, 13)}`)).join('')}Z`; // bumps: control points outside const disc = (x, rr) => dot(x + 54, 43, rr, palette.band) + dot(x + 54, 43, rr * 0.45, palette.tintBand); return svg(348, 86, panel(0, 70, shape(crenated, palette.band), 1) + panel(120, 34, disc(120, 17), 0) + panel(240, 8, disc(240, 23), -1)); } const drawings = { target, bulb, flask, caution, safety, check, cell, mosaic, fusion, osmosis }; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Noto Serif': ['400', '400i', '700'], Lexend: ['700', '800'], // text, display, 'Barlow Semi Condensed': ['400', '600', '700'] }; // labels // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([prepareFonts(markdown, config(), kitFonts(FONTS)), ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))]); // Folio 27 is odd like page 1, always a recto: parity follows the page (gotcha: parity-page1-recto) const continuation = { pageNumbering: { startAt: 27 }, headings: { h1: 1, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 2 const doc = await buildDocumentWithFonts({ markdown, resources, continuation }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'Living Matter, chapter 2', es: 'Materia viva, capítulo 2' }) });
Kit · core, fonts, viewer, images: in jedem Rezept gleich · 270 Zeilen// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

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

Varianten

#Einen Kasten über beide Spalten setzen

span="page" an einer Direktive setzt jede Kastenart über beide Spalten; BOX 2.1 verschachtelt zusätzlich eine Gruppe :::columns, um ihre Abbildung neben ihren Text zu setzen.

-:::callout{type="warning" title="Membrane or wall?"}
+:::callout{type="warning" title="Membrane or wall?" span="page"}

#Das Badge an der Bundseite aufhängen

'inner' verschiebt das Badge zur Bundseite, links auf einer rechten und rechts auf einer linken Seite; lassen Sie auf dieser Seite Platz für die Hälfte, die aus dem Kasten heraushängt.

-    icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }),
+    icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'inner' }) }),

#Den Reiter auf die rechte Ecke stellen

'top-right', die Vorgabe, spiegelt das ganze Etikett: Der Reiter wandert an die rechte Ecke, der Kolben links daneben, und die Linie läuft von links herein.

-      position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default)
+      position: 'top-right',

Häufige Fehler

Häufiger Fehler

Fontsource-Dateien decken Buchstaben ab, keine Symbole

Das Kit bettet die latin-Datei einer Schrift von Fontsource ein, dazu ihre latin-ext-Datei für č, ł, † und ihre greek-Datei für α, χ (bei einer Familie, die eine mitliefert: Lora und Gelasio tun es nicht), wenn der Text diese Zeichen setzt, auf dem Bildschirm und im PDF. Symbole wie →, ≈, ✓ und ★ stehen in keiner dieser Dateien und fehlen im PDF: zeichnen Sie sie, setzen Sie sie als Mathematik oder wählen Sie eine Schrift, deren Dateien sie enthalten. Im PDF eingebettete Schriften →

Häufiger Fehler

Ein 'top'-Gleitobjekt kommt auf die Seite nach seinem Verweis, außer es darf die verweisende Seite eröffnen

Standardmäßig steht ein Gleitobjekt nie über seinem eigenen Verweis, sodass ein seitenbreites 'top'-Gleitobjekt, auf das auf Seite N verwiesen wird, Seite N+1 eröffnet. Setzen Sie placement.citingPage: true an der Ressource oder am defaultPlacement ihres Typs (oder layout.floatsAtCitingPage für das ganze Buch, seit postext 1.25), dann steht es stattdessen oben auf Seite N, und der Text über dem Verweis rückt darunter, sofern es in layout.maxTopFraction passt und die verweisende Zeile auf dieser Seite bleibt. Es eröffnet nie die erste Seite eines Kapitels oder eine Seite nach einem erzwungenen Umbruch: dort eröffnet es weiterhin die nächste Seite, und 'auto' oder 'bottom' können den Fuß der verweisenden Seite einnehmen. Platzierung von Abbildungen →

Häufiger Fehler

Seite 1 ist eine rechte Seite: Seiten mit physischen Nummern planen

Seite 1 ist eine rechte Seite und Seite 2 die erste linke, planen Sie Doppelseiten also mit physischen Seitenzahlen: ein Auftakt auf einer geraden Seite steht der ungeraden Seite danach gegenüber. Seiten- und Spaltenumbrüche →

Layoutwarnung · unknownCalloutType

Unbekannter Kastentyp

Ursache. Ein :::callout nennt einen Typ, den calloutStyles nicht definiert, er übernimmt daher stillschweigend den ersten Stil.

Lösung. Definieren Sie den Stil oder korrigieren Sie das Attribut type. Doku →

  • Der Reiter wird nur gedruckt, wenn die Direktive label="BOX 2.1" hat und der Stil ein Objekt label. Ein Stil ohne dieses Objekt ignoriert das Attribut.
  • Postext nummeriert Kästen nicht. Das Etikett ist der wörtliche Text der Direktive, also tippen Sie jede Nummer, BOX 2.1, BOX 2.2, von Hand.
  • Ein Eck-Badge hängt um seine halbe Breite über die Seite des Kastens hinaus, auf der Außenseite der Seite in den Rand. In der anderen Spalte derselben Seite hängt es in den Spaltenabstand, also halten Sie Kästen mit Badges in der Außenspalte oder lassen Sie ihnen Platz.
  • lists.color eines Kastens färbt seine Aufzählungszeichen und die Nummern seiner nummerierten Listen gleichermaßen, sodass die nummerierten Schritte und Fragen hier im Farbton ihres Kastens gedruckt werden. Um nur die Aufzählungszeichen eines Kastens zu färben, lassen Sie lists.color aus seinem Stil weg und setzen unorderedLists.color für das Dokument; die Nummern behalten dann orderedLists.color.

Nachweise

Text
Eigener Text, CC BY 4.0
Schriften
Noto Serif (SIL OFL 1.1) · Lexend (SIL OFL 1.1) · Barlow Semi Condensed (SIL OFL 1.1)
Sandbox