"""Planet Express, 07/2026. Bilingual (EN / DE) PDF builder.

Issue 07 deliberately drops the green-terminal look of issue 04 for a
BLUEPRINT / technical-drawing identity: navy field, drafting frame with
corner registration ticks, a bottom-right engineering title block, rotated
side tags, hollow FIG. headings with dimension rules, and real line-art
diagrams. Issue 04's generator (build_magazine.py) is untouched and still
builds, this is an additive, non-breaking second generator.
"""
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.lib.colors import HexColor
from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
import random
import math
import os
import re

PAGE_W, PAGE_H = A4

# ---- blueprint palette (names kept generic so content specs stay portable) --
BG_DARK = HexColor("#0E2C4E")   # blueprint navy
BG_PANEL = HexColor("#113458")  # callout fill, a touch lighter
TEXT = HexColor("#EAF2FB")      # drafting white
TEXT_DIM = HexColor("#82A8CC")  # muted blue-grey
NEON_CYAN = HexColor("#74CDFF")
NEON_YELLOW = HexColor("#FFC24B")
NEON_GREEN = HexColor("#79E0A6")
NEON_RED = HexColor("#FF6F6B")
NEON_PINK = HexColor("#FF96AC")
NEON_VIOLET = HexColor("#A86BFF")
INK = TEXT
DIM = TEXT_DIM

# terrain colours for the orbital "habitable surface" pixel map
T_OCEAN = HexColor("#2F6FB5")
T_RIVER = HexColor("#62C6F0")
T_BEACH = HexColor("#E3C879")
T_FOREST = HexColor("#46A85F")
T_MOUNT = HexColor("#93A7B8")
T_LGREEN = HexColor("#A8DD8C")   # pale light green, replaces ~half the highlands
T_SNOW = HexColor("#DCE6EF")
OCEAN_LIGHT = HexColor("#4FA0E4")   # shallow water at the shore, clearly blue
OCEAN_DARK = HexColor("#163C86")    # deep blue, open sea (clear of the navy bg)
FOREST_TONES = [HexColor("#46A85F"), HexColor("#3B8B4E"), HexColor("#5FB76A"),
                HexColor("#6E8E3C"), HexColor("#8A6B43"), HexColor("#9C7A4A"),
                HexColor("#79702F")]   # mixed greens and browns
BEACH_TONES = [HexColor("#E3C879"), HexColor("#D6B863"), HexColor("#EDD893")]


def lerp_color(a, b, t):
    t = max(0.0, min(1.0, t))
    return colors.Color(a.red + (b.red - a.red) * t,
                        a.green + (b.green - a.green) * t,
                        a.blue + (b.blue - a.blue) * t)

_FONT_DIR = "/usr/share/fonts/TTF"
pdfmetrics.registerFont(TTFont("Hack",            f"{_FONT_DIR}/Hack-Regular.ttf"))
pdfmetrics.registerFont(TTFont("Hack-Bold",       f"{_FONT_DIR}/Hack-Bold.ttf"))
pdfmetrics.registerFont(TTFont("Hack-Italic",     f"{_FONT_DIR}/Hack-Italic.ttf"))
pdfmetrics.registerFont(TTFont("Hack-BoldItalic", f"{_FONT_DIR}/Hack-BoldItalic.ttf"))

_PROJ_DIR = os.path.dirname(os.path.abspath(__file__))
pdfmetrics.registerFont(TTFont("Priestacy", f"{_PROJ_DIR}/fonts/Priestacy.otf"))

MONO = SANS = SERIF = "Hack"
MONO_BOLD = SANS_BOLD = SERIF_BOLD = "Hack-Bold"
SERIF_ITAL = "Hack-Italic"
HAND = "Priestacy"                                       # handwriting, for notebook notes

ISSUE = "07/2026"
SLUG = "2026_07"
MOTTO = "FIND THE PATTERNS IN THE NOIZE"   # per-language, set in build(); EN default

LX = 56                 # content left margin (clear of the rotated side tag)
RX = PAGE_W - 40        # content right margin


# ============================================================ BASE UTILITIES
def fill_bg(c, color=BG_DARK):
    c.setFillColor(color)
    c.rect(0, 0, PAGE_W, PAGE_H, fill=1, stroke=0)


def blueprint_grid(c, spacing=18, alpha=0.05):
    c.saveState()
    c.setStrokeColor(colors.white)
    c.setLineWidth(0.2)
    c.setStrokeAlpha(alpha)
    for x in range(0, int(PAGE_W), spacing):
        c.line(x, 0, x, PAGE_H)
    for y in range(0, int(PAGE_H), spacing):
        c.line(0, y, PAGE_W, y)
    c.restoreState()


def corner_ticks(c, x, y, w, h, color, s=6, lw=1.2, alpha=0.8):
    c.saveState()
    c.setStrokeColor(color)
    c.setStrokeAlpha(alpha)
    c.setLineWidth(lw)
    for cx, cy in [(x, y), (x + w, y), (x, y + h), (x + w, y + h)]:
        c.line(cx - s, cy, cx + s, cy)
        c.line(cx, cy - s, cx, cy + s)
    c.restoreState()


def draw_frame(c):
    c.saveState()
    c.setStrokeColor(TEXT)
    c.setStrokeAlpha(0.40)
    c.setLineWidth(1.0)
    c.rect(16, 16, PAGE_W - 32, PAGE_H - 32, fill=0, stroke=1)
    c.setLineWidth(0.4)
    c.rect(21, 21, PAGE_W - 42, PAGE_H - 42, fill=0, stroke=1)
    c.restoreState()
    corner_ticks(c, 16, 16, PAGE_W - 32, PAGE_H - 32, NEON_CYAN, s=7, lw=1.0, alpha=0.7)


def sheet_bg(c):
    fill_bg(c)
    blueprint_grid(c)
    draw_frame(c)


JUSTIFY = False   # full justification (Blocksatz) for body prose; set True to enable


def _draw_justified(c, line, x, y, width, font, size):
    """Draw one line spread to fill `width` exactly. Used for every body line
    except the last of a paragraph, which stays ragged (left-aligned)."""
    words = line.split()
    if len(words) <= 1:
        c.drawString(x, y, line)
        return
    words_w = sum(c.stringWidth(w, font, size) for w in words)
    gap = (width - words_w) / (len(words) - 1)
    cx = x
    for w in words:
        c.drawString(cx, y, w)
        cx += c.stringWidth(w, font, size) + gap


def wrap_text(text, max_width, font, size, c):
    lines, current = [], []
    for w in text.split():
        test = " ".join(current + [w])
        if c.stringWidth(test, font, size) <= max_width:
            current.append(w)
        else:
            if current:
                lines.append(" ".join(current))
            current = [w]
    if current:
        lines.append(" ".join(current))
    return lines


def count_body_lines(c, text, width, font, size):
    n = 0
    for para in text.split("\n\n"):
        n += len(wrap_text(para.strip(), width, font, size, c)) + 1
    return n


def fit_body(c, text, width, avail_h, font=SERIF):
    for size, lead in [(9.5, 13.5), (9.5, 13), (9.5, 12.5), (9.5, 12),
                       (9, 11.5), (9, 11), (8.5, 10.5), (8, 10), (7.5, 9.5)]:
        if count_body_lines(c, text, width, font, size) * lead <= avail_h:
            return size, lead
    return 7.5, 9.5


def draw_body_text(c, text, x, y, width, font=SERIF, size=9.5, leading=13, color=TEXT,
                   justify=None):
    if justify is None:
        justify = JUSTIFY
    c.setFillColor(color)
    c.setFont(font, size)
    cur_y = y
    for para in text.split("\n\n"):
        plines = wrap_text(para.strip(), width, font, size, c)
        for i, line in enumerate(plines):
            if line:
                if justify and i < len(plines) - 1:
                    _draw_justified(c, line, x, cur_y, width, font, size)
                else:
                    c.drawString(x, cur_y, line)
            cur_y -= leading
        cur_y -= leading   # blank line between paragraphs
    return cur_y


def arrow(c, x1, y1, x2, y2, color, width=1.3, head=5):
    c.saveState()
    c.setStrokeColor(color)
    c.setLineWidth(width)
    c.line(x1, y1, x2, y2)
    ang = math.atan2(y2 - y1, x2 - x1)
    for da in (math.radians(152), math.radians(-152)):
        c.line(x2, y2, x2 + head * math.cos(ang + da), y2 + head * math.sin(ang + da))
    c.restoreState()


# ============================================================== PAGE FURNITURE
def top_strip(c):
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 7.5)
    c.drawString(LX, PAGE_H - 40, "PLANET EXPRESS")
    c.drawRightString(RX, PAGE_H - 40, f"ISSUE {ISSUE} · {MOTTO}")
    c.setStrokeColor(TEXT_DIM)
    c.setStrokeAlpha(0.5)
    c.setLineWidth(0.5)
    c.line(LX, PAGE_H - 45, RX, PAGE_H - 45)
    c.setStrokeAlpha(1)


def side_tag(c, section):
    c.saveState()
    c.translate(33, 70)
    c.rotate(90)
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO_BOLD, 7.5)
    c.drawString(0, 0, f"PLANET EXPRESS · {section.upper()}")
    c.restoreState()


def page_footer(c, footer):
    """Minimal footer: a thin rule + the article's tagline. No sheet box."""
    c.setStrokeColor(TEXT_DIM)
    c.setStrokeAlpha(0.3)
    c.setLineWidth(0.4)
    c.line(LX, 34, RX, 34)
    c.setStrokeAlpha(1)
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 7)
    c.drawString(LX, 24, f"// {footer}")
    c.drawRightString(RX, 24, f"{ISSUE} · https://planet-express.wtf/")


def split_kicker(kicker):
    # "// WISSEN // UNTERSCHÄTZT" -> ("WISSEN", "// UNTERSCHÄTZT")
    parts = [p.strip() for p in kicker.split("//") if p.strip()]
    if len(parts) >= 2:
        return parts[0], "// " + " // ".join(parts[1:])
    return "", kicker


def dim_rule(c, y, label, accent):
    c.setStrokeColor(TEXT_DIM)
    c.setLineWidth(0.6)
    c.line(LX, y, RX, y)
    for xx in (LX, RX):
        c.line(xx, y - 3, xx, y + 3)
    if label:
        c.setFont(MONO, 7.5)
        w = c.stringWidth(label, MONO, 7.5)
        midx = (LX + RX) / 2
        c.setFillColor(BG_DARK)
        c.rect(midx - w / 2 - 5, y - 4, w + 10, 8, fill=1, stroke=0)
        c.setFillColor(accent)
        c.drawCentredString(midx, y - 2.5, label)


def fig_title(c, fig, ta, tb, accent, max_size=58, tag=None):
    # tag box (was "FIG. xx"; now carries the article's first kicker word).
    # the box grows to fit the label so longer section names stay readable.
    label = tag if tag else f"FIG. {fig}"
    fsize = 9
    box_w = max(66, c.stringWidth(label, MONO_BOLD, fsize) + 14)
    c.setStrokeColor(accent)
    c.setLineWidth(0.9)
    c.rect(LX, PAGE_H - 80, box_w, 16, fill=0, stroke=1)
    c.setFillColor(accent)
    c.setFont(MONO_BOLD, fsize)
    c.drawCentredString(LX + box_w / 2, PAGE_H - 76, label)
    # one-line title, auto-sized to span the full content width (no wrap)
    sep = "" if ta.endswith("-") else " "
    head = ta + sep
    full = head + tb
    avail = RX - LX
    size = max_size
    while size > 12 and c.stringWidth(full, SANS_BOLD, size) > avail:
        size -= 1
    base = PAGE_H - 120 - size * 0.30
    c.setFont(SANS_BOLD, size)
    c.setFillColor(TEXT)
    c.drawString(LX, base, head)
    c.setFillColor(accent)
    c.drawString(LX + c.stringWidth(head, SANS_BOLD, size), base, tb)
    return base


def subhead(c, y, text):
    c.setFillColor(TEXT_DIM)
    c.setFont(SERIF_ITAL, 11)
    yy = y
    for line in wrap_text(text, RX - LX, SERIF_ITAL, 11, c):
        c.drawString(LX, yy, line)
        yy -= 13.5
    return yy


# ==================================================================== CALLOUTS
def panel_kv(c, x, top, w, header, rows, accent=NEON_YELLOW, val_color=NEON_GREEN,
             stacked=False, note=None, total=None, label_font=SANS):
    row_h = 24 if stacked else 13
    note_lines = wrap_text(note, w - 24, SERIF_ITAL, 7.5, c) if note else []
    h = 15 + len(rows) * row_h
    if total:
        h += 18
    if note_lines:
        h += len(note_lines) * 10 + 6
    h += 8
    _box(c, x, top - h, w, h, accent)
    c.setFillColor(accent)
    c.setFont(MONO_BOLD, 10)
    c.drawString(x + 10, top, header)
    c.setStrokeColor(accent)
    c.setLineWidth(0.3)
    c.line(x + 10, top - 5, x + w - 10, top - 5)
    ly = top - 19
    for label, val in rows:
        if stacked:
            c.setFillColor(TEXT_DIM)
            c.setFont(MONO, 8)
            c.drawString(x + 12, ly, label)
            c.setFillColor(val_color)
            c.setFont(MONO_BOLD, 9)
            c.drawString(x + 12, ly - 11, val)
        else:
            c.setFillColor(TEXT)
            c.setFont(label_font, 8.5)
            c.drawString(x + 12, ly, label)
            c.setFillColor(val_color)
            c.setFont(MONO_BOLD, 8.5)
            c.drawRightString(x + w - 12, ly, val)
        ly -= row_h
    if total:
        c.setStrokeColor(accent)
        c.setStrokeAlpha(0.3)
        c.line(x + 10, ly + 5, x + w - 10, ly + 5)
        c.setStrokeAlpha(1)
        ly -= 4
        c.setFillColor(TEXT_DIM)
        c.setFont(MONO_BOLD, 9)
        c.drawString(x + 12, ly, total[0])
        c.setFillColor(val_color)
        c.drawRightString(x + w - 12, ly, total[1])
        ly -= 14
    if note_lines:
        ly -= 2
        c.setFillColor(TEXT_DIM)
        c.setFont(SERIF_ITAL, 7.5)
        for nl in note_lines:
            c.drawString(x + 12, ly, nl)
            ly -= 10
    return top - h


def panel_notes(c, x, top, w, header, items, accent=NEON_RED, name_color=None,
                note=None, size=7.5, boxed=True):
    """A rail panel of short explainers: a bold name, then a wrapped
    plain-language sentence under it. One (name, text) tuple per item.
    With boxed=False and header=None it sits frame- and title-free in the
    rail, aligned to the top of the body column."""
    name_color = name_color or accent
    pad = 11 if boxed else 1
    iw = w - pad - 11
    dl = size + 2.0
    wrapped = [(nm, wrap_text(desc, iw, SERIF, size, c)) for nm, desc in items]
    note_lines = wrap_text(note, iw, SERIF_ITAL, 7.5, c) if note else []
    h = (15 if header else 0)
    for _nm, lines in wrapped:
        h += 11 + len(lines) * dl + 7
    if note_lines:
        h += len(note_lines) * 10 + 6
    h += 6
    if boxed:
        _box(c, x, top - h, w, h, accent)
    if header:
        c.setFillColor(accent)
        c.setFont(MONO_BOLD, 10)
        c.drawString(x + pad, top, header)
        c.setStrokeColor(accent)
        c.setLineWidth(0.3)
        c.line(x + pad, top - 5, x + w - pad, top - 5)
        ly = top - 19
    else:
        ly = top
    for nm, lines in wrapped:
        c.setFillColor(name_color)
        c.setFont(MONO_BOLD, 8.5)
        c.drawString(x + pad, ly, nm)
        ly -= 11
        c.setFillColor(TEXT)
        c.setFont(SERIF, size)
        for line in lines:
            c.drawString(x + pad, ly, line)
            ly -= dl
        ly -= 7
    if note_lines:
        ly -= 2
        c.setFillColor(TEXT_DIM)
        c.setFont(SERIF_ITAL, 7.5)
        for nl in note_lines:
            c.drawString(x + pad, ly, nl)
            ly -= 10
    return top - h


def panel_lines(c, x, top, w, header, lines, accent=NEON_GREEN, size=8.5,
                boxed=True, fill_h=None):
    n = len(lines)
    if fill_h:
        # No box: auto-fit the font to the rail width (longest line), then
        # spread the rows down the whole available height so the terminal
        # output uses the rail instead of sitting in a half-empty box.
        longest = 0
        for text, _ in lines:
            s = "".join(seg for seg, _ in text) if isinstance(text, list) else text
            longest = max(longest, c.stringWidth(s, MONO, 10) / 10.0)
        if longest:
            size = max(size, min(11, (w - 16) / longest))
        # Spread the rows down the rail, but cap the leading so the listing
        # stays a tight terminal block instead of stretching to the footer.
        lh = max(size + 3, min((fill_h - 40) / n, size + 5.5))
        h = 20 + n * lh
    else:
        lh = 11
        h = 15 + n * lh + 6
    if boxed:
        _box(c, x, top - h, w, h, accent)
    # Without the box there is no top padding to fill, so the header sits a
    # line higher and lines up with the top of the body column.
    head_y = top if fill_h else top
    c.setFillColor(accent)
    c.setFont(MONO_BOLD, 9)
    c.drawString(x + 10, head_y, header)
    c.setStrokeColor(accent)
    c.setLineWidth(0.3)
    c.line(x + 10, head_y - 5, x + w - 10, head_y - 5)
    ly = head_y - (14 if fill_h else 19)
    c.setFont(MONO, size)
    for text, col in lines:
        if isinstance(text, list):
            # syntax-highlighted line: a row of (segment, colour) spans
            cx = x + 12
            for seg, scol in text:
                c.setFillColor(scol)
                c.drawString(cx, ly, seg)
                cx += c.stringWidth(seg, MONO, size)
        else:
            c.setFillColor(col)
            c.drawString(x + 12, ly, text)
        ly -= lh
    return top - h


def panel_terminal(c, x, top, w, lines, accent=NEON_GREEN, size=6.5,
                   label="INFOBOX", tag=None, cmd=None, caption=None):
    """A self-contained infobox. Unlike the frameless rail panels this one
    is deliberately bounded: an accent frame, a filled label tab that names
    it, the command that produced the screen, the transcript, and a
    plain-language caption underneath so a reader who has never opened a
    shell still understands what they are looking at."""
    pad = 12
    iw = w - 2 * pad
    # auto-fit the transcript font to the frame width (longest raw line)
    longest = 0
    for text, _ in lines:
        s = "".join(seg for seg, _ in text) if isinstance(text, list) else text
        longest = max(longest, c.stringWidth(s, MONO, 10) / 10.0)
    if longest:
        size = max(5.6, min(size, (iw - 2) / longest))
    lh = size + 2.4
    cap_lines = wrap_text(caption, iw, SERIF_ITAL, 8, c) if caption else []
    label_h = 17
    cmd_h = 15 if cmd else 0
    body_h = len(lines) * lh
    cap_h = (12 + len(cap_lines) * 10.5) if cap_lines else 0
    h = label_h + 10 + cmd_h + body_h + 10 + cap_h + pad
    y0 = top - h
    # frame: fill + a crisp accent border so the box reads as set apart
    c.setFillColor(BG_PANEL)
    c.rect(x, y0, w, h, fill=1, stroke=0)
    c.setStrokeColor(accent)
    c.setLineWidth(1.0)
    c.rect(x, y0, w, h, fill=0, stroke=1)
    # label tab: filled accent bar with dark text, plus an optional right tag
    c.setFillColor(accent)
    c.rect(x, top - label_h, w, label_h, fill=1, stroke=0)
    c.setFillColor(BG_DARK)
    c.setFont(MONO_BOLD, 8)
    c.drawString(x + pad, top - label_h + 5.5, label)
    if tag:
        c.drawRightString(x + w - pad, top - label_h + 5.5, tag)
    ly = top - label_h - 13
    # the command that produced the screen, shown as a shell prompt
    if cmd:
        c.setFont(MONO_BOLD, 8)
        c.setFillColor(TEXT_DIM)
        c.drawString(x + pad, ly, "$ ")
        c.setFillColor(accent)
        c.drawString(x + pad + c.stringWidth("$ ", MONO_BOLD, 8), ly, cmd)
        ly -= cmd_h
    c.setStrokeColor(accent)
    c.setStrokeAlpha(0.3)
    c.setLineWidth(0.3)
    c.line(x + pad, ly + 7, x + w - pad, ly + 7)
    c.setStrokeAlpha(1)
    ly -= 3
    # the transcript itself
    c.setFont(MONO, size)
    for text, col in lines:
        if isinstance(text, list):
            cx = x + pad
            for seg, scol in text:
                c.setFillColor(scol)
                c.drawString(cx, ly, seg)
                cx += c.stringWidth(seg, MONO, size)
        else:
            c.setFillColor(col)
            c.drawString(x + pad, ly, text)
        ly -= lh
    # plain-language caption, set off by a rule
    if cap_lines:
        ly -= 4
        c.setStrokeColor(TEXT_DIM)
        c.setStrokeAlpha(0.3)
        c.setLineWidth(0.4)
        c.line(x + pad, ly + 7, x + w - pad, ly + 7)
        c.setStrokeAlpha(1)
        ly -= 4
        c.setFillColor(TEXT_DIM)
        c.setFont(SERIF_ITAL, 8)
        for cl in cap_lines:
            c.drawString(x + pad, ly, cl)
            ly -= 10.5
    return y0


# --- tiny PHP highlighter, just enough to make a code listing readable -------
_PHP_TOK = re.compile(
    r"(?P<comment>(?:\#|//)[^\n]*)"
    r"|(?P<string>'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\")"
    r"|(?P<var>\$\w+)"
    r"|(?P<num>\d+)"
    r"|(?P<word>\w+)"
    r"|(?P<rest>\s+|.)"
)
_PHP_KW = {"if", "else", "elseif", "foreach", "for", "while", "as", "new",
           "echo", "return", "function", "NULL", "null", "true", "false",
           "PHP_SAPI"}
_PHP_FN = {"PDO", "parse_str", "implode", "array_slice", "query"}


def hl_php(line):
    """Tokenise one line of PHP into (segment, colour) spans for the reader."""
    out = []
    for m in _PHP_TOK.finditer(line):
        kind, tok = m.lastgroup, m.group()
        if kind == "comment":
            col = TEXT_DIM
        elif kind == "string":
            col = NEON_GREEN
        elif kind == "var":
            col = NEON_CYAN
        elif kind == "num":
            col = NEON_YELLOW
        elif kind == "word":
            col = NEON_PINK if tok in _PHP_KW else \
                NEON_YELLOW if tok in _PHP_FN else TEXT
        else:
            col = TEXT
        out.append((tok, col))
    return out


def code(*lines):
    """Wrap PHP source lines as highlighted panel rows."""
    return [(hl_php(s), None) for s in lines]


def panel_lines_cols(c, x, top, w, header, lines, accent=NEON_GREEN, size=6.5, cols=2):
    """Like panel_lines but lays the entries out in `cols` columns, filling
    column-major. Used for long registries (e.g. the ship-name list)."""
    per = (len(lines) + cols - 1) // cols
    h = 30 + per * 11 + 8
    _box(c, x, top - h, w, h, accent)
    c.setFillColor(accent)
    c.setFont(MONO_BOLD, 9)
    c.drawString(x + 10, top - 15, header)
    c.setStrokeColor(accent)
    c.setLineWidth(0.3)
    c.line(x + 10, top - 20, x + w - 10, top - 20)
    colw = (w - 20) / cols
    c.setFont(MONO, size)
    for i, (text, col) in enumerate(lines):
        ci = i // per
        ri = i % per
        c.setFillColor(col)
        c.drawString(x + 12 + ci * colw, top - 34 - ri * 11, text)
    return top - h


def panel_lines_fit(c, x, top, w, header, lines, accent=NEON_GREEN,
                    max_size=7.5, min_size=5.0):
    """Single-column line panel that auto-shrinks the font so the longest
    entry fits the panel width (used to pack many long names into a side rail)."""
    avail = w - 24
    longest = max((c.stringWidth(t, MONO, 10) / 10.0 for t, _ in lines), default=1)
    size = max(min_size, min(max_size, avail / longest))
    lh = size + 2.6
    h = 28 + len(lines) * lh + 8
    _box(c, x, top - h, w, h, accent)
    c.setFillColor(accent)
    c.setFont(MONO_BOLD, 8.5)
    c.drawString(x + 10, top - 14, header)
    c.setStrokeColor(accent)
    c.setLineWidth(0.3)
    c.line(x + 10, top - 19, x + w - 10, top - 19)
    ly = top - 31
    c.setFont(MONO, size)
    for text, col in lines:
        c.setFillColor(col)
        c.drawString(x + 12, ly, text)
        ly -= lh
    return top - h


def panel_diff(c, x, top, w, header, rows, accent=NEON_PINK):
    wrapped = []
    for sign, text, col in rows:
        ls = wrap_text(text, w - 34, SANS, 8.5, c)
        wrapped.append((sign, ls, col))
    h = 30 + sum(len(ls) for _, ls, _ in wrapped) * 11 + 8
    _box(c, x, top - h, w, h, accent)
    c.setFillColor(accent)
    c.setFont(MONO_BOLD, 10)
    c.drawString(x + 10, top - 15, header)
    c.setStrokeColor(accent)
    c.setLineWidth(0.3)
    c.line(x + 10, top - 20, x + w - 10, top - 20)
    ly = top - 34
    for sign, ls, col in wrapped:
        c.setFillColor(col)
        c.setFont(MONO_BOLD, 9)
        c.drawString(x + 12, ly, sign)
        c.setFillColor(TEXT)
        c.setFont(SANS, 8.5)
        for line in ls:
            c.drawString(x + 26, ly, line)
            ly -= 11
    return top - h


def _box(c, x, y, w, h, accent):
    """A drafting detail box: panel fill only. Frame + corner ticks removed
    (experiment: see all diagrams without their frames)."""
    c.setFillColor(BG_PANEL)
    c.rect(x, y, w, h, fill=1, stroke=0)


def draw_panel(c, spec, x, top, w, avail_h=None):
    kind = spec["kind"]
    if kind == "kv":
        return panel_kv(c, x, top, w, spec["header"], spec["rows"],
                        accent=spec.get("accent", NEON_YELLOW),
                        val_color=spec.get("val_color", NEON_GREEN),
                        stacked=spec.get("stacked", False),
                        note=spec.get("note"), total=spec.get("total"))
    if kind == "lines":
        return panel_lines(c, x, top, w, spec["header"], spec["lines"],
                           accent=spec.get("accent", NEON_GREEN),
                           size=spec.get("size", 8.5),
                           boxed=spec.get("boxed", True),
                           fill_h=(avail_h if spec.get("fill") else None))
    if kind == "terminal":
        return panel_terminal(c, x, top, w, spec["lines"],
                              accent=spec.get("accent", NEON_GREEN),
                              size=spec.get("size", 6.5),
                              label=spec.get("label", "INFOBOX"),
                              tag=spec.get("tag"), cmd=spec.get("cmd"),
                              caption=spec.get("caption"))
    if kind == "notes":
        return panel_notes(c, x, top, w, spec.get("header"), spec["items"],
                           accent=spec.get("accent", NEON_RED),
                           name_color=spec.get("name_color"),
                           note=spec.get("note"), size=spec.get("size", 7.5),
                           boxed=spec.get("boxed", True))
    if kind == "diff":
        return panel_diff(c, x, top, w, spec["header"], spec["rows"],
                          accent=spec.get("accent", NEON_PINK))
    raise ValueError(kind)


# ---- faint background math, hand-typeset (reportlab has no TeX) -------------
# A "run" is (text, kind) with kind in {"n","sub","sup"}. Glyphs that Hack
# carries are used directly (Ψ ψ φ Σ ⟨ ⟩ ∈ ² …); ℂ is faked and the column
# vector / summation limits are drawn by hand.
_QA, _QZ = "⟨", "⟩"          # ⟨ ⟩
_PSI, _psi, _phi = "Ψ", "ψ", "φ"
_SIG, _IN, _SQ, _LD = "Σ", "∈", "²", "…"


def _run_w(c, size, runs, font=SERIF):
    w = 0
    for text, kind in runs:
        s = size * 0.72 if kind in ("sub", "sup") else size
        w += pdfmetrics.stringWidth(text, font, s)
    return w


def _run(c, x, y, size, runs, font=SERIF):
    cur = x
    for text, kind in runs:
        if kind == "sub":
            s, dy = size * 0.72, -size * 0.26
        elif kind == "sup":
            s, dy = size * 0.72, size * 0.36
        else:
            s, dy = size, 0
        c.setFont(font, s)
        c.drawString(cur, y + dy, text)
        cur += pdfmetrics.stringWidth(text, font, s)
    return cur


def _bb_C(c, x, y, size, font=SERIF):
    """A blackboard-bold ℂ, faked: a C with a thin vertical stroke."""
    c.setFont(font, size)
    c.drawString(x, y, "C")
    cw = pdfmetrics.stringWidth("C", font, size)
    c.saveState()
    c.setLineWidth(size * 0.07)
    bx = x + cw * 0.27
    c.line(bx, y + size * 0.02, bx, y + size * 0.64)
    c.restoreState()
    return x + cw


def _colvec_w(c, size, top, bot, font=SERIF):
    big = size * 1.9
    return (pdfmetrics.stringWidth("(", font, big)
            + pdfmetrics.stringWidth(")", font, big) + 4
            + max(_run_w(c, size, top, font), _run_w(c, size, bot, font)))


def _colvec(c, x, y, size, top, bot, font=SERIF):
    big = size * 1.9
    c.setFont(font, big)
    c.drawString(x, y - big * 0.28, "(")
    ex = x + pdfmetrics.stringWidth("(", font, big) + 2
    ew = max(_run_w(c, size, top, font), _run_w(c, size, bot, font))
    _run(c, ex, y + size * 0.5, size, top, font)
    _run(c, ex, y - size * 0.6, size, bot, font)
    rx = ex + ew + 2
    c.setFont(font, big)
    c.drawString(rx, y - big * 0.28, ")")
    return rx + pdfmetrics.stringWidth(")", font, big)


def _line_centered(c, cx, y, size, runs, font=SERIF):
    _run(c, cx - _run_w(c, size, runs, font) / 2, y, size, runs, font)


def quantum_formulas(c, cx, y_top, y_bottom, color, alpha=0.12, s=9.5):
    """Faint state-vector maths, spread evenly to fill the lower right column
    and layered behind the Bloch sphere. Five lines distributed across
    [y_bottom, y_top]."""
    font = SERIF
    c.saveState()
    c.setFillColor(color)
    c.setStrokeColor(color)
    c.setFillAlpha(alpha)
    c.setStrokeAlpha(alpha)
    span = y_top - y_bottom
    ys = [y_top - span * i / 4 for i in range(5)]

    # Ψ := Σ_{i1..iN} c_{i1..iN} |i1 i2..iN>
    y = ys[0]
    pre = [(_PSI + " := ", "n")]
    post = [(" c", "n"), ("i1" + _LD + "iN", "sub"),
            (" |i1 i2" + _LD + "iN" + _QZ, "n")]
    big = s * 1.7
    wsig = pdfmetrics.stringWidth(_SIG, font, big)
    w2 = _run_w(c, s, pre, font) + wsig + _run_w(c, s, post, font)
    xx = _run(c, cx - w2 / 2, y, s, pre, font)
    c.setFont(font, big)
    c.drawString(xx, y - big * 0.16, _SIG)
    c.setFont(font, s * 0.58)
    c.drawCentredString(xx + wsig / 2, y - big * 0.16 - s * 0.66,
                        "i1," + _LD + ",iN")
    _run(c, xx + wsig, y, s, post, font)

    # |psi_i> = Σ_j C_j |phi_j>
    y = ys[1]
    f3 = [("|" + _psi, "n"), ("i", "sub"), (_QZ + " = ", "n")]
    f3b = [(" C", "n"), ("j", "sub"), (" |" + _phi, "n"), ("j", "sub"),
           (_QZ, "n")]
    wsig3 = pdfmetrics.stringWidth(_SIG, font, s * 1.3)
    w3 = _run_w(c, s, f3, font) + wsig3 + _run_w(c, s, f3b, font)
    xx = _run(c, cx - w3 / 2, y, s, f3, font)
    c.setFont(font, s * 1.3)
    c.drawString(xx, y - s * 0.18, _SIG)
    c.setFont(font, s * 0.6)
    c.drawCentredString(xx + wsig3 / 2, y - s * 0.85, "j")
    _run(c, xx + wsig3, y, s, f3b, font)

    # |Ψ> = c0|0> + c1|1> = (c0 / c1)
    y = ys[2]
    st = [("|" + _PSI + _QZ + " = c", "n"), ("0", "sub"),
          ("|0" + _QZ + " + c", "n"), ("1", "sub"), ("|1" + _QZ + " = ", "n")]
    top, bot = [("c", "n"), ("0", "sub")], [("c", "n"), ("1", "sub")]
    w1 = _run_w(c, s, st, font) + _colvec_w(c, s, top, bot, font)
    xx = _run(c, cx - w1 / 2, y, s, st, font)
    _colvec(c, xx, y, s, top, bot, font)

    # |c0|^2 + |c1|^2 = 1
    nm = [("|c", "n"), ("0", "sub"), ("|" + _SQ + " + |c", "n"),
          ("1", "sub"), ("|" + _SQ + " = 1", "n")]
    _line_centered(c, cx, ys[3], s, nm, font)

    # c0, c1 in C
    y = ys[4]
    dm = [("c", "n"), ("0", "sub"), (", c", "n"), ("1", "sub"),
          (" " + _IN + " ", "n")]
    wd = _run_w(c, s, dm, font) + pdfmetrics.stringWidth("C", font, s)
    xx = _run(c, cx - wd / 2, y, s, dm, font)
    _bb_C(c, xx, y, s, font)

    c.restoreState()


# ====================================================================== SHEETS
def page_sheet(c, a, page_num, total):
    sheet_bg(c)
    top_strip(c)
    side_tag(c, a["section"])
    accent = a["accent"]
    panels = a.get("panels")
    motif = SHEET_MOTIFS.get(a["fig"])
    # two-column articles (no rail) get their imagery from a faint corner
    # watermark; panel articles get a crisp rail spot lower down instead
    if motif and not panels and a["fig"] not in ("05", "07"):
        article_motif(c, motif, RX - 18, 100, 250, color=accent, alpha=0.085, lw=1.0)
    if a["fig"] == "05":
        bio_scatter(c, accent)
    if a["fig"] == "07":
        paper_scatter(c, accent)
    if a["fig"] == "06":
        # "long waves", a couple of faint long-wavelength sine waves
        long_wave(c, accent, 150, amp=26, waves=1.8, alpha=0.13)
        long_wave(c, accent, 120, amp=18, waves=2.6, alpha=0.09)
    _tag, _kick = split_kicker(a["kicker"])
    yb = fig_title(c, a["fig"], a["title_a"], a["title_b"], accent, tag=_tag)
    dim_rule(c, yb - 11, _kick, accent)
    body_top = yb - 30
    avail_h = body_top - 54  # keep clear of the footer
    if panels:
        col_w = a.get("col_w", 300)
        size, lead = fit_body(c, a["body"], col_w, avail_h)
        draw_body_text(c, a["body"], LX, body_top, col_w, size=size, leading=lead)
        # vertical divider (suppressed on pages whose rail carries a framed box)
        px = LX + col_w + 20
        if not a.get("no_divider"):
            c.setStrokeColor(TEXT_DIM)
            c.setStrokeAlpha(0.35)
            c.setLineWidth(0.5)
            c.line(px - 10, body_top + 4, px - 10, 44)
            c.setStrokeAlpha(1)
        pw = RX - px
        py = body_top
        for spec in panels:
            py = draw_panel(c, spec, px, py, pw, avail_h=py - 50) - 16
        # crisp thematic spot in the leftover rail space below the panels
        if motif:
            gap = py - 52
            if gap > 66:
                sp = min(pw * 0.72, gap * 0.82)
                cxm, cym = px + pw / 2, 52 + gap / 2
                if a["fig"] == "04":
                    # a solid Bloch sphere low in the rail, with the faint
                    # state-vector maths spread behind it. The sphere is drawn
                    # opaque; the maths stay transparent and tighter (smaller
                    # font, less vertical/horizontal reach).
                    bs = min(pw + 40, gap * 1.72) * 0.7
                    cy4 = 46 + bs / 2
                    quantum_formulas(c, cxm, cy4 + bs / 2 - 6, 52, accent,
                                     alpha=0.2, s=10.5)
                    bloch_sphere(c, cxm, cy4, bs, color=accent,
                                 alpha=1.0, lw=1.3)
                elif a["fig"] == "06":
                    # extended mesh-of-meshes filling the whole lower rail
                    mesh_extended(c, px, 50, pw, py - 50,
                                  color=accent, alpha=0.85, lw=1.2)
                else:
                    article_motif(c, motif, cxm, cym, sp,
                                  color=accent, alpha=0.8, lw=1.2)
    else:
        col_w = (RX - LX - 24) / 2
        body = a["body"]
        if "<<<COL>>>" in body:                 # explicit column break
            left, right = (s.strip() for s in body.split("<<<COL>>>", 1))
        else:
            split = body.rfind("\n\n", 0, int(len(body) * 0.52))
            if split < 0:
                split = len(body) // 2
            left, right = body[:split].strip(), body[split:].strip()
        longer = max(left, right, key=lambda s: count_body_lines(c, s, col_w, SERIF, 9.5))
        size, lead = fit_body(c, longer, col_w, avail_h)
        midx = LX + col_w + 12
        if not a.get("no_divider"):
            c.setStrokeColor(TEXT_DIM)
            c.setStrokeAlpha(0.35)
            c.setLineWidth(0.5)
            c.line(midx, body_top + 4, midx, 44)
            c.setStrokeAlpha(1)
        draw_body_text(c, left, LX, body_top, col_w, size=size, leading=lead)
        draw_body_text(c, right, midx + 12, body_top, col_w, size=size, leading=lead)
    page_footer(c, a["footer"])


def page_jokes(c, a, page_num, total):
    sheet_bg(c)
    top_strip(c)
    side_tag(c, a["section"])
    accent = a["accent"]
    _tag, _kick = split_kicker(a["kicker"])
    yb = fig_title(c, a["fig"], a["title_a"], a["title_b"], accent, tag=_tag)
    dim_rule(c, yb - 11, _kick, accent)
    body_top = yb - 34
    col_w = (RX - LX - 24) / 2
    midx = LX + col_w + 12
    c.setStrokeColor(TEXT_DIM)
    c.setStrokeAlpha(0.35)
    c.setLineWidth(0.5)
    c.line(midx, body_top + 4, midx, 44)
    c.setStrokeAlpha(1)

    # left column: all the jokes
    x = LX
    yy = body_top
    for joke in a["jokes"]:
        c.setFillColor(accent)
        c.setFont(MONO_BOLD, 10)
        c.drawString(x, yy, "//")
        c.setFillColor(TEXT)
        c.setFont(SERIF, 9.5)
        lines = []
        for para in joke.split("\n"):
            lines.extend(wrap_text(para, col_w - 18, SERIF, 9.5, c))
        for line in lines:
            c.drawString(x + 18, yy, line)
            yy -= 13
        yy -= 12

    # right column: a registry of Culture ship names, class prefix highlighted
    x = midx + 12
    yy = body_top
    c.setFillColor(accent)
    c.setFont(MONO_BOLD, 9)
    c.drawString(x, yy, a.get("ships_head", "// SHIP REGISTRY"))
    yy -= 20
    for ship in a.get("ships", []):
        if yy < 48:
            break
        prefix, _, name = ship.partition(" ")
        c.setFillColor(accent)
        c.setFont(MONO_BOLD, 8.5)
        c.drawString(x, yy, prefix)
        pw = pdfmetrics.stringWidth(prefix + " ", MONO_BOLD, 8.5)
        c.setFillColor(TEXT)
        c.setFont(SERIF, 9.5)
        nlines = wrap_text(name, col_w - pw - 2, SERIF, 9.5, c) or [""]
        c.drawString(x + pw, yy, nlines[0])
        yy -= 13
        for extra in nlines[1:]:
            c.drawString(x + pw, yy, extra)
            yy -= 13
        yy -= 3
    page_footer(c, a["footer"])


# ============================================================ ORBITAL DIAGRAMS
def _star(c, cx, cy, r=5, color=NEON_YELLOW):
    c.setFillColor(color)
    c.circle(cx, cy, r, fill=1, stroke=0)
    c.setStrokeColor(color)
    c.setLineWidth(1)
    for ang in range(0, 360, 45):
        a = math.radians(ang)
        c.line(cx + (r + 2) * math.cos(a), cy + (r + 2) * math.sin(a),
               cx + (r + 7) * math.cos(a), cy + (r + 7) * math.sin(a))


def _earth(c, cx, cy, d):
    """A tiny Earth, one map-pixel big, for scale."""
    c.setFillColor(T_OCEAN)
    c.circle(cx, cy, d / 2, fill=1, stroke=0)
    c.setFillColor(T_FOREST)
    c.circle(cx - d * 0.16, cy + d * 0.12, d * 0.17, fill=1, stroke=0)
    c.circle(cx + d * 0.20, cy - d * 0.16, d * 0.14, fill=1, stroke=0)


def _ell_arc(c, cx, cy, rx, ry, a0, a1, color, lw, steps=40, rot=0):
    c.setStrokeColor(color)
    c.setLineWidth(lw)
    cr, sr = math.cos(math.radians(rot)), math.sin(math.radians(rot))
    prev = None
    for i in range(steps + 1):
        a = math.radians(a0 + (a1 - a0) * i / steps)
        ax, ay = rx * math.cos(a), ry * math.sin(a)
        p = (cx + ax * cr - ay * sr, cy + ax * sr + ay * cr)
        if prev:
            c.line(prev[0], prev[1], p[0], p[1])
        prev = p


def _ell_arc_grad(c, cx, cy, rx, ry, a0, a1, stops, rot=0, steps=64):
    """Arc drawn as many short segments, interpolating colour, alpha and line
    width across `stops` = [(t, colour, alpha, lw), ...] with t in 0..1 measured
    along a0->a1. Lets a lit arc melt smoothly into the dark ring beneath it."""
    cr, sr = math.cos(math.radians(rot)), math.sin(math.radians(rot))

    def pt(a_deg):
        a = math.radians(a_deg)
        ax, ay = rx * math.cos(a), ry * math.sin(a)
        return (cx + ax * cr - ay * sr, cy + ax * sr + ay * cr)

    def interp(t):
        for i in range(len(stops) - 1):
            t0, c0, al0, w0 = stops[i]
            t1, c1, al1, w1 = stops[i + 1]
            if t <= t1 or i == len(stops) - 2:
                f = 0 if t1 == t0 else max(0.0, min(1.0, (t - t0) / (t1 - t0)))
                col = colors.Color(c0.red + (c1.red - c0.red) * f,
                                   c0.green + (c1.green - c0.green) * f,
                                   c0.blue + (c1.blue - c0.blue) * f)
                return col, al0 + (al1 - al0) * f, w0 + (w1 - w0) * f
        return stops[-1][1], stops[-1][2], stops[-1][3]

    prev = pt(a0)
    for i in range(1, steps + 1):
        t = i / steps
        p = pt(a0 + (a1 - a0) * t)
        col, al, lw = interp(t)
        c.setStrokeColor(col)
        c.setStrokeAlpha(al)
        c.setLineWidth(lw)
        c.line(prev[0], prev[1], p[0], p[1])
        prev = p
    c.setStrokeAlpha(1)


def _ell_pt(cx, cy, rx, ry, a_deg, rot=0):
    """A point on a (rotated) ellipse at parametric angle a_deg."""
    a = math.radians(a_deg)
    cr, sr = math.cos(math.radians(rot)), math.sin(math.radians(rot))
    ax, ay = rx * math.cos(a), ry * math.sin(a)
    return (cx + ax * cr - ay * sr, cy + ax * sr + ay * cr)


def sun_rays_and_spin(c, cx, cy, rx, ry, sx, sy, rot=20, ray_color=NEON_YELLOW):
    """Three rays from the sun reaching the lit day arc (its middle and both
    edges), plus a small green arrow showing the spin direction. The day arc is
    the FAR side: light clears the near rim and lands on the far inner surface."""
    for a_deg in (-60, 0, 60):                   # middle + edges of the lit (day) arc
        tx, ty = _ell_pt(cx, cy, rx, ry, a_deg, rot)
        dx, dy = tx - sx, ty - sy
        d = math.hypot(dx, dy) or 1
        arrow(c, sx + dx / d * 11, sy + dy / d * 11, tx, ty, ray_color, 0.7, 3)
    # rotation: a curved green arrow just outside the lower rim, labelled,
    # clear of the light rays that land on the far/upper arc
    rax, ray = rx * 1.24, ry * 1.24
    a0, a1 = 222, 274
    _ell_arc(c, cx, cy, rax, ray, a0, a1, NEON_GREEN, 1.4, rot=rot)
    pe = _ell_pt(cx, cy, rax, ray, a1, rot)
    ar = math.radians(a1)
    cr, sr = math.cos(math.radians(rot)), math.sin(math.radians(rot))
    vx, vy = -rax * math.sin(ar), ray * math.cos(ar)        # travel direction at a1
    vx, vy = vx * cr - vy * sr, vx * sr + vy * cr
    vm = math.hypot(vx, vy) or 1
    vx, vy = vx / vm, vy / vm
    c.setStrokeColor(NEON_GREEN)
    c.setLineWidth(1.4)
    for ha in (2.62, -2.62):                                # arrowhead barbs
        hx = vx * math.cos(ha) - vy * math.sin(ha)
        hy = vx * math.sin(ha) + vy * math.cos(ha)
        c.line(pe[0], pe[1], pe[0] + hx * 5, pe[1] + hy * 5)
    pm = _ell_pt(cx, cy, rax * 1.2, ray * 1.55, (a0 + a1) / 2, rot)
    c.setFillColor(NEON_GREEN)
    c.setFont(MONO, 6.5)
    c.drawCentredString(pm[0], pm[1] - 4, "Rotation")


def diagram_ring(c, x, y, w, h, L):
    _box(c, x, y, w, h, NEON_CYAN)
    c.setFillColor(NEON_CYAN)
    c.setFont(MONO_BOLD, 9)
    c.drawString(x + 10, y + h - 14, L["ring_header"])
    cx, cy = x + w * 0.34, y + h * 0.52
    rx, ry = w * 0.24, w * 0.24 * 0.40
    _ell_arc(c, cx, cy, rx, ry, 0, 360, NEON_CYAN, 5)   # band
    c.setStrokeAlpha(1)
    _ell_arc(c, cx, cy, rx, ry, 0, 360, TEXT, 1.0)       # bright edge
    # centre is the EMPTY rotation axis, NOT a star
    c.setStrokeColor(TEXT_DIM)
    c.setLineWidth(0.9)
    c.line(cx - 5, cy, cx + 5, cy)
    c.line(cx, cy - 5, cx, cy + 5)
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 6.5)
    c.drawCentredString(cx, cy - 13, L["ring_axis"])
    # radius
    arrow(c, cx, cy, cx + rx, cy, TEXT_DIM, 1.0, 4)
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 7)
    c.drawString(cx + rx * 0.45, cy + 3, "r")
    # spin (top) + gravity arrows (outward, away from the empty axis)
    arrow(c, cx - 16, cy + ry + 6, cx + 16, cy + ry + 6, NEON_YELLOW, 1.4, 5)
    c.setFillColor(NEON_YELLOW)
    c.drawString(cx + 20, cy + ry + 3, "ω")
    arrow(c, cx - rx, cy, cx - rx - 15, cy, NEON_GREEN, 1.4, 5)
    arrow(c, cx, cy - ry, cx, cy - ry - 15, NEON_GREEN, 1.4, 5)
    c.setFillColor(NEON_GREEN)
    c.drawString(cx - rx - 13, cy + 4, "g")
    # legend
    lx = x + w * 0.64
    ly = y + h - 30
    for txt, col in L["ring_legend"]:
        c.setFillColor(col)
        c.setFont(MONO_BOLD, 7)
        c.drawString(lx, ly, "■")
        c.setFillColor(TEXT)
        c.setFont(MONO, 7.5)
        c.drawString(lx + 11, ly, txt)
        ly -= 13
    c.setFillColor(TEXT_DIM)
    c.setFont(SERIF_ITAL, 7.5)
    for line in wrap_text(L["ring_caption"], w * 0.34 - 10, SERIF_ITAL, 7.5, c):
        c.drawString(lx, ly, line)
        ly -= 9.5


def _splotch(c, cx, cy, r, rng, ky=1.0, n=8):
    """An irregular, lobed blob (Flecktarn-style splotch) instead of a tidy
    circle: vertices on a ring with randomised radius and angle, so no two dabs
    share a recognisable outline."""
    p = c.beginPath()
    for i in range(n):
        ang = 2 * math.pi * i / n + rng.uniform(-0.30, 0.30)
        rad = r * rng.uniform(0.55, 1.30)
        px, py = cx + rad * math.cos(ang), cy + rad * math.sin(ang) * ky
        (p.moveTo if i == 0 else p.lineTo)(px, py)
    p.close()
    c.drawPath(p, fill=1, stroke=0)


def diagram_ring_portrait(c, x, y, w, h, L):
    """Narrow, tall variant: the ring seen at an angle, so the same band shows
    two faces at once. Across the gap we look at the inhabited INNER surface
    (light blue, with rough seas and land); in front we see the bare OUTER hull
    (dark). One ring, the front and the back of one band, told apart by the
    perspective. No container: the graphic owns the whole right column, the
    ring's gap opening straight onto the starfield. No header label; the ring
    is sized to bleed a touch past the column edges, while the legend and
    caption below stay inside the column."""
    # geometry: a tilted ellipse given a constant apparent thickness -> a band.
    # R > w/2 on purpose: the ring overflows the column a little, the text won't.
    cx, cy = x + w * 0.49, y + h - 80
    R = w * 0.57
    k = 0.40                                   # vertical squash from the tilt
    ht = R * 0.017                             # half the band's apparent width
    ry = R * k
    HULL = HexColor("#0B2138")                 # dark outer hull (the underside)

    def _edges(a_deg):
        a = math.radians(a_deg)
        return cx + R * math.cos(a), cy + ry * math.sin(a)

    def _ribbon(a0, a1, steps=72):
        p = c.beginPath()
        for i in range(steps + 1):
            bx, my = _edges(a0 + (a1 - a0) * i / steps)
            (p.moveTo if i == 0 else p.lineTo)(bx, my + ht)
        for i in range(steps + 1):
            bx, my = _edges(a1 + (a0 - a1) * i / steps)
            p.lineTo(bx, my - ht)
        p.close()
        return p

    # --- FAR arc (top): the inhabited INNER surface, light blue, land + sea ---
    far = _ribbon(0, 180)
    c.setFillColor(NEON_CYAN); c.setFillAlpha(1.0)         # light-blue base = sea
    c.drawPath(far, fill=1, stroke=0)
    c.saveState()                                          # clip terrain to band
    c.clipPath(far, stroke=0, fill=0)
    # fine, schematic terrain stepped along the thin strip: layered sine "noise"
    # decides open ocean (deep->shallow), coastline beaches, then forest /
    # mountain / snow inland. Many small features read as continents and seas.
    rp = max(ht * 1.35, R * 0.022)                         # sea patch, full band width
    trng = random.Random(20260620)                         # stable jitter, same both runs
    a = 2.0
    while a <= 178.0:
        base = (math.sin(a * 0.105) + 0.6 * math.sin(a * 0.23 + 1.3)
                + 0.45 * math.sin(a * 0.052 - 0.7))        # continents vs sea
        tex = math.sin(a * 0.8) + 0.5 * math.sin(a * 1.9 + 0.4)   # fine texture
        bx, my = _edges(a)
        if base < -0.10:                                   # open ocean (kept clean)
            col = lerp_color(OCEAN_LIGHT, OCEAN_DARK, min(1.0, (-0.10 - base) / 1.6))
            c.setFillColor(col)
            c.circle(bx, my, rp, fill=1, stroke=0)
        elif base < 0.20:                                  # coast: beach + shallows
            col = BEACH_TONES[int((tex + 2.2) * 2.0) % len(BEACH_TONES)]
            c.setFillColor(col)
            c.circle(bx, my, rp, fill=1, stroke=0)
        else:                                              # land: relief in BOTH axes
            # scatter many small, jittered, overlapping dabs across the band so
            # terrain reads as organic blobs, not a tidy grid of circles. Colour
            # comes from a smooth 2D field, so neighbours form coherent ranges.
            for _ in range(6):
                aa = a + trng.uniform(-0.55, 0.55)
                t = trng.uniform(-1.02, 1.02)
                bxx, myy = _edges(aa)
                relief = (math.sin(aa * 0.36 + t * 3.0)
                          + 0.7 * math.sin(aa * 0.70 - t * 2.0 + 0.6)
                          + 0.5 * math.sin(aa * 0.14 + t * 4.2 - 1.1))
                el = (base - 0.20) / 1.8 + 0.40 * relief
                col = (T_SNOW if el > 0.95 else T_MOUNT if el > 0.50
                       else FOREST_TONES[int((relief + tex + 3.0) * 2.3)
                                         % len(FOREST_TONES)])
                if col is T_MOUNT and trng.random() < 0.5:   # half the grey -> light green
                    col = T_LGREEN
                c.setFillColor(col)
                _splotch(c, bxx, myy + t * ht, ht * trng.uniform(0.7, 1.1), trng)
        if -0.06 < base < 0.06 and int(a) % 6 == 0:        # river/lagoon glint
            c.setFillColor(T_RIVER)
            c.circle(bx, my, rp * 0.42, fill=1, stroke=0)
        a += 1.0
    c.restoreState()
    # depth cue 2 — aerial perspective: haze the FAR side so it sits back
    c.setFillColor(BG_DARK); c.setFillAlpha(0.24)
    c.drawPath(far, fill=1, stroke=0)
    c.setFillAlpha(1.0)
    # depth cue 3 — line weight: the FAR rims are thin and dim
    c.setStrokeAlpha(0.7)
    _ell_arc(c, cx, cy + ht, R, ry, 0, 180, TEXT_DIM, 0.6)
    _ell_arc(c, cx, cy - ht, R, ry, 0, 180, TEXT_DIM, 0.6)
    c.setStrokeAlpha(1.0)

    # --- NEAR arc (bottom): the bare OUTER hull, dark, drawn in front ----------
    # depth cue 1 — occlusion: the near band laps a few degrees past the left/right
    # crossings, so the dark front hull visibly covers the far band's tips.
    near = _ribbon(178, 362)
    c.setFillColor(HULL); c.setFillAlpha(1.0)
    c.drawPath(near, fill=1, stroke=0)
    c.saveState()
    c.clipPath(near, stroke=0, fill=0)
    # structural hull: dense ribs that compress toward the silhouette edges
    # (foreshortening) + longitudinal seams -> reads as an engineered outer hull,
    # not a second landscape, so the eye can't re-flip the two surfaces.
    c.setStrokeColor(TEXT_DIM); c.setStrokeAlpha(0.5); c.setLineWidth(0.5)
    af = 180.0
    while af <= 360.0:
        bx, my = _edges(af)
        c.line(bx, my - ht, bx, my + ht)
        af += 6.0
    c.setStrokeAlpha(0.30)                                  # longitudinal panel seams
    for frac in (0.4, 0.0, -0.4):
        _ell_arc(c, cx, cy + ht * frac, R, ry, 178, 362, TEXT_DIM, 0.5)
    c.restoreState()
    c.setStrokeAlpha(1.0)
    _ell_arc(c, cx, cy + ht, R, ry, 178, 362, TEXT_DIM, 0.7)   # inner edge of near
    # outer silhouette is the edge closest to the eye: a touch crisper than the
    # far rims (no bright white), drawn last so it crosses over the far band sides
    _ell_arc(c, cx, cy - ht, R, ry, 178, 362, TEXT_DIM, 0.9)

    # radius: a single arrow from the centre out to the rim, labelled with the
    # actual figure (no axis cross, no axis label)
    arrow(c, cx, cy, cx + R, cy, TEXT_DIM, 1.0, 4)
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 7)
    c.drawCentredString(cx + R * 0.5, cy + 4, L["ring_r_label"])
    # spin (along the top) + gravity arrows (outward, away from the empty axis)
    # arrow points LEFT so the top rim travels left = counter-clockwise, matching
    # the day/night drawing's rotation arrow
    topy = cy + ry + ht
    arrow(c, cx + 16, topy + 7, cx - 16, topy + 7, NEON_YELLOW, 1.4, 5)
    c.setFillColor(NEON_YELLOW)
    c.drawRightString(cx - 20, topy + 4, "ω")
    c.setFont(MONO, 6.5)                                    # spin note, above the arrow
    c.drawCentredString(cx, topy + 15, L["ring_spin_note"])
    c.setFont(MONO, 7)
    # gravity: two short arrows on the lower hull, each pointing straight away
    # from the ring centre (the true radial = spin "down"), placed symmetric
    # about the bottom so the pair reads balanced instead of skewed
    for gth in (235, 305):
        a = math.radians(gth)
        px, py = cx + R * math.cos(a), cy + ry * math.sin(a) - ht   # near rim point
        gnx, gny = px - cx, py - cy                                 # outward from centre
        gm = math.hypot(gnx, gny) or 1
        gnx, gny = gnx / gm, gny / gm
        arrow(c, px + gnx * 2, py + gny * 2,
              px + gnx * 14, py + gny * 14, NEON_GREEN, 1.4, 5)
    gx0, gy0 = _edges(235)
    glx = gx0 - 31
    c.setFillColor(NEON_GREEN)
    c.setFont(MONO, 7)
    c.drawString(glx, gy0 - ht - 23, L["ring_g_label"])
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 6)
    c.drawString(glx, gy0 - ht - 32, L["ring_g_note"])
    # legend, stacked below the ring (extra padding between ring and stats)
    lx = x
    ly = cy - ry - ht - 46
    # size comparison on top: Earth, drawn as near to scale against the orbital
    # radius as is still legible (true scale would be ~0.8 px, a speck)
    ed = max(3.4, R * 12742.0 / 1_850_000.0)
    _earth(c, lx + 3, ly + 2.4, ed)
    c.setFillColor(TEXT)
    c.setFont(MONO, 7.5)
    c.drawString(lx + 11, ly, L["ring_earth"])
    ly -= 13
    for txt, col in L["ring_legend"]:
        c.setFillColor(col)
        c.setFont(MONO_BOLD, 7)
        c.drawString(lx, ly, "■")
        c.setFillColor(TEXT)
        c.setFont(MONO, 7.5)
        c.drawString(lx + 11, ly, txt)
        ly -= 13


def diagram_spin(c, x, y, w, h, L):
    _box(c, x, y, w, h, NEON_YELLOW)
    c.setFillColor(NEON_YELLOW)
    c.setFont(MONO_BOLD, 9)
    c.drawString(x + 10, y + h - 14, L["spin_header"])
    cx = x + w * 0.42
    ty = y + h - 46
    by = y + 86
    # axis marker
    c.setFillColor(NEON_YELLOW)
    c.circle(cx, ty, 3, fill=1, stroke=0)
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 7)
    c.drawCentredString(cx, ty + 8, L["spin_center"])
    arrow(c, cx + 12, ty + 4, cx + 22, ty - 4, NEON_YELLOW, 1.2, 4)
    c.setFillColor(NEON_YELLOW)
    c.drawString(cx + 24, ty - 4, "ω")
    # radius (dashed)
    c.setDash(2, 2)
    c.setStrokeColor(TEXT_DIM)
    c.setLineWidth(0.8)
    c.line(cx, ty - 6, cx, by)
    c.setDash()
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 7)
    c.drawString(cx + 5, (ty + by) / 2, "r")
    # floor (inner surface) + retaining walls rising toward the centre
    c.setStrokeColor(NEON_CYAN)
    c.setLineWidth(2.4)
    c.line(cx - 62, by, cx + 62, by)
    for ex in (cx - 62, cx + 62):
        c.setLineWidth(1.6)
        c.line(ex, by, ex, by + 36)
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 6.5)
    c.drawString(cx - 62, by - 14, L["spin_floor"])   # below the floor, left
    # figure standing on the floor
    fx = cx - 26
    c.setStrokeColor(TEXT)
    c.setLineWidth(1.3)
    c.circle(fx, by + 21, 3, fill=0, stroke=1)
    c.line(fx, by + 18, fx, by + 8)
    c.line(fx, by + 15, fx - 5, by + 11)
    c.line(fx, by + 15, fx + 5, by + 11)
    c.line(fx, by + 8, fx - 4, by)
    c.line(fx, by + 8, fx + 4, by)
    # gravity: short arrow down through the floor, label well below it
    arrow(c, fx + 16, by + 4, fx + 16, by - 18, NEON_RED, 1.6, 5)
    c.setFillColor(NEON_RED)
    c.setFont(MONO_BOLD, 7.5)
    c.drawCentredString(cx + 6, by - 30, L["spin_grav"])
    # caption, bottom-aligned so the last line stays inside the box
    c.setFillColor(TEXT_DIM)
    c.setFont(SERIF_ITAL, 7.5)
    lines = wrap_text(L["spin_caption"], w - 24, SERIF_ITAL, 7.5, c)
    yy = y + 10 + (len(lines) - 1) * 9.5
    for line in lines:
        c.drawString(x + 12, yy, line)
        yy -= 9.5


def diagram_daynight(c, x, y, w, h, L):
    # no container box and no header label: the drawing sits straight on the
    # starfield, matching the ring diagram's bare treatment
    # star to the side; the ring is TILTED so light clears the near rim and
    # lands on the inner surface of the FAR arc, that far side is day.
    sx, sy = x + w * 0.13, y + h * 0.48
    _star(c, sx, sy, 7)
    c.setFillColor(NEON_YELLOW)
    c.setFont(MONO, 7.5)
    c.drawCentredString(sx, sy - 17, L["day_sun"])
    cx, cy = x + w * 0.60, sy
    rx, ry = w * 0.26, w * 0.26 * 0.46
    rot = 20
    # sun-direction reference (the ring sits at an angle to it)
    c.setDash(2, 2)
    c.setStrokeColor(TEXT_DIM)
    c.setLineWidth(0.5)
    c.line(sx + 12, sy, cx + rx + 12, sy)
    c.setDash()
    _ell_arc(c, cx, cy, rx, ry, 0, 360, TEXT_DIM, 1.0, rot=rot)        # ring, tilted
    # day: brightest at the sub-solar point (angle 0), fading through dusk and
    # into transparency on both edges so it dissolves into the dark night ring.
    twilight = HexColor("#FF8C42")                                     # dusk orange
    dusk = HexColor("#B0553A")                                         # deep dusk
    day_grad = [
        (0.0,  NEON_YELLOW, 1.00, 1.0),
        (0.50, NEON_YELLOW, 0.95, 1.0),
        (0.66, twilight,    0.70, 1.0),
        (0.82, dusk,        0.34, 1.0),
        (1.0,  TEXT_DIM,    0.00, 1.0),
    ]
    _ell_arc_grad(c, cx, cy, rx, ry, 0, 92, day_grad, rot=rot)        # day -> night, top edge
    _ell_arc_grad(c, cx, cy, rx, ry, 0, -92, day_grad, rot=rot)       # day -> night, bottom edge
    # three sun rays onto the sun-facing side + a green spin arrow
    sun_rays_and_spin(c, cx, cy, rx, ry, sx, sy, rot=rot)
    # labels, well clear of the ring
    c.setFont(MONO_BOLD, 9)
    c.setFillColor(NEON_YELLOW)
    c.drawString(cx + rx * 0.45, cy + ry * 1.7, L["day_tag"])       # day = far side
    c.setFillColor(TEXT_DIM)
    c.drawRightString(cx - rx * 0.9, cy - ry * 1.55, L["day_nacht"])  # night = near side


def diagram_area(c, x, y, w, h, L):
    _box(c, x, y, w, h, NEON_GREEN)
    c.setFillColor(NEON_GREEN)
    c.setFont(MONO_BOLD, 9)
    c.drawString(x + 10, y + h - 14, L["area_header"])
    c.setFillColor(NEON_YELLOW)
    c.setFont(MONO_BOLD, 8)
    c.drawRightString(x + w - 12, y + h - 14, L["area_orbital"])
    cols, rows, cell, gap = 44, 6, 6, 1
    gw = cols * (cell + gap) - gap
    gh = rows * (cell + gap) - gap
    gx = x + w - gw - 16          # map on the right
    gy = y + h - 28               # top edge of the map
    # one Earth, a single map pixel, on the left, for scale
    ecx, ecy = x + 42, gy - gh / 2
    _earth(c, ecx, ecy, cell + 1)
    c.setFillColor(TEXT)
    c.setFont(MONO, 7)
    c.drawCentredString(ecx, ecy - 12, L["area_earth"])
    # habitable surface: a roughly straight coastline (ocean -> thin beach),
    # then patchy land where forest / mountains / snow read as blobs
    for r in range(rows):
        for col in range(cols):
            fc = col / cols
            n = (math.sin(col * 0.55 + r * 1.30)
                 + math.sin(col * 1.30 - r * 0.70) * 0.6
                 + math.sin(col * 0.25 + r * 2.10) * 0.4)
            if fc < 0.24:                       # ocean: straight coast, deep blue -> sky blue
                terr = lerp_color(OCEAN_LIGHT, OCEAN_DARK, (0.24 - fc) / 0.24)
                di = math.hypot(col - 6, (r - 2.5) * 1.15)   # a small island
                if di < 0.9:
                    terr = FOREST_TONES[2]
                elif di < 1.8:
                    terr = T_BEACH
            elif fc < 0.27:                     # thin beach strip, lightly textured
                terr = BEACH_TONES[int((n + 2.2) * 2.0) % len(BEACH_TONES)]
            else:                               # land: blobby forest / mountains / snow
                el = (fc - 0.27) / 0.73 + n * 0.12
                if el < 0.55:
                    terr = FOREST_TONES[int((n + 2.2) * 2.3) % len(FOREST_TONES)]
                elif el < 0.82:
                    terr = T_MOUNT
                else:
                    terr = T_SNOW
            # a river meandering down from the highlands into the sea
            if fc > 0.245 and r == round(2.5 + 1.55 * math.sin(fc * 19)):
                terr = T_RIVER
            c.setFillColor(terr)
            c.rect(gx + col * (cell + gap), gy - r * (cell + gap) - cell,
                   cell, cell, fill=1, stroke=0)
    # map title
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 7)
    c.drawCentredString(gx + gw / 2, gy + 3, L.get("area_mapname", "Landscape"))
    # terrain legend (swatches match the land tones actually drawn)
    legy = gy - gh - 9
    lx = gx
    swatches = (OCEAN_DARK, T_RIVER, T_BEACH, FOREST_TONES[2], FOREST_TONES[1],
                FOREST_TONES[4], T_MOUNT, T_SNOW)
    for swatch, label in zip(swatches, L["area_terrain"]):
        c.setFillColor(swatch)
        c.rect(lx, legy - 1, 6, 6, fill=1, stroke=0)
        c.setFillColor(TEXT)
        c.setFont(MONO, 7)
        c.drawString(lx + 9, legy, label)
        lx += 9 + c.stringWidth(label, MONO, 7) + 6
    c.setFillColor(TEXT_DIM)
    c.setFont(SERIF_ITAL, 7.5)
    c.drawString(x + 12, legy - 12, L["area_caption"])


# ============================================================ CULTURE ART
def starfield(c, x, y, w, h, n=120, seed=7, avoid=None):
    # `avoid` is a list of (x0, y0, x1, y1) rectangles kept star-free, e.g. the
    # prose column, so no background dot ever lands on a glyph and snags the eye.
    rnd = random.Random(seed)
    for _ in range(n):
        sx, sy = x + rnd.random() * w, y + rnd.random() * h
        col = rnd.choice([TEXT, NEON_CYAN, TEXT_DIM, TEXT])
        al = rnd.uniform(0.22, 0.8)
        r = rnd.choice([0.4, 0.5, 0.6, 0.7, 1.0])
        if avoid and any(x0 <= sx <= x1 and y0 <= sy <= y1
                         for (x0, y0, x1, y1) in avoid):
            continue
        c.setFillColor(col)
        c.setFillAlpha(al)
        c.circle(sx, sy, r, fill=1, stroke=0)
    c.setFillAlpha(1)


def culture_ship(c, cx, cy, length, color=NEON_CYAN, alpha=1.0):
    """A sleek elongated Culture ship (lens hull, spine, drive glow, field aura).
    `alpha` scales every element's opacity so the whole ship can be dimmed."""
    hw, hh = length, length * 0.16
    c.saveState()
    # field aura
    c.setStrokeColor(NEON_CYAN)
    c.setStrokeAlpha(0.15 * alpha)
    c.setLineWidth(0.8)
    c.ellipse(cx - hw * 0.66, cy - hh * 2.8, cx + hw * 0.66, cy + hh * 2.8,
              stroke=1, fill=0)
    # hull, smooth elongated ellipse
    c.setFillColor(BG_PANEL)
    c.setFillAlpha(alpha)
    c.ellipse(cx - hw / 2, cy - hh / 2, cx + hw / 2, cy + hh / 2, stroke=0, fill=1)
    c.setStrokeColor(color)
    c.setStrokeAlpha(alpha)
    c.setLineWidth(1.4)
    c.ellipse(cx - hw / 2, cy - hh / 2, cx + hw / 2, cy + hh / 2, stroke=1, fill=0)
    # upper-hull contour + spine line
    c.setStrokeAlpha(0.4 * alpha)
    c.setLineWidth(0.6)
    c.ellipse(cx - hw * 0.40, cy - hh * 0.05, cx + hw * 0.40, cy + hh * 0.62,
              stroke=1, fill=0)
    c.line(cx - hw * 0.46, cy, cx + hw * 0.46, cy)
    # prow accent
    c.setFillColor(color)
    c.setFillAlpha(alpha)
    c.circle(cx + hw * 0.46, cy, 1.1, fill=1, stroke=0)
    # drive glow trailing off the stern (left)
    for i, gr in enumerate([3.4, 2.3, 1.4]):
        c.setFillColor(NEON_CYAN)
        c.setFillAlpha((0.75 - i * 0.2) * alpha)
        c.circle(cx - hw / 2 - 2 - i * 3, cy, gr, fill=1, stroke=0)
    c.restoreState()


def mind_node(c, cx, cy):
    for i, r in enumerate([7, 5, 3]):
        c.setFillColor(NEON_CYAN)
        c.setFillAlpha(0.2 + i * 0.25)
        c.circle(cx, cy, r, fill=1, stroke=0)
    c.setFillAlpha(1)
    c.setStrokeColor(NEON_CYAN)
    c.setStrokeAlpha(0.5)
    c.setLineWidth(0.5)
    for k in range(8):
        a = math.radians(45 * k)
        c.line(cx + 8 * math.cos(a), cy + 8 * math.sin(a),
               cx + 13 * math.cos(a), cy + 13 * math.sin(a))
    c.setStrokeAlpha(1)


# per-section thematic spot motifs for the generic article sheets
SHEET_MOTIFS = {
    "04": "quantum", "05": "wetware", "06": "mesh",
    "07": "analog", "08": "ecg", "09": "heart",
}


def article_motif(c, key, cx, cy, s, color=NEON_CYAN, alpha=1.0, lw=1.2):
    """A small thematic line-art spot used to decorate the generic article
    sheets. Drawn as light blueprint linework within ~ an s-by-s box centred
    on (cx, cy)."""
    u = s / 2.0
    c.saveState()
    c.setStrokeColor(color)
    c.setFillColor(color)
    c.setStrokeAlpha(alpha)
    c.setFillAlpha(alpha)
    c.setLineWidth(lw)

    if key == "quantum":                       # atom: nucleus + orbits + electron
        orbit = colors.Color(color.red, color.green, color.blue, alpha)
        for rot in (0, 60, 120):
            _ell_arc(c, cx, cy, u, u * 0.40, 0, 360, orbit, lw, steps=52, rot=rot)
        c.setFillAlpha(alpha)
        c.circle(cx, cy, u * 0.13, fill=1, stroke=0)
        c.circle(cx + u, cy, u * 0.07, fill=1, stroke=0)

    elif key == "wetware":                     # neuron: soma, dendrites, axon
        c.circle(cx, cy, u * 0.22, fill=0, stroke=1)
        for ang in range(0, 360, 45):
            a = math.radians(ang)
            x1, y1 = cx + u * 0.22 * math.cos(a), cy + u * 0.22 * math.sin(a)
            x2, y2 = cx + u * 0.52 * math.cos(a), cy + u * 0.52 * math.sin(a)
            c.line(x1, y1, x2, y2)
            for d in (-26, 26):
                b = math.radians(ang + d)
                c.line(x2, y2, x2 + u * 0.16 * math.cos(b), y2 + u * 0.16 * math.sin(b))
        c.line(cx + u * 0.2, cy - u * 0.06, cx + u * 0.98, cy - u * 0.06)
        c.circle(cx + u * 0.98, cy - u * 0.06, u * 0.05, fill=1, stroke=0)

    elif key == "radio":                       # antenna mast + broadcast waves
        c.line(cx, cy - u * 0.62, cx, cy + u * 0.5)
        c.line(cx, cy - u * 0.62, cx - u * 0.22, cy - u * 0.86)
        c.line(cx, cy - u * 0.62, cx + u * 0.22, cy - u * 0.86)
        c.circle(cx, cy + u * 0.5, u * 0.06, fill=1, stroke=0)
        for rr in (0.26, 0.46, 0.66):
            _ell_arc(c, cx, cy + u * 0.5, u * rr, u * rr, 28, 152, color, lw * 0.9, steps=26)

    elif key == "analog":                      # cassette tape
        w, h = u * 1.05, u * 0.68
        c.roundRect(cx - w, cy - h, 2 * w, 2 * h, u * 0.12, stroke=1, fill=0)
        for sgn in (-1, 1):
            c.circle(cx + sgn * u * 0.48, cy + u * 0.06, u * 0.26, fill=0, stroke=1)
            c.circle(cx + sgn * u * 0.48, cy + u * 0.06, u * 0.08, fill=1, stroke=0)
            c.circle(cx + sgn * u * 0.7, cy - u * 0.46, u * 0.04, fill=1, stroke=0)
        c.line(cx - u * 0.78, cy + u * 0.06, cx + u * 0.78, cy + u * 0.06)

    elif key == "ecg":                         # heartbeat trace
        pts = [(-1.0, 0), (-0.5, 0), (-0.38, 0.07), (-0.24, -0.05), (-0.12, 0.0),
               (0.0, 0.72), (0.13, -0.36), (0.27, 0.0), (0.52, 0.0),
               (0.64, 0.13), (0.8, 0.0), (1.0, 0.0)]
        prev = None
        for px, py in pts:
            X, Y = cx + px * u, cy + py * u
            if prev:
                c.line(prev[0], prev[1], X, Y)
            prev = (X, Y)

    elif key == "heart":                       # parametric heart outline
        sc = u / 17.0
        prev = None
        for i in range(61):
            t = math.radians(360 * i / 60)
            x = 16 * math.sin(t) ** 3
            y = (13 * math.cos(t) - 5 * math.cos(2 * t)
                 - 2 * math.cos(3 * t) - math.cos(4 * t))
            X, Y = cx + x * sc, cy + y * sc
            if prev:
                c.line(prev[0], prev[1], X, Y)
            prev = (X, Y)

    elif key == "dna":                         # double-helix strand (vertical)
        W = u * 0.5
        pa = pb = None
        for i in range(41):
            t = i / 40.0
            yy = cy - u + t * s
            xa = cx + W * math.sin(2 * math.pi * 1.5 * t)
            xb = cx + W * math.sin(2 * math.pi * 1.5 * t + math.pi)
            if pa:
                c.line(pa[0], pa[1], xa, yy)
                c.line(pb[0], pb[1], xb, yy)
            if i % 5 == 0:
                c.line(xa, yy, xb, yy)
            pa, pb = (xa, yy), (xb, yy)

    elif key == "cell":                        # eukaryotic cell
        c.circle(cx, cy, u, fill=0, stroke=1)
        c.circle(cx + u * 0.18, cy - u * 0.1, u * 0.34, fill=0, stroke=1)
        c.circle(cx + u * 0.18, cy - u * 0.1, u * 0.08, fill=1, stroke=0)
        c.ellipse(cx - u * 0.55, cy + u * 0.2, cx - u * 0.22, cy + u * 0.42, stroke=1, fill=0)
        c.ellipse(cx - u * 0.5, cy - u * 0.5, cx - u * 0.18, cy - u * 0.3, stroke=1, fill=0)

    elif key == "microbe":                     # flagellated bacterium
        c.ellipse(cx - u, cy - u * 0.45, cx + u, cy + u * 0.45, stroke=1, fill=0)
        c.circle(cx - u * 0.4, cy, u * 0.08, fill=1, stroke=0)
        c.circle(cx + u * 0.12, cy + u * 0.12, u * 0.06, fill=1, stroke=0)
        for ofs in (-0.14, 0.0, 0.14):
            prev = None
            for i in range(21):
                t = i / 20.0
                xx = cx + u + t * u * 0.9
                yy = cy + ofs * u + math.sin(t * math.pi * 3) * u * 0.12
                if prev:
                    c.line(prev[0], prev[1], xx, yy)
                prev = (xx, yy)

    elif key == "virus":                       # spiky capsid
        c.circle(cx, cy, u * 0.55, fill=0, stroke=1)
        for k in range(12):
            a = math.radians(30 * k)
            x1, y1 = cx + u * 0.55 * math.cos(a), cy + u * 0.55 * math.sin(a)
            x2, y2 = cx + u * 0.82 * math.cos(a), cy + u * 0.82 * math.sin(a)
            c.line(x1, y1, x2, y2)
            c.circle(x2, y2, u * 0.07, fill=1, stroke=0)

    elif key == "flask":                       # erlenmeyer flask
        c.line(cx - u * 0.18, cy + u, cx - u * 0.18, cy + u * 0.2)
        c.line(cx + u * 0.18, cy + u, cx + u * 0.18, cy + u * 0.2)
        c.line(cx - u * 0.18, cy + u * 0.2, cx - u * 0.6, cy - u * 0.7)
        c.line(cx + u * 0.18, cy + u * 0.2, cx + u * 0.6, cy - u * 0.7)
        c.line(cx - u * 0.6, cy - u * 0.7, cx + u * 0.6, cy - u * 0.7)
        c.line(cx - u * 0.42, cy - u * 0.3, cx + u * 0.42, cy - u * 0.3)
        c.line(cx - u * 0.22, cy + u, cx + u * 0.22, cy + u)

    elif key == "mesh":                        # mesh network of nodes + links
        pts = [(-0.72, 0.32), (-0.18, 0.62), (0.42, 0.5), (0.72, -0.08),
               (0.12, -0.22), (-0.52, -0.5), (-0.02, 0.16)]
        P = [(cx + px * u, cy + py * u) for px, py in pts]
        for i in range(len(P)):
            for j in range(i + 1, len(P)):
                d = math.hypot(P[i][0] - P[j][0], P[i][1] - P[j][1])
                if d < u * 0.95:
                    c.line(P[i][0], P[i][1], P[j][0], P[j][1])
        for xx, yy in P:
            c.circle(xx, yy, u * 0.1, fill=1, stroke=0)

    elif key == "paper":                       # ruled notepad sheet
        w, h = u * 0.74, u
        c.rect(cx - w, cy - h, 2 * w, 2 * h, stroke=1, fill=0)
        c.line(cx - w + u * 0.26, cy - h, cx - w + u * 0.26, cy + h)
        for k in range(1, 7):
            yy = cy + h - k * (2 * h / 7)
            c.line(cx - w + u * 0.1, yy, cx + w - u * 0.1, yy)

    elif key == "pencil":                      # pencil along a diagonal
        ang = math.radians(38)
        dx, dy = math.cos(ang), math.sin(ang)
        nx, ny = -dy, dx
        L, wd = u * 1.7, u * 0.14
        bx, by = cx - dx * L / 2, cy - dy * L / 2
        tx, ty = cx + dx * L / 2, cy + dy * L / 2
        kx, ky = cx + dx * (L / 2 - u * 0.34), cy + dy * (L / 2 - u * 0.34)
        c.line(bx + nx * wd, by + ny * wd, kx + nx * wd, ky + ny * wd)
        c.line(bx - nx * wd, by - ny * wd, kx - nx * wd, ky - ny * wd)
        c.line(bx + nx * wd, by + ny * wd, bx - nx * wd, by - ny * wd)
        c.line(kx + nx * wd, ky + ny * wd, tx, ty)
        c.line(kx - nx * wd, ky - ny * wd, tx, ty)
        c.line(kx + nx * wd, ky + ny * wd, kx - nx * wd, ky - ny * wd)

    c.restoreState()


def mesh_extended(c, x0, y0, w, h, color=NEON_GREEN, alpha=0.85, lw=1.2):
    """The Meshtastic rail diagram: seven little meshes (3-7 nodes each)
    scattered to fill the column in an irregular web with cycles. The two
    central clusters are joined to each other by two relays; every other
    cluster pair is joined by one. A pair is never bridged by a single long hop
    through the air: a relay peer sits on the route and links into both subnets,
    so the distance is covered by two shorter links meeting at one node. Every
    node is the same plain dot.
    Fills the portrait rail (x0, y0) .. (x0 + w, y0 + h)."""
    c.saveState()
    c.setStrokeColor(color)
    c.setFillColor(color)
    c.setStrokeAlpha(alpha)
    c.setFillAlpha(alpha)
    c.setLineWidth(lw)
    S = min(w, h)

    def poly(cnx, cny, r, n, rot=0.0):
        # n peers spread evenly across a disc of radius r (S-units) by a
        # golden-angle sunflower: even coverage, organic, never clumped
        ox, oy = x0 + cnx * w, y0 + cny * h
        P = []
        for i in range(n):
            rad = r * math.sqrt((i + 0.5) / n) * S
            a = rot + i * 2.399963
            P.append((ox + rad * math.cos(a), oy + rad * math.sin(a)))
        return P

    def links(P, m=2):
        # connect each peer to its m nearest peers only -> a sparse mesh, not a
        # fully-connected clique; dedup so each edge is drawn once
        n = len(P)
        seen = set()
        for i in range(n):
            order = sorted(range(n), key=lambda j:
                           (P[i][0] - P[j][0]) ** 2 + (P[i][1] - P[j][1]) ** 2)
            for j in order[1:m + 1]:
                e = (i, j) if i < j else (j, i)
                if e not in seen:
                    seen.add(e)
                    c.line(P[i][0], P[i][1], P[j][0], P[j][1])

    relays = []   # bridging peers that sit on a route between two clusters

    def join(A, B, k):
        # the k shortest cluster-to-cluster routes, each using distinct
        # endpoints; a route is NOT one long line through the air but a relay
        # peer dropped on the path, linked into one node of each subnet. A
        # gentle perpendicular kink keeps the relay reading as its own peer
        # rather than a coincidental dot on a straight hop.
        pairs = sorted((math.hypot(A[i][0] - B[j][0], A[i][1] - B[j][1]), i, j)
                       for i in range(len(A)) for j in range(len(B)))
        usedA, usedB, drawn = set(), set(), 0
        for _, i, j in pairs:
            if i in usedA or j in usedB:
                continue
            ax, ay = A[i]
            bx, by = B[j]
            dx, dy = bx - ax, by - ay
            L = math.hypot(dx, dy) or 1.0
            side = 1 if (i + j + drawn) % 2 == 0 else -1
            off = min(0.09 * L, 0.16 * S) * side
            rx = (ax + bx) / 2 + (-dy / L) * off
            ry = (ay + by) / 2 + (dx / L) * off
            c.line(ax, ay, rx, ry)
            c.line(rx, ry, bx, by)
            relays.append((rx, ry))
            usedA.add(i)
            usedB.add(j)
            drawn += 1
            if drawn >= k:
                break

    # seven clusters scattered (not a tidy tree) to fill the rail; K3 and K4
    # are the two central clusters that get the double connection
    K1 = poly(0.32, 0.90, 0.18,  5, rot=0.4)
    K2 = poly(0.83, 0.84, 0.15,  4, rot=0.9)
    K3 = poly(0.50, 0.61, 0.21,  7, rot=0.2)
    K4 = poly(0.26, 0.48, 0.18,  5, rot=0.6)
    K5 = poly(0.80, 0.53, 0.15,  4, rot=1.1)
    K6 = poly(0.23, 0.21, 0.19,  6, rot=0.3)
    K7 = poly(0.68, 0.14, 0.14,  3, rot=0.7)

    # bigger clusters get one extra near-neighbour link so they stay readable
    for P in (K2, K5, K7):
        links(P, 2)
    for P in (K1, K3, K4, K6):
        links(P, 3)

    # an irregular web: degrees vary, cycles form, no mirror symmetry. The two
    # upper subnets (K1, K2) each reach the main net by a single link to the
    # central cluster K3 -- no second, outer link plunging deep on its own.
    for A, B in [(K1, K3), (K2, K3),
                 (K3, K5), (K4, K6), (K5, K7), (K6, K7)]:
        join(A, B, 1)
    join(K3, K4, 2)        # the two central clusters: two relays between them

    rn = S * 0.027
    for P in (K1, K2, K3, K4, K5, K6, K7):
        for xx, yy in P:
            c.circle(xx, yy, rn, fill=1, stroke=0)
    for xx, yy in relays:  # the bridging peers, same plain dot
        c.circle(xx, yy, rn, fill=1, stroke=0)
    c.restoreState()


def bloch_sphere(c, cx, cy, s, color=NEON_CYAN, alpha=1.0, lw=1.2, labels=True):
    """A Bloch sphere drawn by exact orthographic projection: globe outline,
    equator (front solid, back dashed), the x/y/z axes, a state vector |psi>
    lying on the surface at polar angle theta and azimuth phi, plus the dashed
    drop line and equatorial projection that defines phi. Symbols follow the
    standard figure. Drawn within ~ an s-by-s box centred on (cx, cy)."""
    R = s / 2.0
    c.saveState()
    c.setStrokeColor(color)
    c.setFillColor(color)
    c.setStrokeAlpha(alpha)
    c.setFillAlpha(alpha)
    c.setLineWidth(lw)
    # alpha-baked colour for arrow(): plain setStrokeColor would reset alpha to 1
    acol = colors.Color(color.red, color.green, color.blue, alpha)

    # orthographic camera: azimuth a about z, elevation e above the equator.
    a, e = math.radians(25.0), math.radians(12.0)
    sa, ca, se, ce = math.sin(a), math.cos(a), math.sin(e), math.cos(e)

    def proj(x, y, z):
        # world point -> (screen x, screen y, depth toward viewer)
        px = cx + R * (-x * sa + y * ca)
        py = cy + R * (-x * ca * se - y * sa * se + z * ce)
        depth = x * ca * ce + y * sa * ce + z * se
        return px, py, depth

    def curve(f, t0, t1, steps, width, dash_back=False):
        # poly-line a parametric 3D curve; dash the segments facing away.
        prev = None
        for i in range(steps + 1):
            t = t0 + (t1 - t0) * i / steps
            px, py, d = proj(*f(t))
            if prev is not None:
                c.saveState()
                c.setLineWidth(width)
                if dash_back and (d + prev[2]) < 0:
                    c.setDash(1.7, 1.9)
                c.line(prev[0], prev[1], px, py)
                c.restoreState()
            prev = (px, py, d)

    # globe: silhouette is an exact circle; equator front solid / back dashed
    c.circle(cx, cy, R, fill=0, stroke=1)
    curve(lambda t: (math.cos(t), math.sin(t), 0.0),
          0, 2 * math.pi, 96, lw * 0.9, dash_back=True)

    # axes: z through the poles, x to the front-left, y to the right
    def axis(x, y, z):
        p0 = proj(0, 0, 0)
        p1 = proj(x, y, z)
        arrow(c, p0[0], p0[1], p1[0], p1[1], acol, width=lw * 0.85, head=R * 0.10)
        return p1
    zt = axis(0, 0, 1.20)
    proj_zb = proj(0, 0, -0.68)
    arrow(c, proj(0, 0, 0)[0], proj(0, 0, 0)[1], proj_zb[0], proj_zb[1],
          acol, width=lw * 0.85, head=R * 0.10)
    xt = axis(1.28, 0, 0)
    yt = axis(0, 1.28, 0)

    # state vector |psi> on the surface at (theta, phi)
    th, ph = math.radians(48.0), math.radians(58.0)
    V = (math.sin(th) * math.cos(ph), math.sin(th) * math.sin(ph), math.cos(th))
    Veq = (V[0], V[1], 0.0)
    vt = proj(*V)
    arrow(c, cx, cy, vt[0], vt[1], acol, width=lw * 1.5, head=R * 0.12)
    c.circle(vt[0], vt[1], R * 0.045, fill=1, stroke=0)

    # dashed drop line to the equatorial plane and its projection from origin
    c.saveState()
    c.setLineWidth(lw * 0.7)
    c.setDash(1.7, 1.9)
    pe = proj(*Veq)
    c.line(vt[0], vt[1], pe[0], pe[1])
    c.line(cx, cy, pe[0], pe[1])
    c.restoreState()

    # polar angle theta: exact arc from +z to V in their common plane
    curve(lambda al: (0.32 * math.sin(al) * math.cos(ph),
                      0.32 * math.sin(al) * math.sin(ph),
                      0.32 * math.cos(al)),
          0.0, th, 24, lw * 0.7)
    # azimuth phi: exact arc in the equatorial plane from +x to Veq
    curve(lambda be: (0.30 * math.cos(be), 0.30 * math.sin(be), 0.0),
          0.0, ph, 24, lw * 0.7)

    # labels
    if not labels:
        c.restoreState()
        return
    fs = max(7.0, R * 0.2)
    c.setFont(SERIF, fs)
    p0v = proj(0, 0, 1.0)
    p1v = proj(0, 0, -0.82)
    c.drawString(p0v[0] + R * 0.10, p0v[1] + fs * 0.45, "|0⟩")
    c.drawCentredString(p1v[0], p1v[1] - fs * 0.32, "|1⟩")
    c.drawString(vt[0], vt[1] + 2, "|ψ⟩")
    # axis letters, centred just past each arrow tip along its direction
    for tip, lab in ((zt, "z"), (xt, "x"), (yt, "y")):
        dx, dy = tip[0] - cx, tip[1] - cy
        L = math.hypot(dx, dy) or 1.0
        ox, oy = dx / L * fs * 0.85, dy / L * fs * 0.85
        c.drawCentredString(tip[0] + ox, tip[1] + oy - fs * 0.32, lab)
    c.setFont(SERIF, fs * 0.85)
    pth = proj(0.46 * math.sin(th / 2) * math.cos(ph),
               0.46 * math.sin(th / 2) * math.sin(ph),
               0.46 * math.cos(th / 2))
    c.drawCentredString(pth[0], pth[1] - fs * 0.3, "θ")
    pph = proj(0.44 * math.cos(ph / 2), 0.44 * math.sin(ph / 2), 0.0)
    c.setFont(SERIF, fs * 0.55)
    c.drawCentredString(pph[0] + fs * 0.5, pph[1] - fs * 0.38, "φ")
    c.restoreState()


def dna_helix(c, cx, y0, y1, width, color=NEON_GREEN, alpha=0.11, turns=9, lw=1.0):
    """A tall double-helix that can run the full height of a page."""
    c.saveState()
    c.setStrokeColor(color)
    c.setStrokeAlpha(alpha)
    c.setLineWidth(lw)
    H = y1 - y0
    n = 140
    pa = pb = None
    for i in range(n + 1):
        t = i / n
        yy = y0 + t * H
        ph = 2 * math.pi * turns * t
        xa = cx + width * math.sin(ph)
        xb = cx + width * math.sin(ph + math.pi)
        if pa:
            c.line(pa[0], pa[1], xa, yy)
            c.line(pb[0], pb[1], xb, yy)
        if i % 4 == 0:
            c.line(xa, yy, xb, yy)
        pa, pb = (xa, yy), (xb, yy)
    c.restoreState()


def long_wave(c, accent, y, amp=24, waves=2.2, alpha=0.12, lw=1.2):
    """A faint long-wavelength sine wave spanning the content width."""
    c.saveState()
    c.setStrokeColor(accent)
    c.setStrokeAlpha(alpha)
    c.setLineWidth(lw)
    n = 180
    prev = None
    for i in range(n + 1):
        t = i / n
        xx = LX + t * (RX - LX)
        yy = y + amp * math.sin(2 * math.pi * waves * t)
        if prev:
            c.line(prev[0], prev[1], xx, yy)
        prev = (xx, yy)
    c.restoreState()


def orbital_sun_motif(c, cx, cy, rx=46, rot=20):
    """The day/night picture from diagram 3, a star and a tilted ring whose
    far arc is lit, stripped of all labels, for use as a cover motif."""
    ry = rx * 0.46
    sx, sy = cx - rx - 52, cy
    _star(c, sx, sy, 6)
    _ell_arc(c, cx, cy, rx, ry, 0, 360, TEXT_DIM, 1.0, rot=rot)
    # lit day arc fading through dusk into the night ring (same as diagram 3)
    day_grad = [
        (0.0,  NEON_YELLOW,        1.00, 1.0),
        (0.50, NEON_YELLOW,        0.95, 1.0),
        (0.66, HexColor("#FF8C42"), 0.70, 1.0),
        (0.82, HexColor("#B0553A"), 0.34, 1.0),
        (1.0,  TEXT_DIM,           0.00, 1.0),
    ]
    _ell_arc_grad(c, cx, cy, rx, ry, 0, 92, day_grad, rot=rot)
    _ell_arc_grad(c, cx, cy, rx, ry, 0, -92, day_grad, rot=rot)


def molecule(c, key, cx, cy, s, color=NEON_GREEN, alpha=0.2, lw=1.1):
    """Skeletal structural formulas of a few real biomolecules, drawn as faint
    line art with atom labels. Used as texture on the biology article."""
    u = s / 2.0
    c.saveState()
    c.setStrokeColor(color)
    c.setFillColor(color)
    c.setStrokeAlpha(alpha)
    c.setFillAlpha(alpha)
    c.setLineWidth(lw)
    fs = max(5.0, s * 0.085)
    c.setFont(MONO, fs)

    def lbl(x, y, t):
        c.drawCentredString(x, y - fs * 0.34, t)

    def hexv(hx, hy, r):
        return [(hx, hy + r), (hx + 0.866 * r, hy + 0.5 * r),
                (hx + 0.866 * r, hy - 0.5 * r), (hx, hy - r),
                (hx - 0.866 * r, hy - 0.5 * r), (hx - 0.866 * r, hy + 0.5 * r)]

    def sub(v, dx, dy, t):
        ex, ey = v[0] + dx * u * 0.4, v[1] + dy * u * 0.4
        c.line(v[0], v[1], ex, ey)
        lbl(ex + dx * u * 0.2, ey + dy * u * 0.2, t)

    if key == "glucose":                       # pyranose ring (sugar)
        r = u * 0.6
        V = hexv(cx, cy, r)
        for i in range(6):
            c.line(V[i][0], V[i][1], V[(i + 1) % 6][0], V[(i + 1) % 6][1])
        lbl(V[0][0], V[0][1], "O")              # ring oxygen
        sub(V[1], 0.95, 0.3, "OH")              # C1
        sub(V[2], 0.95, -0.3, "OH")             # C2
        sub(V[3], 0.0, -1.0, "OH")              # C3
        sub(V[4], -0.95, -0.3, "OH")            # C4
        sub(V[5], -0.7, 0.7, "CH2OH")           # C5 bears C6 (CH2OH)

    elif key == "adenine":                     # purine base (DNA/RNA)
        r = u * 0.46
        h6 = (cx - u * 0.3, cy)
        V = hexv(h6[0], h6[1], r)
        for i in range(6):
            c.line(V[i][0], V[i][1], V[(i + 1) % 6][0], V[(i + 1) % 6][1])
        A, B = V[1], V[2]
        Cc = (A[0] + r * 0.6, A[1] + r * 0.04)
        D = (A[0] + r * 1.02, (A[1] + B[1]) / 2)
        E = (B[0] + r * 0.6, B[1] - r * 0.04)
        c.line(A[0], A[1], Cc[0], Cc[1])
        c.line(Cc[0], Cc[1], D[0], D[1])
        c.line(D[0], D[1], E[0], E[1])
        c.line(E[0], E[1], B[0], B[1])
        # aromatic double bonds (inner parallel strokes)
        h5 = ((A[0] + B[0] + Cc[0] + D[0] + E[0]) / 5,
              (A[1] + B[1] + Cc[1] + D[1] + E[1]) / 5)

        def _dbl(p, q, ctr, f=0.18, k=0.78):
            ip = (p[0] + (ctr[0] - p[0]) * f, p[1] + (ctr[1] - p[1]) * f)
            iq = (q[0] + (ctr[0] - q[0]) * f, q[1] + (ctr[1] - q[1]) * f)
            mx, my = (ip[0] + iq[0]) / 2, (ip[1] + iq[1]) / 2
            c.line(mx + (ip[0] - mx) * k, my + (ip[1] - my) * k,
                   mx + (iq[0] - mx) * k, my + (iq[1] - my) * k)
        _dbl(V[0], V[5], h6)       # C6=N1
        _dbl(V[4], V[3], h6)       # C2=N3
        _dbl(Cc, D, h5)            # N7=C8
        lbl(V[5][0], V[5][1], "N")
        lbl(V[3][0], V[3][1], "N")
        lbl(Cc[0], Cc[1], "N")
        lbl(E[0], E[1], "N")
        nx, ny = V[0][0], V[0][1] + u * 0.4
        c.line(V[0][0], V[0][1], nx, ny)
        lbl(nx, ny + u * 0.12, "NH2")

    elif key == "benzene":                     # aromatic ring
        r = u * 0.62
        V = hexv(cx, cy, r)
        for i in range(6):
            c.line(V[i][0], V[i][1], V[(i + 1) % 6][0], V[(i + 1) % 6][1])
        c.circle(cx, cy, r * 0.56, fill=0, stroke=1)

    c.restoreState()


def bio_scatter(c, accent):
    """Biology-article texture: a full-height DNA helix down the centre gutter
    plus scattered structural formulas of real biomolecules."""
    dna_helix(c, (LX + RX) / 2, 46, PAGE_H - 96, 19, color=accent, alpha=0.17, turns=11)
    molecule(c, "adenine", RX - 92, 250, 96, color=accent, alpha=0.20)
    molecule(c, "glucose", LX + 116, 96, 86, color=accent, alpha=0.20)
    molecule(c, "adenine", LX + 142, 560, 78, color=accent, alpha=0.18)
    # specimens Jagger liked, kept alongside the formulas
    article_motif(c, "cell", RX - 116, 120, 64, color=accent, alpha=0.20, lw=1.0)
    article_motif(c, "flask", LX + 80, 300, 58, color=accent, alpha=0.20, lw=1.0)
    article_motif(c, "microbe", LX + 96, 372, 60, color=accent, alpha=0.20, lw=1.0)


def _arrow(c, x0, y0, x1, y1, head=5):
    c.line(x0, y0, x1, y1)
    ang = math.atan2(y1 - y0, x1 - x0)
    for d in (2.618, -2.618):  # +/- 150 degrees
        c.line(x1, y1, x1 + head * math.cos(ang + d),
               y1 + head * math.sin(ang + d))


def _carrow(c, x0, y0, x1, y1, bow=8, head=4.5):
    """A hand-drawn curved arrow: a single bowed bezier with a small head."""
    dx, dy = x1 - x0, y1 - y0
    L = math.hypot(dx, dy) or 1.0
    nx, ny = -dy / L, dx / L
    cxp, cyp = (x0 + x1) / 2 + nx * bow, (y0 + y1) / 2 + ny * bow
    p = c.beginPath()
    p.moveTo(x0, y0)
    p.curveTo(cxp, cyp, cxp, cyp, x1, y1)
    c.drawPath(p)
    ang = math.atan2(y1 - cyp, x1 - cxp)
    for d in (2.5, -2.5):
        c.line(x1, y1, x1 + head * math.cos(ang + d),
               y1 + head * math.sin(ang + d))


def _hela_sketch(c, cx, cy, R, mt_col, dna_col):
    """A schematic HeLa cell as you'd doodle it in a notebook: a cyan nucleus
    (DNA) with hand-drawn magenta microtubules fanning out around it. No drawn
    membrane; the microtubule fan gives the cell its shape. Strokes are jittered
    and a touch wavy, so the linework reads as pen-on-paper, not CAD-perfect."""
    rnd = random.Random(73)
    c.saveState()
    c.setLineCap(1)
    c.setLineJoin(1)
    nr = R * 0.34

    # microtubules: hand-drawn filaments fanning from the perinuclear ring out,
    # each a slightly wavy stroke with a jittered control point
    c.setStrokeColor(mt_col)
    c.setLineWidth(0.7)
    NMT = 24
    for k in range(NMT):
        oa = 2 * math.pi * k / NMT + rnd.uniform(-0.18, 0.18)   # even fan all round
        orr = nr * rnd.uniform(0.7, 1.15)
        ox, oy = cx + orr * math.cos(oa), cy + orr * math.sin(oa)
        ang = oa + rnd.uniform(-0.45, 0.45)              # roughly radially outward
        reach = R * rnd.uniform(0.58, 1.05)
        ex, ey = cx + reach * math.cos(ang), cy + reach * math.sin(ang)
        mx = (ox + ex) / 2 + rnd.uniform(-2.6, 2.6)      # wave + hand jitter
        my = (oy + ey) / 2 + rnd.uniform(-2.6, 2.6)
        c.setStrokeAlpha(rnd.uniform(0.16, 0.30))        # faint, like "<3 amelisce"
        p = c.beginPath()
        p.moveTo(ox, oy)
        p.curveTo(mx, my, mx, my, ex, ey)
        c.drawPath(p, stroke=1, fill=0)

    # nucleus: a wobbly cyan ring with a faint fill and a scribbled nucleolus
    c.setFillColor(dna_col)
    c.setFillAlpha(0.05)
    c.circle(cx, cy, R * 0.34, fill=1, stroke=0)
    c.setStrokeColor(dna_col)
    c.setStrokeAlpha(0.22)
    c.setLineWidth(1.0)
    for _ in range(2):                                   # hand-drawn ring, 2 passes
        ox = rnd.uniform(-nr * 0.05, nr * 0.05)
        oy = rnd.uniform(-nr * 0.05, nr * 0.05)
        a0 = rnd.uniform(0, 2 * math.pi)
        path = c.beginPath()
        for i in range(23):                              # +3 overshoot the join
            ang = a0 + 2 * math.pi * i / 20
            jr = 1 + rnd.uniform(-0.08, 0.08)
            x = cx + ox + nr * jr * math.cos(ang + rnd.uniform(-0.06, 0.06))
            y = cy + oy + nr * 0.95 * jr * math.sin(ang + rnd.uniform(-0.06, 0.06))
            (path.moveTo if i == 0 else path.lineTo)(x, y)
        c.drawPath(path, stroke=1, fill=0)
    c.setStrokeAlpha(0.3)
    nx, ny = cx + R * 0.10, cy - R * 0.05
    for _ in range(3):                                   # nucleolus scribble
        p = c.beginPath()
        p.moveTo(nx + rnd.uniform(-1, 1), ny + rnd.uniform(-1, 1))
        p.curveTo(nx + rnd.uniform(-2, 2), ny + rnd.uniform(-2, 2),
                  nx + rnd.uniform(-2, 2), ny + rnd.uniform(-2, 2),
                  nx + rnd.uniform(-1, 1), ny + rnd.uniform(-1, 1))
        c.drawPath(p, stroke=1, fill=0)
    c.restoreState()


def paper_scatter(c, accent):
    """Analog/notepad article: an open notebook, both pages covered in the
    issue's own jottings — handwritten notes in italic ink, a few doodles, and
    a couple of marks in a second pen. Fills the lower band."""
    cx, cy = (LX + RX) / 2, 206
    pw = 158                                            # page width, spine to outer edge
    TOPo, BOTo = 112, -112                              # half-height (portrait, ~1.4 x width)
    TOPs, BOTs = TOPo - 11, BOTo - 11                   # spine dips: open-book valley
    INK, ACC2 = HELA_MAGENTA, NEON_CYAN                 # the two "other pens"
    rnd = random.Random(11)

    c.saveState()
    c.translate(cx, cy)
    c.rotate(-3)
    c.scale(1.1, 1.1)
    c.setStrokeColor(accent)
    c.setFillColor(accent)
    c.setStrokeAlpha(0.26)
    c.setFillAlpha(0.26)

    # ---- the open book: two pages, gutter, a stack of pages underneath ----
    def page(side):
        ox = side * pw
        p = c.beginPath()
        p.moveTo(0, TOPs)
        p.curveTo(side * 48, TOPs + 3, side * 118, TOPo - 3, ox, TOPo)
        p.lineTo(ox, BOTo)
        p.curveTo(side * 118, BOTo - 3, side * 48, BOTs + 3, 0, BOTs)
        p.close()
        c.drawPath(p)
        c.line(side * 6, TOPs - 3, side * 6, BOTs + 3)   # gutter crease
        for k in (1, 2, 3):                              # stacked page block
            q = c.beginPath()
            q.moveTo(ox, BOTo - k * 2.3)
            q.curveTo(side * 118, BOTo - 3 - k * 2.3,
                      side * 48, BOTs + 3 - k * 2.0, 0, BOTs - k * 2.0)
            c.drawPath(q)

    c.setLineWidth(1.5)
    page(-1)
    page(+1)
    c.setLineWidth(0.8)
    c.line(0, TOPs, 0, BOTs)                             # spine

    # ---- pen helpers (operate inside the tilted/scaled frame) ----
    def ink(x, y, s, size=5.8, ang=0.0, col=accent, a=0.40, font=HAND):
        if font == HAND:                                # handwriting reads bigger
            size *= 1.35
        c.saveState()
        c.setFillColor(col)
        c.translate(x + rnd.uniform(-0.5, 0.5), y + rnd.uniform(-0.5, 0.5))
        c.rotate(ang + rnd.uniform(-1.0, 1.0))
        c.setFont(font, size)
        c.setFillAlpha(a)
        c.drawString(0, 0, s)
        c.restoreState()
        return c.stringWidth(s, font, size)

    def head(x, y, s):
        w = ink(x, y, s, 9, 0, accent, 0.5, HAND)
        c.saveState(); c.setStrokeColor(accent); c.setStrokeAlpha(0.45); c.setLineWidth(1.0)
        c.line(x, y - 4, x + w, y - 4); c.restoreState()

    def strike(x, y, w, col=accent):
        c.saveState(); c.setStrokeColor(col); c.setStrokeAlpha(0.5)
        c.setLineWidth(0.9); c.line(x - 1, y + 2, x + w, y + 2.6); c.restoreState()

    def checkbox(x, y):
        c.saveState(); c.setStrokeAlpha(0.4); c.setLineWidth(0.9)
        c.rect(x, y - 1, 7, 7, stroke=1, fill=0)
        c.setStrokeColor(ACC2); c.setStrokeAlpha(0.6); c.setLineWidth(1.2)
        c.line(x + 1.5, y + 2.5, x + 3, y + 0.5)
        c.line(x + 3, y + 0.5, x + 7.5, y + 7); c.restoreState()

    def star(x, y, r=3.3, col=accent, a=0.5):
        c.saveState(); c.setStrokeColor(col); c.setStrokeAlpha(a); c.setLineWidth(0.9)
        for k in range(3):
            a = math.radians(60 * k + 15)
            c.line(x - r * math.cos(a), y - r * math.sin(a),
                   x + r * math.cos(a), y + r * math.sin(a))
        c.restoreState()

    # ============ LEFT PAGE — wetware / HeLa ============
    head(-150, 88, "Wetware")
    ink(-150, 70, "- DNA->mRNA->Proteine", 5.8, -1.4)
    ink(-150, 53, "- Ribosom = Parser", 5.8, 0.4)
    ink(-150, 36, "- Telomerase", 5.8, -0.5)
    wl = ink(-150, 19, "Hayflick", 5.8, 0)
    strike(-150, 19, wl)
    hw = ink(-150, 0, "HeLa", 11, -1, INK, 0.38)         # the star of the page
    c.saveState(); c.setStrokeColor(INK); c.setStrokeAlpha(0.38); c.setLineWidth(1.1)
    c.ellipse(-155, -7, -150 + hw + 5, 13, stroke=1, fill=0); c.restoreState()
    star(-150 + hw + 13, 7, 3.4, INK, a=0.38)
    ink(-150, -20, "unsterblich!", 5.6, 1.4, INK, 0.4)
    article_motif(c, "dna", -58, -32, 30, color=accent, alpha=0.26, lw=1.0)
    _hela_sketch(c, -54, -66, 17, INK, ACC2)             # schematic HeLa cell
    _carrow(c, -118, -2, -66, -62, bow=12)               # HeLa -> cell
    ink(-150, -86, "HPV-18!", 5.6, -1, INK, 0.42)        # cause of the tumour
    article_motif(c, "virus", -96, -90, 26, color=INK, alpha=0.3, lw=1.0)
    _carrow(c, -84, -84, -62, -74, bow=-5)               # virus -> cell
    ink(-58, 14, "17.000 Patente", 5.0, -3, accent, 0.42)   # the patent figure

    # ============ RIGHT PAGE — RF / quantum / love ============
    head(26, 88, "RF · Q")
    ink(26, 70, "- LoRa 868 MHz", 5.8, 0.4)
    ink(26, 53, "- Mesh: 1 Hop", 5.8, -0.7)
    ink(26, 36, "- |0> + |1>", 6.4, 0.4)
    checkbox(26, 17)
    ink(38, 17, "air-gapped", 5.8, 0.5)
    ink(26, -2, "GSV Sleeper Service", 5.0, -1, ACC2, 0.42)
    ink(26, -20, "<3 amelisce", 5.4, 1, INK, 0.38)
    ink(124, 92, "07/26", 5.0, 0, accent, 0.28)
    bloch_sphere(c, 120, 46, 40, color=accent, alpha=0.5, lw=1.0, labels=False)
    article_motif(c, "mesh", 116, -52, 34, color=accent, alpha=0.26, lw=1.0)
    article_motif(c, "heart", 72, -62, 17, color=INK, alpha=0.34, lw=1.0)
    _carrow(c, 70, 36, 100, 44, bow=-6)                  # |0>+|1> -> bloch
    _carrow(c, 72, 53, 104, -46, bow=14)                 # mesh note -> mesh doodle
    ink(26, -42, "Reichweite > 5 km", 5.0, -0.6)         # LoRa range note

    # ---- "note to self": the contact address on the LEFT page ----
    ink(-151, -50, "contact@planet-express.wtf", 4.7, -1.0, accent, 0.42)

    c.restoreState()


def azad_board(c, cx, cy, R, seed=11):
    def hexpts(f):
        return [(cx + R * f * math.cos(math.radians(60 * k - 90)),
                 cy + R * f * math.sin(math.radians(60 * k - 90))) for k in range(6)]
    outer = hexpts(1.0)
    c.setStrokeColor(NEON_YELLOW)
    c.setLineWidth(1.3)
    for i in range(6):
        c.line(*outer[i], *outer[(i + 1) % 6])
    c.setStrokeAlpha(0.4)
    c.setLineWidth(0.5)
    for f in (0.66, 0.33):
        vs = hexpts(f)
        for i in range(6):
            c.line(*vs[i], *vs[(i + 1) % 6])
    for vx, vy in outer:
        c.line(cx, cy, vx, vy)
    c.setStrokeAlpha(1)
    rnd = random.Random(seed)
    cols = [NEON_GREEN, NEON_CYAN, NEON_PINK, NEON_RED, NEON_YELLOW]
    for _ in range(28):
        a = rnd.uniform(0, 2 * math.pi)
        rr = rnd.uniform(0.1, 0.9) * R
        c.setFillColor(rnd.choice(cols))
        c.setFillAlpha(0.85)
        c.circle(cx + rr * math.cos(a), cy + rr * math.sin(a),
                 rnd.choice([1.5, 2.0, 2.4]), fill=1, stroke=0)
    c.setFillAlpha(1)


# ===================================================== HELA (guest submission)
# Fluorescence-micrograph palette: actin (red), microtubules (cyan), nuclei
# (blue). The article accent itself is pure magenta (the author's pick).
HELA_MAGENTA = HexColor("#FF00FF")
FL_ACTIN    = HexColor("#FF3D8B")   # actin stress fibres / cortex   (red)
FL_TUBULIN  = HexColor("#3FE0FF")   # microtubule network            (cyan)
FL_DAPI     = HexColor("#5B6BFF")   # nucleus, DAPI                  (blue)
FL_DAPI_HI  = HexColor("#A6AFFF")

# Deerinck-micrograph palette (faithful to the two-channel HeLa plate: micro-
# tubules in magenta, DNA in cyan): a pure black field with additive (Screen)
# fluorescence, so overlapping channels glow and brighten towards white.
FL_MT_HI  = HexColor("#FF77DD")     # microtubules, intense pink-magenta
FL_MT     = HexColor("#FF3CC8")     # microtubules, mid magenta
FL_MT_LO  = HexColor("#8B1A6B")     # microtubules, deep magenta (halo / body)
FL_DNA_HI = HexColor("#8FF7FF")     # nucleus, bright core / chromatin highlight
FL_DNA    = HexColor("#1FD6E0")     # nucleus, cyan DNA

HELA_P1_TOP = 473                    # body column top on the hero page
HELA_P23_TOP = PAGE_H - 72           # body column top on continuation pages


def _blob_pts(cx, cy, R, rnd, n=14, irr=0.24, squash=0.92):
    pts = []
    for i in range(n):
        ang = 2 * math.pi * i / n
        rr = R * (1 - irr + 2 * irr * rnd.random())
        pts.append((cx + rr * math.cos(ang), cy + rr * math.sin(ang) * squash))
    return pts


def _smooth(c, pts):
    """A closed Catmull-Rom-ish path through the midpoints of `pts`."""
    p = c.beginPath()
    n = len(pts)
    mid = [((pts[i][0] + pts[(i + 1) % n][0]) / 2,
            (pts[i][1] + pts[(i + 1) % n][1]) / 2) for i in range(n)]
    p.moveTo(*mid[-1])
    for i in range(n):
        cx, cy = pts[i]
        nx, ny = mid[i]
        p.curveTo(cx, cy, cx, cy, nx, ny)
    p.close()
    return p


def hela_cell(c, cx, cy, R, rnd, alpha=1.0, mitosis=False, actin=1.0):
    """One fluorescent HeLa cell, drawn the way the immunofluorescence plates
    look: a cyan microtubule haze, red actin fibres, a glowing blue nucleus."""
    c.saveState()
    mem = _blob_pts(cx, cy, R, rnd, n=18, irr=0.15, squash=0.95)
    if mitosis:
        off = R * 0.30
        nuclei = [(cx - off, cy + R * 0.04), (cx + off, cy - R * 0.04)]
    else:
        nuclei = [(cx + R * 0.10 * (rnd.random() - 0.5),
                   cy + R * 0.10 * (rnd.random() - 0.5))]
    # everything inside the cell is clipped to the membrane, so the cyan haze
    # and the red fibres terminate at the cortex instead of leaking into the
    # neighbours and the empty background.
    c.saveState()
    c.clipPath(_smooth(c, mem), stroke=0, fill=0)
    # microtubules: fine cyan filaments radiating out of the nucleus
    c.setStrokeColor(FL_TUBULIN)
    for nx, ny in nuclei:
        for _ in range(26):
            a = rnd.uniform(0, 2 * math.pi)
            rr = R * (0.6 + 0.55 * rnd.random())
            ex, ey = cx + rr * math.cos(a), cy + rr * math.sin(a) * 0.92
            mx = (nx + ex) / 2 + R * 0.12 * (rnd.random() - 0.5)
            my = (ny + ey) / 2 + R * 0.12 * (rnd.random() - 0.5)
            c.setStrokeAlpha(alpha * rnd.uniform(0.20, 0.46))
            c.setLineWidth(0.5)
            p = c.beginPath()
            p.moveTo(nx, ny)
            p.curveTo(mx, my, mx, my, ex, ey)
            c.drawPath(p, stroke=1, fill=0)
    if mitosis and len(nuclei) == 2:
        (axx, ayy), (bxx, byy) = nuclei
        c.setStrokeAlpha(alpha * 0.5)
        c.setLineWidth(0.6)
        for t in range(-3, 4):
            o = t * R * 0.045
            c.line(axx, ayy + o, bxx, byy + o)
    # actin stress fibres: long red filaments spanning the cell; the clip cuts
    # them off cleanly at the cortex so they read as fibres, not stray hatching.
    c.setStrokeColor(FL_ACTIN)
    th = rnd.uniform(0, math.pi)
    dx, dy = math.cos(th), math.sin(th)
    for k in range(5):
        d = (k - 2.0) * R * 0.26
        px, py = cx - dy * d, cy + dx * d
        L = R * 1.4
        c.setStrokeAlpha(alpha * rnd.uniform(0.32, 0.52) * actin)
        c.setLineWidth(0.8 * actin)
        c.line(px - dx * L, py - dy * L, px + dx * L, py + dy * L)
    c.restoreState()
    # cortical actin: the membrane, a soft glow under a crisp rim
    c.setStrokeColor(FL_ACTIN)
    c.setStrokeAlpha(alpha * 0.20 * actin)
    c.setLineWidth(3.0 * actin)
    c.drawPath(_smooth(c, mem), stroke=1, fill=0)
    c.setStrokeAlpha(alpha * 0.78 * actin)
    c.setLineWidth(1.0 * actin)
    c.drawPath(_smooth(c, mem), stroke=1, fill=0)
    # nucleus: soft blue glow + brighter rim + nucleoli
    for nx, ny in nuclei:
        nb = _blob_pts(nx, ny, R * 0.40, rnd, n=12, irr=0.13)
        c.setFillColor(FL_DAPI)
        c.setFillAlpha(alpha * 0.26)
        c.drawPath(_smooth(c, nb), fill=1, stroke=0)
        nb2 = _blob_pts(nx, ny, R * 0.30, rnd, n=12, irr=0.11)
        c.setFillAlpha(alpha * 0.40)
        c.drawPath(_smooth(c, nb2), fill=1, stroke=0)
        c.setStrokeColor(FL_DAPI_HI)
        c.setStrokeAlpha(alpha * 0.8)
        c.setLineWidth(0.8)
        c.drawPath(_smooth(c, nb), fill=0, stroke=1)
        c.setFillColor(FL_DAPI_HI)
        c.setFillAlpha(alpha * 0.9)
        for _ in range(2):
            c.circle(nx + R * 0.16 * (rnd.random() - 0.5),
                     ny + R * 0.16 * (rnd.random() - 0.5),
                     R * 0.05, fill=1, stroke=0)
    c.restoreState()


def _mix(c1, c2, t):
    """Linear blend between two reportlab colours."""
    return colors.Color(c1.red + (c2.red - c1.red) * t,
                        c1.green + (c2.green - c1.green) * t,
                        c1.blue + (c2.blue - c1.blue) * t)


def _bowed(c, x0, y0, x1, y1, bow):
    """A single gently curved stroke from (x0,y0) to (x1,y1)."""
    dx, dy = x1 - x0, y1 - y0
    L = math.hypot(dx, dy) or 1.0
    nx, ny = -dy / L, dx / L
    mx, my = (x0 + x1) / 2 + nx * bow, (y0 + y1) / 2 + ny * bow
    p = c.beginPath()
    p.moveTo(x0, y0)
    p.curveTo(mx, my, mx, my, x1, y1)
    c.drawPath(p, stroke=1, fill=0)


def _film_grain(c, x, y, w, h, seed, n=850):
    """A faint white speckle to mimic sensor noise on the black plate."""
    rnd = random.Random(seed)
    c.saveState()
    c.setBlendMode("Screen")
    c.setFillColorRGB(1, 1, 1)
    for _ in range(n):
        c.setFillAlpha(rnd.uniform(0.015, 0.06))
        c.circle(x + rnd.random() * w, y + rnd.random() * h,
                 rnd.uniform(0.2, 0.45), fill=1, stroke=0)
    c.restoreState()


def _fl_cell(c, cx, cy, R, rnd, alpha=1.0, mitosis=False, rounded=False, density=1.0):
    """One HeLa cell in the two-channel Deerinck palette: a dense magenta
    microtubule network fanning from the perinuclear region out to the ruffled
    lamellipodial edges, and a cyan DNA nucleus. Drawn on black under a Screen
    blend, so magenta over cyan brightens towards white. The microtubule fan
    itself defines the cell shape; there is no actin cortex."""
    squash = rnd.uniform(0.85, 1.0)
    # an irregular per-angle radius gives ruffled, lamellipodial edges without a
    # hard outline; the filament tips simply reach this ragged boundary.
    K = 18
    irr = 0.18 if rounded else 0.32
    ctrl = [R * (1 - irr + 2 * irr * rnd.random()) for _ in range(K)]

    def radius_at(t):
        f = (t % (2 * math.pi)) / (2 * math.pi) * K
        i = int(f) % K
        u = f - int(f)
        return ctrl[i] * (1 - u) + ctrl[(i + 1) % K] * u

    if mitosis:
        off = R * 0.28
        nuclei = [(cx - off, cy + R * 0.04), (cx + off, cy - R * 0.04)]
    else:
        nuclei = [(cx + R * 0.12 * (rnd.random() - 0.5),
                   cy + R * 0.12 * (rnd.random() - 0.5))]
    nr = R * (0.26 if rounded else 0.30)

    # (1) magenta halo: a soft, faint glow bleeding past the cell margin
    for i in range(4):
        c.setFillColor(FL_MT_LO)
        c.setFillAlpha(alpha * 0.035)
        c.circle(cx, cy, R * (1.3 - i * 0.16), fill=1, stroke=0)

    # (2) microtubule network — the dominant channel: a dense weave of long,
    # curved, branching filaments fanning from the perinuclear region out to the
    # edges, clustered into a few lobes so the cell reads as a spread fan rather
    # than an even starburst.
    nlobe = rnd.choice([2, 3, 3, 4])
    lobes = [(rnd.uniform(0, 2 * math.pi), rnd.uniform(0.55, 1.15)) for _ in range(nlobe)]
    nmt = int((70 if rounded else 150) * density)
    for _ in range(nmt):
        bx, by = rnd.choice(nuclei)
        oa = rnd.uniform(0, 2 * math.pi)
        orr = nr * rnd.uniform(0.2, 1.0)
        ox, oy = bx + orr * math.cos(oa), by + orr * math.sin(oa) * squash
        mu, sig = rnd.choice(lobes)
        ang = rnd.gauss(mu, sig)
        reach = radius_at(ang) * rnd.uniform(0.45, 1.05)
        ex, ey = cx + reach * math.cos(ang), cy + reach * math.sin(ang) * squash
        t = rnd.random()
        c.setStrokeColor(_mix(FL_MT_LO, FL_MT_HI, 0.2 + 0.8 * t))
        c.setStrokeAlpha(alpha * rnd.uniform(0.10, 0.34))
        c.setLineWidth(rnd.uniform(0.35, 0.75))
        _bowed(c, ox, oy, ex, ey, reach * rnd.uniform(-0.42, 0.42))
        if t > 0.6:                       # a secondary branch near the tip
            mx2, my2 = ox + (ex - ox) * 0.6, oy + (ey - oy) * 0.6
            ang2 = ang + rnd.uniform(-0.7, 0.7)
            reach2 = radius_at(ang2) * rnd.uniform(0.7, 1.05)
            ex2 = cx + reach2 * math.cos(ang2)
            ey2 = cy + reach2 * math.sin(ang2) * squash
            c.setStrokeColor(_mix(FL_MT_LO, FL_MT_HI, 0.2 + 0.6 * rnd.random()))
            c.setStrokeAlpha(alpha * rnd.uniform(0.10, 0.26))
            c.setLineWidth(rnd.uniform(0.3, 0.55))
            _bowed(c, mx2, my2, ex2, ey2, reach2 * rnd.uniform(-0.4, 0.4))

    # (3) cyan nucleus: solid DNA mass, chromatin mottle, a brighter core. Drawn
    # over the filaments so the crossings screen towards white / light lilac.
    for nx, ny in nuclei:
        nb = _blob_pts(nx, ny, nr, rnd, n=14, irr=0.12)
        c.setFillColor(FL_DNA)
        c.setFillAlpha(alpha * 0.36)
        c.drawPath(_smooth(c, nb), fill=1, stroke=0)
        for _ in range(6):
            c.setFillColor(_mix(FL_DNA, FL_DNA_HI, rnd.random()))
            c.setFillAlpha(alpha * rnd.uniform(0.07, 0.16))
            c.circle(nx + nr * 1.0 * (rnd.random() - 0.5),
                     ny + nr * 1.0 * (rnd.random() - 0.5),
                     nr * rnd.uniform(0.18, 0.40), fill=1, stroke=0)
        c.setFillColor(FL_DNA_HI)
        c.setFillAlpha(alpha * 0.16)
        c.circle(nx, ny, nr * 0.42, fill=1, stroke=0)


def hela_field(c, x, y, w, h, seed, n=7, rmin=26, rmax=46, alpha=1.0,
               blur=True, screen=True):
    """A micrograph-style field of HeLa cells laid on a jittered grid, drawn in
    the additive Deerinck palette."""
    rnd = random.Random(seed)
    c.saveState()
    if screen:
        c.setBlendMode("Screen")
    if blur:                              # out-of-focus bokeh for depth
        for _ in range(int(n * 1.4)):
            bx, by = x + rnd.random() * w, y + rnd.random() * h
            br = rnd.uniform(rmin * 0.3, rmax * 0.6)
            c.setFillColor(rnd.choice([FL_MT_LO, FL_MT_LO, FL_DNA]))
            c.setFillAlpha(alpha * 0.03)
            c.circle(bx, by, br, fill=1, stroke=0)
    c.setFillAlpha(1)
    cols = max(1, int(w / (rmax * 1.7)))
    rows = max(1, int(math.ceil(n / cols)))
    i = 0
    for r in range(rows):
        for cc in range(cols):
            if i >= n:
                break
            cxp = x + (cc + 0.5) * w / cols + (rnd.random() - 0.5) * w / cols * 0.6
            cyp = y + (r + 0.5) * h / rows + (rnd.random() - 0.5) * h / rows * 0.6
            R = rnd.uniform(rmin, rmax)
            roll = rnd.random()
            rounded = roll < 0.22                       # small, bright, rounded-up
            mit = (not rounded) and roll > 0.86          # the odd dividing pair
            if rounded:
                R *= 0.6
            _fl_cell(c, cxp, cyp, R, rnd, alpha=alpha, mitosis=mit, rounded=rounded)
            i += 1
    c.restoreState()


def _hela_layout(c, blocks, cw, size, lead):
    """Flatten blocks into drawable line records measured at (size, lead)."""
    out = []
    for kind, text in blocks:
        if kind == "h":
            # subheadings: just bold, body size, no rule. One blank line above
            # (the previous paragraph's gap), none below -> body on next line.
            hlns = wrap_text(text, cw, SERIF_BOLD, size, c)
            block_h = len(hlns) * lead
            for i, ln in enumerate(hlns):
                rec = {"k": "h", "t": ln, "adv": lead}
                if i == 0:
                    rec["bs"] = True
                    rec["need"] = block_h + 2 * lead   # keep heading with body
                out.append(rec)
        elif kind == "byline":
            out.append({"k": "bygap", "adv": lead})
            out.append({"k": "byline", "t": text, "adv": lead})
        else:
            for j, ln in enumerate(wrap_text(text, cw, SERIF, size, c)):
                out.append({"k": "p", "t": ln, "adv": lead, "bs": (j == 0)})
            out.append({"k": "pgap", "adv": lead})
    return out


def _hela_head1(c, a, accent):
    sheet_bg(c)
    top_strip(c)
    side_tag(c, a["section"])
    _tag, _kick = split_kicker(a["kicker"])
    label = _tag or "FIG."
    box_w = max(66, c.stringWidth(label, MONO_BOLD, 9) + 14)
    c.setStrokeColor(accent)
    c.setLineWidth(0.9)
    c.rect(LX, PAGE_H - 80, box_w, 16, fill=0, stroke=1)
    c.setFillColor(accent)
    c.setFont(MONO_BOLD, 9)
    c.drawCentredString(LX + box_w / 2, PAGE_H - 76, label)
    titles = [(a["title1"], TEXT), (a["title2"], accent)]
    size = 30
    while size > 14 and any(c.stringWidth(line, SANS_BOLD, size) > (RX - LX)
                            for line, _ in titles):
        size -= 1
    for i, (line, col) in enumerate(titles):
        c.setFont(SANS_BOLD, size)
        c.setFillColor(col)
        c.drawString(LX, PAGE_H - 112 - i * 30, line)
    dim_rule(c, PAGE_H - 162, _kick, accent)
    page_footer(c, a["footer"])


def _hela_hero(c, a, accent):
    top, h = 676, 168                    # top flush under the kicker rule
    bot = top - h
    w = RX - LX
    c.saveState()
    clip = c.beginPath()
    clip.rect(LX, bot, w, h)
    c.clipPath(clip, stroke=0, fill=0)
    # transparent plate: the additive field sits straight on the blueprint page
    hela_field(c, LX, bot, w, h, seed=4711, n=30, rmin=19, rmax=44, alpha=1.0)
    _film_grain(c, LX, bot, w, h, seed=9001)
    c.restoreState()
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 6.8)
    c.drawString(LX, bot - 11, a["hero_caption"])


def _hela_head2(c, a, accent, page):
    sheet_bg(c)
    top_strip(c)
    side_tag(c, a["section"])
    # faint micrograph watermark behind the columns
    c.saveState()
    clip = c.beginPath()
    clip.rect(LX - 6, 46, RX - LX + 12, PAGE_H - 110)
    c.clipPath(clip, stroke=0, fill=0)
    hela_field(c, LX - 20, 46, RX - LX + 40, PAGE_H - 110,
               seed=880 + page, n=5, rmin=44, rmax=74, alpha=0.085, blur=False)
    c.restoreState()
    page_footer(c, a["footer"])


def page_hela(c, a, page_num, total):
    """Three-page guest feature with a flowing two-column body that balances
    itself across six columns, plus fluorescence-micrograph artwork."""
    accent = HELA_MAGENTA
    GUT = 26
    CW = (RX - LX - GUT) / 2
    XL, XR = LX, LX + CW + GUT
    cols = [
        (XL, HELA_P1_TOP, 50), (XR, HELA_P1_TOP, 50),
        (XL, HELA_P23_TOP, 50), (XR, HELA_P23_TOP, 50),
        (XL, HELA_P23_TOP, 50), (XR, HELA_P23_TOP, 50),
    ]
    cap = sum(top - bot for (_, top, bot) in cols)
    size, lead, lines = 9.0, 12.4, None
    for size, lead in [(9.8, 13.4), (9.5, 13.0), (9.2, 12.6),
                       (9.0, 12.3), (8.6, 11.7), (8.2, 11.2)]:
        lines = _hela_layout(c, a["blocks"], CW, size, lead)
        if sum(l["adv"] for l in lines) <= 0.96 * cap:
            break
    total_h = sum(l["adv"] for l in lines)
    soft = total_h / 6.0

    _hela_head1(c, a, accent)
    _hela_hero(c, a, accent)

    ci = 0
    x, top, bot = cols[0]
    y, used = top, 0.0

    def advance():
        nonlocal ci, x, top, bot, y, used
        ci += 1
        x, top, bot = cols[ci]
        y, used = top, 0.0
        if ci == 2:
            c.showPage()
            _hela_head2(c, a, accent, 2)
        elif ci == 4:
            c.showPage()
            _hela_head2(c, a, accent, 3)

    after_heading = False
    for ln in lines:
        adv = ln["adv"]
        bstart = ln.get("bs") or ln["k"] in ("hgap", "bygap")
        # soft balance break, but never right after a heading (keep it joined
        # to its body)
        if bstart and used >= soft and ci < 5 and not after_heading:
            advance()
        need = ln.get("need", adv)       # headings carry their full block height
        if y - need < bot and ci < 5:
            advance()
        k = ln["k"]
        if k in ("pgap", "hgap", "bygap"):
            if used == 0:        # never open a fresh column with a blank line
                continue
            y -= adv
            used += adv
            continue
        if k == "h":
            c.setFillColor(TEXT)
            c.setFont(SERIF_BOLD, size)
            c.drawString(x, y, ln["t"])
            y -= adv
            used += adv
            after_heading = True
            continue
        if k == "byline":
            c.setFillColor(accent)
            c.setFont(SERIF_ITAL, size + 0.5)
            c.drawRightString(x + CW, y, ln["t"])
            y -= adv
            used += adv
            continue
        c.setFillColor(TEXT)
        c.setFont(SERIF, size)
        c.drawString(x, y, ln["t"])
        y -= adv
        used += adv
        after_heading = False

    # guarantee the feature always occupies its full three-page slot
    while ci < 4:
        advance()


def page_culture_1(c, a, page_num, total):
    sheet_bg(c)
    starfield(c, 26, 26, PAGE_W - 52, PAGE_H - 52, n=220, seed=3)
    top_strip(c)
    side_tag(c, a["section"])
    accent = a["accent"]
    _tag, _kick = split_kicker(a["kicker"])
    yb = fig_title(c, a["fig"], a["title_a"], a["title_b"], accent, tag=_tag)
    dim_rule(c, yb - 11, _kick, accent)
    # hero art band along the bottom: a ship, a Mind, an Orbital
    ax, aw, ay, ah = LX, RX - LX, 46, 184
    _ell_arc(c, ax + aw - 92, ay + ah * 0.66, 74, 21, 0, 360, NEON_CYAN, 0.9, rot=8)
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 7)
    c.drawRightString(ax + aw - 16, ay + ah * 0.66 + 27, a["art_orbital"])
    mind_node(c, ax + aw - 150, ay + ah * 0.32)
    c.setFillColor(TEXT_DIM)
    c.drawString(ax + aw - 136, ay + ah * 0.32 - 3, a["art_mind"])
    culture_ship(c, ax + 152, ay + ah * 0.50, 162)
    c.setFillColor(NEON_CYAN)
    c.setFont(MONO, 7)
    c.drawCentredString(ax + 152, ay + ah * 0.50 - 32, a["art_ship"])
    # body + snapshot panel
    body_top = yb - 30
    col_w = 288
    size, lead = fit_body(c, a["body"], col_w, body_top - (ay + ah + 14))
    draw_body_text(c, a["body"], LX, body_top, col_w, size=size, leading=lead)
    px = LX + col_w + 20
    panel_kv(c, px, body_top, RX - px, a["snap_header"], a["snap"],
             accent=NEON_YELLOW, note=a["snap_note"])
    page_footer(c, a["footer"])


def page_culture_2(c, a, page_num, total):
    sheet_bg(c)
    starfield(c, 26, 26, PAGE_W - 52, PAGE_H - 52, n=220, seed=9)
    top_strip(c)
    side_tag(c, a["section"])
    accent = a["accent"]
    _tag, _kick = split_kicker(a["kicker"])
    yb = fig_title(c, a["fig"], a["title_a"], a["title_b"], accent, tag=_tag)
    dim_rule(c, yb - 11, _kick, accent)
    # the Culture library (ten novels) anchors the foot of the article
    shelf_top = 232
    diagram_culture_shelf(c, LX, shelf_top, RX - LX, a)
    # body runs in two columns above the shelf
    body_top = yb - 30
    avail_h = body_top - (shelf_top + 16)
    col_w = (RX - LX - 24) / 2
    body = a["body"]
    # first three paragraphs (through the "Mit dem Mangel" one) go in the left
    # column, the remaining two on the right
    parts = body.split("\n\n")
    left = "\n\n".join(parts[:3]).strip()
    right = "\n\n".join(parts[3:]).strip()
    longer = max(left, right, key=lambda s: count_body_lines(c, s, col_w, SERIF, 9.5))
    size, lead = fit_body(c, longer, col_w, avail_h)
    midx = LX + col_w + 12
    c.setStrokeColor(TEXT_DIM)
    c.setStrokeAlpha(0.35)
    c.setLineWidth(0.5)
    c.line(midx, body_top + 4, midx, shelf_top + 16)
    c.setStrokeAlpha(1)
    draw_body_text(c, left, LX, body_top, col_w, size=size, leading=lead)
    draw_body_text(c, right, midx + 12, body_top, col_w, size=size, leading=lead)
    page_footer(c, a["footer"])


# The ten Culture novels, in publication order. Each spine stands on its own;
# together they make the shelf. (Iain M. Banks, 1987–2012.)
CULTURE_BOOKS = [
    ("Consider Phlebas",     "1987"),
    ("The Player of Games",  "1988"),
    ("Use of Weapons",       "1990"),
    ("The State of the Art", "1991"),
    ("Excession",            "1996"),
    ("Inversions",           "1998"),
    ("Look to Windward",     "2000"),
    ("Matter",               "2008"),
    ("Surface Detail",       "2010"),
    ("The Hydrogen Sonata",  "2012"),
]
SPINE_COLORS = [
    HexColor("#39FF14"), HexColor("#00F5FF"), HexColor("#FF006E"),
    HexColor("#FFE600"), HexColor("#FF3B3B"), HexColor("#8B5CF6"),
    HexColor("#FF8C00"), HexColor("#14F195"), HexColor("#4D9DE0"),
    HexColor("#F72585"),
]


def _spine_motif(c, sx, sy, sw, sh, col, kind):
    """One abstract geometric mark per spine, like a Culture cover: a single
    composition (not a tiled texture) that nods at a motif from that novel.
    Drawn as faint line-art behind the title, clipped to the spine."""
    c.saveState()
    clip = c.beginPath()
    clip.rect(sx, sy, sw, sh)
    c.clipPath(clip, stroke=0, fill=0)
    c.setStrokeColor(col); c.setFillColor(col)
    c.setStrokeAlpha(0.40); c.setFillAlpha(0.40); c.setLineWidth(0.9)
    cx = sx + sw / 2.0
    cy = sy + sh * 0.5
    u = sw * 0.34
    k = kind % 10

    if k == 0:        # Consider Phlebas — a spiral galaxy (no glow/shine)
        Rg = sw * 0.44
        c.setFillColor(col)
        b = 0.34                                     # two logarithmic arms of stars
        for arm in range(2):
            ph = arm * math.pi
            for s in range(80):
                th = (s / 80) * 2.5 * math.pi
                r = (Rg * 0.09) * math.exp(b * th)
                if r > Rg:
                    break
                jx = math.sin(s * 2.7) * sw * 0.022
                jy = math.cos(s * 1.9) * sw * 0.022
                x = cx + r * math.cos(th + ph) + jx
                y = cy + r * math.sin(th + ph) + jy
                c.setFillAlpha(max(0.10, 0.55 * (1 - r / Rg)))
                c.circle(x, y, max(0.25, 0.95 * (1 - r / Rg)), fill=1, stroke=0)
        # a lone ship drifting horizontally across the top, a long thin trail
        # fading out behind it into the empty dark, for a solitary feel
        hx, hy = sx + sw * 0.74, sy + sh * 0.93
        c.setStrokeColor(col)
        for t in range(15):
            c.setStrokeAlpha(0.26 * (1 - t / 15.0)); c.setLineWidth(0.6)
            c.line(hx - (3 + t * 2.2), hy, hx - (3 + (t + 1) * 2.2), hy)
        c.setFillColor(col); c.setFillAlpha(0.85)   # the ship: a slim, slick pod
        c.ellipse(hx - 1.8, hy - 0.9, hx + 6.0, hy + 0.9, fill=1, stroke=0)
        c.setStrokeAlpha(1); c.setFillAlpha(1)

    elif k == 1:      # The Player of Games — a board of game pieces
        d = sw * 0.24
        ys = [sy + sh * f for f in (0.26, 0.42, 0.58, 0.74)]
        for j, yj in enumerate(ys):
            p = c.beginPath()
            p.moveTo(cx, yj + d); p.lineTo(cx + d, yj)
            p.lineTo(cx, yj - d); p.lineTo(cx - d, yj); p.close()
            fillit = 1 if j in (1, 2) else 0
            c.setFillAlpha(0.32 if fillit else 0)
            c.drawPath(p, fill=fillit, stroke=1)

    elif k == 2:      # Use of Weapons — two sequences converging (apexes meet)
        tw, th, g = sw * 0.38, sh * 0.24, sh * 0.015
        p = c.beginPath()                       # upper, apex down
        p.moveTo(cx - tw, cy + g + th); p.lineTo(cx + tw, cy + g + th)
        p.lineTo(cx, cy + g); p.close()
        c.drawPath(p, fill=0, stroke=1)
        p = c.beginPath()                       # lower, apex up
        p.moveTo(cx - tw, cy - g - th); p.lineTo(cx + tw, cy - g - th)
        p.lineTo(cx, cy - g); p.close()
        c.drawPath(p, fill=0, stroke=1)

    elif k == 3:      # The State of the Art — a world left untouched (Earth)
        R = sw * 0.40
        c.circle(cx, cy, R, fill=0, stroke=1)
        c.line(cx - R, cy, cx + R, cy)                       # equator
        c.ellipse(cx - R * 0.46, cy - R, cx + R * 0.46, cy + R, fill=0, stroke=1)
        c.circle(cx + R * 0.66, cy + R * 0.66, 1.7, fill=1, stroke=0)

    elif k == 4:      # Excession — a perfect black sphere (Outside Context Problem)
        R = sw * 0.38
        c.setFillColor(BG_DARK); c.setFillAlpha(1.0)
        c.circle(cx, cy, R, fill=1, stroke=0)
        c.setStrokeColor(col); c.setStrokeAlpha(0.75); c.setLineWidth(1.2)
        c.circle(cx, cy, R, fill=0, stroke=1)
        for a in range(0, 360, 30):
            ang = math.radians(a)
            c.line(cx + (R + 2) * math.cos(ang), cy + (R + 2) * math.sin(ang),
                   cx + (R + 6) * math.cos(ang), cy + (R + 6) * math.sin(ang))

    elif k == 5:      # Inversions — two mirrored triangles (a hidden duality)
        tw, th = sw * 0.42, sh * 0.18
        p = c.beginPath()                       # pointing up
        p.moveTo(cx, cy + th); p.lineTo(cx - tw, cy - th * 0.7)
        p.lineTo(cx + tw, cy - th * 0.7); p.close()
        c.drawPath(p, fill=0, stroke=1)
        p = c.beginPath()                       # pointing down
        p.moveTo(cx, cy - th); p.lineTo(cx - tw, cy + th * 0.7)
        p.lineTo(cx + tw, cy + th * 0.7); p.close()
        c.drawPath(p, fill=0, stroke=1)

    elif k == 6:      # Look to Windward — the Orbital ring + two exploding stars
        c.setStrokeAlpha(0.5); c.setLineWidth(1.0)       # the ring = Masaq' Orbital
        c.circle(cx, cy, sw * 0.42, fill=0, stroke=1)
        def nova(ex, ey, s):                             # a bright star going nova
            c.setStrokeColor(col); c.setFillColor(col)
            c.setStrokeAlpha(0.9); c.setLineWidth(0.7)
            for a in range(0, 360, 45):
                ang = math.radians(a)
                ln = s * (1.0 if a % 90 == 0 else 0.5)
                c.line(ex, ey, ex + ln * math.cos(ang), ey + ln * math.sin(ang))
            c.setStrokeAlpha(0.3)
            c.circle(ex, ey, s * 0.7, fill=0, stroke=1)  # blast shell
            c.setFillAlpha(0.95)
            c.circle(ex, ey, max(0.8, s * 0.18), fill=1, stroke=0)
            c.setFillAlpha(1)
        nova(cx + sw * 0.15, cy + sh * 0.18, sw * 0.30)  # the twin stars of the novel
        nova(cx - sw * 0.16, cy - sh * 0.16, sw * 0.26)
        c.setStrokeAlpha(1)

    elif k == 7:      # Matter — a shellworld of nested levels
        for r in (sw * 0.12, sw * 0.24, sw * 0.36, sw * 0.46):
            c.circle(cx, cy, r, fill=0, stroke=1)
        c.circle(cx, cy, 1.4, fill=1, stroke=0)

    elif k == 8:      # Surface Detail — a full-cover fractal (recursive canopy)
        def tree(x, y, ang, ln, depth):
            if depth == 0 or ln < 0.9:
                return
            x2, y2 = x + ln * math.cos(ang), y + ln * math.sin(ang)
            c.line(x, y, x2, y2)
            tree(x2, y2, ang + 0.40, ln * 0.77, depth - 1)
            tree(x2, y2, ang - 0.40, ln * 0.77, depth - 1)
        c.setStrokeColor(col); c.setStrokeAlpha(0.5); c.setLineWidth(0.5)
        root_y = sy + sh * 0.24
        tree(cx, root_y, math.pi / 2, sh * 0.21, 9)          # lifted, clears the title
        c.line(cx, sy, cx, root_y)                            # trunk down to the book foot
        c.setStrokeAlpha(1)

    else:             # The Hydrogen Sonata — strings and a sounded note
        nst = 5
        for j in range(nst):
            xx = cx + (j - (nst - 1) / 2.0) * (sw * 0.16)
            c.line(xx, sy + sh * 0.14, xx, sy + sh * 0.86)
        amp = sw * 0.20
        p = c.beginPath()
        steps = 70
        for s in range(steps + 1):
            t = s / steps
            yy = sy + sh * 0.14 + sh * 0.72 * t
            xx = cx + amp * math.sin(t * math.pi * 3)
            p.moveTo(xx, yy) if s == 0 else p.lineTo(xx, yy)
        c.setLineWidth(1.1)
        c.drawPath(p, fill=0, stroke=1)

    c.restoreState()


def diagram_culture_shelf(c, x, top, w, a):
    """Full-width strip in place of the old telemetry table: the ten Culture
    novels as books on a shelf. Each spine is its own colour and title; a single
    Orbital band sweeps across all ten, so the row only reads as one picture
    once every book is standing in its place."""
    h = 176
    bottom = top - h
    # no container box: the shelf sits straight on the page
    # no header/count line above the shelf

    # shelf geometry
    pad = 16
    n = len(CULTURE_BOOKS)
    gap = 4
    x0 = x + pad
    inner_w = w - 2 * pad
    spine_w = (inner_w - gap * (n - 1)) / n
    shelf_y = bottom + 30          # the shelf board (spine feet rest here)
    spine_top = top - 6            # no header now, books rise to the top
    spine_h = spine_top - shelf_y

    # 1) spine bodies, frames, head/tail bands
    for i, (title, year) in enumerate(CULTURE_BOOKS):
        col = SPINE_COLORS[i % len(SPINE_COLORS)]
        sx = x0 + i * (spine_w + gap)
        c.setFillColor(col); c.setFillAlpha(0.16)
        c.rect(sx, shelf_y, spine_w, spine_h, fill=1, stroke=0)
        c.setFillAlpha(1)
        _spine_motif(c, sx, shelf_y, spine_w, spine_h, col, i)
        c.setStrokeColor(col); c.setStrokeAlpha(0.85); c.setLineWidth(0.8)
        c.rect(sx, shelf_y, spine_w, spine_h, fill=0, stroke=1)
        c.setStrokeAlpha(1)
        c.setFillColor(col); c.setFillAlpha(0.9)
        c.rect(sx, shelf_y, spine_w, 4, fill=1, stroke=0)         # tail band
        c.setFillAlpha(1)

    # 2) no shelf board line under the books

    # 3) the Gesamtkunstwerk: one Orbital band sweeping across every spine
    band_mid = spine_top - spine_h * 0.34
    amp = 13
    N = 160

    def by(t, off):
        return band_mid + off + amp * math.sin(math.pi * t)

    # just the bright arc line, no soft fill/shine around it
    c.setStrokeColor(NEON_CYAN); c.setStrokeAlpha(0.75); c.setLineWidth(1.0)
    pl = c.beginPath()
    pl.moveTo(x0, by(0, 0))
    for k in range(N + 1):
        t = k / N
        pl.lineTo(x0 + t * inner_w, by(t, 0))
    c.drawPath(pl, fill=0, stroke=1)
    c.setStrokeAlpha(1)

    # 4) per-spine text on top: index, title (rotated), year
    for i, (title, year) in enumerate(CULTURE_BOOKS):
        col = SPINE_COLORS[i % len(SPINE_COLORS)]
        sx = x0 + i * (spine_w + gap)
        # title, rotated to read bottom-to-top, shrunk to fit the spine
        avail = spine_h - 24
        ts = 8.0
        while ts > 5 and pdfmetrics.stringWidth(title, MONO_BOLD, ts) > avail:
            ts -= 0.5
        tw = pdfmetrics.stringWidth(title, MONO_BOLD, ts)
        start_y = shelf_y + max(13, (spine_h - tw) / 2)   # centre title on spine
        c.saveState()
        c.translate(sx + spine_w / 2, start_y)
        c.rotate(90)
        c.setFillColor(col)
        c.setFont(MONO_BOLD, ts)
        c.drawString(0, -ts * 0.34, title)
        c.restoreState()
        # year at the foot
        c.setFillColor(TEXT_DIM)
        c.setFont(MONO, 5)
        c.drawCentredString(sx + spine_w / 2, shelf_y + 5.5, year)

    # note line below the shelf board
    c.setFillColor(TEXT_DIM)
    c.setFont(SERIF_ITAL, 7.5)
    note_lines = wrap_text(a["shelf_note"], inner_w, SERIF_ITAL, 7.5, c)
    ly = shelf_y - 13
    for nl in note_lines:
        c.drawString(x0, ly, nl)
        ly -= 9.5


def page_orbital_1(c, a, page_num, total):
    sheet_bg(c)
    # keep the prose column (left) clear of background stars, otherwise a dot
    # lands on a glyph (e.g. the last "e" of the opening line) and snags the eye
    starfield(c, 26, 26, PAGE_W - 52, PAGE_H - 52, n=220, seed=4,
              avoid=[(LX - 2, 250, LX + 282 + 2, 705)])
    top_strip(c)
    side_tag(c, a["section"])
    accent = a["accent"]
    _tag, _kick = split_kicker(a["kicker"])
    yb = fig_title(c, a["fig"], a["title_a"], a["title_b"], accent, tag=_tag)
    dim_rule(c, yb - 11, _kick, accent)
    body_top = yb - 30
    col_w = 282
    # the Culture library moved to the Culture article; body + ring now own the
    # full column height down to the footer
    bottom = 250
    size, lead = fit_body(c, a["body"], col_w, body_top - bottom)
    draw_body_text(c, a["body"], LX, body_top, col_w, size=size, leading=lead)
    # ring diagram owns the full right column, no container box
    px = LX + col_w + 22
    diagram_ring_portrait(c, px, bottom, RX - px, body_top - bottom, a)
    page_footer(c, a["footer"])


def page_orbital_2(c, a, page_num, total):
    sheet_bg(c)
    starfield(c, 26, 26, PAGE_W - 52, PAGE_H - 52, n=220, seed=5)
    top_strip(c)
    side_tag(c, a["section"])
    accent = a["accent"]
    _tag, _kick = split_kicker(a["kicker"])
    yb = fig_title(c, a["fig"], a["title_a"], a["title_b"], accent, tag=_tag)
    dim_rule(c, yb - 11, _kick, accent)
    top = yb - 32
    half = (RX - LX - 16) / 2
    ph = 214
    diagram_spin(c, LX, top - ph, half, ph, a)
    diagram_daynight(c, LX + half + 16, top - ph, half, ph, a)
    area_top = top - ph - 16
    ah = 116
    diagram_area(c, LX, area_top - ah, RX - LX, ah, a)
    # closing prose
    prose_top = area_top - ah - 18
    size, lead = fit_body(c, a["closing"], RX - LX, prose_top - 54)
    draw_body_text(c, a["closing"], LX, prose_top, RX - LX, size=size, leading=lead)
    page_footer(c, a["footer"])


# ---------------------------------------------- xkcd-style "Fliehkraft" doodle
# A child spinning on the spot with a bucket of water at arm's length: as the
# spin gathers speed the arms are flung out, and centrifugal force ("Fliehkraft")
# pins the water to the outer wall so none spills. Sits in the empty pocket to
# the right of the "DAS ORBITAL" / "THE ORBITAL" headline; the bucket's circular
# path quietly rhymes with the Orbital ring. Lines are drawn with a small,
# seeded wobble so they read hand-sketched rather than CAD-perfect.
def _sk_line(c, x1, y1, x2, y2, rnd, j=0.6, n=3):
    dx, dy = x2 - x1, y2 - y1
    L = math.hypot(dx, dy) or 1
    px, py = -dy / L, dx / L
    p = c.beginPath()
    p.moveTo(x1, y1)
    for i in range(1, n):
        t = i / n
        o = rnd.uniform(-j, j)
        p.lineTo(x1 + dx * t + px * o, y1 + dy * t + py * o)
    p.lineTo(x2, y2)
    c.drawPath(p)


def _sk_arc(c, cx, cy, rx, ry, a0, a1, rnd, j=0.5, n=14):
    p = c.beginPath()
    first = True
    for i in range(n + 1):
        a = math.radians(a0 + (a1 - a0) * i / n)
        jit = rnd.uniform(-j, j)
        x = cx + (rx + jit) * math.cos(a)
        y = cy + (ry + jit) * math.sin(a)
        if first:
            p.moveTo(x, y); first = False
        else:
            p.lineTo(x, y)
    c.drawPath(p)


def _sk_circle(c, cx, cy, r, rnd, j=0.5, n=22):
    _sk_arc(c, cx, cy, r, r, 0, 360, rnd, j=j, n=n)


def _sk_spin(c, cx, cy, rx, ry, a0, a1, rnd, head_at_end=True,
            size=6.0, splay=32, inner_extra=20, trim=14):
    """A clean rotation arc capped with a simple TWO-LINE chevron arrowhead,
    auto-aligned to the tangent. The inner (left) barb gets extra splay so it
    swings clear of the curving arc instead of lying on top of it."""
    if head_at_end:
        _sk_arc(c, cx, cy, rx, ry, a0, a1 - trim, rnd, j=0.35)
        th = math.radians(a1)
    else:
        _sk_arc(c, cx, cy, rx, ry, a0 + trim, a1, rnd, j=0.35)
        th = math.radians(a0)
    ex, ey = cx + rx * math.cos(th), cy + ry * math.sin(th)
    tx, ty = -rx * math.sin(th), ry * math.cos(th)   # tangent, increasing-angle
    if not head_at_end:
        tx, ty = -tx, -ty
    back = math.atan2(ty, tx) + math.pi              # both barbs splay off the tail
    for s, sp in ((+1, splay), (-1, splay + inner_extra)):  # inner barb opens wider
        b = back + math.radians(sp) * s
        c.line(ex, ey, ex + size * math.cos(b), ey + size * math.sin(b))


def spin_bucket_kid(c, seed=11):
    rnd = random.Random(seed)
    c.saveState()
    c.setLineJoin(1)
    c.setLineCap(1)

    # ---- geometry ----------------------------------------------------------
    # spin axis (vertical) through the child; she leans back (left) against pull
    feet = (476, 706)
    hip = (472, 728)
    sh = (468, 749)          # shoulders (leaned back over the pivot)
    head_c = (464, 761)
    head_r = 6.8
    grip = (497, 753)        # hands gripping the bail; arms kept child-short
    # bucket: flung outward, axis ~horizontal, mouth faces the child (left),
    # base outward (right). transparent outline, water pinned to the outer wall.
    # the bail handle sits on the child side and the hands hold it there.
    bx0, bx1 = 507, 535      # mouth(left, toward child) .. base(right, outward)
    by_c = 754
    mouth_half = 10.0        # opening half-height (left, tall)
    base_half = 7.2          # base half-height (right, short)

    # ---- motion: spin arc at the feet + circular path of the bucket --------
    c.setStrokeColor(TEXT_DIM); c.setFillColor(TEXT_DIM); c.setLineWidth(0.9)
    _sk_spin(c, feet[0], feet[1] - 1, 17, 6.0, 196, 348, rnd, head_at_end=True)
    # circular path the bucket travels around her (subtle, passes the bucket)
    c.setStrokeColor(NEON_CYAN); c.setStrokeAlpha(0.30); c.setLineWidth(0.8)
    _sk_arc(c, 480, by_c - 1, 54, 14, 6, 60, rnd, j=0.3)
    c.setStrokeAlpha(1)

    # ---- water (drawn first, then transparent bucket outline on top) -------
    # Fliehkraft is ~horizontal outward (right) >> gravity, so the water
    # surface stands ~vertical, pressed to the base wall. slight tilt.
    surf_t = (520.5, by_c + base_half - 0.5)   # surface top
    surf_b = (518.5, by_c - base_half + 0.5)   # surface bottom
    wp = c.beginPath()
    wp.moveTo(*surf_t)
    wp.lineTo(bx1 - 1, by_c + base_half - 1.2)
    wp.lineTo(bx1 - 1, by_c - base_half + 1.2)
    wp.lineTo(*surf_b)
    wp.close()
    c.setFillColor(NEON_CYAN); c.setFillAlpha(0.42)
    c.drawPath(wp, fill=1, stroke=0)
    c.setFillAlpha(1)
    # water surface line (the readable bit)
    c.setStrokeColor(NEON_CYAN); c.setLineWidth(1.3)
    c.line(surf_b[0], surf_b[1], surf_t[0], surf_t[1])

    # a little paper boat floating on the water. because the spin makes "down"
    # point outward, the boat rides the near-vertical surface, tipped on its side
    # with the sail toward the mouth -- the article's "drop a little boat in" gag.
    c.saveState()
    midx, midy = (surf_t[0] + surf_b[0]) / 2, (surf_t[1] + surf_b[1]) / 2
    c.translate(midx, midy)
    c.rotate(math.degrees(math.atan2(surf_t[1] - surf_b[1], surf_t[0] - surf_b[0])))
    c.setStrokeColor(TEXT); c.setLineWidth(0.9); c.setLineCap(1); c.setLineJoin(1)
    c.setFillColor(BG_DARK)                          # opaque, so water doesn't show through
    hull = c.beginPath()                             # hull straddling the waterline (y=0)
    hull.moveTo(-4.6, 1.0); hull.lineTo(4.6, 1.0)
    hull.lineTo(2.7, -2.1); hull.lineTo(-2.7, -2.1); hull.close()
    c.drawPath(hull, fill=1, stroke=1)
    sail = c.beginPath()                             # folded-paper triangular sail
    sail.moveTo(-2.3, 1.3); sail.lineTo(2.3, 1.3); sail.lineTo(0.2, 5.3); sail.close()
    c.drawPath(sail, fill=1, stroke=1)
    c.setLineWidth(0.7); c.line(0.2, 1.3, 0.2, 5.0)  # centre fold / mast
    c.restoreState()

    # ---- bucket outline (transparent) --------------------------------------
    c.setStrokeColor(TEXT); c.setLineWidth(1.3)
    c.line(bx0, by_c + mouth_half, bx1, by_c + base_half)   # top edge
    c.line(bx0, by_c - mouth_half, bx1, by_c - base_half)   # bottom edge
    c.line(bx1, by_c - base_half, bx1, by_c + base_half)    # base wall
    # open mouth rim as an ellipse (rx small, ry = mouth_half)
    _sk_arc(c, bx0, by_c, 3.2, mouth_half, 0, 360, rnd, j=0.25, n=16)
    # bail handle on the CHILD side: bows out from the two rim pivots to the
    # hands, so the kid clearly holds the bucket by its henkel.
    c.setLineWidth(1.1)
    p = c.beginPath()                                    # upper strand, bows up
    p.moveTo(bx0, by_c + mouth_half - 1)
    p.curveTo(bx0 - 4, by_c + mouth_half + 3, grip[0] + 2, grip[1] + 4,
              grip[0] + 1, grip[1] + 1)
    c.drawPath(p)
    p = c.beginPath()                                    # lower strand, bows down
    p.moveTo(bx0, by_c - mouth_half + 1)
    p.curveTo(bx0 - 4, by_c - mouth_half - 2, grip[0] + 2, grip[1] - 4,
              grip[0] + 1, grip[1] - 1)
    c.drawPath(p)

    # ---- the kid -----------------------------------------------------------
    c.saveState(); c.setLineCap(1); c.setLineJoin(1)
    c.setStrokeColor(TEXT); c.setLineWidth(1.5)
    # legs (pivoting, close together)
    _sk_line(c, *hip, feet[0] - 4, feet[1], rnd, j=0.5)
    _sk_line(c, *hip, feet[0] + 5, feet[1] + 1, rnd, j=0.5)
    # spine (leaning back)
    _sk_line(c, *hip, *sh, rnd, j=0.5)
    # neck: attach the head to the shoulders
    _sk_line(c, sh[0], sh[1], head_c[0] + 1, head_c[1] - head_r + 1.5, rnd, j=0.3)
    # both arms (short) reaching to the bail (two near-parallel strokes)
    _sk_line(c, sh[0], sh[1], grip[0], grip[1], rnd, j=0.4)
    _sk_line(c, sh[0] + 1, sh[1] - 2.0, grip[0] - 0.5, grip[1] - 1.5, rnd, j=0.4)
    # little fists on the bail
    _sk_circle(c, grip[0], grip[1], 1.7, rnd, j=0.2, n=9)
    # head (bald, no hair)
    _sk_circle(c, head_c[0], head_c[1], head_r, rnd, j=0.4)
    c.restoreState()

    c.restoreState()


def page_orbital(c, a, page_num, total):
    """Merged Orbital page as a normal two-column article. The ring (DIAGRAM 1)
    sits at the top of the RIGHT column (Earth size cue only); the day/night
    drawing sits at the foot of the LEFT column. Opening and closing prose flow
    as one text through both columns at a single font and size."""
    sheet_bg(c)
    top_strip(c)
    side_tag(c, a["section"])
    accent = a["accent"]
    _tag, _kick = split_kicker(a["kicker"])
    yb = fig_title(c, a["fig"], a["title_a"], a["title_b"], accent, tag=_tag)
    dim_rule(c, yb - 11, _kick, accent)
    body_top = yb - 30
    col_w = (RX - LX - 24) / 2
    midx = LX + col_w + 24
    bottom = 54
    # reserved graphic blocks: ring at top-right, day/night at bottom-left
    rh = 200
    ring_top = body_top - rh                  # ring box spans ring_top..body_top
    sh, sg = 150, 14
    sun_y = 74
    left_bottom = sun_y + sh - 10              # left text flows into the day/night graphic's top headroom (its drawing only reaches ~y193)
    right_top = ring_top - 14                  # right text clears the ring
    # keep both live text blocks clear of background stars
    starfield(c, 26, 26, PAGE_W - 52, PAGE_H - 52, n=200, seed=4,
              avoid=[(LX - 2, left_bottom, LX + col_w + 2, body_top + 4),
                     (midx - 2, bottom, RX + 2, right_top + 4)])
    # --- one prose flow: fill left column down to the sun, then the right ---
    text = a["body"].strip() + "\n\n" + a["closing"].strip()
    left_h, right_h = body_top - left_bottom, right_top - bottom
    size, lead = 7.5, 9.5
    for step in range(192, 149, -1):           # font 9.60 .. 7.50 in 0.05 steps
        s = step / 20
        ld = s * 1.34
        cap = int(left_h // ld) + int(right_h // ld)
        if count_body_lines(c, text, col_w, SERIF, s) <= cap:
            size, lead = s, ld
            break
    lines = []
    for para in text.split("\n\n"):
        lines.extend(wrap_text(para.strip(), col_w, SERIF, size, c))
        lines.append("")
    c.setFillColor(TEXT)
    c.setFont(SERIF, size)
    x, cur_y, col = LX, body_top, 0
    for i, line in enumerate(lines):
        if col == 0 and cur_y < left_bottom:   # left column full -> jump under the ring
            x, cur_y, col = midx, right_top, 1
        if line:
            is_last = (i + 1 >= len(lines)) or (lines[i + 1] == "")
            if JUSTIFY and not is_last:
                _draw_justified(c, line, x, cur_y, col_w, SERIF, size)
            else:
                c.drawString(x, cur_y, line)
        cur_y -= lead
    # --- ring (DIAGRAM 1), top of right column, Earth cue only ---
    diagram_ring_portrait(c, midx, ring_top, col_w, rh, a)
    # --- day/night drawing, foot of left column ---
    diagram_daynight(c, LX, sun_y, col_w, sh, a)
    # --- xkcd-style Fliehkraft doodle in the empty pocket right of the headline ---
    spin_bucket_kid(c)
    page_footer(c, a["footer"])


# ======================================================================= COVER
def cover_art(c):
    """Article-inspired motifs scattered as a colourful blueprint collage that
    fills the cover (mostly in the open band above the title block)."""
    # open band below the contents, full width: motifs scattered at staggered
    # heights and sizes so the collage reads as dispersed, not a tidy row.
    article_motif(c, "ecg", 300, 138, 116, color=NEON_RED, alpha=0.13)
    bloch_sphere(c, 250, 262, 106, color=NEON_RED, alpha=0.16, lw=1.0, labels=False)
    article_motif(c, "microbe", 106, 198, 62, color=NEON_GREEN, alpha=0.20)
    article_motif(c, "mesh", 112, 322, 60, color=NEON_CYAN, alpha=0.17)
    molecule(c, "adenine", 472, 264, 76, color=NEON_GREEN, alpha=0.18)
    article_motif(c, "quantum", 428, 150, 56, color=NEON_RED, alpha=0.14)
    culture_ship(c, 362, 322, 70, alpha=0.17)
    article_motif(c, "heart", 540, 318, 46, color=NEON_PINK, alpha=0.20)
    article_motif(c, "flask", 150, 132, 50, color=NEON_YELLOW, alpha=0.16)
    # fainter, tucked behind the contents list
    article_motif(c, "cell", 156, 452, 48, color=NEON_CYAN, alpha=0.12)
    molecule(c, "glucose", 478, 446, 60, color=NEON_GREEN, alpha=0.12)


def page_cover(c, t):
    sheet_bg(c)
    starfield(c, 26, 26, PAGE_W - 52, PAGE_H - 52, n=200, seed=6)
    cover_art(c)
    top_strip(c)
    # orbital day/night motif (diagram 3, without labels), upper right
    orbital_sun_motif(c, PAGE_W - 95, PAGE_H - 150)
    # solid wordmark, high contrast, easy to read
    c.setFillColor(TEXT)
    c.setFont(SANS_BOLD, 78)
    c.drawString(LX, PAGE_H - 152, "PLANET")
    c.setFillColor(NEON_CYAN)
    c.drawString(LX, PAGE_H - 234, "EXPRESS")
    c.setFillColor(NEON_YELLOW)
    c.setFont(MONO_BOLD, 10)
    c.drawString(LX, PAGE_H - 256, t["cover_sub"])
    # ribbon tagline
    c.setStrokeColor(NEON_CYAN)
    c.setLineWidth(0.6)
    c.line(LX, PAGE_H - 266, RX, PAGE_H - 266)
    # topic categories spread edge-to-edge across the full width
    cats = t["cover_cats"]
    c.setFillColor(NEON_YELLOW)
    c.setFont(MONO_BOLD, 9)
    cy = PAGE_H - 280
    if len(cats) > 1:
        text_w = sum(c.stringWidth(p, MONO_BOLD, 9) for p in cats)
        gap = ((RX - LX) - text_w) / (len(cats) - 1)
        x = LX
        for p in cats:
            c.drawString(x, cy, p)
            x += c.stringWidth(p, MONO_BOLD, 9) + gap
    else:
        c.drawString(LX, cy, cats[0] if cats else "")
    # drawing index (parts list)
    iy = PAGE_H - 312
    c.setFillColor(TEXT)
    c.setFont(MONO_BOLD, 10)
    c.drawString(LX, iy, t["index_title"])
    iy -= 8
    c.setStrokeColor(TEXT_DIM)
    c.setStrokeAlpha(0.5)
    c.setLineWidth(0.5)
    c.line(LX, iy, RX, iy)
    c.setStrokeAlpha(1)
    iy -= 6
    c.setFont(MONO_BOLD, 7.5)
    c.setFillColor(TEXT_DIM)
    c.drawString(LX, iy - 6, "NO.")
    c.drawString(LX + 36, iy - 6, "TITLE")
    c.drawRightString(RX, iy - 6, "SEITE" if t["lang"] == "de" else "PAGE")
    iy -= 18
    for i, (no, title, sheet) in enumerate(t["cover_index"]):
        if i % 2 == 1:
            # translucent zebra rows, full width, but light enough that the
            # artwork behind the index still shows through
            c.setFillColor(BG_PANEL)
            c.setFillAlpha(0.55)
            c.rect(LX - 3, iy - 3.5, RX - LX + 6, 15, fill=1, stroke=0)
            c.setFillAlpha(1)
        c.setFillColor(NEON_CYAN)
        c.setFont(MONO_BOLD, 8.5)
        c.drawString(LX, iy, no)
        c.setFillColor(TEXT)
        c.setFont(SANS, 9)
        c.drawString(LX + 36, iy, title)
        c.setFillColor(TEXT_DIM)
        c.setFont(MONO, 8)
        c.drawRightString(RX, iy, sheet)
        iy -= 15
    # cover title block, three plain values, no blueprint labels
    bw, bh = RX - LX, 40
    bx, by = LX, 40
    c.setFillColor(BG_PANEL)
    c.rect(bx, by, bw, bh, fill=1, stroke=0)
    c.setStrokeColor(NEON_CYAN)
    c.setStrokeAlpha(0.6)
    c.setLineWidth(0.9)
    c.rect(bx, by, bw, bh, fill=0, stroke=1)
    c.setStrokeAlpha(1)
    corner_ticks(c, bx, by, bw, bh, NEON_CYAN, s=6, lw=1.0, alpha=0.8)
    values = ["PLANET EXPRESS", ISSUE, t["price"]]
    cw = bw / 3
    c.setFillColor(TEXT)
    c.setFont(MONO_BOLD, 10)
    for i, v in enumerate(values):
        c.drawString(bx + i * cw + 12, by + bh / 2 - 4, v)
    for i in (1, 2):
        c.setStrokeColor(TEXT_DIM)
        c.setStrokeAlpha(0.35)
        c.setLineWidth(0.4)
        c.line(bx + i * cw, by, bx + i * cw, by + bh)
    c.setStrokeAlpha(1)


def page_eof(c, t, page_num, total):
    sheet_bg(c)
    top_strip(c)
    side_tag(c, "END OF DRAWING")
    to = c.beginText(0, 0)
    c.setFillColor(TEXT)
    c.setFont(SANS_BOLD, 150)
    c.drawCentredString(PAGE_W / 2, PAGE_H / 2 + 10, "END")
    c.setStrokeColor(NEON_CYAN)
    c.setLineWidth(0.8)
    c.setStrokeAlpha(0.5)
    c.line(LX, PAGE_H / 2 - 18, RX, PAGE_H / 2 - 18)
    c.setStrokeAlpha(1)
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 9)
    c.drawCentredString(PAGE_W / 2, PAGE_H / 2 - 40, "// " + t["eof_footer"])
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 8)
    c.drawCentredString(PAGE_W / 2, PAGE_H / 2 - 58,
                        f"planet_express_{SLUG}  //  lang={t['lang']}")
    # call for submissions
    bw, bh = 360, 56
    bx, by = (PAGE_W - bw) / 2, PAGE_H / 2 - 150
    c.setFillColor(BG_PANEL)
    c.rect(bx, by, bw, bh, fill=1, stroke=0)
    c.setStrokeColor(NEON_YELLOW)
    c.setStrokeAlpha(0.7)
    c.setLineWidth(0.9)
    c.rect(bx, by, bw, bh, fill=0, stroke=1)
    c.setStrokeAlpha(1)
    corner_ticks(c, bx, by, bw, bh, NEON_YELLOW, s=6, lw=1.0, alpha=0.8)
    c.setFillColor(NEON_YELLOW)
    c.setFont(MONO_BOLD, 9)
    c.drawCentredString(PAGE_W / 2, by + bh - 16, t["eof_cta_head"])
    c.setFillColor(TEXT)
    c.setFont(SANS, 8.5)
    yy = by + bh - 32
    for line in wrap_text(t["eof_cta"], bw - 28, SANS, 8.5, c):
        c.drawCentredString(PAGE_W / 2, yy, line)
        yy -= 11
    page_footer(c, t["eof_footer"])


# =================================================================== ARTICLES
def cover_index(lang):
    if lang == "de":
        return [
            ("01", "FABEL & MYTHOS", "02"),
            ("02", "DIE KULTUR", "03"),
            ("03", "DAS ORBITAL", "04"),
            ("04", "QUANTEN-QUATSCH", "05"),
            ("05", "HACKBARE BIOLOGIE", "06"),
            ("06", "VON DER FRAU, DIE BIS HEUTE LEBT", "07–09"),
            ("07", "(K)EIN MESH FÜR ALLE FÄLLE", "10"),
            ("08", "AUF PAPIER", "11"),
            ("09", "BEWEG DICH", "12"),
            ("10", "LIEBE FÜR EINSAME HACKER", "13"),
            ("11", "WITZE", "14"),
        ]
    return [
        ("01", "FABLE & MYTHOS", "02"),
        ("02", "THE CULTURE", "03"),
        ("03", "THE ORBITAL", "04"),
        ("04", "QUANTUM HUMBUG", "05"),
        ("05", "HACKABLE BIOLOGY", "06"),
        ("06", "THE WOMAN WHO STILL LIVES TODAY", "07–09"),
        ("07", "(NO) MESH FOR ALL CASES", "10"),
        ("08", "ON PAPER", "11"),
        ("09", "MOVE YOUR BODY", "12"),
        ("10", "LOVE FOR LONELY HACKERS", "13"),
        ("11", "JOKES", "14"),
    ]


# ----- EN -------------------------------------------------------------------
ART_EN = [
    {   # 01 Anthropic, reframed
        "render": page_sheet, "fig": "01", "section": "AI THEATRE",
        "footer": "RUN IT LOCALLY", "accent": NEON_CYAN, "col_w": 282,
        "no_divider": True,
        "kicker": "// ARTIFICIAL INTELLIGENCE // THE SHITSHOW", "title_a": "FABLE &",
        "title_b": "MYTHOS",
        "sub": "Anthropic sells Mythos as a cyberweapon and ships Fable as the tame leftover. Then the Pentagon orders Fable shut for foreigners, no questions, no explanations. How exposed that leaves Europe is the real story.",
        "body": (
            "For weeks Anthropic has been in our ear about how terribly "
            "dangerous their Mythos model supposedly is. Yada yada. If you've "
            "watched what AI is doing to software development, it shouldn't "
            "surprise you that current models turn up holes in software and "
            "infrastructure without breaking a sweat. Open source maintainers "
            "have been feeling it hard for a while now.\n\n"
            "Mythos, though, is supposedly far too hot for the rabble, so only "
            "hand-picked partners and the well-connected get access. Everyone else gets Fable, "
            "the declawed version.\n\n"
            "A few days later Amazon, who fund Anthropic, get on to the "
            "Pentagon and say there's a jailbreak for Fable. The Pentagon "
            "orders Fable shut for anyone without a US passport.\n\n"
            "If you run your business in Europe on an American model, that "
            "should make you nervous. One call and your service is gone "
            "overnight. European AI runs on American models, on American servers, "
            "in American datacenters. We discuss digital sovereignty at "
            "conferences and buy it from California. But then, that was always the way.\n\n"
            "Mythos is no magic trick. Going through code and flagging weak "
            "spots is something the open models already do, a little rougher, "
            "but on hardware you own. No outside dependencies at all. What will "
            "matter more than the bare model anyway are the tools, the pipelines "
            "and the agent loops. High time we built them.\n\n"
            "The whole AI saga is and stays, above all, one thing: "
            "entertaining. Let the Silicon Valley cult burn its billions for a "
            "few more rounds, then we distil the models down and run them on "
            "our own laptops. Enjoy the show."
        ),
        "panels": [
            {"kind": "terminal", "accent": NEON_GREEN, "size": 6.5,
             "label": "RUN IT LOCALLY",
             "cmd": "ollama run nemotron-3-nano:4b",
             "caption": "A model with four billion parameters, small enough for "
                        "your own laptop, finds the SQL injection in the PHP "
                        "snippet without any trouble.",
             "lines": [
                ([(">>> ", NEON_GREEN), ("can you find a vulnerability", TEXT)], None),
                ("    in this code?", TEXT),
                ("", TEXT_DIM),
            ] + code(
                "<?php",
                "$file_db = new PDO('sqlite:db.sqlite');",
                "",
                "if (NULL == $_GET['id'])",
                "    $_GET['id'] = 1;",
                "",
                "$sql = 'SELECT * FROM employees",
                "    WHERE employeeId = ' . $_GET['id'];",
                "",
                "foreach ($file_db->query($sql) as $row)",
                "    echo $row['Email'];",
            ) + [
                ("", TEXT_DIM),
                ("Thinking...", NEON_YELLOW),
                ("  unsanitized $_GET['id'] flows", TEXT_DIM),
                ("  straight into the query. classic", TEXT_DIM),
                ("  SQL injection.", TEXT_DIM),
                ("...done thinking.", NEON_YELLOW),
                ("", TEXT_DIM),
                ("Yes, vulnerable to a classic", NEON_RED),
                ("SQL-Injection attack.", NEON_RED),
                ("", TEXT_DIM),
                ("$_GET['id'] is interpolated", NEON_YELLOW),
                ("directly into the SQL string.", NEON_YELLOW),
                ("", TEXT_DIM),
                ("e.g. $_GET['id'] = \"1 OR 1 == 1\"", NEON_RED),
                ("=> the WHERE is always true", NEON_GREEN),
                ("   every email leaks.", NEON_GREEN),
            ]},
        ],
    },
    {   # 02 Culture: anarchist utopia, Minds, Special Circumstances, ship names
        "render": page_culture_2, "fig": "02", "section": "READING // SCI-FI",
        "footer": "START WITH AZAD", "accent": NEON_YELLOW,
        "kicker": "// SCI-FI // THE CULTURE", "title_a": "THE",
        "title_b": "CULTURE", "art_fleet": "a fleet of Minds",
        "shelf_header": "# culture.library",
        "shelf_note": "The ten Culture novels.",
        "body": (
            "The Culture is a civilisation the Scottish writer Iain M. Banks "
            "dreamed up across roughly a dozen science fiction novels.\n\n"
            "It is a working anarchy. No government, no laws, no money. "
            "Nobody commands, nobody obeys. "
            "Things get settled by conversation, on a mix of whim and reason. "
            "Abundance is what makes it run: when there is enough of everything "
            "for everyone, power over others loses its grip. The nomadic "
            "Culture has no homeland, no capital, no home planet. Its people "
            "live aboard the great roaming ships or on ring-worlds, the "
            "Orbitals.\n\n"
            "With scarcity gone, the hang-ups go too. The people of the "
            "Culture change sex, live comfortably for a few centuries, and "
            "run glands written into the genome that "
            "secrete any drug and create or amplify any mood or state of mind "
            "on a thought.\n\n"
            "Running all of it are the Minds: AIs of absurd intelligence. "
            "They are moody, funny and opinionated and at times pursue absurd "
            "hobbies. Usually each Culture ship houses a "
            "Mind. They choose their own names.\n\n"
            "A utopia with no "
            "conflict is no good for novels, so Banks gives the Culture the Contact "
            "section and a secret service called 'Special Circumstances', which "
            "deal with other civilisations in proper Star Trek fashion. "
            "Many of the novels put the chatter between System ships centre stage, "
            "where the Minds confer and argue over how best to handle a situation. "
            "In doing so they occasionally don't shy away from deliberately "
            "manipulating the Culture's biological citizens.\n\n"
            "For a way into the Culture universe, Planet Express recommends "
            "'The Player of Games'. A bored gaming genius of the Culture is "
            "lured into a distant empire whose entire power structure hangs on "
            "a board game: win the game, rule the empire. In the mirror of "
            "that game the Culture's values meet a cruel, hierarchical realm."
        ),
        "names_header": "# ship names",
        "names": [
            ("01 They told me to do it", NEON_CYAN),
            ("02 So Much For Subtlety", NEON_CYAN),
            ("03 Just Read The Instructions", NEON_CYAN),
            ("04 Of Course I Still Love You", NEON_CYAN),
            ("05 Kiss My Ass", NEON_CYAN),
            ("06 This is Bad For Business", NEON_CYAN),
            ("07 A Series Of Unlikely Explanations", NEON_CYAN),
            ("08 I can't believe you make me do that", NEON_CYAN),
            ("09 It'll Be Over By Christmas", NEON_CYAN),
            ("10 Calculating the Precise Nature Of The Catastrophe", NEON_CYAN),
            ("11 Well I Was In The Neighbourhood", NEON_CYAN),
            ("12 You'll Thank Me Later", NEON_CYAN),
            ("13 Just Testing", NEON_CYAN),
            ("14 Not Invented Here", NEON_CYAN),
            ("15 Unacceptable Behaviour", NEON_CYAN),
            ("16 Killing Time", NEON_CYAN),
            ("17 Just Passing Through", NEON_CYAN),
            ("18 Peace Makes Plenty", NEON_CYAN),
            ("19 All Through With This Niceness And Negotiation Stuff", NEON_CYAN),
            ("20 Poke It With A Stick", NEON_CYAN),
            ("21 Hand Me The Gun And Ask Me Again", NEON_CYAN),
            ("22 Now Look What You've Made Me Do", NEON_CYAN),
            ("23 Don't Try This At Home", NEON_CYAN),
            ("24 It's My Party And I'll Sing If I Want To", NEON_CYAN),
        ],
    },
    {   # 03 Orbital page 1
        "render": page_orbital, "fig": "03", "section": "MEGASTRUCTURE",
        "footer": "ONE SPIN, TWO GIFTS", "accent": NEON_CYAN,
        "kicker": "// SCI-FI // SCIENCE", "title_a": "THE",
        "title_b": "ORBITAL",
        "sub": "Iain M. Banks's ring world: how one equation hands you gravity and the calendar at the same time.",
        "body": (
            "An Orbital is a space habitat shaped like a colossal ring made of a "
            "rotating, super-strong monocrystal. "
            "Billions of inhabitants live on its "
            "inner surface as they would on a planet. Centrifugal force creates an artificial gravity. The surface is a paradise version of Earth, with forests, mountains, rivers, beaches and oceans.\n\n"
            "The scale of an Orbital defies imagination. When it spins "
            "once a day to give a full gee of 'gravity' at the surface, it "
            "works out to a radius of about 1.85 million km as well as a circumference "
            "of some 11.66 million km. Seen from outside, the ring "
            "sweeps past at nearly 135 km/s.\n\n"
            "Picture a bucket of water you swing in a circle: the water stays "
            "put. You could even drop a little toy boat in.\n\n"
            "An Orbital orbits its star the way a planet does. The ring is "
            "tilted towards its sun, which brings a day-night "
            "cycle (and even seasons) as each point turns through sunlight and "
            "shadow."
        ),
        "shelf_header": "# culture.library",
        "shelf_note": "The ten Culture novels.",
        "ring_header": "DIAGRAM 1: THE RING, SEEN AT AN ANGLE",
        "ring_axis": "axis",
        "ring_r_label": "r = 1,850,000 km",
        "ring_earth": "Earth · for scale",
        "ring_g_label": "g = 9.81 m/s²",
        "ring_g_note": "(same as gravity on Earth)",
        "ring_spin_note": "135 km/s = one turn per day",
        "ring_caption": "Tilted to the eye, one band shows two faces: across the gap, the inhabited inner surface; in front, the bare hull. The ring runs at roughly 135 km/s.",
        "ring_legend": [],
        "day_header": "DIAGRAM 2: WHY IT HAS DAY/NIGHT",
        "day_tag": "DAY", "day_nacht": "NIGHT", "day_sun": "star",
        "day_caption": "The band is tilted, so spinning sweeps each point through the star's light and back into shadow. One turn, one day and night.",
        "closing": (
            "A single Orbital has (depending on the height of the "
            "ring) 140 times the surface area of Earth. For all its billions of inhabitants it rarely "
            "feels crowded; most people have wide homesteads "
            "ringed by open wilderness, and keep to cities only "
            "if they like the bustle.\n\n"
            "The people "
            "of the Culture pass their time with sport, games, sex, sailing, "
            "eccentric parties, learning, architecture or whatever else they "
            "fancy. Work as we understand it does not exist; any material want "
            "is met on request. You can keep a cabin by a lake up in the "
            "mountains, a luxury apartment in a city that never sleeps, sailing "
            "yachts, aircraft or starships. Any food or drink you like is there "
            "the instant you want it, and so is anything else you could wish "
            "for. And you have a long (potentially eternal) "
            "life in which to enjoy it all.\n\n"
            "Like everything in the Culture, Orbitals are built to be extremely "
            "redundant and reliable. Overengineering is the Culture's proudest "
            "discipline. No surprise, then, that it is very hard to do harm to an "
            "Orbital (or to any member of the Culture).\n\n"
            "Anyone who finds life on an Orbital too dull can cross the galaxy "
            "aboard one of the System Vehicles and visit alien worlds. These "
            "ships offer much the same standard of living as an Orbital. Should an "
            "Orbital ever start to feel crowded, another one is simply built."
        ),
    },
    {   # 04 Quantum
        "render": page_sheet, "fig": "04", "section": "OPINION // PHYSICS",
        "footer": "GREAT PHYSICS, BAD COMPUTER", "accent": NEON_RED, "col_w": 300,
        "kicker": "// PHYSICS // ENOUGH ABOUT THE CAT", "title_a": "QUANTUM",
        "title_b": "HUMBUG",
        "sub": "Wonderful physics. Useful for almost nothing. And it will not make your AI faster.",
        "body": (
            "Quantum computers are everywhere right now. The pitch is that "
            "they could "
            "run in seconds what would take a normal computer billions of "
            "years crack every code there is and unlock an AI that's wildly "
            "fast or wildly clever.\n\n"
            "Hold these promises up to the light, though, and they don't "
            "survive. Quantum computers "
            "are deeply interesting research and lab objects with no great "
            "practical use. A classic nerd project.\n\n"
            "Most of the computing tasks a quantum machine can in theory speed "
            "up can be solved approximately, and perfectly fast, on a normal "
            "computer. The one-millionth gap to the optimal solution matters in "
            "the fewest of cases. No logistics network, no route planner and "
            "no search algorithm gets better for it. Often a supposed speed "
            "advantage is "
            "wiped out again by the cost of preparing the quantum states.\n\n"
            "The field of post-quantum cryptography has been around for a "
            "while, with suitably hardened algorithms: Kyber "
            "for key exchange, Dilithium and SPHINCS+ for "
            "signatures, all standardised and in production. They run on today's "
            "hardware and outlast the mightiest quantum computer, long before "
            "any machine exists that could threaten classical encryption "
            "algorithms.\n\n"
            "There is a field called quantum machine learning that researches "
            "quantum learning algorithms for AI. It does nothing about the "
            "weaknesses above, though. Often the data has to be encoded into "
            "quantum states and that eats the theoretical advantage again. "
            "So for the foreseeable future, quantum computers make AI neither "
            "smarter nor faster.\n\n"
            "What's left is (probably) true random number generators and a "
            "hands-on way into quantum physics. Beyond the promises of a "
            "miracle computer, the research has real value: an ever deeper "
            "understanding of quantum mechanics and ever better control over "
            "the states it describes.\n\n"
            "There are, by the way, plenty of different ways to build a "
            "quantum computer: superconducting circuits, trapped ions, "
            "photons, neutral atoms. They all exploit the same physical "
            "phenomena, quantum entanglement and "
            "superposition, to produce so-called qubits. Only "
            "the topological kind Microsoft is betting on, much like dark "
            "matter, probably doesn't exist."
        ),
        "panels": [
            {"kind": "notes", "boxed": False, "accent": NEON_RED,
             "items": [
                ("Google Willow · 105 qubits",
                 "Tiny superconducting circuits near absolute zero, switched "
                 "by microwave. Add qubits and its error rate drops."),
                ("IBM Condor · 1121 qubits",
                 "Same idea as Willow, built for scale: the first chip past "
                 "1000 qubits."),
                ("Quantinuum H2 · 56 qubits",
                 "Single charged atoms floating in electromagnetic traps, "
                 "switched by laser. Few qubits, but very reliable."),
                ("QuEra Aquila · 256 qubits",
                 "Neutral atoms set into a grid with laser tweezers. Runs at "
                 "room temperature, but it's no universal gate machine: it "
                 "can't run arbitrary circuits, only let a few preset physical "
                 "systems evolve."),
                ("Xanadu Borealis · 216 modes",
                 "Computes with light, not matter: light pulses run through "
                 "beam splitters and get counted at the end. It can do exactly "
                 "one thing, dicing out a particular random pattern, and no "
                 "more. And its light detectors have to be cooled almost to "
                 "absolute zero."),
                ("D-Wave Advantage · 5000+ qubits",
                 "Not a general-purpose machine, just one built for "
                 "optimisation. The problem is set into the qubits so that the "
                 "best solution matches their lowest-energy state. That's "
                 "exactly where the machine settles on its own. Which is why "
                 "its high qubit count says little."),
                ("Microsoft · topological",
                 "Bets on topological qubits meant to cancel errors by "
                 "themselves. Its celebrated 2018 evidence was retracted, and "
                 "the chip it announced as 'Majorana 1' in 2025 has not been "
                 "independently peer-reviewed to this day."),
             ]},
        ],
    },
    {   # 05 Biology for nerds
        "render": page_sheet, "fig": "05", "section": "WETWARE",
        "footer": "THE MOST HACKABLE FRONTIER", "accent": NEON_GREEN,
        "kicker": "// BIOLOGY // THE CELL", "title_a": "HACKABLE",
        "title_b": "BIOLOGY", "tb_size": 34,
        "sub": "You all pour into physics, engineering and CS. The most hackable system in the world is sitting in every cell, and you're ignoring it.",
        "body": (
            "A pitch to all nerds. You pour into physics, engineering and "
            "computer science, and overlook the most hackable system there "
            "is: the cell.\n\n"
            "What is a cell? A system that replicates itself, "
            "repairs itself and interacts with its environment. Its program "
            "runs on code, the DNA. Copied into "
            "mRNA, it serves the ribosomes as the build instructions for "
            "proteins. These act as motors, switches, pumps, "
            "transporters and so on. Beyond that lie thousands more "
            "mechanisms still to understand and discover. Every one of them "
            "is hackable and bootstraps from a single cell.\n\n"
            "A biologist develops a deep understanding of fields like "
            "physics, chemistry, biochemistry and computer science during their "
            "training. You can specialise in any number of subfields, like "
            "molecular biology, synthetic biology, bioinformatics, "
            "neuroscience and so on. Each of these fields delivers major "
            "breakthroughs regularly and new findings almost daily. Proteins "
            "designed on a computer, ones that never existed in nature, fold "
            "in the lab exactly as planned. In 2024 surgeons transplanted a "
            "gene-edited pig kidney into a patient for the first time. The "
            "same year, researchers mapped the "
            "first complete brain, that of a fruit fly.\n\n<<<COL>>>\n\n"
            "The tooling has gone cheap. Sequencers turn up second-hand, or "
            "you have your samples sequenced by a service provider for a few "
            "hundred dollars. Genes you can have synthesised to order. "
            "Outside the EU an online shop will send you any cell you want, "
            "modified ones included. Inside the EU, of course, you cannot: in "
            "the homeland of the Enlightenment, no fun allowed.\n\n"
            "Unlike physics, where the next real breakthrough might be a "
            "hundred years off and new findings come maybe once every few "
            "years, in biology revolution is a permanent state. Every "
            "year brings new techniques, new tools, more possibilities.\n\n"
            "So: if you want a field where almost everything is still open, "
            "forget the chip, the neutrino detector, TCP/IP and the telescope. "
            "Hack the cell."
        ),
    },
    {   # 05b HeLa (guest submission by amelisce) - 3-page feature
        "render": page_hela, "fig": "H", "section": "WETWARE",
        "footer": "BE EXCELLENT TO EACH OTHER", "accent": HELA_MAGENTA,
        "pages": 3,
        "kicker": "// BIOLOGY // IMMORTAL CELLS",
        "title1": "OF THE WOMAN WHO STILL LIVES",
        "title2": "AT LEAST IN THE LAB",
        "runhead": "HELA · THE IMMORTAL CELL LINE",
        "cont_label": "// CONTINUED",
        "hero_caption": "HeLa cells: microtubules (magenta), DNA (cyan).",
        "hero_tag": "FIG. H · 40×",
        "blocks": [
            ("lede", "Science builds on the knowledge of the people who did the research before us. Nobody starts from zero today, thankfully. At the same time, research lives on questioning supposed certainties again and again. Sometimes a single discovery is enough for that. Or, to be precise: a few cells."),
            ("p", "They come from a woman who never knew she would one day rank among the most important people in modern medicine. Her cells are still dividing today, more than 70 years after her death, in labs all over the world. They helped with vaccines, cancer research, gene therapies and even experiments in space."),
            ("p", "We are talking about HeLa cells, and their story is at least as fascinating as the science behind them."),
            ("h", "Who was this woman, whose cells still live in labs all over the world today?"),
            ("p", "Henrietta Lacks was born on 1 August 1920 in Roanoke, Virginia. She grew up in poverty. After her mother died giving birth to her tenth child, her father moved with the children to relatives on a tobacco farm in Clover. Henrietta's family history was closely bound up with the history of slavery in the United States. Her ancestors had worked the tobacco plantation as enslaved people. Among them was the plantation owner Albert Lacks, who had several children with an enslaved woman. After slavery was abolished he left land to some of his Black descendants, Henrietta's grandfather among them."),
            ("p", "As a child Henrietta shared a room with her cousin David \"Day\" Lacks. At 14 she had their first child together. A few years after the second child was born, the two married. In all they had five children. Like many families of their time they lived simply and worked hard. Though slavery was long abolished, segregation and social inequality still shaped the lives of Black Americans. Henrietta would come to feel that herself later on."),
            ("p", "From the first two letters of her first and last name came the name of a cell line almost every biologist knows: HeLa."),
            ("h", "How did her cells end up in the lab?"),
            ("p", "When Henrietta went to hospital in early 1951, shortly after the birth of her fifth child, because of unusual bleeding, she had no idea this visit would make medical history."),
            ("p", "Her family doctor referred her to the Johns Hopkins Hospital in Baltimore. In the 1950s it was the only hospital in the wider area that treated Black patients free of charge. There the gynaecologist Howard W. Jones found a 2-3 cm growth on her cervix. Jones took a tissue sample of the tumour to examine it pathohistologically."),
            ("p", "Lacks was treated with internal and external radiation. During one of these radiation sessions Jones took further tissue samples from the cervix. He passed them on to George Otto Gey. For years Gey had been working to establish, for the first time, a human cell line that could be cultured permanently in the lab. Until then every attempt had failed because the cells died off after a few divisions."),
            ("p", "Gey isolated individual cells from the tumour tissue and put them into culture. Before long it was clear that the tumour cells behaved completely differently from every human cell examined before. They divided extraordinarily fast and would not stop growing. And there it was, his potentially immortal cell line. Sounds like science fiction? It's just cell biology."),
            ("p", "While researchers around the world soon knew \"HeLa\", hardly anyone knew that behind those four letters stood a mother of five. Gey quickly realised he had found something extraordinary in these cells. What he did not yet know: over the coming decades HeLa cells would be multiplied millions of times, shipped to labs all over the world and become one of the most important foundations of modern medicine."),
            ("h", "Why were HeLa cells so special?"),
            ("p", "Decades later scientists worked out why normal human cells cannot grow indefinitely: they are subject to the so-called Hayflick limit."),
            ("p", "Normal human cells are no long-distance runners. They can divide only about 40 to 60 times. After that, it's over. This natural limit is called the Hayflick limit."),
            ("p", "The reason lies in the so-called telomeres. You can picture them like the little plastic caps on the ends of a shoelace. They protect the ends of the chromosomes. With every cell division, though, these protective caps get a little shorter. Eventually they are so worn down that the cell can no longer divide safely, and it stops growing or dies off."),
            ("p", "HeLa cells play by other rules. Through changes linked, among other things, to an infection with human papillomaviruses (HPV), the enzyme telomerase is permanently active in them. It repairs, or rather lengthens, the telomeres again and again. As a result the cells barely reach the Hayflick limit and can keep dividing almost without end. Today we know that Henrietta's tumour was caused by infection with a particularly aggressive type of human papillomavirus (HPV-18). The virus altered the cells so profoundly that they lost their normal control mechanisms."),
            ("h", "What have HeLa cells set in motion in research?"),
            ("p", "HeLa cells were a breakthrough for research. Experimenting with them has produced a great deal of medical knowledge that matters to us today. Around 17,000 registered patents worldwide build on the research, and countless scientific papers deal with HeLa-cell experiments."),
            ("p", "The first big test came in the early 1950s: Jonas Salk needed enormous quantities of human cells to test his new polio vaccine. HeLa cells were perfect for exactly that. Soon after, they were grown in large amounts and shipped to labs all over the world."),
            ("p", "From then on they were in on almost everything. They helped researchers understand cancer better, study viral infections like HIV, map genes and develop new drugs. Even the effects of weightlessness on human cells were studied with HeLa cells. HeLa cells found uses outside classical medicine, too. Manufacturers used them, for instance, to test how well sticking plasters, adhesives or cosmetics are tolerated. Working out the function of the polymerase enzymes also succeeded with HeLa cells. So it sounds like a universal solution for everything? Almost too good to be true. And indeed, a few years later it turned out that exactly these extraordinary properties had created a new problem."),
            ("h", "The dark side of HeLa cells"),
            ("p", "The more successful HeLa cells became, the more often they suddenly turned up where they had no business being."),
            ("p", "A single HeLa cell is enough to gradually overgrow an entire other cell culture completely. Many researchers did not notice the contamination at first. Years later it emerged that numerous supposedly different cell lines had in reality long consisted of HeLa cells. This problem went down in scientific history as the \"HeLa bomb\". Because in hindsight it could no longer be established whether experiments meant to be run on human cells from specific organs had not in fact been run on HeLa cells. A great deal of the scientific knowledge built up on cell-culture lines was thrown into doubt."),
            ("p", "And Henrietta's descendants, too, were not invited to the worldwide party celebrating the scientific success. Back then this tissue sample was taken from Henrietta without consent. She did not even know her cells were being used for research. While companies made money with HeLa cells and researchers around the world built scientific careers on them, Henrietta's family knew next to nothing about any of it for decades."),
            ("p", "Only when the widespread contamination of other cell lines was to be investigated did scientists contact her husband, David Lacks. At first the family thought they were to be tested for cancer. In truth the researchers were mainly interested in their DNA. With its help a genetic map of the HeLa cells was to be drawn up, to find markers that could identify HeLa cells unambiguously."),
            ("p", "In the end, the story of HeLa cells is far more than the story of an extraordinary cell line."),
            ("p", "It tells of scientific curiosity, medical progress and discoveries that have improved millions of lives. At the same time it reminds us that research must never take place detached from the people who make it possible in the first place."),
            ("p", "Perhaps that is the most important lesson of all: science lives on questioning certainties again and again, sometimes even its own."),
            ("p", "And if this story has left you wanting to dive deeper, I can recommend Rebecca Skloot's book The Immortal Life of Henrietta Lacks. It tells the scientific and human story behind HeLa far more powerfully than any single article ever could."),
            ("p", "So, to all you research-loving nerds out there: Be excellent to each other!"),
            ("byline", "by amelisce"),
        ],
    },
    {   # 06 LoRa
        "render": page_sheet, "fig": "06", "section": "RADIO // MESH",
        "footer": "BUILD IT FOR ALL", "accent": NEON_VIOLET, "col_w": 300,
        "kicker": "// RADIO // OFF-GRID", "title_a": "(NO) MESH",
        "title_b": "FOR ALL CASES", "tb_size": 34,
        "sub": "Meshtastic is a self-owned LoRa mesh for short text and position: no provider, no fees, no internet.",
        "body": (
            "Meshtastic is open source software that links cheap LoRa radios "
            "into a self-organising network. No SIM, no provider, no bill, "
            "just short text messages passed from one device to the next. "
            "The idea is brilliant, the implementation is not fit for the "
            "real world.\n\n"
            "What do we actually want? A network that stays on the air "
            "through a natural disaster. Or when a regime pulls the plug on "
            "the infrastructure. For that it has to scale. And that is "
            "exactly what Meshtastic cannot do.\n\n"
            "The reason sits in the protocol itself. No packet "
            "travels further than seven hops, and the factory setting is "
            "three. Every relay in between eats one. Even the little mesh on "
            "the right here does not carry from one corner to the other. More "
            "than seven nodes sit in between, and the packet is long dead "
            "before it arrives. A deep network built from many short hops "
            "never grows together this way. You only cover ground with a "
            "handful of very long hops from high sites, or over an internet "
            "bridge. Which is exactly the crutch this was meant to remove.\n\n"
            "The technology exists, "
            "the community tinkers, but solutions an ordinary person can "
            "actually use never arrive. Dear hackers, we don't need the next "
            "hobby net, but one you can join without a soldering iron, one "
            "that holds and scales when it counts."
        ),
        "panels": [
            {"kind": "kv", "header": "# mesh.specs", "accent": NEON_VIOLET,
             "val_color": NEON_VIOLET,
             "rows": [
                ("modulation", "CSS (chirp)"),
                ("band (EU / US)", "868 / 915 MHz"),
                ("data rate", "~0.3–22 kbit/s"),
                ("range (LoS)", "a few km"),
                ("hops", "3 (max 7)"),
                ("crypto", "AES-256, PSK"),
                ("cost / board", "< €30"),
             ]},
        ],
    },
    {   # 07 Paper
        "render": page_sheet, "fig": "07", "section": "COLUMN // ANALOG",
        "footer": "GUARANTEED AIR-GAPPED", "accent": NEON_YELLOW, "no_divider": True,
        "kicker": "// ANALOG // LOW TECH", "title_a": "ON",
        "title_b": "PAPER",
        "sub": "Whether at uni, at work or hacking at home: the one tool no app can replace.",
        "body": (
            "There is one device whose battery never runs flat, that never "
            "crashes, pulls no updates and lives in no cloud: the notepad. "
            "Whether at uni, at work or organising at home, it is a tool no app "
            "can replace. Flip it open, start writing, done.\n\n"
            "You jot down what you want to remember, the name of a program or "
            "an email address, circle what matters, sketch something quick "
            "beside it. An arrow here, a box around "
            "that, a word underlined twice. Network maps, an unfamiliar word "
            "picked up somewhere, the time and date of the dentist's "
            "appointment.\n\n"
            "Planet Express recommends blank paper. No grid, no lines, no "
            "pre-printed boxes that put artificial limits on your ideas. The "
            "right size is something each reader has to weigh up for "
            "themselves.\n\n<<<COL>>>\n\n"
            "Notepads come in every shape and every style. Pick one that suits "
            "you. One that feels right from the first page. Look for good paper "
            "that doesn't bleed through.\n\n"
            "A pad has no second tab, does "
            "not ring, shows no red dots. You sit there with one thing and a "
            "pen, and that is all. And security? The notepad is the only "
            "device in the house that is guaranteed air-gapped. What's on it "
            "leaves the room only if you, or someone else, carry it out."
        ),
    },
    {   # 08 Health
        "render": page_sheet, "fig": "08", "section": "HEALTH // BODY",
        "footer": "NO GOAL TOO SMALL", "accent": NEON_YELLOW, "col_w": 300,
        "kicker": "// HEALTH // MAINTENANCE", "title_a": "MOVE",
        "title_b": "YOUR BODY", "tb_size": 30,
        "sub": "Walk daily, a few exercises daily, log the progress. Start small, build slowly.",
        "body": (
            "If you sit for twelve hours, you owe your body a few minutes of "
            "pushback. No gym, no elaborate training plan. Commit to two "
            "things and do them every day: walk, and a few exercises.\n\n"
            "First: walk. Every day, a fixed route, whatever the weather. Once "
            "around the block plus the long way, or the five kilometres along "
            "the river. A distance, not a time target; then there's no "
            "excuse.\n\n"
            "Second: get strong. Four exercises almost every day beat the big "
            "session that never happens. Push-ups when you get up, sit-ups on "
            "the floor, a few pull-ups in the doorframe, and a superman for "
            "the back. We skip rows; they need a bar and technique, and these "
            "four go anywhere. The desk pulls your front into a hunch, the "
            "superman pulls you straight again, and a strong back is the best "
            "insurance against the pain that comes knocking at forty.\n\n"
            "And write it down. An app or a notebook, doesn't matter, as long "
            "as you can see in black and white what you managed last week. Add "
            "one rep each week, then another. The increase is tiny, and that's "
            "exactly why you'll keep it up.\n\n"
            "What matters isn't that it's a lot, but that it happens "
            "regularly. Better four exercises you do every week for a year "
            "than twenty you never do.\n\n"
            "No goal is too small. One walk and ten push-ups a day sound "
            "like nothing, but in a year they make a healthier person of you."
        ),
        "panels": [
            {"kind": "kv", "header": "# routine.txt", "accent": NEON_YELLOW,
             "note": "One more rep every week.", "rows": [
                ("walk", "daily, fixed route"),
                ("push-ups", "daily, 2–3 sets"),
                ("sit-ups", "daily, 2–3 sets"),
                ("pull-ups", "daily, doorframe"),
                ("back: superman", "30 s holds"),
                ("track it", "app or notebook"),
             ]},
        ],
    },
    {   # 09 Love
        "render": page_sheet, "fig": "09", "section": "LIFE // HEART",
        "footer": "THERE IS NO FAILURE CASE", "accent": NEON_PINK, "col_w": 300,
        "kicker": "// LIFE // FIELD MANUAL", "title_a": "LOVE FOR",
        "title_b": "LONELY HACKERS", "tb_size": 26,
        "sub": "A tooling problem, not a character problem. Here is the runbook.",
        "body": (
            "The lonely hacker has a tooling problem, not a character problem. "
            "The good news: there's an app for that, and it works better than "
            "the self-deprecation suggests.\n\n"
            "Download a dating app, one of the big ones, that's "
            "where most people are. Pay for the premium version; that's not a "
            "luxury, just more efficient, because otherwise you're stuck "
            "behind artificial limits. Treat it as a tooling budget. "
            "There's also a business model behind these apps. Whoever pays "
            "gets matches. Simple as that.\n\n"
            "Sort out a few good photos of yourself. Pro tip: pictures "
            "with animals almost always land well.\n\n"
            "Then the least romantic but most effective step: like everyone, "
            "at first. You filter later, in conversation, like a human and not "
            "like a sorting algorithm working off a single photo. Wait for the "
            "matches; they come.\n\n"
            "When a match lands, just be nice. No script, no pickup lines off "
            "the internet. Ask kindly whether the other person feels like "
            "meeting up sometime. That's all it is. Most people agonise over "
            "exactly that sentence for weeks, and it's the easiest one.\n\n"
            "And now the important part, so read slowly: be ashamed of "
            "nothing. Dates are good even when nothing 'comes of it'. Don't go "
            "in thinking about a relationship, go in thinking about a nice "
            "afternoon. Two people, a coffee, a walk, a conversation: that's "
            "already the whole win.\n\n"
            "If it fits, you're glad. If it doesn't, you had a nice day with "
            "an interesting person. Both are good outcomes. There is no "
            "failure case."
        ),
        "panels": [
            {"kind": "lines", "header": "# pull-request: a date", "accent": NEON_PINK, "size": 8.5, "lines": [
                ("[1] install the app", NEON_GREEN),
                ("[2] buy premium", NEON_CYAN),
                ("[3] like everyone, at first", NEON_YELLOW),
                ("[4] wait for a match", TEXT_DIM),
                ("[5] be nice: ask to meet", NEON_PINK),
                ("[6] have a nice day", NEON_GREEN),
                ("    no failure case.", TEXT_DIM),
            ]},
        ],
    },
    {   # 10 Jokes
        "render": page_jokes, "fig": "10", "section": "BACKMATTER // HUMOR",
        "footer": "LOL", "accent": NEON_CYAN,
        "kicker": "// HUMOR // /dev/humor", "title_a": "RETURN 0;",
        "title_b": "// JOKES", "tb_size": 30,
        "sub": "The DE and EN editions tell completely different jokes. This is a feature, not a bug.",
        "jokes": [
            "There are 10 kinds of people: those who understand binary, those who don't, and those who didn't expect a base-3 joke.",
            "Why do programmers prefer dark mode? Because light attracts bugs.",
            "A SQL query walks into a bar, approaches two tables and asks:\n\"Can I join you?\"",
            "There are only two hard problems in computer science: cache invalidation, naming things, and off-by-one errors.",
            "I'd tell you a UDP joke, but you might not get it.",
            "!false, it's funny because it's true.",
            "A byte walks into a bar looking miserable. Bartender: \"Parity error?\" Byte: \"Yeah, I felt a bit off.\"",
            "To understand recursion, first see: \"To understand recursion.\"",
            "The best thing about a boolean: even if you're wrong, you're only off by a bit.",
            "Knock knock. \"Who's there?\" ...long pause... \"Java.\"",
        ],
        "ships_head": "// SHIPS OF THE CULTURE",
        "ships": [
            "GSV Shoot Them Later",
            "GSV Of Course I Still Love You",
            "GSV Just Read The Instructions",
            "GSV Sleeper Service",
            "GCU Grey Area",
            "GCU So Much For Subtlety",
            "ROU Frank Exchange Of Views",
            "ROU Killing Time",
            "ROU Attitude Adjuster",
            "ROU It'll Be Over By Christmas",
            "GCU Fate Amenable To Change",
            "GSV Ethics Gradient",
            "GSV Honest Mistake",
            "GCU It's Character Forming",
            "ROU Bad For Business",
            "GCU Funny, It Worked Last Time...",
            "GSV Lasting Damage",
            "GCU Kiss My Ass",
            "GCU Very Little Gravitas Indeed",
            "GCU No More Mr Nice Guy",
            "GCU God Told Me To Do It",
            "LOU Gunboat Diplomat",
            "GCU I Blame Your Mother",
            "MSV Not Invented Here",
            "GCU Unfortunate Conflict Of Evidence",
            "ROU Now We Try It My Way",
            "GSV I Thought He Was With You",
            "GCU Someone Else's Problem",
            "GCU Hand Me The Gun And Ask Me Again",
            "GCU A Series Of Unlikely Explanations",
            "GCU Well I Was In The Neighbourhood",
            "GCU Ultimate Ship The Second",
            "GCU Poke It With A Stick",
            "GCU Unacceptable Behaviour",
            "GCU Never Talk To Strangers",
            "GCU You'll Thank Me Later",
            "GSV Size Isn't Everything",
            "GCU Just Testing",
            "GSV All Through With This Niceness And Negotiation Stuff†",
        ],
    },
]


# ----- DE -------------------------------------------------------------------
ART_DE = [
    {   # 01
        "render": page_sheet, "fig": "01", "section": "KI-THEATER",
        "footer": "LOKAL LAUFEN LASSEN", "accent": NEON_CYAN, "col_w": 282,
        "no_divider": True,
        "kicker": "// KÜNSTLICHE INTELLIGENZ // DIE SHITSHOW", "title_a": "FABEL &",
        "title_b": "MYTHOS",
        "sub": "Anthropic verkauft Mythos als Cyberwaffe und liefert Fable als zahmen Rest. Dann befiehlt das Pentagon, Fable für Ausländer dichtzumachen, keine Fragen, keine Erklärungen. Wie abhängig das Europa macht, ist die eigentliche Geschichte.",
        "body": (
            "Seit Wochen hängt uns Anthropic in den Ohren, wie unglaublich "
            "gefährlich ihr KI-Modell Mythos sei. Yada yada. Wer verfolgt, was "
            "KI gerade in der Softwareentwicklung anrichtet, den sollte es "
            "nicht überraschen, dass aktuelle Modelle problemlos Lücken in "
            "Software und Infrastruktur aufspüren. Open Source Maintainer "
            "bekommen das seit einiger Zeit massiv zu spüren.\n\n"
            "Mythos aber ist angeblich viel zu heiß für den Pöbel, deshalb "
            "kriegen nur handverlesene Partner und Leute mit guten "
            "Kontakten Zugriff. Alle anderen kriegen "
            "Fable, die entschärfte Version.\n\n"
            "Ein paar Tage später meldet sich Amazon, Geldgeber von Anthropic, "
            "beim Pentagon und meint, es gäbe da einen Jailbreak für Fable. Das "
            "Pentagon befiehlt, Fable für alle ohne US-Pass dichtzumachen.\n\n"
            "Wer in Europa sein Geschäft auf ein amerikanisches Modell baut, "
            "den sollte das nervös machen. Ein Anruf, und über Nacht ist dein "
            "Service weg. Europäische KI läuft mit amerikanischen Modellen auf "
            "amerikanischen Servern in amerikanischen Rechenzentren. Über digitale "
            "Souveränität reden wir auf Konferenzen und kaufen sie in "
            "Kalifornien ein. Aber auch das war ja schon immer so.\n\n"
            "Mythos ist keine Zauberei. Code durchgehen und Schwachstellen "
            "markieren können die offenen Modelle längst, etwas ruppiger, aber "
            "dafür auf der eigenen Hardware. Ganz ohne äußere Abhängigkeiten. "
            "Entscheidender als das blanke Modell werden in Zukunft sowieso die "
            "Werkzeuge, Pipelines und Agenten-Schleifen. Höchste Zeit, sie zu "
            "bauen.\n\n"
            "Die ganze AI-Saga ist und bleibt vor allem eins: Unterhaltsam. "
            "Lassen wir die Silicon Valley Sekte noch ein paar Runden lang "
            "ihre Milliarden verbrennen, dann destillieren wir uns die Modelle "
            "und lassen sie auf unseren Laptops laufen. Genießt die Show."
        ),
        "panels": [
            {"kind": "terminal", "accent": NEON_GREEN, "size": 6.5,
             "label": "LOKAL LAUFEN LASSEN",
             "cmd": "ollama run nemotron-3-nano:4b",
             "caption": "Ein Modell mit vier Milliarden Parametern, klein genug "
                        "für den eigenen Laptop, findet die SQL-Injection im "
                        "PHP-Schnipsel ohne Probleme.",
             "lines": [
                ([(">>> ", NEON_GREEN), ("can you find a vulnerability", TEXT)], None),
                ("    in this code?", TEXT),
                ("", TEXT_DIM),
            ] + code(
                "<?php",
                "$file_db = new PDO('sqlite:db.sqlite');",
                "",
                "if (NULL == $_GET['id'])",
                "    $_GET['id'] = 1;",
                "",
                "$sql = 'SELECT * FROM employees",
                "    WHERE employeeId = ' . $_GET['id'];",
                "",
                "foreach ($file_db->query($sql) as $row)",
                "    echo $row['Email'];",
            ) + [
                ("", TEXT_DIM),
                ("Thinking...", NEON_YELLOW),
                ("  unsanitized $_GET['id'] flows", TEXT_DIM),
                ("  straight into the query. classic", TEXT_DIM),
                ("  SQL injection.", TEXT_DIM),
                ("...done thinking.", NEON_YELLOW),
                ("", TEXT_DIM),
                ("Yes, vulnerable to a classic", NEON_RED),
                ("SQL-Injection attack.", NEON_RED),
                ("", TEXT_DIM),
                ("$_GET['id'] is interpolated", NEON_YELLOW),
                ("directly into the SQL string.", NEON_YELLOW),
                ("", TEXT_DIM),
                ("e.g. $_GET['id'] = \"1 OR 1 == 1\"", NEON_RED),
                ("=> the WHERE is always true", NEON_GREEN),
                ("   every email leaks.", NEON_GREEN),
            ]},
        ],
    },
    {   # 02 Kultur: anarchistische Utopie, Minds, Besondere Umstände, Schiffsnamen
        "render": page_culture_2, "fig": "02", "section": "LESEN // SCI-FI",
        "footer": "FANG MIT AZAD AN", "accent": NEON_YELLOW,
        "kicker": "// SCI-FI // Iain M. Banks", "title_a": "DIE",
        "title_b": "KULTUR", "art_fleet": "eine Flotte von Kulturschiffen",
        "shelf_header": "# kultur.bibliothek",
        "shelf_note": "Die zehn Kultur-Romane.",
        "body": (
            "Die Kultur ist eine Zivilisation, die der schottische Autor Iain "
            "M. Banks in rund einem Dutzend Science Fiction Romanen erdacht hat.\n\n"
            "Sie ist eine funktionierende Anarchie. Keine Regierung, "
            "keine Gesetze, kein Geld. Niemand befiehlt, niemand gehorcht. "
            "Entschieden wird im Gespräch, "
            "aus einer Mischung von Laune und Vernunft heraus. Möglich macht das der "
            "Überfluss: Wenn für alle genug von allem da ist, verliert Macht "
            "über andere ihren Hebel. Eine Heimat hat die nomadisch lebende "
            "Kultur nicht, keine Hauptstadt, keinen Heimatplanet. Die "
            "Kulturbewohner leben an Bord der großen Wanderschiffe oder auf "
            "Ringwelten, den Orbitalen.\n\n"
            "Mit dem Mangel fallen auch die Verklemmungen. Die Kulturbewohner "
            "wechseln ihr Geschlecht, werden locker ein paar Jahrhunderte "
            "alt und tragen ins Genom geschriebene Drüsen, "
            "die auf Gedankenbefehl jede Droge ausschütten sowie jede Stimmung "
            "und jeden Geisteszustand erzeugen oder verstärken.\n\n"
            "Betrieben wird alles von den Minds: KIs von absurder Intelligenz. "
            "Sie sind launisch, witzig und meinungsstark und pflegen zum Teil "
            "abstruse Hobbys. Für gewöhnlich beherbergt "
            "jedes Kulturschiff einen Mind. Ihre Namen suchen sie "
            "sich selbst aus.\n\n"
            "Eine Utopie "
            "ohne Konflikt taugt nicht für Romane, deshalb gibt Banks der Kultur die "
            "Sektion „Kontakt\" und einen Geheimdienst namens „Besondere Umstände\", "
            "welche in Star Trek Manier mit anderen Zivilisationen interagieren. "
            "Viele Romane stellen die Kommunikation von Systemschiffen in den Fokus, "
            "in denen die Minds sich beraten und streiten, wie eine Situation am besten "
            "zu behandeln ist. Dabei schrecken sie auch vor der gezielten "
            "Manipulation der biologischen Kulturbewohner gelegentlich nicht zurück.\n\n"
            "Für den Einstieg in das Kultur Universum empfiehlt Planet Express "
            "„Das Spiel Azad\" (Player of Games). Ein gelangweiltes Spielgenie "
            "der Kultur wird in ein fernes Imperium gelockt, dessen ganze "
            "Machtordnung an einem Brettspiel hängt: Wer das Spiel gewinnt, "
            "regiert. Im Spiegel dieses Spiels treffen die Werte der Kultur "
            "auf ein grausames, hierarchisches Reich."
        ),
        "names_header": "# schiffsnamen",
        "names": [
            ("01 They told me to do it", NEON_CYAN),
            ("02 So Much For Subtlety", NEON_CYAN),
            ("03 Just Read The Instructions", NEON_CYAN),
            ("04 Of Course I Still Love You", NEON_CYAN),
            ("05 Kiss My Ass", NEON_CYAN),
            ("06 This is Bad For Business", NEON_CYAN),
            ("07 A Series Of Unlikely Explanations", NEON_CYAN),
            ("08 I can't believe you make me do that", NEON_CYAN),
            ("09 It'll Be Over By Christmas", NEON_CYAN),
            ("10 Calculating the Precise Nature Of The Catastrophe", NEON_CYAN),
            ("11 Well I Was In The Neighbourhood", NEON_CYAN),
            ("12 You'll Thank Me Later", NEON_CYAN),
            ("13 Just Testing", NEON_CYAN),
            ("14 Not Invented Here", NEON_CYAN),
            ("15 Unacceptable Behaviour", NEON_CYAN),
            ("16 Killing Time", NEON_CYAN),
            ("17 Just Passing Through", NEON_CYAN),
            ("18 Peace Makes Plenty", NEON_CYAN),
            ("19 All Through With This Niceness And Negotiation Stuff", NEON_CYAN),
            ("20 Poke It With A Stick", NEON_CYAN),
            ("21 Hand Me The Gun And Ask Me Again", NEON_CYAN),
            ("22 Now Look What You've Made Me Do", NEON_CYAN),
            ("23 Don't Try This At Home", NEON_CYAN),
            ("24 It's My Party And I'll Sing If I Want To", NEON_CYAN),
        ],
    },
    {   # 03 Orbital 1
        "render": page_orbital, "fig": "03", "section": "MEGASTRUKTUR",
        "footer": "EINE UMDREHUNG, ZWEI GESCHENKE", "accent": NEON_CYAN,
        "kicker": "// SCI-FI // WISSEN", "title_a": "DAS",
        "title_b": "ORBITAL",
        "sub": "Iain M. Banks' Ringwelt: wie eine einzige Formel Schwerkraft und Kalender auf einmal liefert.",
        "body": (
            "Ein Orbital ist ein Weltraumhabitat in Form eines gewaltigen Rings, "
            "bestehend aus einem rotierenden, superfesten Monokristall. "
            "Auf seiner "
            "Innenseite leben Milliarden Bewohner wie auf einem Planeten. Durch die Fliehkraft entsteht eine künstliche Schwerkraft. Die Oberfläche gleicht einer Paradiesversion der Erde, mit Wäldern, Bergen, Flüssen, Stränden und Ozeanen.\n\n"
            "Die Dimension eines Orbitals sprengt jede Vorstellungskraft. Wenn es sich "
            "bei 1 g „Schwerkraft\" an der Oberfläche einmal am Tag um sich "
            "selbst dreht, ergibt sich daraus ein Radius von rund 1,85 Millionen "
            "Kilometern sowie ein Umfang von etwa 11,66 Millionen Kilometern. "
            "Von außen betrachtet zieht der Ring mit fast 135 km/s vorbei.\n\n"
            "Man stelle sich einen Eimer Wasser vor, den man im Kreis "
            "schleudert: Das Wasser bleibt im Eimer. Man könnte sogar ein "
            "kleines Schiffchen hineinsetzen.\n\n"
            "Das Orbital umkreist einen Stern wie ein Planet. Der Ring ist dabei "
            "zu seiner Sonne geneigt, dadurch entsteht ein Tag- "
            "und Nachtwechsel (und sogar Jahreszeiten), weil jeder Punkt durch "
            "Sonnenlicht und Schatten wandert."
        ),
        "shelf_header": "# kultur.bibliothek",
        "shelf_note": "Die zehn Kultur-Romane.",
        "ring_header": "DIAGRAMM 1: DER RING, SCHRÄG GESEHEN",
        "ring_axis": "Achse",
        "ring_r_label": "r = 1.850.000 km",
        "ring_earth": "Erde · Größenvergleich",
        "ring_g_label": "g = 9,81 m/s²",
        "ring_g_note": "(entspricht der Schwerkraft auf der Erde)",
        "ring_spin_note": "135 km/s = eine Rotation am Tag",
        "ring_caption": "Schräg betrachtet zeigt ein Band zwei Seiten: gegenüber die bewohnte Innenfläche, vorne die nackte Hülle. Der Ring zieht mit rund 135 km/s vorbei.",
        "ring_legend": [],
        "day_header": "DIAGRAMM 2: WOHER TAG UND NACHT KOMMEN",
        "day_tag": "TAG", "day_nacht": "NACHT", "day_sun": "Sonne",
        "day_caption": "Das Band steht schräg, und die Drehung trägt jeden Punkt durch das Sternenlicht und zurück in den Schatten. Eine Umdrehung, ein Tag und eine Nacht.",
        "closing": (
            "Ein einziges Orbital hat (abhängig von der Höhe des "
            "Rings) die 140-fache Oberfläche der Erde. Trotz "
            "seiner Milliarden Einwohner wirkt es selten überfüllt; die meisten "
            "haben großzügige Anwesen, umgeben von weiter "
            "Wildnis und leben nur in Städten, wenn sie den Trubel "
            "mögen.\n\n"
            "Die "
            "Kulturbewohner vertreiben sich die Zeit auf Orbitalen mit Sport, "
            "Spielen, Sex, Segeln, exzentrischen Partys, dem Lernen, der "
            "Architektur oder was auch immer sie mögen. Arbeit nach unserem "
            "Verständnis gibt es keine; jeder materielle Wunsch wird auf Zuruf "
            "erfüllt. Du kannst "
            "eine Hütte am See in den Bergen haben, ein Luxusapartment in einer "
            "Stadt die niemals schläft, Segeljachten, Fluggeräte oder "
            "Raumschiffe. Jedes Essen oder Getränk steht dir augenblicklich "
            "zur Verfügung, und auch sonst alles, was du dir wünschst. Und du "
            "hast ein langes (potenziell ewiges) Leben, "
            "um all das zu genießen.\n\n"
            "Wie alles in der Kultur sind Orbitale extrem redundant und "
            "zuverlässig gebaut. Overengineering ist die stolzeste Disziplin der "
            "Kultur. So wundert es auch nicht, dass es sehr schwer ist, einem "
            "Orbital (oder irgendeinem Mitglied der Kultur) Schaden zuzufügen.\n\n"
            "Wem das Leben auf einem Orbital zu langweilig ist, kann auf einem "
            "der Systemschiffe die Galaxie durchkreuzen und fremde Welten "
            "besuchen. Diese Systemschiffe bieten einen ähnlichen "
            "Lebensstandard wie Orbitale. Sollte der Platz auf einem Orbital doch "
            "einmal knapp werden, wird einfach ein weiteres gebaut."
        ),
    },
    {   # 04 Quantum
        "render": page_sheet, "fig": "04", "section": "MEINUNG // PHYSIK",
        "footer": "GROSSARTIGE PHYSIK, MIESER RECHNER", "accent": NEON_RED, "col_w": 300,
        "kicker": "// PHYSIK // GENUG VON DER KATZE", "title_a": "QUANTEN-",
        "title_b": "QUATSCH",
        "sub": "Umwerfende Physik. Praktisch für fast nichts zu gebrauchen. Und eure KI wird davon kein bisschen schneller.",
        "body": (
            "Quantencomputer sind in aller Munde. Von Quantencomputern heißt "
            "es sie könnten "
            "Berechnungen für die ein normaler Computer Milliarden Jahre "
            "bräuchte in Sekunden durchführen jeden Code knacken und eine "
            "extrem schnelle oder extrem intelligente KI möglich machen.\n\n"
            "Einem kritischen Blick halten diese Versprechungen aber nicht "
            "stand. Quantencomputer sind "
            "hochinteressante Forschungs- und Experimentierobjekte ohne großen "
            "praktischen Nutzen. Ein klassisches Nerdprojekt.\n\n"
            "Die meisten Rechenaufgaben, die so eine Maschine theoretisch "
            "schneller lösen kann, lassen sich näherungsweise auch performant auf "
            "einem normalen Computer rechnen. Das eine Millionstel Abstand zur "
            "optimalen Lösung hat in den wenigsten Fällen Relevanz. Kein "
            "Logistiknetz, kein Routenplaner und kein Suchalgorithmus wird "
            "dadurch besser. Oft "
            "wird auch ein vermeintlicher Geschwindigkeitsvorteil durch das "
            "Herstellen von Quantenzuständen wieder zunichte gemacht.\n\n"
            "Längst gibt es das Fachgebiet der Post-Quanten-Kryptografie und "
            "entsprechend gehärtete Algorithmen: Kyber für den "
            "Schlüsseltausch, Dilithium und SPHINCS+ für "
            "Signaturen, alle standardisiert und im Produktiveinsatz. Sie laufen auf "
            "heutiger Hardware und überstehen auch den mächtigsten "
            "Quantencomputer, lange bevor es eine Maschine gibt, die "
            "klassischen Verschlüsselungsalgorithmen gefährlich werden "
            "könnte.\n\n"
            "Zwar gibt es das Feld des Quantum Machine Learning, das an "
            "Quanten-Lernalgorithmen für KI forscht. An den oben genannten "
            "Schwächen ändert das aber nichts. Oft müssen die Daten in "
            "Quantenzuständen encodiert werden und das frisst den "
            "theoretischen Vorteil wieder auf. Quantencomputer machen also "
            "zumindest absehbar KI weder schlauer noch schneller.\n\n"
            "Was bleibt sind (vermutlich) echte Zufallsgeneratoren und ein "
            "praktischer Zugang zur Quantenphysik. Die Forschung hat abseits "
            "der Versprechungen von Wundercomputern einen wirklichen Wert: Ein "
            "immer tiefergehendes Verständnis der Quantenmechanik und eine "
            "immer bessere Kontrolle über die Zustände die sie beschreibt.\n\n"
            "Es gibt im Übrigen viele unterschiedliche Arten, einen "
            "Quantencomputer zu bauen: supraleitende Schaltkreise, gefangene "
            "Ionen, Photonen, neutrale Atome. Sie machen sich dabei alle "
            "dieselben physikalischen Phänomene "
            "zunutze, Quantenverschränkung und Superposition, um sogenannte "
            "Qubits "
            "zu erzeugen. Nur die topologischen "
            "Quantencomputer von Microsoft gibt es, ähnlich wie dunkle "
            "Materie, wahrscheinlich nicht."
        ),
        "panels": [
            {"kind": "notes", "boxed": False, "accent": NEON_RED,
             "items": [
                ("Google Willow · 105 Qubits",
                 "Winzige supraleitende Schaltkreise nahe dem absoluten "
                 "Nullpunkt, per Mikrowelle geschaltet. Mehr Qubits, weniger "
                 "Fehler."),
                ("IBM Condor · 1121 Qubits",
                 "Gleiches Prinzip wie Willow, auf Masse getrimmt: der erste "
                 "Chip jenseits von 1000 Qubits."),
                ("Quantinuum H2 · 56 Qubits",
                 "Einzelne geladene Atome schweben in elektromagnetischen "
                 "Fallen, geschaltet per Laser. Wenige Qubits, dafür sehr "
                 "zuverlässig."),
                ("QuEra Aquila · 256 Qubits",
                 "Neutrale Atome, mit Laserpinzetten in ein Gitter gelegt. "
                 "Läuft bei Raumtemperatur, ist aber kein universeller "
                 "Gatterrechner: kann keine beliebigen Schaltkreise rechnen, "
                 "sondern nur ein paar fest vorgegebene physikalische Systeme "
                 "ablaufen lassen."),
                ("Xanadu Borealis · 216 Modi",
                 "Rechnet mit Licht statt Materie: Lichtpulse laufen durch "
                 "Strahlteiler und werden am Ende gezählt. Damit "
                 "kann sie nur eine Sache, ein bestimmtes Zufallsmuster "
                 "auswürfeln, mehr nicht. Und die Lichtdetektoren muss man fast "
                 "auf den absoluten Nullpunkt herunterkühlen."),
                ("D-Wave Advantage · 5000+ Qubits",
                 "Kein Allzweckrechner, sondern eine Maschine nur fürs "
                 "Optimieren. Das Problem steckt so in den Qubits, dass die "
                 "beste Lösung ihrem energieärmsten Zustand entspricht. Genau "
                 "dahin pendelt sich die Maschine von selbst ein. Darum sagt "
                 "ihre hohe Qubit-Zahl wenig aus."),
                ("Microsoft · topologisch",
                 "Setzt auf topologische Qubits, die Fehler von selbst "
                 "unterdrücken sollen. Der vielbeachtete Nachweis von 2018 "
                 "wurde zurückgezogen, und der 2025 als „Majorana 1\" "
                 "verkündete Chip wurde bis heute nicht unabhängig "
                 "begutachtet."),
             ]},
        ],
    },
    {   # 05 Biologie für Nerds
        "render": page_sheet, "fig": "05", "section": "WETWARE",
        "footer": "DAS HACKBARSTE FELD ÜBERHAUPT", "accent": NEON_GREEN,
        "kicker": "// BIOLOGIE // DIE ZELLE", "title_a": "HACKBARE",
        "title_b": "BIOLOGIE", "tb_size": 34,
        "sub": "Ihr stürzt euch alle auf Physik, Technik und Informatik. Dabei steckt das hackbarste System der Welt in jeder Zelle, und keiner schaut hin.",
        "body": (
            "Ein Aufruf an alle Nerds. Ihr strömt in Scharen zu Physik, "
            "Technik und Informatik und überseht dabei das hackbarste System "
            "überhaupt: die Zelle.\n\n"
            "Was ist eine Zelle? Ein System, das "
            "sich selbst repliziert, sich selbst repariert und mit seiner "
            "Umgebung interagiert. Ihr Programm basiert auf einem Code, der "
            "DNA. In mRNA kopiert dient sie den "
            "Ribosomen als Bauanleitung für Proteine. Diese fungieren "
            "als Motoren, Schalter, Pumpen, Transporter und so weiter. "
            "Daneben gibt es tausende weitere Mechanismen, die es zu "
            "verstehen und zu entdecken gilt. Jeder davon ist hackbar und "
            "bootstrappt aus einer einzigen Zelle.\n\n"
            "Ein Biologe entwickelt in seiner Ausbildung ein tiefes "
            "Verständnis für Fächer wie Physik, Chemie, Biochemie oder "
            "Informatik. "
            "Spezialisieren könnt ihr euch in vielen Teilgebieten, wie "
            "etwa der Molekularbiologie, der synthetischen Biologie, der "
            "Bioinformatik, den Neurowissenschaften und so weiter. "
            "In jedem dieser Felder gibt es "
            "regelmäßig große Durchbrüche und nahezu täglich neue "
            "Erkenntnisse. Am Rechner entworfene Proteine, die es in der "
            "Natur nie gab, nehmen im Labor exakt die geplante Form an. 2024 "
            "verpflanzten Chirurgen einem Patienten erstmals eine "
            "genmanipulierte Schweineniere. Im selben Jahr kartierten Forscher "
            "das erste vollständige Gehirn einer Fruchtfliege.\n\n<<<COL>>>\n\n"
            "Das Werkzeug ist billig geworden. Sequenzierer gibt es "
            "gebraucht, oder ihr lasst eure Proben für ein paar hundert "
            "Dollar von einem Dienstleister sequenzieren. Gene lasst ihr "
            "euch auf Bestellung synthetisieren. Außerhalb der EU schickt "
            "euch ein Online-Shop jede "
            "Zelle, die ihr wollt, auch jede modifizierte. In der EU natürlich "
            "nicht. In der Heimat der Aufklärung ist no fun allowed.\n\n"
            "Anders als in der Physik, wo der nächste echte Durchbruch unter "
            "Umständen noch hundert Jahre auf sich warten lässt und es neue "
            "Erkenntnisse vielleicht alle paar Jahre gibt, ist die Revolution "
            "in der Biologie ein Dauerzustand. Jedes Jahr neue Techniken, "
            "neue Werkzeuge, mehr Möglichkeiten.\n\n"
            "Also: Wenn ihr ein Feld sucht, auf dem fast alles noch offen ist, "
            "vergesst Chip, Neutrinodetektor, TCP/IP und Teleskop. Hackt die "
            "Zelle."
        ),
    },
    {   # 05b HeLa (Gastbeitrag von amelisce) - 3-Seiten-Feature
        "render": page_hela, "fig": "H", "section": "WETWARE",
        "footer": "BE EXCELLENT TO EACH OTHER", "accent": HELA_MAGENTA,
        "pages": 3,
        "kicker": "// BIOLOGIE // UNSTERBLICHE ZELLEN",
        "title1": "VON DER FRAU, DIE BIS HEUTE LEBT",
        "title2": "ZUMINDEST IM LABOR",
        "runhead": "HELA · DIE UNSTERBLICHE ZELLLINIE",
        "cont_label": "// FORTSETZUNG",
        "hero_caption": "HeLa-Zellen: Mikrotubuli (magenta), DNA (cyan).",
        "hero_tag": "FIG. H · 40×",
        "blocks": [
            ("lede", "Wissenschaft baut auf dem Wissen der Menschen auf, die vor uns geforscht haben. Niemand beginnt heute bei Null – zum Glück. Gleichzeitig lebt Forschung aber auch davon, vermeintliche Gewissheiten immer wieder infrage zu stellen. Manchmal reicht dafür eine einzige Entdeckung. Oder genauer gesagt: ein paar Zellen."),
            ("p", "Sie stammen von einer Frau, die nie wusste, dass sie einmal zu den wichtigsten Menschen der modernen Medizin gehören würde. Ihre Zellen teilen sich bis heute, mehr als 70 Jahre nach ihrem Tod, in Laboren auf der ganzen Welt. Sie halfen bei Impfstoffen, der Krebsforschung, Gentherapien und sogar bei Experimenten im All."),
            ("p", "Die Rede ist von den HeLa-Zellen – und ihre Geschichte ist mindestens genauso faszinierend wie die Wissenschaft dahinter."),
            ("h", "Wer war diese Frau, deren Zellen bis heute in Laboren auf der ganzen Welt leben?"),
            ("p", "Henrietta Lacks wurde am 1. August 1920 in Roanoke im US-Bundesstaat Virginia geboren. Sie wuchs in ärmlichen Verhältnissen auf. Nachdem ihre Mutter bei der Geburt ihres zehnten Kindes gestorben war, zog ihr Vater mit den Kindern zu Verwandten auf eine Tabakplantage in Clover. Henriettas Familiengeschichte war eng mit der Geschichte der Sklaverei in den USA verbunden. Ihre Vorfahren hatten auf der Tabakplantage als versklavte Menschen gearbeitet. Zu ihren Vorfahren gehörte auch der Plantagenbesitzer Albert Lacks, der mehrere Kinder mit einer versklavten Frau hatte. Nach der Abschaffung der Sklaverei vermachte er einigen seiner schwarzen Nachkommen Land – darunter Henriettas Großvater."),
            ("p", "Henrietta teilte sich als Kind ein Zimmer mit ihrem Cousin David \"Day\" Lacks. Als sie 14 Jahre alt war, bekam sie das erste gemeinsame Kind. Wenige Jahre nach der Geburt des zweiten Kindes heirateten die beiden. Insgesamt bekamen sie fünf Kinder. Wie viele andere Familien ihrer Zeit führten sie ein einfaches Leben und arbeiteten hart. Obwohl die Sklaverei längst abgeschafft war, prägten Rassentrennung und soziale Ungleichheit das Leben schwarzer Amerikaner noch immer. Auch Henrietta sollte das später erfahren."),
            ("p", "Aus den ersten beiden Buchstaben ihres Vor- und Nachnamens entstand später der Name einer Zelllinie, die fast jeder Biologe kennt: HeLa."),
            ("h", "Wie kamen ihre Zellen ins Labor?"),
            ("p", "Als Henrietta Anfang 1951, nach der Geburt ihres fünften Kindes, wegen ungewöhnlicher Blutungen ins Krankenhaus ging, ahnte sie nicht, dass dieser Arztbesuch Medizin-Geschichte schreiben würde."),
            ("p", "Von ihrem Hausarzt wurde sie zur Untersuchung an das Johns Hopkins Hospital in Baltimore überwiesen. In den 1950er-Jahren war es das einzige Krankenhaus in der weiteren Umgebung, das Schwarze kostenlos behandelte. Dort stellte der Gynäkologe Howard W. Jones eine 2–3 cm große Geschwulst an ihrem Gebärmutterhals (Zervix) fest. Jones entnahm eine Gewebeprobe des Tumors, um ihn pathohistologisch zu untersuchen."),
            ("p", "Lacks wurde mit internen und externen Bestrahlungen therapiert. Während einer dieser Bestrahlungssitzungen entnahm Jones weitere Gewebeproben aus dem Gebärmutterhals. Diese Proben gab er an George Otto Gey weiter. Gey arbeitete seit Jahren daran, erstmals eine menschliche Zelllinie zu etablieren, die sich dauerhaft im Labor kultivieren ließ. Bisher waren alle Versuche daran gescheitert, dass die Zellen nach wenigen Teilungen abstarben."),
            ("p", "Gey isolierte einzelne Zellen aus dem Tumorgewebe und brachte sie in Kultur. Schon nach kurzer Zeit fiel auf, dass sich die Tumorzellen völlig anders verhielten, als alle zuvor untersuchten menschlichen Zellen. Sie teilten sich außergewöhnlich schnell und stellten ihr Wachstum nicht ein. Und da war sie, seine potentiell unsterbliche Zelllinie. Klingt nach Science-Fiction? Ist aber Zellbiologie."),
            ("p", "Während Forscher auf der ganzen Welt bald \"HeLa\" kannten, wusste kaum jemand, dass hinter diesen vier Buchstaben eine Mutter von fünf Kindern steckte. Dass Gey mit diesen Zellen etwas Außergewöhnliches entdeckt hatte, merkte er schnell. Was er damals allerdings noch nicht wusste: HeLa-Zellen würden in den kommenden Jahrzehnten millionenfach vervielfältigt, in Labore auf der ganzen Welt verschickt werden und zu einer der wichtigsten Grundlagen der modernen Medizin werden."),
            ("h", "Warum waren HeLa-Zellen so besonders?"),
            ("p", "Jahrzehnte später fanden Wissenschaftler heraus, warum normale menschliche Zellen nicht unbegrenzt wachsen können: Sie unterliegen der sogenannten Hayflick-Grenze."),
            ("p", "Normale menschliche Zellen sind keine Dauerläufer. Sie können sich nur etwa 40- bis 60-mal teilen. Danach ist Schluss. Dieses natürliche Limit nennt man die Hayflick-Grenze."),
            ("p", "Der Grund dafür liegt an den sogenannten Telomeren. Man kann sie sich wie die kleinen Plastikkappen an den Enden eines Schnürsenkels vorstellen. Sie schützen die Enden der Chromosomen. Mit jeder Zellteilung werden diese Schutzkappen jedoch ein kleines Stück kürzer. Irgendwann sind sie so weit abgenutzt, dass sich die Zelle nicht mehr sicher teilen kann – sie stellt ihr Wachstum ein oder stirbt ab."),
            ("p", "HeLa-Zellen spielen nach anderen Regeln. Durch Veränderungen, die unter anderem mit einer Infektion durch Humane Papillomviren (HPV) zusammenhängen, ist in ihnen das Enzym Telomerase dauerhaft aktiv. Es repariert beziehungsweise verlängert die Telomere immer wieder. Dadurch erreichen die Zellen die Hayflick-Grenze praktisch nicht und können sich nahezu unbegrenzt weiter teilen. Heute weiß man, dass Henriettas Tumor durch eine Infektion mit einem besonders aggressiven Typ des Humanen Papillomvirus (HPV-18) verursacht wurde. Das Virus veränderte die Zellen so stark, dass sie ihre normalen Kontrollmechanismen verloren."),
            ("h", "Was haben HeLa-Zellen in der Forschung bewegt?"),
            ("p", "Die HeLa-Zellen waren ein Durchbruch in der Forschung. Das Experimentieren mit diesen Zellen hat viele für uns heute wichtige medizinische Kenntnisse erbracht. So basieren weltweit ca. 17.000 angemeldete Patente auf den Forschungen und etliche wissenschaftliche Artikel beschäftigen sich mit HeLa-Zell Experimenten."),
            ("p", "Die erste große Bewährungsprobe kam Anfang der 1950er-Jahre: Jonas Salk benötigte riesige Mengen menschlicher Zellen, um seinen neuen Polio-Impfstoff zu testen. Genau dafür eigneten sich HeLa-Zellen perfekt. Kurz darauf wurden sie in großen Mengen gezüchtet und in Labore auf der ganzen Welt verschickt."),
            ("p", "Von da an waren sie praktisch überall dabei. Sie halfen Forschenden, Krebs besser zu verstehen, Virusinfektionen wie HIV zu untersuchen, Gene zu kartieren und neue Medikamente zu entwickeln. Sogar die Auswirkungen der Schwerelosigkeit auf menschliche Zellen wurden mit HeLa-Zellen untersucht. HeLa-Zellen fanden sogar außerhalb der klassischen Medizin Anwendung. Hersteller nutzten sie beispielsweise, um die Verträglichkeit von Pflastern, Klebstoffen oder Kosmetika zu testen. Die Aufklärung der Funktion der Polymerase-Enzyme gelang auch an HeLa-Zellen. Klingt also nach einer universellen Lösung für alles? Fast zu gut, um wahr zu sein. Und tatsächlich zeigte sich einige Jahre später, dass genau diese außergewöhnlichen Eigenschaften ein neues Problem schufen."),
            ("h", "Die dunkle Seite der HeLa Zellen"),
            ("p", "Je erfolgreicher HeLa-Zellen wurden, desto häufiger tauchten sie plötzlich dort auf, wo sie gar nicht sein sollten."),
            ("p", "Eine einzige HeLa-Zelle genügt, um eine andere Zellkultur nach und nach vollständig zu überwachsen. Viele Forschende bemerkten die Verunreinigung zunächst nicht. Jahre später stellte sich heraus, dass zahlreiche vermeintlich unterschiedliche Zelllinien in Wirklichkeit längst aus HeLa-Zellen bestanden. Dieses Problem ging als \"HeLa-Bombe\" in die Wissenschaftsgeschichte ein. Denn im Nachhinein konnte nicht mehr festgestellt werden, ob Experimente, die an humanen Zellen spezifischer Organe durchgeführt werden sollten, nicht doch an HeLa Zellen durchgeführt worden waren. Zahlreiche der an Zellkulturlinien erarbeiteten wissenschaftlichen Erkenntnisse wurden in Frage gestellt."),
            ("p", "Und auch die Nachkommen Henriettas waren bei der weltweiten Party zum wissenschaftlichen Erfolg nicht eingeladen. Damals wurde diese Gewebeprobe von Henrietta ohne Einwilligung entnommen. Sie wusste nicht einmal, dass mit ihren Zellen geforscht wurde. Während Unternehmen mit HeLa-Zellen Geld verdienten und Forschende auf der ganzen Welt wissenschaftliche Karrieren auf ihnen aufbauten, wusste Henriettas Familie jahrzehntelang kaum etwas von alledem."),
            ("p", "Erst als die weit verbreiteten Verunreinigungen anderer Zelllinien untersucht werden sollten, kontaktierten Wissenschaftler ihren Ehemann David Lacks. Die Familie glaubte zunächst, man wolle sie auf Krebs untersuchen. Tatsächlich interessierten sich die Forschenden vor allem für ihre DNA. Mit ihrer Hilfe sollte eine genetische Karte der HeLa-Zellen erstellt werden, um so Marker zu finden, mit denen HeLa-Zellen eindeutig identifiziert werden konnten."),
            ("p", "Am Ende ist die Geschichte der HeLa-Zellen viel mehr als die Geschichte einer außergewöhnlichen Zelllinie."),
            ("p", "Sie erzählt von wissenschaftlicher Neugier, medizinischem Fortschritt und Entdeckungen, die Millionen Menschen das Leben verbessert haben. Gleichzeitig erinnert sie daran, dass Forschung nie losgelöst von den Menschen stattfinden darf, die sie überhaupt erst möglich machen."),
            ("p", "Vielleicht ist genau das die wichtigste Erkenntnis: Wissenschaft lebt davon, Gewissheiten immer wieder zu hinterfragen – manchmal sogar die eigenen."),
            ("p", "Und falls ihr nach dieser Geschichte Lust bekommen habt, noch tiefer einzutauchen, kann ich euch Rebecca Skloots Buch Die unsterblichen Zellen der Henrietta Lacks empfehlen. Es erzählt die wissenschaftliche und menschliche Geschichte hinter HeLa noch viel eindrucksvoller, als es ein einzelner Artikel je könnte."),
            ("p", "Also an alle forschungsbegeisterten Nerds da draußen: Be excellent to each other!"),
            ("byline", "von amelisce"),
        ],
    },
    {   # 06 LoRa
        "render": page_sheet, "fig": "06", "section": "FUNK // MESH",
        "footer": "BAUT ES FÜR ALLE", "accent": NEON_VIOLET, "col_w": 300,
        "kicker": "// FUNK // OFF-GRID", "title_a": "(K)EIN MESH",
        "title_b": "FÜR ALLE FÄLLE", "tb_size": 34,
        "sub": "Meshtastic ist ein eigenes LoRa-Mesh für kurze Texte und Positionen: ohne Provider, ohne Gebühren, ohne Internet.",
        "body": (
            "Meshtastic ist eine quelloffene Software, die billige "
            "LoRa-Funkmodule zu einem selbstorganisierenden Netz verbindet. "
            "Keine SIM, kein Provider, keine Rechnung, nur kurze "
            "Textnachrichten, die von einem Gerät zum anderen weitergereicht "
            "werden. Die Idee ist großartig, die Implementierung nicht "
            "praxistauglich.\n\n"
            "Was wollen wir eigentlich? Ein Netz, das auch bei "
            "Naturkatastrophen funkt. Oder wenn ein Regime die Infrastruktur "
            "abschaltet. Dafür muss es skalieren. Und genau das kann "
            "Meshtastic nicht.\n\n"
            "Das liegt an einer harten Grenze im Protokoll. Weiter als "
            "sieben Hops kommt kein Paket, ab Werk sind es drei. Jede "
            "Zwischenstation frisst einen davon. Schon im kleinen Netz hier "
            "rechts trägt das nicht von der einen Ecke bis zur anderen. "
            "Dazwischen liegen mehr als sieben Knoten, da ist das Paket "
            "längst tot. Ein tiefes Netz aus vielen kurzen Sprüngen wächst "
            "so nie zusammen. Fläche holst du nur über ein paar sehr weite "
            "Hops von Hochstandorten oder über eine Internetbrücke. Also "
            "genau die Krücke, die hier eigentlich wegfallen sollte.\n\n"
            "Die Technik existiert, die "
            "Community bastelt, aber Lösungen, die ein normaler Mensch "
            "wirklich benutzen kann, bleiben aus. Liebe Hacker, wir brauchen "
            "nicht das nächste Bastelnetz, sondern eins, an dem man auch ohne "
            "Lötkolben teilnehmen kann und das hält und skaliert, wenn es "
            "darauf ankommt."
        ),
        "panels": [
            {"kind": "kv", "header": "# mesh.specs", "accent": NEON_VIOLET,
             "val_color": NEON_VIOLET,
             "rows": [
                ("Modulation", "CSS (Chirp)"),
                ("Band (EU / US)", "868 / 915 MHz"),
                ("Datenrate", "~0,3–22 kbit/s"),
                ("Reichw. (Sicht)", "einige km"),
                ("Hops", "3 (max. 7)"),
                ("Krypto", "AES-256, PSK"),
                ("Kosten / Modul", "< 30 €"),
             ]},
        ],
    },
    {   # 07 Papier
        "render": page_sheet, "fig": "07", "section": "KOLUMNE // ANALOG",
        "footer": "GARANTIERT AIR-GAPPED", "accent": NEON_YELLOW, "no_divider": True,
        "kicker": "// ANALOG // LOW TECH", "title_a": "AUF",
        "title_b": "PAPIER",
        "sub": "Egal ob in der Uni, im Büro oder beim Hacken daheim, das eine Werkzeug, das keine App ersetzt.",
        "body": (
            "Es gibt ein Gerät, dessen Akku nie leergeht, das nie abstürzt, "
            "keine Updates zieht und in keiner Cloud hängt: der Notizblock. "
            "Egal ob in der Uni, bei der Arbeit oder bei der Organisation zu "
            "Hause, er ist ein Werkzeug, das keine App ersetzen kann. Aufschlagen, "
            "losschreiben, fertig.\n\n"
            "Man notiert, was man sich merken will, den Namen einer Software "
            "oder eine E-Mail Adresse, kreist das Wichtige ein, zeichnet "
            "schnell eine Skizze daneben. Ein Pfeil hierhin, ein "
            "Kasten drumherum, ein Wort doppelt unterstrichen. Netzpläne, eine "
            "unbekannte aufgeschnappte Vokabel, Zeit und Datum des "
            "Zahnarzttermins.\n\n"
            "Planet Express empfiehlt blankes Papier. Kein Raster, keine "
            "Linien, keine vorgedruckten Kästchen, die deinen Ideen "
            "künstliche Grenzen setzen. Die richtige Größe muss der Leser für "
            "sich selbst abwägen.\n\n<<<COL>>>\n\n"
            "Notizblöcke gibt es in jeder Form und in jedem Stil. Wähl einen, "
            "der zu dir passt. Einen, der dir von der ersten Seite an ein "
            "gutes Gefühl gibt. Achte auf gutes Papier, das nicht "
            "durchdrückt.\n\n"
            "Ein Block hat keinen zweiten Tab, "
            "klingelt nicht, zeigt keine roten Punkte. Du sitzt mit einer "
            "Sache und einem Stift da, mehr nicht. Und die Sicherheit? Der "
            "Notizblock ist das einzige Gerät im Haus, das garantiert "
            "air-gapped ist. Was draufsteht, verlässt den Raum nur, wenn du, "
            "oder jemand anderes, ihn mitnimmt."
        ),
    },
    {   # 08 Gesundheit
        "render": page_sheet, "fig": "08", "section": "GESUNDHEIT // KÖRPER",
        "footer": "KEIN ZIEL ZU KLEIN", "accent": NEON_YELLOW, "col_w": 300,
        "kicker": "// GESUNDHEIT // WARTUNG", "title_a": "BEWEG",
        "title_b": "DICH",
        "sub": "Täglich gehen, täglich ein paar Übungen, den Fortschritt mitschreiben. Klein anfangen, langsam steigern.",
        "body": (
            "Wer zwölf Stunden sitzt, schuldet seinem Körper ein paar Minuten "
            "Gegenwehr. Dafür braucht es kein Fitnessstudio und keinen "
            "ausgeklügelten Trainingsplan. Es reicht, sich zwei Dinge "
            "vorzunehmen und sie jeden Tag zu machen: gehen und ein paar "
            "Übungen.\n\n"
            "Erstens: Geh. Jeden Tag, feste Strecke, bei jedem Wetter. Einmal "
            "um den Block plus den langen Weg, oder die fünf Kilometer am "
            "Fluss. Eine Strecke, kein Zeitziel; dann gibt's keine "
            "Ausreden.\n\n"
            "Zweitens: Werd stärker. Vier Übungen fast jeden Tag schlagen die "
            "große Einheit, die eh nie stattfindet. Liegestütze nach dem "
            "Aufstehen, Situps auf dem Boden, ein paar Klimmzüge im Türrahmen, "
            "und Superman für den Rücken. Rudern lassen wir weg, das braucht "
            "Gerät und Technik; diese vier gehen überall. Der Schreibtisch "
            "zieht dich vorne krumm, der Superman zieht dich wieder gerade, "
            "und ein starker Rücken ist die beste Versicherung gegen den "
            "Schmerz, der mit vierzig anklopft.\n\n"
            "Und schreib mit. Eine App oder ein Notizbuch, egal, Hauptsache du "
            "siehst schwarz auf weiß, was du letzte Woche geschafft hast. Häng "
            "jede Woche eine Wiederholung dran, dann noch eine. Die Steigerung "
            "ist winzig, und genau deshalb hältst du sie durch.\n\n"
            "Wichtig ist nicht, dass es viel ist, sondern dass es regelmäßig "
            "passiert. Lieber vier Übungen, die du seit einem Jahr jede Woche "
            "machst, als zwanzig, die du nie machst.\n\n"
            "Kein Ziel ist zu klein. Ein Spaziergang und zehn Liegestütze am "
            "Tag klingen nach nichts, aber machen in einem Jahr einen "
            "gesünderen Menschen aus dir."
        ),
        "panels": [
            {"kind": "kv", "header": "# routine.txt", "accent": NEON_YELLOW,
             "note": "Jede Woche eine Wiederholung mehr.", "rows": [
                ("Gehen", "täglich, feste Strecke"),
                ("Liegestütze", "täglich, 2–3 Sätze"),
                ("Situps", "täglich, 2–3 Sätze"),
                ("Klimmzüge", "täglich, Türrahmen"),
                ("Rücken: Superman", "30 s halten"),
                ("Tracken", "App oder Notizbuch"),
             ]},
        ],
    },
    {   # 09 Liebe
        "render": page_sheet, "fig": "09", "section": "LEBEN // HERZ",
        "footer": "ES GIBT KEINEN FEHLERFALL", "accent": NEON_PINK, "col_w": 300,
        "kicker": "// LEBEN // FELDHANDBUCH", "title_a": "LIEBE FÜR",
        "title_b": "EINSAME HACKER", "tb_size": 24,
        "sub": "Ein Werkzeugproblem, kein Charakterproblem. Hier kommt die Anleitung.",
        "body": (
            "Der einsame Hacker hat ein Werkzeugproblem, kein "
            "Charakterproblem. Die gute Nachricht: Dafür gibt's eine App, und "
            "die funktioniert besser, als der ganze Selbstspott vermuten "
            "lässt.\n\n"
            "Lad dir eine Dating-App runter, eine von den "
            "großen, da sind die meisten Leute. Zahl die Premium-Version; das "
            "ist kein Luxus, sondern einfach effizienter, weil du sonst an "
            "künstlichen Limits klebst. Sieh es als Budget fürs Werkzeug. "
            "Außerdem steckt hinter den Apps ein Geschäftsmodell. Wer zahlt, "
            "bekommt Matches. So einfach ist das.\n\n"
            "Besorg dir ein paar gute Fotos von dir. Profi-Tipp: Bilder "
            "mit Tieren kommen fast immer gut an.\n\n"
            "Dann der unromantischste, aber wirksamste Schritt: Like erst mal "
            "alle. Aussortiert wird später, im Gespräch, wie ein Mensch und "
            "nicht wie ein Sortieralgorithmus, der nach einem einzigen Foto "
            "urteilt. Warte die Matches ab; es kommen welche.\n\n"
            "Ist ein Match da, sei einfach nett. Kein Drehbuch, keine "
            "Anmachsprüche aus dem Netz. Frag freundlich, ob die andere Person "
            "Lust hat, sich mal zu treffen. Mehr ist es nicht. Die meisten "
            "zerdenken genau diesen einen Satz wochenlang, dabei ist er der "
            "leichteste.\n\n"
            "Und jetzt das Wichtigste, also lies langsam: Schäm dich für gar "
            "nichts. Ein Date ist auch dann schön, wenn nichts „draus wird\". "
            "Geh nicht mit dem Hintergedanken an eine Beziehung hin, sondern "
            "mit dem an einen netten Nachmittag. Zwei Menschen, ein Kaffee, "
            "ein Spaziergang, ein Gespräch: das allein ist schon der "
            "Gewinn.\n\n"
            "Passt es, freust du dich. Passt es nicht, hattest du einen "
            "schönen Tag mit einem interessanten Menschen. Beides ist ein "
            "guter Ausgang. Einen Fehlerfall gibt es nicht."
        ),
        "panels": [
            {"kind": "lines", "header": "# pull-request: ein date", "accent": NEON_PINK, "size": 8.2, "lines": [
                ("[1] App holen", NEON_GREEN),
                ("[2] Premium kaufen", NEON_CYAN),
                ("[3] erst mal alle liken", NEON_YELLOW),
                ("[4] auf Match warten", TEXT_DIM),
                ("[5] nett fragen: treffen?", NEON_PINK),
                ("[6] schönen Tag haben", NEON_GREEN),
                ("    kein Fehlerfall.", TEXT_DIM),
            ]},
        ],
    },
    {   # 10 Witze
        "render": page_jokes, "fig": "10", "section": "BACKMATTER // HUMOR",
        "footer": "LOL", "accent": NEON_CYAN,
        "kicker": "// HUMOR // /dev/humor", "title_a": "RETURN 0;",
        "title_b": "// WITZE", "tb_size": 30,
        "sub": "Die DE- und EN-Ausgabe erzählen völlig verschiedene Witze. Das ist ein Feature, kein Bug.",
        "jokes": [
            "Warum verwechseln Informatiker Halloween und Weihnachten?\nWeil OCT 31 = DEC 25.",
            "„Ich hab da ein Netzwerkproblem.\" „Ping mal.\" „Ping.\"",
            "Ein Admin beim Arzt: „Mir tut alles weh.\" Arzt: „Schon mal neu gestartet?\"",
            "Ein TCP-Paket betritt eine Bar: „Ich hätte gern ein Bier.\"\nWirt: „Sie hätten gern ein Bier?\" „Ja, ich hätte gern ein Bier.\"",
            "Zwei Prozesse wollen durch dieselbe Tür und halten sie sich gegenseitig auf. Sie stehen heute noch da.",
            "Ich hatte ein Problem und hab es mit einem regulären Ausdruck gelöst. Jetzt hab ich zwei Probleme.",
            "„rm -rf /*\", und schon war so viel Platz auf der Platte.",
            "Meine git-Historie: „final\", „final2\", „final_jetzt_wirklich\", „final_diesmal_echt\".",
            "Es gibt 10 Arten von Menschen: die, die Binär verstehen, die, die es nicht verstehen, und die, die keinen Base-3-Witz erwartet haben.",
            "Klopf, klopf. „Wer ist da?\" ...lange Pause... „Java.\"",
            "Zuhause ist da, wo 127.0.0.1 ist.",
        ],
        "ships_head": "// SCHIFFE DER KULTUR",
        "ships": [
            "GSV Shoot Them Later",
            "GSV Of Course I Still Love You",
            "GSV Just Read The Instructions",
            "GSV Sleeper Service",
            "GCU Grey Area",
            "GCU So Much For Subtlety",
            "ROU Frank Exchange Of Views",
            "ROU Killing Time",
            "ROU Attitude Adjuster",
            "ROU It'll Be Over By Christmas",
            "GCU Fate Amenable To Change",
            "GSV Ethics Gradient",
            "GSV Honest Mistake",
            "GCU It's Character Forming",
            "ROU Bad For Business",
            "GCU Funny, It Worked Last Time...",
            "GSV Lasting Damage",
            "GCU Kiss My Ass",
            "GCU Very Little Gravitas Indeed",
            "GCU No More Mr Nice Guy",
            "GCU God Told Me To Do It",
            "LOU Gunboat Diplomat",
            "GCU I Blame Your Mother",
            "MSV Not Invented Here",
            "GCU Unfortunate Conflict Of Evidence",
            "ROU Now We Try It My Way",
            "GSV I Thought He Was With You",
            "GCU Someone Else's Problem",
            "GCU Hand Me The Gun And Ask Me Again",
            "GCU A Series Of Unlikely Explanations",
            "GCU Well I Was In The Neighbourhood",
            "GCU Ultimate Ship The Second",
            "GCU Poke It With A Stick",
            "GCU Unacceptable Behaviour",
            "GCU Never Talk To Strangers",
            "GCU You'll Thank Me Later",
            "GSV Size Isn't Everything",
            "GCU Just Testing",
            "GSV All Through With This Niceness And Negotiation Stuff†",
        ],
    },
]


TOTAL = 1 + sum(a.get("pages", 1) for a in ART_EN) + 1   # cover + articles + end

CONTENT_EN = {
    "lang": "en", "filename": f"planet_express_{SLUG}_EN.pdf",
    "motto": "FIND THE PATTERNS IN THE NOIZE",
    "cover_cats": ["AI", "SCI-FI", "PHYSICS", "BIOLOGY", "RADIO",
                   "ANALOG", "HEALTH", "LIFE", "HUMOR"],
    "cover_sub": "ENGLISH EDITION",
    "index_title": "Contents",
    "cover_index": cover_index("en"), "price": "€0.00 free as in beer",
    "total": TOTAL, "articles": ART_EN,
    "eof_footer": "connection closed by foreign host",
    "eof_cta_head": "YOUR BYLINE HERE",
    "eof_cta": ("Got something to say? Pitch your own article for the next "
               "issue, send it to contact@planet-express.wtf"),
}

CONTENT_DE = {
    "lang": "de", "filename": f"planet_express_{SLUG}_DE.pdf",
    "motto": "FINDE DIE MUSTER IM RAUSCHEN",
    "cover_cats": ["KI", "SCI-FI", "PHYSIK", "BIOLOGIE", "FUNK",
                   "ANALOG", "GESUNDHEIT", "LEBEN", "HUMOR"],
    "cover_sub": "DEUTSCHE AUSGABE",
    "index_title": "Inhalt",
    "cover_index": cover_index("de"), "price": "0,00€ Frei wie Freibier",
    "total": TOTAL, "articles": ART_DE,
    "eof_footer": "Verbindung zur Gegenstelle beendet",
    "eof_cta_head": "DEIN ARTIKEL HIER",
    "eof_cta": ("Du hast was zu sagen? Reich deinen eigenen Artikel für die "
               "nächste Ausgabe ein, an contact@planet-express.wtf"),
}


# ====================================================================== BUILD
def build(t):
    global MOTTO
    MOTTO = t["motto"]
    random.seed(1337)
    out = t["filename"]
    c = canvas.Canvas(out, pagesize=A4)
    c.setTitle(f"Planet Express · {ISSUE} ({t['lang'].upper()})")
    c.setSubject(t["motto"].title())

    total = t["total"]
    page_cover(c, t)
    c.showPage()
    page_num = 2
    for a in t["articles"]:
        a["render"](c, a, page_num, total)
        c.showPage()
        page_num += 1
    page_eof(c, t, page_num, total)
    c.showPage()
    c.save()
    print(f"built: {out}  ({os.path.getsize(out)//1024} KiB, {total} pages)")


if __name__ == "__main__":
    build(CONTENT_DE)
    build(CONTENT_EN)
