"""Planet Express — 04/2026. Bilingual (EN / DE) PDF builder."""
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 os
import sys

PAGE_W, PAGE_H = A4

NEON_GREEN = HexColor("#39FF14")
NEON_PINK = HexColor("#FF006E")
NEON_CYAN = HexColor("#00F5FF")
NEON_YELLOW = HexColor("#FFE600")
NEON_RED = HexColor("#FF3B3B")
BG_DARK = HexColor("#0A0E14")
BG_PANEL = HexColor("#14181F")
TEXT_DIM = HexColor("#A0A8B3")
TEXT = HexColor("#E6EDF3")

# Hack — monospace throughout. Installed at /usr/share/fonts/TTF/.
_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"))

MONO = "Hack"
MONO_BOLD = "Hack-Bold"
SANS = "Hack"
SANS_BOLD = "Hack-Bold"
SERIF = "Hack"
SERIF_BOLD = "Hack-Bold"
SERIF_ITAL = "Hack-Italic"


# ================= CONTENT DICTS =================
CONTENT_EN = {
    "lang": "en",
    "filename": "planet_express_2026_04_EN.pdf",

    # Cover
    "issue_top_left": "// 04/2026 // AIR-GAPPED EDITION",
    "issue_top_right": "$ cat /dev/mag | hexdump -C",
    "title_a": "PLANET",
    "title_b": "EXPRESS",
    "tagline_l": "FINDING PATTERNS IN THE NOIZE",
    "tagline_r": "EAT SLEEP HACK REPEAT",
    "cover_lines": [
        ("01", "THE AXIOS AFFAIR", NEON_CYAN),
        ("02", "SPEEDRUNNING TRYHACKME ANY %", NEON_PINK),
        ("03", "NMAP: STILL THE ONE", NEON_YELLOW),
        ("04", "SEVEN TERMINAL TRICKS", NEON_GREEN),
        ("05", "CTF AFTER THE AGENT", NEON_CYAN),
        ("06", "HACK HEALTHIER: VITAMINS", NEON_YELLOW),
    ],
    "barcode_line": "free as in beer",

    # Axios
    "axios_kicker": "// FEATURE 01 // SUPPLY CHAIN",
    "axios_title_a": "THE AXIOS",
    "axios_title_b": "AFFAIR",
    "axios_sub": "On March 31, 2026, axios 1.14.1 and 0.30.4 were malicious for three hours. 180 million downloads a week. Here is how it ran.",
    "axios_body_1": (
        "There is a specific shape of modern dependency: small, boring, solves "
        "exactly one problem, and has installed itself into roughly every "
        "JavaScript project on Earth. axios is that dependency. Around 180 "
        "million npm downloads per week, split across two main branches: 1.x "
        "and 0.30.x. The HTTP client most teams did not choose, running inside "
        "their build anyway.\n\n"
        "Ubiquity is the risk. What makes a supply-chain attack work is not "
        "cleverness — it is reach.\n\n"
        "The history has precedent. event-stream in 2018 reached two million "
        "weekly downloads before its maintainer handed it to a stranger, who "
        "shipped a wallet stealer aimed at Copay users. ua-parser-js in 2021 "
        "carried a coin miner and credential stealer through a compromised "
        "maintainer account. colors.js and faker.js in 2022 were sabotaged by "
        "their own author. node-ipc in 2022 wiped files based on geolocation. "
        "xz-utils in 2025 was a patient three-year campaign that came within "
        "one beta release of backdooring sshd on every Linux distribution.\n\n"
        "On March 31, 2026 at 00:21 UTC, axios itself was hit. Three hours "
        "later, at 03:20, the window closed again. What follows is the "
        "mechanics of the attack, stage by stage, as it actually ran."
    ),
    "axios_body_2": (
        "Stage zero: compromised maintainer account. Not a typosquat, not a "
        "side-channel — a legitimate axios maintainer lost control of their "
        "npmjs account. The first visible artefact was the new contact email "
        "on the account: ifstap@proton.me.\n\n"
        "Stage one: a hidden dependency. The attacker added a new package "
        "named plain-crypto-js to axios, published in versions 4.2.0 and "
        "4.2.1. The name sounded harmless. The whole attack lived inside that "
        "dependency, not in axios itself — a diff against axios would have "
        "shown nothing unusual.\n\n"
        "Stage two: postinstall hook. plain-crypto-js carried a scripts entry "
        "in its package.json that ran `node setup.js` on every npm install. "
        "No user consent, no prompt, just code executing at install time.\n\n"
        "Stage three: SILKBELL, the dropper. setup.js was obfuscated with XOR "
        "and Base64; C2 URLs and OS-specific commands were only assembled at "
        "runtime. fs, os and execSync were required dynamically to dodge "
        "static analysis. After the drop, setup.js deleted itself and renamed "
        "package.json to package.md to cover its tracks.\n\n"
        "Stage four: OS-specific payload. On Windows, SILKBELL copied "
        "powershell.exe to %PROGRAMDATA%\\wt.exe — the filename of the "
        "legitimate Windows Terminal, used as camouflage. A PowerShell script "
        "was pulled via curl from packages.npm.org (a lookalike domain, not "
        "the real npm registry), POST body `product1`. On macOS, a Mach-O "
        "landed in /Library/Caches/com.apple.act.mond, body `product0`. On "
        "Linux, a Python backdoor went to /tmp/ld.py, body `product2`.\n\n"
        "Stage five: WAVESHAPER.V2, the actual backdoor. It beaconed back to "
        "sfrclak.com (142.11.206.73) over port 8000 every 60 seconds. "
        "Commands: `kill`, `rundir` (directory enumeration), `runscript` "
        "(AppleScript), `peinject` (PE injection into a process). One "
        "hard-coded User-Agent stayed put: mozilla/4.0 (compatible; msie 8.0; "
        "windows nt 5.1; trident/4.0) — an IE8 masquerade the WAVESHAPER "
        "family has been carrying for years, and the thing that ultimately "
        "made attribution easy.\n\n"
        "Stage six: persistence, Windows only. A hidden "
        "%PROGRAMDATA%\\system.bat plus a registry entry under "
        "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run with the "
        "value \"MicrosoftUpdate\" brought the backdoor back on every user "
        "logon. No persistence was documented for macOS or Linux — probably "
        "because the one-shot reach was already enough.\n\n"
        "Stage seven: aftermath. Hundreds of thousands of stolen secrets "
        "may be circulating as a direct result. The defences are not "
        "exotic — lockfiles, `npm ci --ignore-scripts`, "
        "scoped tokens, egress allow-lists in CI, ephemeral runners. None of "
        "it is deployed at the scale the ecosystem needs. It has been that "
        "way since 2012, and will be that way in 2030. The only open "
        "question is which package is next."
    ),
    "axios_art_lines": [
        ("[0] maintainer account hijacked",        NEON_CYAN),
        ("     └─ new email: ifstap@proton.me",    TEXT),
        ("           ▼",                           NEON_PINK),
        ("[1] plain-crypto-js @ 4.2.0 / 4.2.1",    NEON_CYAN),
        ("     └─ injected as axios dependency",   TEXT),
        ("           ▼",                           NEON_PINK),
        ("[2] postinstall: node setup.js",         NEON_CYAN),
        ("           ▼",                           NEON_PINK),
        ("[3] SILKBELL (XOR + Base64 dropper)",    NEON_CYAN),
        ("     ├─ win: %PROGRAMDATA%\\wt.exe",     TEXT),
        ("     ├─ mac: /Library/Caches/...mond",   TEXT),
        ("     └─ lin: /tmp/ld.py",                TEXT),
        ("           ▼",                           NEON_PINK),
        ("[4] WAVESHAPER.V2 → sfrclak.com:8000",   NEON_RED),
        ("           ▼",                           NEON_PINK),
        ("[5] persistence: Run\\MicrosoftUpdate",  TEXT_DIM),
    ],
    "axios_art_label": "2026-03-31 · 00:21 – 03:20 UTC",
    "axios_term_header": "$ npm install --production",
    "axios_term_lines": [
        ("added 1847 packages in 23s", TEXT),
        ("", TEXT),
        ("19 packages are looking for funding", TEXT_DIM),
        ("  run `npm fund` for details", TEXT_DIM),
        ("", TEXT),
        ("found 0 vulnerabilities", NEON_GREEN),
        ("", TEXT),
        ("$ node server.js", NEON_GREEN),
        ("server listening on :3000", TEXT),
        ("POST packages.npm.org/product1  200", NEON_RED),
        ("POST packages.npm.org/product1  200", NEON_RED),
        ("POST packages.npm.org/product1  200", NEON_RED),
        ("POST packages.npm.org/product1  200", NEON_RED),
        ("", TEXT),
        ("# found 0 vulnerabilities", TEXT_DIM),
        ("# found 1 apocalypse", NEON_PINK),
    ],
    "axios_timeline_header": "# SUPPLY_CHAIN.log",
    "axios_timeline": [
        ("2018", "event-stream", "wallet theft"),
        ("2021", "ua-parser-js", "miner + stealer"),
        ("2022", "colors.js", "author protest"),
        ("2022", "node-ipc", "protestware wipe"),
        ("2025", "xz-utils", "3-year backdoor"),
        ("2026", "axios", "infostealer drop"),
        ("20XX", "?", "pending"),
    ],

    # CTF — speedrunning tryhackme any%
    "ctf_kicker": "// FEATURE 02 // FIELD REPORT",
    "ctf_title_a": "SPEEDRUNNING",
    "ctf_title_b": "TRYHACKME ANY %",
    "ctf_sub": ("A bot that works through an entire cybersec syllabus on its own — "
                "and gets a little sharper with every room."),
    "ctf_body_1": (
        "TryHackMe is a learning platform for IT security. You get assigned a "
        "\"room\" — an exercise with explanatory text, questions, and usually "
        "a virtual target machine. Correct answers earn XP, fill the progress "
        "bar, unlock the next room. Hundreds of them, sorted into learning "
        "paths.\n\n"
        "The question behind this experiment: what happens when you point "
        "claude-code — an LLM agent that lives in the shell, reads and writes "
        "files, runs commands — at that syllabus and just wait?\n\n"
        "The first piece is trivial. A Python script starts Playwright, does a "
        "one-time manual login (the captcha wants a human), saves the cookie "
        "jar. From then on, a small tool talks to the internal API directly: "
        "GET /api/v2/rooms/tasks returns every question in a room, "
        "POST /api/v2/rooms/answer submits an answer. That is the entire "
        "platform from the inside.\n\n"
        "The agent gets a room, reads the task, figures out the answer, types "
        "it in. As long as the question is \"which word appears in the second "
        "paragraph\", this is trivial. The moment nmap has to be run against "
        "a virtual machine or a login form has to be cracked, the API-clicker "
        "turns into a small hacker: it opens a shell, calls nmap 10.10.x.y, "
        "reads the open ports, picks the next tool on its own.\n\n"
        "An orchestrator doesn't run one agent but many in parallel. Each gets "
        "exactly one room, a fixed time budget, its own context window. While "
        "one is fighting SQL injection, the second is clearing Linux "
        "forensics, the third is solving a reverse-engineering puzzle."
    ),
    "ctf_body_2": (
        "The interesting part is what happens between rooms.\n\n"
        "Next to the code sits a directory called skills/ — the bot's memory. "
        "Inside: a SKILL.md describing how to approach any room (read first, "
        "then scan, then exploit on purpose). A lessons.md collecting "
        "observations from prior solves (\"some static-site flags are "
        "base64-encoded inside the JS bundle; grep is enough\"). A recipes "
        "folder with one markdown file per attack technique — SMB recon, JWT "
        "tampering, buffer overflow — written as a ready-to-use playbook.\n\n"
        "Before each solve the agent reads all of it. After each solve, if it "
        "has seen something new on the way, it writes the finding back. New "
        "file, new entry, small diff.\n\n"
        "The bot grows with the corpus. Rooms that broke it in the first week "
        "fall in minutes in the second, because the matching recipe appeared "
        "in between. The setup tunes itself along the way — every solved room "
        "leaves a little more documentation for the next one.\n\n"
        "Two things happen in parallel here, and they refuse to separate "
        "cleanly. Learning: about APIs, captchas, rate limits, the anatomy of "
        "an attack. Cheating: agents clicking \"Check\" on questions nobody "
        "read. The progress bar moves anyway.\n\n"
        "For the CTF scene this says little that is new. A leaderboard was "
        "never a credential, just an attendance signal. Automation was going "
        "to catch up eventually; it just got there sooner than the curricula "
        "budgeted for.\n\n"
        "For IT security as a whole it says more. Attackers have always "
        "worked with scripts; what is new is how much of the work an LLM bot "
        "with a few markdown files as memory will now take off their hands. "
        "The threshold from \"experienced red teamer\" to \"someone with a "
        "free weekend\" has collapsed. The honest question for anyone on the "
        "blue side is which of those things your counterpart can now automate "
        "that you cannot. The answer tends to sting."
    ),
    "ctf_art_label": "rooms cleared · rolling 24h · y=count, x=hour",
    "ctf_pool_header": "# AGENT_POOL.dispatch",
    "ctf_pool_path": "path: cybersecurity101",
    "ctf_pool": [
        ("agent-001", "nmaplivehostdisc", "18:42", "DONE", "+400"),
        ("agent-002", "linuxforensics",   "—:—",   "RUN",  ""),
        ("agent-003", "webfundamentals",  "11:29", "DONE", "+300"),
        ("agent-004", "introtodfir",      "—:—",   "RUN",  ""),
        ("agent-005", "burpsuitebasics",  "03:11", "FAIL", ""),
        ("agent-006", "wiresharkbasics",  "—:—",   "RUN",  ""),
        ("…",         "…",                "…",     "…",    "…"),
    ],
    "ctf_pool_stats": [
        ("pool size",    "rolling"),
        ("completed",    "34 / 43"),
        ("wall runtime", "07:21:44"),
        ("tokens burnt", "2.8M"),
        ("recipes",      "growing"),
    ],
    "ctf_aquarium_caption": "# /dev/aquarium",

    # nmap
    "nmap_kicker": "// TOOL SPOTLIGHT",
    "nmap_title": "NMAP",
    "nmap_sub": "The Swiss army knife that refuses to retire.",
    "nmap_body": (
        "Gordon Lyon — Fyodor — released nmap in 1997 as an article for Phrack. Twenty-nine "
        "years later it is still the first thing anyone types when a new network appears in "
        "front of them. There is no higher compliment you can pay a piece of software.\n\n"
        "What makes nmap durable is that it is not opinionated. It does not try to be "
        "pretty. It does not try to be an agent. It does not want to live in your browser. "
        "It is a flashlight you aim at a network, and it tells you what it saw.\n\n"
        "The most underrated thing about nmap is that the naked form is already enough. "
        "Just `nmap <target>`. No flags, no scripts, no tuning. It scans the top thousand "
        "TCP ports, guesses what is running on each, and hands back a shape of the target. "
        "That shape is usually enough to decide where to look next. The cheatsheet below "
        "exists to answer questions the first scan raised."
    ),
    "nmap_cheats_header": "# CHEATSHEET",
    "nmap_cheats": [
        ("-sS", "stealth SYN scan"),
        ("-sV", "probe version info"),
        ("-O", "OS fingerprint"),
        ("-A", "aggressive (all the above)"),
        ("-p-", "all 65535 ports"),
        ("-T4", "fast timing template"),
        ("--open", "show only open"),
        ("--script", "NSE script engine"),
        ("vuln", "vuln detection scripts"),
        ("smb-enum-*", "smb recon pack"),
        ("http-title", "grab http titles"),
        ("-iL <file>", "read targets from file"),
    ],
    "nmap_oneliner_header": "$ nmap without flags:",
    "nmap_oneliner": "nmap 10.10.10.42",
    "nmap_oneliner_t1": "also valid: nmap example.com   nmap 192.168.1.1-254   nmap 10.0.0.0/24",
    "nmap_oneliner_t2": "top 1000 TCP ports, service guesses.",

    # terminal tricks
    "term_kicker": "// COLUMN // TERMINAL TRICKS",
    "term_title_a": "SEVEN TRICKS",
    "term_title_b": "YOU'LL ACTUALLY USE",
    "term_tricks": [
        ("01", "!!",
         "Repeat the last command. Combine with sudo: `sudo !!` — the\n"
         "single most-used two-character sequence in ops history."),
        ("02", "Ctrl-R",
         "Reverse incremental search through shell history. Start typing.\n"
         "Ctrl-R again to cycle back further. Stop trying to remember flags."),
        ("03", "ssh root@segfault.net",
         "Password: `segfault`. A fresh Kali VM with root, gifted by THC —\n"
         "a new box per login, Tor + VPN included. A lab with zero setup."),
        ("04", "cd -",
         "Jumps back to the previous directory. Ping-pong between two places\n"
         "without typing their paths. Pairs with `pushd` / `popd` if you're fancy."),
        ("05", "python3 -m http.server 8000",
         "Instant file server in the current directory. Move files between VMs,\n"
         "hand a pcap to a teammate, stage a payload. Zero dependencies."),
        ("06", "ss -tulpn",
         "Listening sockets with the process that owns each. Replaces netstat on\n"
         "modern systems. Everyone should know what is listening on their box."),
        ("07", "script -t timing.log session.log",
         "Records your entire terminal session — keystrokes, timing, output —\n"
         "and replays it with `scriptreplay`. Your future self will thank you."),
    ],

    # warez / leaks / forum ad — nerial.uk
    "warez_kicker": "// ADVERTISEMENT",
    "warez_group": "nerial.uk/",
    "warez_sub": "games · movies · series · music · books · leaks",
    "warez_url": "https://nerial.uk/",
    "warez_releases_header": "# catalog",
    "warez_releases": [
        ("Cyberpunk 2077 + all DLCs",        "[CPY]",    "ISO"),
        ("GTA VI cutscene dump (full)",      "[???]",    "MP4"),
        ("DAZN",                             "[STREAM]", "M3U8"),
        ("Epstein Files, uncensored",        "[LEAK]",   "PDF"),
        ("Adobe 2025 Master Suite",          "[TRB]",    "ZIP"),
        ("internal PRISM deck",              "[DOC]",    "PPT"),
        ("Starcraft + Brood War (orig.)",    "[RAZ]",    "ISO"),
        ("Windows 12 Pro (pre-RTM)",         "[FTCU]",   "ISO"),
        ("Scrubs (2026)",                    "[NERIAL]", "MKV"),
        ("Rick & Morty S13 (pre-air)",       "[NTb]",    "MKV"),
        ("Sky",                              "[STREAM]", "M3U8"),
        ("The Last of Us Part III (dev)",    "[LEAK]",   "PKG"),
        ("Avatar 3 — Fire and Ash",          "[EVO]",    "MKV"),
        ("Stranger Things S05 complete",     "[NTb]",    "MKV"),
        ("Zelda — Tears of the Kingdom II",  "[VENOM]",  "XCI"),
        ("Half Life 3 (internal build)",     "[3DM]",    "ISO"),
        ("Dune: Messiah (SCR.DVDRip)",       "[FGT]",    "MKV"),
        ("Adobe Creative Cloud 2026",        "[XFORCE]", "ZIP"),
    ],

    # agent-era reflection (feature 05)
    "agent_kicker": "// FEATURE 05 // COMMENTARY",
    "agent_title_a": "CTF AFTER",
    "agent_title_b": "THE AGENT",
    "agent_sub": (
        "The TryHackMe piece on page 4 was the easy part: the tool works. "
        "What follows is harder — what an auto-solver does to a scene that "
        "was built on humans doing the solving."
    ),
    "agent_body": (
        "An agent that clears rooms turns the flag into a receipt for "
        "something else — for the ability to wire tools together and let "
        "them run. That is still a real skill, just not the one the score "
        "column was ever measuring.\n\n"
        "The chess parallel carries further than it looks at first glance. "
        "Deep Blue did not end chess; what ended was one particular story "
        "about it — that humans at the top were the ceiling. What survived "
        "was the sport as a human activity, played at human pace and "
        "cleanly separated from engine analysis. Online platforms now flag "
        "engine-assisted play the way dope tests flag PEDs. Centaur chess, "
        "the human-plus-engine hybrid, had its fifteen-year moment and "
        "then faded, because the engines alone were better anyway.\n\n"
        "Speedrunning absorbed the same pressure differently. TAS — "
        "tool-assisted — is its own category, clearly labeled and "
        "frame-perfect, and no human will ever catch it. Nobody pretends "
        "a TAS run and an RTA run are comparable; the community drew the "
        "line early, and it has held ever since.\n\n"
        "CTFs have not drawn that line yet. THM's ladder, HTB's ranks, "
        "the monthly scoreboards — all of them already contain some "
        "unknown share of agent work, and the signal degrades from the "
        "bottom up. At some point the rankings stop meaning what people "
        "read into them, and the platforms either open a division of "
        "their own — agents allowed, clearly labeled — or collapse into "
        "pure decoration.\n\n"
        "The defenders still have room. Puzzle designers have long leaned "
        "on what models handle badly: steganography that hinges on human "
        "visual perception, OSINT against unpredictable sources, physical "
        "artefacts that have to be held in hand, out-of-band interactions "
        "the agent's harness never sees. The top of the curve gets harder "
        "for everyone, agent or not. The bottom — tutorial rooms, CVE "
        "recaps, the known web patterns — is already cleared ground.\n\n"
        "For the players, the skill moves up a layer: writing the agent, "
        "instrumenting the tools, knowing when the model is bluffing and "
        "when it is right, recognising the shape of a problem it will "
        "fail on before it burns an hour on it. That is real competence. "
        "It just sits closer to SRE than to offense, and it will not "
        "feel the same as popping a box by hand at 3am. Some people will "
        "love it. Many of the ones who loved CTFs will not.\n\n"
        "The honest argument comes from the small scene. Private CTFs "
        "among friends, with no scoreboard, no XP, no platform — those "
        "keep running. The mechanism was never the points anyway; it "
        "was the room with the whiteboard and six people arguing over "
        "the same PCAP. Agents do not touch any of that. What they "
        "touch is the public ladder, and the public ladder was already "
        "the weakest part of the hobby.\n\n"
        "None of this ends CTFs; what ends is their role as a ranking "
        "system. The learning stays. The scoreboards go, or they split. "
        "What comes after is either labeled honestly — agents welcome, "
        "humans welcome, same flag, different columns — or it becomes "
        "a category war fought with verification schemes nobody quite "
        "trusts. Chess picked the first path. So did speedrunning. CTFs "
        "have not picked yet."
    ),
    "agent_fork_rows": [
        ("CHESS",    "human ──┬─→ engine pool welcome",     "         └─→ human-only rating",       "decided"),
        ("SPEEDRUN", "human ──┬─→ TAS has its own lane",    "         └─→ RTA holds the record",    "decided"),
        ("CTF",      "human ──┬─→ agent-assisted ?",        "         └─→ verified-human ?",        "open"),
    ],
    "agent_fork_label": "the fork exists. the label has not been printed yet.",
    "agent_panel_header": "# post_agent.diff",
    "agent_panel_rows": [
        ("ladder / XP",           "SIGNAL → NOISE"),
        ("tutorial rooms",        "AUTO-CLEARED"),
        ("cve recap challenges",  "AUTO-CLEARED"),
        ("stego (visual)",        "STILL HARD"),
        ("osint (human src)",     "STILL HARD"),
        ("physical / OOB",        "STILL HARD"),
        ("private scene CTFs",    "UNTOUCHED"),
        ("writing the agent",     "NEW SKILL"),
        ("knowing when to stop",  "NEW SKILL"),
    ],
    "agent_panel_foot": "chess split the category. speedrunning split the category. ctf has not.",

    # Vitamins
    "vit_kicker": "// FEATURE 06 // HEALTH",
    "vit_title_a": "HACK",
    "vit_title_b": "HEALTHIER: VITAMINS",
    "vit_sub": "What pizza-at-three and screen-instead-of-sun quietly cost you — and what fifty cents from the drugstore actually does about it.",
    "vit_body": (
        "Hacker food is not a food pyramid. Pizza after midnight, Club-Mate "
        "instead of water, frozen vegetables as an alibi — and daylight "
        "mostly through the kitchen window. Even if you cook with discipline, "
        "you don't automatically get everything in sufficient quantity: a few "
        "trace elements, the fat-soluble vitamins, magnesium during stressful "
        "stretches. Twelve hours in front of a monitor plus the occasional "
        "kebab turns the gap into the baseline.\n\n"
        "The good news is that the gap closes for the price of one energy "
        "drink. The bad news is that every other podcast is trying to sell "
        "you a 40-euro monthly subscription instead — personalised powder, "
        "proprietary blend, influencer code at checkout. The mixture inside "
        "differs from what is printed on the drugstore effervescent tablet "
        "mostly in the packaging.\n\n"
        "Our recommendation is unspectacular. One multivitamin and one "
        "multimineral effervescent tablet per day, roughly 50 cents each. "
        "Glass of water, ten seconds of fizz, done.\n\n"
        "Plus, for one specific reason: vitamin D3. From October to April, "
        "across most of central Europe, the sun is too low for skin to make "
        "any D3 at all. If you also work indoors the rest of the time, you "
        "start February with a nearly empty reservoir. Around 1,000 IU a day "
        "keeps that reservoir stable. D3 is fat-soluble and stores well in "
        "the body, so the arithmetic is flexible: 2,000 IU every two days, "
        "20,000 IU every twenty days. Only the yearly average matters.\n\n"
        "And one recommendation that isn't a supplement at all: a daily "
        "walk. Twenty minutes is enough. Daylight, pulse a notch above "
        "desk-rate, eyes looking at something further away than a monitor. "
        "It does not replace the D3, but it replaces a surprising amount of "
        "everything else."
    ),
    "vit_panel_header": "# recipe.txt",
    "vit_panel_lines": [
        ("multivitamin fizz",   "50 ct"),
        ("multimineral fizz",   "50 ct"),
        ("vitamin D3 (1000 IU)","3 ct / day"),
        ("walk (20 min)",       "free"),
    ],
    "vit_panel_total": ("total",  "~ 1.05 € / day"),
    "vit_d3_header": "# depot.plan",
    "vit_d3_lines": [
        ("daily",         "1 x 1,000 IU"),
        ("every 2 days",  "1 x 2,000 IU"),
        ("every 20 days", "1 x 20,000 IU"),
    ],
    "vit_d3_note": "D3 is fat-soluble — yearly average counts, not the single day.",
    "vit_rant_header": "# podcast_vs_drugstore.diff",
    "vit_rant_lines": [
        ("-", "personalised blend, 40 €/mo, influencer code",  NEON_RED),
        ("+", "drugstore effervescent, 50 ct, no subscription", NEON_GREEN),
    ],

    # EOF
    "eof_footer": "connection closed by foreign host",
}


CONTENT_DE = {
    "lang": "de",
    "filename": "planet_express_2026_04_DE.pdf",

    # Cover
    "issue_top_left": "// 04/2026 // AIR-GAPPED",
    "issue_top_right": "$ cat /dev/mag | hexdump -C",
    "title_a": "PLANET",
    "title_b": "EXPRESS",
    "tagline_l": "FINDING PATTERNS IN THE NOIZE",
    "tagline_r": "EAT SLEEP HACK REPEAT",
    "cover_lines": [
        ("01", "DIE AXIOS-AFFÄRE", NEON_CYAN),
        ("02", "TRYHACKME IM ANY % SPEEDRUN", NEON_PINK),
        ("03", "NMAP: IMMER NOCH KÖNIG", NEON_YELLOW),
        ("04", "SIEBEN TERMINAL-KNIFFE", NEON_GREEN),
        ("05", "CTF NACH DEM AGENTEN", NEON_CYAN),
        ("06", "GESÜNDER HACKEN: VITAMINE", NEON_YELLOW),
    ],
    "barcode_line": "frei wie freibier",

    # Axios
    "axios_kicker": "// FEATURE 01 // LIEFERKETTE",
    "axios_title_a": "DIE AXIOS-",
    "axios_title_b": "AFFÄRE",
    "axios_sub": "Am 31. März 2026 war axios drei Stunden lang bösartig. 180 Millionen Downloads pro Woche, zwei Versionen betroffen — die Mechanik des Angriffs, Stufe für Stufe.",
    "axios_body_1": (
        "Es gibt eine bestimmte Sorte moderner Abhängigkeit: klein, "
        "unscheinbar, löst genau ein Problem — und sitzt inzwischen in so gut "
        "wie jedem JavaScript-Projekt dieses Planeten. axios ist so eine. "
        "Rund 180 Millionen npm-Downloads pro Woche, verteilt auf zwei "
        "Hauptzweige: 1.x und 0.30.x. Ein HTTP-Client, den die wenigsten "
        "Teams bewusst eingebaut haben und der trotzdem in jedem Build "
        "mitfährt.\n\n"
        "Genau diese Allgegenwart ist das eigentliche Risiko. Was einen "
        "Supply-Chain-Angriff trägt, ist selten Cleverness, fast immer "
        "Reichweite.\n\n"
        "Die Liste der Vorläufer ist lang. event-stream erreichte 2018 zwei "
        "Millionen wöchentliche Downloads, bevor sein Maintainer das Paket "
        "an einen Fremden übergab, der einen Wallet-Stealer für Copay-Nutzer "
        "ausrollte. ua-parser-js schleuste 2021 über einen gekaperten "
        "Maintainer-Account Krypto-Miner und Credential-Stealer aus. "
        "colors.js und faker.js wurden 2022 vom eigenen Autor sabotiert. "
        "node-ipc begann im selben Jahr, Dateien nach Geolokation zu "
        "löschen. xz-utils war 2025 eine dreijährige Geduldsarbeit — nur "
        "noch ein Beta-Release davon entfernt, sshd auf jeder "
        "Linux-Distribution mit einer Backdoor zu versehen.\n\n"
        "Am 31. März 2026 um 00:21 UTC traf es axios selbst. Drei Stunden "
        "später, um 03:20, war das Fenster wieder zu. Was folgt, ist die "
        "Mechanik des Angriffs, Stufe für Stufe, so wie sie tatsächlich "
        "lief."
    ),
    "axios_body_2": (
        "Stufe null: kompromittierter Maintainer-Account. Kein Typosquat, "
        "kein Nebenschauplatz — einem legitimen axios-Maintainer wurde sein "
        "npmjs-Konto aus der Hand genommen. Erstes sichtbares Artefakt: "
        "eine neu eingetragene Kontakt-E-Mail, ifstap@proton.me.\n\n"
        "Stufe eins: versteckte Abhängigkeit. Der Angreifer hängte axios "
        "ein neues Paket an, plain-crypto-js, in den Versionen 4.2.0 und "
        "4.2.1. Der Name klang harmlos; die eigentliche Angriffslogik saß "
        "vollständig in dieser Abhängigkeit, nicht im axios-Kern. Ein Diff "
        "auf axios selbst hätte nichts Verdächtiges gezeigt.\n\n"
        "Stufe zwei: postinstall-Hook. plain-crypto-js trug in seiner "
        "package.json einen scripts-Eintrag, der bei jedem npm install "
        "automatisch `node setup.js` aufrief. Keine Rückfrage, kein "
        "Consent — Code direkt zur Installationszeit.\n\n"
        "Stufe drei: SILKBELL, der Dropper. setup.js war XOR- und "
        "Base64-obfuskiert; C2-URLs und OS-spezifische Befehle entstanden "
        "erst zur Laufzeit. fs, os und execSync wurden dynamisch geladen, "
        "statische Analyse lief ins Leere. Nach dem Drop löschte sich "
        "setup.js selbst und benannte package.json in package.md um — damit "
        "die Forensik später länger sucht.\n\n"
        "Stufe vier: OS-abhängige Payload. Unter Windows kopierte SILKBELL "
        "powershell.exe nach %PROGRAMDATA%\\wt.exe — der Name eines "
        "legitimen Windows-Terminals, klassische Tarnung. Ein "
        "PowerShell-Skript wurde per curl von packages.npm.org nachgezogen "
        "— einer Lookalike-Domain, nicht der echten npm-Registry —, "
        "POST-Body `product1`. Unter macOS landete eine Mach-O-Binärdatei "
        "in /Library/Caches/com.apple.act.mond, Body `product0`. Unter "
        "Linux lief eine Python-Backdoor in /tmp/ld.py, Body `product2`.\n\n"
        "Stufe fünf: WAVESHAPER.V2, die eigentliche Backdoor. Sie meldete "
        "sich im 60-Sekunden-Takt über Port 8000 bei sfrclak.com "
        "(142.11.206.73). Befehle: `kill`, `rundir` (Verzeichnis "
        "enumerieren), `runscript` (AppleScript), `peinject` (PE-Binary in "
        "einen Prozess injizieren). Ein fester User-Agent verriet die "
        "Familie — mozilla/4.0 (compatible; msie 8.0; windows nt 5.1; "
        "trident/4.0), eine IE8-Maskerade, die WAVESHAPER seit Jahren "
        "mitschleppt.\n\n"
        "Stufe sechs: Persistenz, ausschließlich unter Windows. Eine "
        "versteckte %PROGRAMDATA%\\system.bat plus ein Registry-Eintrag "
        "unter HKCU\\...\\Run mit dem Wert „MicrosoftUpdate\" sorgten "
        "dafür, dass die Backdoor jeden Login neu anlief. Für macOS und "
        "Linux gab es keine Persistenz — der einmalige Durchlauf genügte.\n\n"
        "Stufe sieben: Nachspiel. Hunderttausende gestohlene Secrets "
        "dürften seither kursieren. Die Gegenmittel sind keine "
        "Raketenwissenschaft — Lockfiles, `npm ci --ignore-scripts`, scoped "
        "Tokens, Egress-Allow-Lists im CI, ephemere Runner. Breit "
        "ausgerollt ist davon nichts, 2012 nicht und 2030 auch nicht. "
        "Offen bleibt nur, welches Paket als Nächstes drankommt."
    ),
    "axios_art_lines": [
        ("[0] Maintainer-Account gekapert",       NEON_CYAN),
        ("     └─ neue E-Mail: ifstap@proton.me", TEXT),
        ("           ▼",                           NEON_PINK),
        ("[1] plain-crypto-js @ 4.2.0 / 4.2.1",    NEON_CYAN),
        ("     └─ als axios-Abhängigkeit ergänzt", TEXT),
        ("           ▼",                           NEON_PINK),
        ("[2] postinstall: node setup.js",         NEON_CYAN),
        ("           ▼",                           NEON_PINK),
        ("[3] SILKBELL (XOR + Base64)",            NEON_CYAN),
        ("     ├─ Win: %PROGRAMDATA%\\wt.exe",     TEXT),
        ("     ├─ mac: /Library/Caches/...mond",   TEXT),
        ("     └─ lin: /tmp/ld.py",                TEXT),
        ("           ▼",                           NEON_PINK),
        ("[4] WAVESHAPER.V2 → sfrclak.com:8000",   NEON_RED),
        ("           ▼",                           NEON_PINK),
        ("[5] Persistenz: Run\\MicrosoftUpdate",   TEXT_DIM),
    ],
    "axios_art_label": "31.03.2026 · 00:21 – 03:20 UTC",
    "axios_term_header": "$ npm install --production",
    "axios_term_lines": [
        ("added 1847 packages in 23s", TEXT),
        ("", TEXT),
        ("19 packages are looking for funding", TEXT_DIM),
        ("  run `npm fund` for details", TEXT_DIM),
        ("", TEXT),
        ("found 0 vulnerabilities", NEON_GREEN),
        ("", TEXT),
        ("$ node server.js", NEON_GREEN),
        ("server listening on :3000", TEXT),
        ("POST packages.npm.org/product1  200", NEON_RED),
        ("POST packages.npm.org/product1  200", NEON_RED),
        ("POST packages.npm.org/product1  200", NEON_RED),
        ("POST packages.npm.org/product1  200", NEON_RED),
        ("", TEXT),
        ("# found 0 vulnerabilities", TEXT_DIM),
        ("# found 1 Weltuntergang", NEON_PINK),
    ],
    "axios_timeline_header": "# LIEFERKETTE.log",
    "axios_timeline": [
        ("2018", "event-stream", "Wallet-Klau"),
        ("2021", "ua-parser-js", "Miner + Stealer"),
        ("2022", "colors.js", "Autor-Protest"),
        ("2022", "node-ipc", "Protestware"),
        ("2025", "xz-utils", "3-Jahre-Backdoor"),
        ("2026", "axios", "Infostealer-Drop"),
        ("20XX", "?", "ausstehend"),
    ],

    # CTF — tryhackme any%-speedrun
    "ctf_kicker": "// FEATURE 02 // FELDBERICHT",
    "ctf_title_a": "TRYHACKME",
    "ctf_title_b": "IM ANY % SPEEDRUN",
    "ctf_sub": ("Ein Bot, der einen kompletten Cybersec-Lehrplan allein abarbeitet — "
                "und mit jedem Raum ein Stück souveräner wird."),
    "ctf_body_1": (
        "TryHackMe ist eine Lernplattform für IT-Sicherheit. Dort bekommt man "
        "einen „Raum\" zugewiesen — eine Aufgabe aus Erklärtexten, "
        "Kontrollfragen und meist einer virtuellen Zielmaschine. Richtige "
        "Antworten bringen XP, füllen den Fortschrittsbalken und schalten den "
        "nächsten Raum frei. Hunderte davon, sauber in Lernpfade einsortiert."
        "\n\n"
        "Die Ausgangsfrage dieses Versuchs: Was passiert, wenn man claude-code "
        "— einen LLM-Agenten, der in der Shell lebt, Dateien liest, schreibt "
        "und Befehle ausführt — auf diesen Lehrplan loslässt und einfach "
        "wartet?\n\n"
        "Der erste Baustein ist unspektakulär. Ein Python-Skript fährt "
        "Playwright hoch, loggt sich einmalig von Hand ein (das Captcha will "
        "einen Menschen) und legt den Cookie-Jar ab. Von da an redet ein "
        "dünnes Tool direkt mit der internen API: GET /api/v2/rooms/tasks "
        "liefert jede Frage eines Raums, POST /api/v2/rooms/answer schickt "
        "eine Antwort zurück. Mehr ist die Plattform von innen nicht.\n\n"
        "Der Agent bekommt einen Raum, liest die Aufgabe, überlegt, trägt "
        "die Antwort ein. Solange die Frage lautet „welches Wort steht im "
        "zweiten Absatz\", ist das trivial. Sobald aber nmap gegen eine "
        "Ziel-VM laufen muss oder ein Login-Formular fällig wird, mutiert "
        "der API-Klicker zum kleinen Hacker: Shell auf, nmap 10.10.x.y, "
        "offene Ports gelesen, passendes Werkzeug selbst gewählt.\n\n"
        "Ein Orchestrator fährt nicht einen, sondern mehrere Agenten "
        "gleichzeitig. Jeder bekommt genau einen Raum, ein festes "
        "Zeitbudget und ein eigenes Kontextfenster. Während der eine an "
        "einer SQL-Injection sitzt, räumt der zweite Linux-Forensik, der "
        "dritte ein Reverse-Engineering-Rätsel."
    ),
    "ctf_body_2": (
        "Der interessante Teil ist das, was zwischen den Räumen passiert.\n\n"
        "Neben dem Code liegt ein Verzeichnis namens skills/ — das Gedächtnis "
        "des Bots. Darin: eine SKILL.md, die beschreibt, wie ein Raum "
        "grundsätzlich anzugehen ist (erst lesen, dann scannen, dann gezielt "
        "ausnutzen). Eine lessons.md mit Notizen aus früheren Solves („manche "
        "Static-Site-Flags stehen base64-kodiert im JS-Bundle — grep "
        "reicht\"). Ein recipes/-Ordner, je eine Markdown-Datei pro "
        "Angriffstechnik — SMB-Recon, JWT-Manipulation, Buffer-Overflow — "
        "jeweils als fertiges Kochrezept.\n\n"
        "Vor jedem Solve liest der Agent alles davon. Nach jedem Solve, wenn "
        "er unterwegs etwas Neues gesehen hat, schreibt er den Fund zurück: "
        "neue Datei, neuer Eintrag, kleines Diff.\n\n"
        "Der Bot wächst mit dem Korpus. Räume, die in der ersten Woche an "
        "ihm gescheitert sind, fallen in der zweiten in Minuten, weil "
        "inzwischen das passende Rezept dafür existiert. Das Setup pflegt "
        "sich dabei selbst — jeder gelöste Raum hinterlässt ein Stück mehr "
        "Dokumentation für den nächsten.\n\n"
        "Zwei Dinge laufen parallel, und sie lassen sich kaum sauber "
        "trennen. Lernen: über APIs, Captchas, Rate Limits, die Anatomie "
        "eines Angriffs. Schummeln: die Agenten klicken „Check\" auf "
        "Fragen, die niemand gelesen hat. Der Fortschrittsbalken bewegt "
        "sich trotzdem.\n\n"
        "Für die CTF-Szene ist das wenig überraschend. Ein Leaderboard war "
        "nie ein Zeugnis, sondern ein Anwesenheitssignal; Automatisierung "
        "würde es früher oder später einholen. Nur ist sie jetzt eben "
        "schneller da, als die Lehrpläne gerechnet haben.\n\n"
        "Für IT-Sicherheit insgesamt wiegt es schwerer. Skripte gab es "
        "schon immer; neu ist, wie groß der Anteil der Arbeit ist, den ein "
        "Bot mit ein paar Markdown-Dateien als Gedächtnis übernimmt. Die "
        "Einstiegshürde „erfahrener Red Teamer\" ist gerade erst auf "
        "„jemand mit einem freien Wochenende\" gefallen. Die ehrliche Frage "
        "an die blaue Seite lautet: Was kann die Gegenseite heute schon "
        "automatisieren, was wir noch nicht können? Die Antwort fällt "
        "unangenehm aus."
    ),
    "ctf_art_label": "geräumte Räume · rollend 24h · y=Anzahl, x=Stunde",
    "ctf_pool_header": "# AGENT_POOL.dispatch",
    "ctf_pool_path": "lernpfad: cybersecurity101",
    "ctf_pool": [
        ("agent-001", "nmaplivehostdisc", "18:42", "DONE", "+400"),
        ("agent-002", "linuxforensics",   "—:—",   "RUN",  ""),
        ("agent-003", "webfundamentals",  "11:29", "DONE", "+300"),
        ("agent-004", "introtodfir",      "—:—",   "RUN",  ""),
        ("agent-005", "burpsuitebasics",  "03:11", "FAIL", ""),
        ("agent-006", "wiresharkbasics",  "—:—",   "RUN",  ""),
        ("…",         "…",                "…",     "…",    "…"),
    ],
    "ctf_pool_stats": [
        ("Pool-Größe",   "rollend"),
        ("erledigt",     "34 / 43"),
        ("Wall-Runtime", "07:21:44"),
        ("Tokens verbraten", "2,8 M"),
        ("Rezepte",      "wachsend"),
    ],
    "ctf_aquarium_caption": "# /dev/aquarium",

    # nmap
    "nmap_kicker": "// WERKZEUG",
    "nmap_title": "NMAP",
    "nmap_sub": "Das Schweizer Taschenmesser, das sich weigert, in Rente zu gehen.",
    "nmap_body": (
        "Gordon Lyon — Fyodor — hat nmap 1997 als Artikel im Phrack "
        "veröffentlicht. Neunundzwanzig Jahre später ist es immer noch das "
        "Erste, was jemand tippt, wenn ein neues Netz vor der Nase liegt. Ein "
        "größeres Kompliment kann man einem Stück Software nicht machen.\n\n"
        "Was nmap langlebig macht: Es hat keine Meinung. Will nicht hübsch "
        "sein, will kein Agent sein, will nicht in deinem Browser wohnen. Es "
        "ist eine Taschenlampe, die du auf ein Netz richtest, und es sagt "
        "dir, was es gesehen hat.\n\n"
        "Das am häufigsten Unterschätzte an nmap: Die nackte Form reicht. "
        "Einfach `nmap <Ziel>`. Keine Flags, keine Skripte, kein Feintuning. "
        "Der Scan nimmt die Top-1000-TCP-Ports, rät pro Port den "
        "dahinterliegenden Dienst und liefert eine erste Silhouette des "
        "Ziels. In den meisten Fällen genügt diese Silhouette, um zu "
        "entscheiden, wo als Nächstes hingeschaut wird. Der Spickzettel "
        "unten beantwortet die Fragen, die der erste Scan aufwirft."
    ),
    "nmap_cheats_header": "# SPICKZETTEL",
    "nmap_cheats": [
        ("-sS", "stealth SYN-Scan"),
        ("-sV", "Version erkennen"),
        ("-O", "OS-Fingerabdruck"),
        ("-A", "aggressiv (alles davon)"),
        ("-p-", "alle 65535 Ports"),
        ("-T4", "schnelles Timing"),
        ("--open", "nur offene zeigen"),
        ("--script", "NSE-Script-Engine"),
        ("vuln", "Vuln-Detection-Skripte"),
        ("smb-enum-*", "SMB-Recon-Paket"),
        ("http-title", "HTTP-Titel greifen"),
        ("-iL <datei>", "Ziele aus Datei lesen"),
    ],
    "nmap_oneliner_header": "$ nmap ohne Flags:",
    "nmap_oneliner": "nmap 10.10.10.42",
    "nmap_oneliner_t1": "genauso erlaubt: nmap example.com   nmap 192.168.1.1-254   nmap 10.0.0.0/24",
    "nmap_oneliner_t2": "Top-1000-TCP-Ports, Dienst-Tipp pro Port.",

    # terminal tricks
    "term_kicker": "// KOLUMNE // TERMINAL-TRICKS",
    "term_title_a": "SIEBEN KNIFFE,",
    "term_title_b": "DIE DU WIRKLICH NUTZT",
    "term_tricks": [
        ("01", "!!",
         "Wiederholt den letzten Befehl. Mit sudo kombiniert: `sudo !!` — die\n"
         "meistgenutzte Zwei-Zeichen-Sequenz der Ops-Geschichte."),
        ("02", "Strg-R",
         "Rückwärts-Suche durch die Shell-History. Einfach lostippen,\n"
         "Strg-R nochmal für weiter zurück. Spart dir das Merken von Flags."),
        ("03", "ssh root@segfault.net",
         "Passwort: `segfault`. Eine frische Kali-VM mit Root, geschenkt von THC —\n"
         "neuer Rechner pro Login, Tor + VPN inklusive. Labor ohne Setup."),
        ("04", "cd -",
         "Springt ins vorherige Verzeichnis. Ping-Pong zwischen zwei Orten,\n"
         "ohne Pfade zu tippen. Zusammen mit `pushd` / `popd`, wenn es ordentlich sein soll."),
        ("05", "python3 -m http.server 8000",
         "Sofort-Fileserver im aktuellen Verzeichnis. Dateien zwischen VMs schieben,\n"
         "ein pcap an Kolleg:innen reichen, Payloads stagen. Null Abhängigkeiten."),
        ("06", "ss -tulpn",
         "Zeigt lauschende Sockets samt Prozess dahinter. Ersetzt netstat auf\n"
         "modernen Systemen. Jede:r sollte wissen, was auf der eigenen Kiste lauscht."),
        ("07", "script -t timing.log session.log",
         "Nimmt die komplette Terminal-Session auf — Tasten, Timing, Output —\n"
         "und spielt sie mit `scriptreplay` ab. Dein zukünftiges Ich dankt dir."),
    ],

    # warez / leaks / forum ad — nerial.uk
    "warez_kicker": "// WERBUNG",
    "warez_group": "nerial.uk/",
    "warez_sub": "spiele · filme · serien · musik · bücher · leaks",
    "warez_url": "https://nerial.uk/",
    "warez_releases_header": "# katalog",
    "warez_releases": [
        ("Cyberpunk 2077 + alle DLCs",       "[CPY]",    "ISO"),
        ("GTA VI Cutscene-Dump (voll)",      "[???]",    "MP4"),
        ("DAZN",                             "[STREAM]", "M3U8"),
        ("Epstein Files, unzensiert",        "[LEAK]",   "PDF"),
        ("Adobe 2025 Master Suite",          "[TRB]",    "ZIP"),
        ("internes PRISM-Deck",              "[DOC]",    "PPT"),
        ("Starcraft + Brood War (orig.)",    "[RAZ]",    "ISO"),
        ("Windows 12 Pro (pre-RTM)",         "[FTCU]",   "ISO"),
        ("Scrubs (2026)",                    "[NERIAL]", "MKV"),
        ("Rick & Morty S13 (pre-air)",       "[NTb]",    "MKV"),
        ("Sky",                              "[STREAM]", "M3U8"),
        ("The Last of Us Part III (Dev)",    "[LEAK]",   "PKG"),
        ("Avatar 3 — Fire and Ash",          "[EVO]",    "MKV"),
        ("Stranger Things S05 komplett",     "[NTb]",    "MKV"),
        ("Zelda — Tears of the Kingdom II",  "[VENOM]",  "XCI"),
        ("Half Life 3 (internal build)",     "[3DM]",    "ISO"),
        ("Dune: Messiah (SCR.DVDRip)",       "[FGT]",    "MKV"),
        ("Adobe Creative Cloud 2026",        "[XFORCE]", "ZIP"),
    ],

    # agent-era reflection (feature 05)
    "agent_kicker": "// FEATURE 05 // KOMMENTAR",
    "agent_title_a": "CTF NACH",
    "agent_title_b": "DEM AGENTEN",
    "agent_sub": (
        "Der TryHackMe-Artikel auf Seite 4 war der einfache Teil: Der Agent "
        "funktioniert. Der schwerere Teil ist, was so ein Auto-Solver mit "
        "einer Szene macht, die darauf gebaut hat, dass Menschen die "
        "Aufgaben lösen."
    ),
    "agent_body": (
        "Ein Agent, der Räume leerräumt, macht aus der Flag eine Quittung "
        "für etwas anderes — für die Fähigkeit, Tools zu verdrahten und "
        "laufen zu lassen. Das bleibt eine echte Fertigkeit, nur eben "
        "nicht die, die der Score je gemessen hat.\n\n"
        "Der Schach-Vergleich trägt weiter, als es auf den ersten Blick "
        "aussieht. Deep Blue hat Schach nicht beendet; beendet wurde nur "
        "die Erzählung, dass oben der Mensch die Decke sei. Geblieben ist "
        "der Sport als menschliche Tätigkeit, im menschlichen Tempo "
        "gespielt und sauber von der Engine-Analyse getrennt. Online-"
        "Plattformen kennzeichnen Engine-Hilfe heute so, wie Dopingtests "
        "PEDs kennzeichnen. Centaur-Schach, die Mischform aus Mensch und "
        "Engine, hatte fünfzehn Jahre lang einen Moment — und verschwand "
        "wieder, weil die Engines allein ohnehin besser spielten.\n\n"
        "Speedrunning hat denselben Druck anders aufgefangen. TAS — "
        "tool-assisted — ist eine eigene Kategorie, klar gelabelt und "
        "framegenau, und dorthin wird kein Mensch je kommen. Niemand tut "
        "so, als wären ein TAS-Lauf und ein RTA-Lauf vergleichbar; die "
        "Community hat die Trennlinie früh gezogen, und sie hält bis "
        "heute.\n\n"
        "Bei CTFs ist diese Linie noch nicht gezogen. In den THM-Leitern, "
        "HTB-Rängen und monatlichen Scoreboards steckt längst ein "
        "unbekannter Anteil Agent-Arbeit, das Signal verrauscht von unten. "
        "Irgendwann bedeuten die Rankings nicht mehr, was die Leute in "
        "sie hineinlesen — dann müssen die Plattformen entweder eine "
        "eigene Division aufmachen (Agenten erlaubt, klar markiert) oder "
        "sie verkommen zur Dekoration.\n\n"
        "Der Verteidigung bleibt Spielraum. Puzzle-Designer stützen sich "
        "längst auf das, was Modelle schlecht können: Steganographie, die "
        "an menschlicher Bildwahrnehmung hängt; OSINT gegen unvorhersehbare "
        "Quellen; physische Artefakte, die man in der Hand halten muss; "
        "Out-of-Band-Interaktionen, die das Agent-Harness gar nicht "
        "mitbekommt. Das obere Ende der Kurve wird für alle härter, Agent "
        "hin oder her. Das untere Ende — Tutorial-Räume, CVE-Nachbauten, "
        "bekannte Web-Muster — ist bereits geräumtes Gelände.\n\n"
        "Für die Spieler wandert die Fertigkeit eine Ebene nach oben: den "
        "Agenten schreiben, die Tools instrumentieren, erkennen, wann das "
        "Modell blufft und wann es recht hat, und die Form eines Problems "
        "sehen, an der er scheitern wird — bevor darin eine Stunde "
        "verbrennt. Das ist echte Kompetenz. Nur liegt sie näher an SRE "
        "als an Offense, und sie fühlt sich nicht so an wie eine Box, die "
        "man nachts um drei von Hand aufreißt. Manche werden es lieben. "
        "Viele, die CTFs geliebt haben, werden es nicht.\n\n"
        "Das ehrlichste Argument kommt aus der kleinen Szene. Private "
        "CTFs unter Freunden, ohne Scoreboard, ohne XP, ohne Plattform — "
        "die laufen weiter. Der Mechanismus waren ja nie die Punkte, "
        "sondern der Raum mit dem Whiteboard und den sechs Leuten, die "
        "über denselben PCAP streiten. Dort richten Agenten nichts an; "
        "was sie anrühren, ist die öffentliche Leiter, und die war schon "
        "vorher der schwächste Teil des Hobbys.\n\n"
        "Nichts davon beendet CTFs; beendet wird nur die Rolle als "
        "Ranking-System. Das Lernen bleibt, die Scoreboards gehen oder "
        "teilen sich. Was danach kommt, ist entweder ehrlich gelabelt — "
        "Agenten willkommen, Menschen willkommen, gleiche Flag, getrennte "
        "Spalten — oder ein Kategorienkrieg mit Verifikationsverfahren, "
        "denen niemand so richtig traut. Schach hat den ersten Weg "
        "gewählt. Speedrunning auch. CTFs haben noch nicht gewählt."
    ),
    "agent_fork_rows": [
        ("SCHACH",   "Mensch ──┬─→ Engine-Pool willkommen",   "          └─→ Mensch-only-Rating",      "entschieden"),
        ("SPEEDRUN", "Mensch ──┬─→ TAS hat eigene Spur",      "          └─→ RTA hält den Rekord",     "entschieden"),
        ("CTF",      "Mensch ──┬─→ agent-gestützt ?",         "          └─→ Mensch verifiziert ?",    "offen"),
    ],
    "agent_fork_label": "die Gabelung existiert. das Schild ist noch nicht aufgestellt.",
    "agent_panel_header": "# post_agent.diff",
    "agent_panel_rows": [
        ("Leiter / XP",             "SIGNAL → RAUSCHEN"),
        ("Tutorial-Räume",          "AUTO-GEKLÄRT"),
        ("CVE-Nachbau-Challenges",  "AUTO-GEKLÄRT"),
        ("Stego (visuell)",         "BLEIBT SCHWER"),
        ("OSINT (Mensch-Quelle)",   "BLEIBT SCHWER"),
        ("physisch / OOB",          "BLEIBT SCHWER"),
        ("private Szene-CTFs",      "UNBERÜHRT"),
        ("den Agenten schreiben",   "NEUE SKILL"),
        ("wissen, wann man stoppt", "NEUE SKILL"),
    ],
    "agent_panel_foot": "schach hat die kategorie geteilt. speedrunning auch. ctf noch nicht.",

    # Vitamins
    "vit_kicker": "// FEATURE 06 // GESUNDHEIT",
    "vit_title_a": "GESÜNDER",
    "vit_title_b": "HACKEN: VITAMINE",
    "vit_sub": "Was bei Pizza um drei und Bildschirm statt Sonne leise auf der Strecke bleibt — und was fünfzig Cent aus der Drogerie dagegen tun.",
    "vit_body": (
        "Hacker-Kost ist keine Ernährungspyramide. Pizza nach Mitternacht, "
        "Club-Mate statt Wasser, Tiefkühl-Gemüse als Alibi — und Tageslicht "
        "überwiegend durchs Küchenfenster. Selbst wer diszipliniert kocht, "
        "bekommt nicht automatisch alles in ausreichender Menge: ein paar "
        "Spurenelemente, die fettlöslichen Vitamine, Magnesium in "
        "stressigen Phasen. Zwölf Stunden vor dem Monitor plus "
        "gelegentlicher Döner, und die Lücke wird zur Regel.\n\n"
        "Die gute Nachricht: Sie lässt sich zum Preis eines Energy-Drinks "
        "schließen. Die schlechte: Jeder zweite Podcast verkauft dafür ein "
        "40-Euro-Monatsabo — personalisiertes Pulver, proprietäre Mischung, "
        "Influencer-Code an der Kasse. Zwischen dieser Mischung und dem, "
        "was auf einer Drogerie-Brausetablette steht, liegt im Wesentlichen "
        "die Verpackung.\n\n"
        "Die Empfehlung der Redaktion ist deshalb unspektakulär: eine "
        "Multivitamin- und eine Multimineral-Brausetablette pro Tag, "
        "zusammen rund ein Euro aus der Drogerie. Ein Glas Wasser, zehn "
        "Sekunden Plopp, fertig.\n\n"
        "Dazu, aus einem spezifischen Grund: Vitamin D3. Von Oktober bis "
        "April steht die Sonne über weiten Teilen Mitteleuropas zu flach, "
        "als dass die Haut überhaupt noch D3 bilden könnte; wer ohnehin "
        "drinnen sitzt, startet den Februar mit fast leerem Speicher. "
        "Rund 1.000 IE täglich halten den Pegel stabil. D3 ist fettlöslich "
        "und legt sich als Depot ab — man darf also rechnen: 2.000 IE alle "
        "zwei Tage, 20.000 IE alle zwanzig. Nur der Jahresdurchschnitt "
        "zählt.\n\n"
        "Und eine Empfehlung, die kein Präparat ist: täglich zwanzig "
        "Minuten Spaziergang. Tageslicht, Puls eine Stufe über Sitzruhe, "
        "Augen auf etwas, das weiter weg ist als der Monitor. Ersetzt kein "
        "D3. Ersetzt erstaunlich viel anderes."
    ),
    "vit_panel_header": "# rezept.txt",
    "vit_panel_lines": [
        ("Multivitamin-Brause",     "50 ct"),
        ("Multimineral-Brause",     "50 ct"),
        ("Vitamin D3 (1.000 IE)",   "3 ct / Tag"),
        ("Spaziergang (20 min)",    "gratis"),
    ],
    "vit_panel_total": ("Summe",  "~ 1,05 € / Tag"),
    "vit_d3_header": "# depot.plan",
    "vit_d3_lines": [
        ("täglich",      "1 x 1.000 IE"),
        ("alle 2 Tage",  "1 x 2.000 IE"),
        ("alle 20 Tage", "1 x 20.000 IE"),
    ],
    "vit_d3_note": "D3 ist fettlöslich — der Jahresdurchschnitt zählt, nicht der einzelne Tag.",
    "vit_rant_header": "# podcast_vs_drogerie.diff",
    "vit_rant_lines": [
        ("-", "personalisierte Mischung, 40 €/Monat, Influencer-Code", NEON_RED),
        ("+", "Drogerie-Brause, 50 ct, kein Abo",                      NEON_GREEN),
    ],

    # EOF
    "eof_footer": "Verbindung zur Gegenstelle beendet",
}


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


def draw_grid(c, color, spacing=10, alpha=0.08):
    c.saveState()
    c.setStrokeColor(color)
    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 draw_scanlines(c, alpha=0.05):
    c.saveState()
    c.setStrokeColor(colors.white)
    c.setStrokeAlpha(alpha)
    c.setLineWidth(0.5)
    y = 0
    while y < PAGE_H:
        c.line(0, y, PAGE_W, y)
        y += 3
    c.restoreState()


def draw_hex_dump(c, x, y, width, height, color=NEON_GREEN, alpha=0.6):
    c.saveState()
    c.setFillColor(color)
    c.setFillAlpha(alpha)
    c.setFont(MONO, 6.5)
    line_h = 8
    rows = int(height / line_h)
    for i in range(rows):
        addr = f"{0x4000 + i*16:08x}"
        hex_bytes = " ".join(f"{random.randint(0,255):02x}" for _ in range(16))
        ascii_part = "".join(
            chr(random.randint(32, 126)) if random.random() > 0.3 else "."
            for _ in range(16)
        )
        line = f"{addr}  {hex_bytes}  {ascii_part}"
        c.drawString(x, y + height - (i + 1) * line_h, line)
    c.restoreState()


def draw_binary_rain(c, x, y, w, h, color=NEON_GREEN, alpha=0.10):
    c.saveState()
    c.setFillColor(color)
    c.setFillAlpha(alpha)
    c.setFont(MONO, 7)
    cols = int(w / 6)
    rows = int(h / 9)
    for ci in range(cols):
        for ri in range(rows):
            bit = random.choice(["0", "1"])
            c.drawString(x + ci * 6, y + h - (ri + 1) * 9, bit)
    c.restoreState()


def draw_skull(c, x, y, color=NEON_PINK, size=7):
    skull = [
        "       ______",
        "    .-\"      \"-.",
        "   /            \\",
        "  |   .--. .--.  |",
        "  |   \\__/ \\__/  |",
        "  |     .--.     |",
        "   \\   (    )   /",
        "    '-.______.-'",
        "    /|  |  |  |\\",
        "   / |  |  |  | \\",
    ]
    c.saveState()
    c.setFillColor(color)
    c.setFont(MONO_BOLD, size)
    for i, line in enumerate(skull):
        c.drawString(x, y - i * (size + 1), line)
    c.restoreState()


def draw_header_bar(c, page_num, section_label):
    c.saveState()
    c.setFillColor(BG_PANEL)
    c.rect(0, PAGE_H - 18, PAGE_W, 18, fill=1, stroke=0)
    c.setStrokeColor(NEON_GREEN)
    c.setLineWidth(0.5)
    c.line(0, PAGE_H - 18, PAGE_W, PAGE_H - 18)
    c.setFillColor(NEON_GREEN)
    c.setFont(MONO_BOLD, 8)
    c.drawString(15, PAGE_H - 12, "> planet-express.wtf")
    c.setFillColor(TEXT_DIM)
    c.drawCentredString(PAGE_W / 2, PAGE_H - 12, section_label.upper())
    c.setFillColor(NEON_PINK)
    c.drawRightString(PAGE_W - 15, PAGE_H - 12, f"// PG {page_num:02d}")
    c.restoreState()


def draw_footer(c, page_num, txt):
    c.saveState()
    c.setStrokeColor(NEON_GREEN)
    c.setLineWidth(0.3)
    c.line(15, 22, PAGE_W - 15, 22)
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 7)
    c.drawString(15, 12, f"04/2026 // {txt}")
    c.setFillColor(NEON_CYAN)
    c.drawRightString(PAGE_W - 15, 12, f"0x{page_num:04x}")
    c.restoreState()


def wrap_text(text, max_width, font, size, c):
    words = text.split()
    lines = []
    current = []
    for w in words:
        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 draw_body_text(c, text, x, y, width, font=SERIF, size=9.5, leading=13, color=TEXT):
    c.setFillColor(color)
    c.setFont(font, size)
    lines = []
    for para in text.split("\n\n"):
        para_lines = wrap_text(para.strip(), width, font, size, c)
        lines.extend(para_lines)
        lines.append("")
    cur_y = y
    for line in lines:
        if line:
            c.drawString(x, cur_y, line)
        cur_y -= leading
    return cur_y


def draw_barcode(c, x, y, w=180, h=28):
    c.saveState()
    c.setFillColor(TEXT)
    bx = x
    while bx < x + w:
        bw = random.choice([1, 1, 2, 1, 3])
        c.rect(bx, y, bw, h, fill=1, stroke=0)
        bx += bw + 1
    c.restoreState()


# ================= ART HELPERS =================
def draw_dependency_tree(c, x, y, w, lines, label):
    """ASCII-style dep tree in a dim panel. Returns bottom y."""
    c.saveState()
    c.setFillColor(BG_PANEL)
    h = 14 + len(lines) * 11 + 22
    c.rect(x, y - h, w, h, fill=1, stroke=0)
    c.setStrokeColor(NEON_CYAN)
    c.setStrokeAlpha(0.3)
    c.setLineWidth(0.3)
    c.rect(x, y - h, w, h, fill=0, stroke=1)
    c.setStrokeAlpha(1)
    c.setFont(MONO, 8.5)
    cy = y - 14
    for line, col in lines:
        c.setFillColor(col)
        c.drawString(x + 10, cy, line)
        cy -= 11
    c.setFillColor(TEXT_DIM)
    c.setFont(SERIF_ITAL, 7.5)
    c.drawString(x + 10, cy - 6, label)
    c.restoreState()
    return y - h


def draw_sparkbars(c, x, y, w, h, n_bars=40, label="", seed=0):
    """Retro bar-chart art — n bars of pseudo-random height."""
    c.saveState()
    c.setFillColor(BG_PANEL)
    c.rect(x, y - h, w, h, fill=1, stroke=0)
    c.setStrokeColor(NEON_GREEN)
    c.setStrokeAlpha(0.25)
    c.setLineWidth(0.3)
    c.rect(x, y - h, w, h, fill=0, stroke=1)
    c.setStrokeAlpha(1)

    rnd = random.Random(seed)
    bar_area_h = h - 28
    gap = 1
    bar_w = (w - 20 - (n_bars - 1) * gap) / n_bars
    base_y = y - h + 18

    c.setFillColor(NEON_GREEN)
    for i in range(n_bars):
        bh = rnd.randint(int(bar_area_h * 0.20), int(bar_area_h * 0.95))
        bx = x + 10 + i * (bar_w + gap)
        alpha = 0.55 + (bh / bar_area_h) * 0.45
        c.setFillAlpha(alpha)
        c.rect(bx, base_y, bar_w, bh, fill=1, stroke=0)
    c.setFillAlpha(1)

    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 7)
    c.drawString(x + 10, y - h + 6, label)
    c.restoreState()
    return y - h


def draw_fork_diagram(c, x, y, w, rows, label=""):
    """Stacked per-row: header line (prefix + tail tag), then two branches. Rows = [(prefix, branch_a, branch_b, tail)]."""
    c.saveState()
    line_h = 10.5
    row_h = 3 * line_h + 6  # heading + 2 branches + spacing
    total_h = 14 + len(rows) * row_h + 20
    c.setFillColor(BG_PANEL)
    c.rect(x, y - total_h, w, total_h, fill=1, stroke=0)
    c.setStrokeColor(NEON_CYAN)
    c.setStrokeAlpha(0.3)
    c.setLineWidth(0.3)
    c.rect(x, y - total_h, w, total_h, fill=0, stroke=1)
    c.setStrokeAlpha(1)

    cy = y - 14
    decided_markers = {"decided", "entschieden"}
    for prefix, branch_a, branch_b, tail in rows:
        # heading line: prefix on the left, tail tag on the right
        c.setFillColor(NEON_CYAN)
        c.setFont(MONO_BOLD, 8.5)
        c.drawString(x + 10, cy, prefix)
        if tail:
            c.setFillColor(NEON_GREEN if tail in decided_markers else NEON_PINK)
            c.setFont(MONO_BOLD, 7)
            c.drawRightString(x + w - 10, cy, f"[{tail.upper()}]")
        # two branches
        c.setFillColor(TEXT)
        c.setFont(MONO, 7.5)
        c.drawString(x + 16, cy - line_h, branch_a)
        c.setFillColor(TEXT_DIM)
        c.drawString(x + 16, cy - 2 * line_h, branch_b)
        cy -= row_h

    c.setFillColor(TEXT_DIM)
    c.setFont(SERIF_ITAL, 7.5)
    c.drawString(x + 10, y - total_h + 8, label)
    c.restoreState()
    return y - total_h


def draw_aquarium(c, t, y=60, h=170):
    """ASCII-art aquarium panel for CTF page 2."""
    x = 25
    w = PAGE_W - 50
    c.saveState()
    c.setFillColor(BG_PANEL)
    c.rect(x, y, w, h, fill=1, stroke=0)
    c.setStrokeColor(NEON_CYAN)
    c.setStrokeAlpha(0.3)
    c.setLineWidth(0.4)
    c.rect(x, y, w, h, fill=0, stroke=1)
    c.setStrokeAlpha(1)

    # caption
    c.setFillColor(NEON_CYAN)
    c.setFont(MONO_BOLD, 9)
    c.drawString(x + 10, y + h - 14, t["ctf_aquarium_caption"])

    # rows drawn top-down inside the panel
    line_h = 10.5
    top = y + h - 30
    left = x + 12

    # layered cells: each cell is (color, text) printed at column x offsets.
    # Rendered in row order from top of panel downwards.
    rows = [
        [(NEON_CYAN,  "~ ~~  ~ ~~~~   ~~  ~ ~~~  ~~~ ~  ~~~~   ~  ~~~   ~ ~~  ~~~~   ~ ~~  ~  ~~~~  ~ ")],
        [(TEXT_DIM,   "       o              .                 o                  .            o      ")],
        [(TEXT_DIM,   "   o       .      o         .      o              o                .           ")],
        [(NEON_PINK,  "                             ><(((('>                                           ")],
        [(TEXT,       "    ><(((('>                                     .            <`)))><           ")],
        [(NEON_YELLOW,"                    o             ><(((º>                                       ")],
        [(TEXT_DIM,   "                                                   .                  ><>       ")],
        [(NEON_GREEN, "   ){     (}    )}           ){       (}          ){      (}         ){     (}  ")],
        [(NEON_GREEN, "   ){     (}    )}           ){       (}          ){      (}         ){     (}  ")],
        [(NEON_GREEN, "    |      |     |            |        |           |       |          |      |  ")],
        [(TEXT_DIM,   "..............................................................................."),
         (NEON_YELLOW, "")],
    ]

    for i, cells in enumerate(rows):
        for col, txt in cells:
            if not txt:
                continue
            c.setFillColor(col)
            c.setFont(MONO, 8.5)
            c.drawString(left, top - i * line_h, txt)

    c.restoreState()


# ================= PAGES =================
def page_cover(c, t):
    fill_bg(c, BG_DARK)
    draw_grid(c, NEON_GREEN, spacing=14, alpha=0.06)
    draw_binary_rain(c, 0, 0, PAGE_W, PAGE_H, NEON_GREEN, alpha=0.10)

    c.setFillColor(NEON_PINK)
    c.setFillAlpha(0.15)
    for _ in range(6):
        yy = random.randint(100, int(PAGE_H) - 100)
        c.rect(0, yy, PAGE_W, random.randint(2, 8), fill=1, stroke=0)
    c.setFillAlpha(1)

    c.setFillColor(NEON_GREEN)
    c.setFont(MONO_BOLD, 9)
    c.drawString(25, PAGE_H - 35, t["issue_top_left"])
    c.drawRightString(PAGE_W - 25, PAGE_H - 35, t["issue_top_right"])

    c.setStrokeColor(NEON_GREEN)
    c.setLineWidth(0.4)
    c.line(25, PAGE_H - 42, PAGE_W - 25, PAGE_H - 42)

    c.setFillColor(TEXT)
    c.setFont(SANS_BOLD, 82)
    c.drawString(25, PAGE_H - 130, t["title_a"])
    c.setFillColor(NEON_GREEN)
    c.drawString(25, PAGE_H - 205, t["title_b"])

    c.setFillColor(NEON_PINK)
    c.setFillAlpha(0.45)
    c.drawString(28, PAGE_H - 208, t["title_b"])
    c.setFillAlpha(1)

    c.setFillColor(NEON_GREEN)
    c.rect(25, PAGE_H - 240, PAGE_W - 50, 22, fill=1, stroke=0)
    c.setFillColor(BG_DARK)
    c.setFont(MONO_BOLD, 11)
    c.drawString(35, PAGE_H - 234, t["tagline_l"])
    c.drawRightString(PAGE_W - 35, PAGE_H - 234, t["tagline_r"])

    y = PAGE_H - 290
    for num, title, col in t["cover_lines"]:
        c.setFillColor(col)
        c.setFont(MONO_BOLD, 14)
        c.drawString(30, y, f"[{num}]")
        c.setFillColor(TEXT)
        c.setFont(SANS_BOLD, 14)
        c.drawString(65, y, title)
        y -= 22

    draw_hex_dump(c, PAGE_W - 220, 60, 195, 180, NEON_GREEN, alpha=0.55)

    draw_skull(c, 30, 230, NEON_PINK, size=8)

    draw_barcode(c, 30, 60, w=180, h=30)
    c.setFont(MONO, 7)
    c.setFillColor(TEXT_DIM)
    c.drawString(30, 51, t["barcode_line"])

    draw_scanlines(c, alpha=0.05)


def page_axios_1(c, t, page_num):
    fill_bg(c, BG_DARK)
    draw_header_bar(c, page_num, "FEATURE // SUPPLY CHAIN")

    c.setFillColor(NEON_CYAN)
    c.setFont(MONO_BOLD, 10)
    c.drawString(25, PAGE_H - 50, t["axios_kicker"])

    c.setFillColor(TEXT)
    c.setFont(SANS_BOLD, 42)
    c.drawString(25, PAGE_H - 95, t["axios_title_a"])
    c.setFillColor(NEON_CYAN)
    c.drawString(25, PAGE_H - 135, t["axios_title_b"])

    c.setFillColor(TEXT_DIM)
    c.setFont(SERIF_ITAL, 11.5)
    sub_lines = wrap_text(t["axios_sub"], PAGE_W - 50, SERIF_ITAL, 11.5, c)
    sy = PAGE_H - 155
    for line in sub_lines:
        c.drawString(25, sy, line)
        sy -= 14

    c.setStrokeColor(NEON_CYAN)
    c.setLineWidth(0.5)
    c.line(25, sy - 2, PAGE_W - 25, sy - 2)

    col_w = (PAGE_W - 70) / 2
    draw_body_text(c, t["axios_body_1"], 25, sy - 18, col_w,
                   font=SERIF, size=9.5, leading=13)

    # Right column: terminal mockup
    term_x = 25 + col_w + 20
    term_y = sy - 18
    term_w = col_w
    term_h = 240
    c.setFillColor(BG_PANEL)
    c.rect(term_x, term_y - term_h, term_w, term_h, fill=1, stroke=0)
    c.setStrokeColor(NEON_GREEN)
    c.setLineWidth(0.4)
    c.rect(term_x, term_y - term_h, term_w, term_h, fill=0, stroke=1)

    c.setFillColor(NEON_GREEN)
    c.setFont(MONO_BOLD, 8)
    c.drawString(term_x + 8, term_y - 14, t["axios_term_header"])
    c.setStrokeColor(NEON_GREEN)
    c.setLineWidth(0.2)
    c.line(term_x + 8, term_y - 18, term_x + term_w - 8, term_y - 18)

    ly = term_y - 32
    c.setFont(MONO, 8)
    for text, col in t["axios_term_lines"]:
        c.setFillColor(col)
        c.drawString(term_x + 10, ly, text)
        ly -= 11

    # Dependency-tree art below terminal
    art_y = term_y - term_h - 18
    draw_dependency_tree(c, term_x, art_y, term_w,
                         t["axios_art_lines"], t["axios_art_label"])

    draw_footer(c, page_num, "CONT. NEXT PAGE")


def page_axios_2(c, t, page_num):
    fill_bg(c, BG_DARK)
    draw_header_bar(c, page_num, "FEATURE // SUPPLY CHAIN")

    c.setFillColor(NEON_CYAN)
    c.setFont(MONO_BOLD, 10)
    c.drawString(25, PAGE_H - 50, t["axios_kicker"] + " // CONT.")

    col_w = (PAGE_W - 70) / 2
    half = len(t["axios_body_2"]) // 2
    split_pt = t["axios_body_2"].rfind("\n\n", 0, half + 200)
    if split_pt < 0:
        split_pt = half
    body_l = t["axios_body_2"][:split_pt].strip()
    body_r = t["axios_body_2"][split_pt:].strip()

    draw_body_text(c, body_l, 25, PAGE_H - 75, col_w,
                   font=SERIF, size=9.5, leading=13)
    draw_body_text(c, body_r, 25 + col_w + 20, PAGE_H - 75, col_w,
                   font=SERIF, size=9.5, leading=13)

    # Timeline panel
    tl_y = 60
    tl_h = 100
    c.setFillColor(BG_PANEL)
    c.rect(25, tl_y, PAGE_W - 50, tl_h, fill=1, stroke=0)

    c.setFillColor(NEON_RED)
    c.setFont(MONO_BOLD, 9)
    c.drawString(35, tl_y + tl_h - 15, t["axios_timeline_header"])

    c.setStrokeColor(NEON_RED)
    c.setLineWidth(0.4)
    c.line(40, tl_y + 40, PAGE_W - 40, tl_y + 40)

    step = (PAGE_W - 80) / (len(t["axios_timeline"]) - 1)
    for i, (year, evt, detail) in enumerate(t["axios_timeline"]):
        cx = 40 + i * step
        c.setFillColor(NEON_RED)
        c.circle(cx, tl_y + 40, 2.5, fill=1, stroke=0)
        c.setFillColor(NEON_CYAN)
        c.setFont(MONO_BOLD, 8)
        c.drawCentredString(cx, tl_y + 55, year)
        c.setFillColor(TEXT)
        c.setFont(SANS_BOLD, 7)
        c.drawCentredString(cx, tl_y + 28, evt)
        c.setFillColor(TEXT_DIM)
        c.setFont(MONO, 6)
        c.drawCentredString(cx, tl_y + 18, detail)

    draw_footer(c, page_num, "HOW THE SAUSAGE GETS MADE")


def page_ctf_1(c, t, page_num):
    fill_bg(c, BG_DARK)
    draw_header_bar(c, page_num, "FEATURE // CULTURE")

    c.setFillColor(NEON_PINK)
    c.setFont(MONO_BOLD, 10)
    c.drawString(25, PAGE_H - 50, t["ctf_kicker"])

    c.setFillColor(TEXT)
    c.setFont(SANS_BOLD, 42)
    c.drawString(25, PAGE_H - 95, t["ctf_title_a"])
    c.setFillColor(NEON_PINK)
    c.drawString(25, PAGE_H - 135, t["ctf_title_b"])

    c.setFillColor(TEXT_DIM)
    c.setFont(SERIF_ITAL, 11.5)
    sub_lines = wrap_text(t["ctf_sub"], PAGE_W - 50, SERIF_ITAL, 11.5, c)
    sy = PAGE_H - 155
    for line in sub_lines:
        c.drawString(25, sy, line)
        sy -= 14

    c.setStrokeColor(NEON_PINK)
    c.setLineWidth(0.5)
    c.line(25, sy - 2, PAGE_W - 25, sy - 2)

    col_w = (PAGE_W - 70) / 2
    draw_body_text(c, t["ctf_body_1"], 25, sy - 18, col_w,
                   font=SERIF, size=9.5, leading=13)

    # Right column: agent pool dashboard
    lb_x = 25 + col_w + 20
    lb_y = sy - 18
    lb_w = col_w
    lb_h = 260
    c.setFillColor(BG_PANEL)
    c.rect(lb_x, lb_y - lb_h, lb_w, lb_h, fill=1, stroke=0)

    c.setFillColor(NEON_YELLOW)
    c.setFont(MONO_BOLD, 10)
    c.drawString(lb_x + 10, lb_y - 16, t["ctf_pool_header"])
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 8)
    c.drawString(lb_x + 10, lb_y - 28, t["ctf_pool_path"])
    c.setStrokeColor(NEON_YELLOW)
    c.setLineWidth(0.3)
    c.line(lb_x + 10, lb_y - 34, lb_x + lb_w - 10, lb_y - 34)

    status_color = {
        "DONE": NEON_GREEN,
        "RUN":  NEON_CYAN,
        "FAIL": NEON_RED,
        "…":    TEXT_DIM,
    }

    yy = lb_y - 50
    for agent_id, slug, tm, status, xp in t["ctf_pool"]:
        c.setFillColor(TEXT_DIM)
        c.setFont(MONO, 8)
        c.drawString(lb_x + 10, yy, agent_id)
        c.setFillColor(TEXT)
        c.setFont(MONO_BOLD, 8.5)
        # truncate slug to fit
        max_slug = 16
        show_slug = slug if len(slug) <= max_slug else slug[:max_slug-1] + "…"
        c.drawString(lb_x + 62, yy, show_slug)
        c.setFillColor(TEXT_DIM)
        c.setFont(MONO, 8)
        c.drawString(lb_x + 165, yy, tm)
        col = status_color.get(status, TEXT_DIM)
        c.setFillColor(col)
        c.setFont(MONO_BOLD, 8)
        c.drawString(lb_x + 200, yy, status)
        if xp:
            c.setFillColor(NEON_GREEN)
            c.setFont(MONO, 8)
            c.drawString(lb_x + 228, yy, xp)
        yy -= 15

    # separator
    c.setStrokeColor(NEON_YELLOW)
    c.setLineWidth(0.2)
    c.line(lb_x + 10, yy + 4, lb_x + lb_w - 10, yy + 4)

    # stats
    yy -= 8
    for label, val in t["ctf_pool_stats"]:
        c.setFillColor(TEXT_DIM)
        c.setFont(MONO, 8)
        c.drawString(lb_x + 10, yy, label)
        c.setFillColor(NEON_CYAN)
        c.setFont(MONO_BOLD, 8)
        c.drawRightString(lb_x + lb_w - 10, yy, val)
        yy -= 12

    # Spark-bar art below pool panel
    art_y = lb_y - lb_h - 20
    draw_sparkbars(c, lb_x, art_y, lb_w, 90,
                   n_bars=40, label=t["ctf_art_label"], seed=42)

    draw_footer(c, page_num, "CONT. NEXT PAGE")


def page_ctf_2(c, t, page_num):
    fill_bg(c, BG_DARK)
    draw_header_bar(c, page_num, "FEATURE // CULTURE")

    c.setFillColor(NEON_PINK)
    c.setFont(MONO_BOLD, 10)
    c.drawString(25, PAGE_H - 50, t["ctf_kicker"] + " // CONT.")

    col_w = (PAGE_W - 70) / 2
    half = len(t["ctf_body_2"]) // 2
    split_pt = t["ctf_body_2"].rfind("\n\n", 0, half + 200)
    if split_pt < 0:
        split_pt = half
    body_l = t["ctf_body_2"][:split_pt].strip()
    body_r = t["ctf_body_2"][split_pt:].strip()

    draw_body_text(c, body_l, 25, PAGE_H - 75, col_w,
                   font=SERIF, size=9.5, leading=13)
    draw_body_text(c, body_r, 25 + col_w + 20, PAGE_H - 75, col_w,
                   font=SERIF, size=9.5, leading=13)

    draw_aquarium(c, t, y=60, h=170)

    draw_footer(c, page_num, "KNOW WHICH ONE YOU'RE DOING")


def page_nmap(c, t, page_num):
    fill_bg(c, BG_DARK)
    draw_header_bar(c, page_num, "TOOLING")

    c.setFillColor(NEON_YELLOW)
    c.setFont(MONO_BOLD, 10)
    c.drawString(25, PAGE_H - 50, t["nmap_kicker"])

    c.setFillColor(TEXT)
    c.setFont(SANS_BOLD, 46)
    c.drawString(25, PAGE_H - 95, t["nmap_title"])
    c.setFillColor(NEON_YELLOW)
    c.setFont(SANS_BOLD, 17)
    sub_lines = wrap_text(t["nmap_sub"], PAGE_W - 50, SANS_BOLD, 17, c)
    sy = PAGE_H - 118
    for line in sub_lines:
        c.drawString(25, sy, line)
        sy -= 20

    c.setStrokeColor(NEON_YELLOW)
    c.setLineWidth(0.5)
    c.line(25, sy - 2, PAGE_W - 25, sy - 2)

    draw_body_text(c, t["nmap_body"], 25, sy - 18, PAGE_W - 270,
                   font=SERIF, size=10, leading=13.5)

    # Cheatsheet panel
    cx = PAGE_W - 230
    cy = sy - 18
    cw = 205
    ch = 335
    c.setFillColor(BG_PANEL)
    c.rect(cx, cy - ch, cw, ch, fill=1, stroke=0)

    c.setFillColor(NEON_YELLOW)
    c.setFont(MONO_BOLD, 10)
    c.drawString(cx + 10, cy - 15, t["nmap_cheats_header"])
    c.setStrokeColor(NEON_YELLOW)
    c.setLineWidth(0.3)
    c.line(cx + 10, cy - 20, cx + cw - 10, cy - 20)

    yy = cy - 35
    for flag, desc in t["nmap_cheats"]:
        c.setFillColor(NEON_GREEN)
        c.setFont(MONO_BOLD, 9)
        c.drawString(cx + 12, yy, flag.ljust(12))
        c.setFillColor(TEXT)
        c.setFont(SANS, 9)
        c.drawString(cx + 80, yy, desc)
        yy -= 13

    # One-liner box
    ex_y = 80
    c.setFillColor(BG_PANEL)
    c.rect(25, ex_y, PAGE_W - 50, 90, fill=1, stroke=0)
    c.setStrokeColor(NEON_GREEN)
    c.setLineWidth(0.4)
    c.rect(25, ex_y, PAGE_W - 50, 90, fill=0, stroke=1)
    c.setFillColor(NEON_GREEN)
    c.setFont(MONO_BOLD, 9)
    c.drawString(35, ex_y + 75, t["nmap_oneliner_header"])
    c.setFillColor(NEON_CYAN)
    c.setFont(MONO_BOLD, 10)
    c.drawString(35, ex_y + 55, t["nmap_oneliner"])
    c.setFillColor(TEXT_DIM)
    c.setFont(SERIF_ITAL, 9)
    c.drawString(35, ex_y + 35, t["nmap_oneliner_t1"])
    c.drawString(35, ex_y + 22, t["nmap_oneliner_t2"])

    draw_footer(c, page_num, "FLASHLIGHT // NETWORK")


def page_terminal(c, t, page_num):
    fill_bg(c, BG_DARK)
    draw_header_bar(c, page_num, "COLUMN")

    c.setFillColor(NEON_GREEN)
    c.setFont(MONO_BOLD, 10)
    c.drawString(25, PAGE_H - 50, t["term_kicker"])

    c.setFillColor(TEXT)
    c.setFont(SANS_BOLD, 34)
    c.drawString(25, PAGE_H - 90, t["term_title_a"])
    c.setFillColor(NEON_GREEN)
    c.drawString(25, PAGE_H - 125, t["term_title_b"])

    c.setStrokeColor(NEON_GREEN)
    c.setLineWidth(0.5)
    c.line(25, PAGE_H - 133, PAGE_W - 25, PAGE_H - 133)

    yy = PAGE_H - 160
    for num, cmd, desc in t["term_tricks"]:
        c.setFillColor(NEON_PINK)
        c.setFont(MONO_BOLD, 16)
        c.drawString(25, yy, num)
        c.setFillColor(NEON_CYAN)
        c.setFont(MONO_BOLD, 13)
        c.drawString(65, yy, cmd)
        c.setFillColor(TEXT_DIM)
        c.setFont(SERIF, 9.5)
        for i, line in enumerate(desc.split("\n")):
            c.drawString(65, yy - 15 - i * 12, line.strip())
        yy -= 60

    draw_footer(c, page_num, "MUSCLE MEMORY")


def page_warez(c, t, page_num):
    # Different bg — darker with heavy green grid for warez vibes
    fill_bg(c, HexColor("#000000"))
    draw_grid(c, NEON_GREEN, spacing=8, alpha=0.12)

    c.setFillColor(NEON_GREEN)
    c.setFont(MONO_BOLD, 9)
    c.drawString(25, PAGE_H - 35, t["warez_kicker"])
    c.drawRightString(PAGE_W - 25, PAGE_H - 35, "// 04/2026")

    panel_x = 40
    panel_w = PAGE_W - 80

    # Top deco block bar
    c.saveState()
    c.setFillColor(NEON_GREEN)
    bw = 4
    for i in range(int(panel_w / (bw + 1))):
        hh = random.choice([4, 6, 8, 10, 8, 6, 4])
        c.rect(panel_x + i * (bw + 1), PAGE_H - 62 - hh, bw, hh, fill=1, stroke=0)
    c.restoreState()

    # Wordmark — now reads "nerial.uk/" so the title itself is the URL
    c.setFillColor(NEON_PINK)
    c.setFillAlpha(0.45)
    c.setFont(SANS_BOLD, 46)
    c.drawCentredString(PAGE_W / 2 + 3, PAGE_H - 112, t["warez_group"])
    c.setFillAlpha(1)
    c.setFillColor(NEON_GREEN)
    c.drawCentredString(PAGE_W / 2, PAGE_H - 110, t["warez_group"])

    # Sub — descriptive, no secrecy
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 9.5)
    c.drawCentredString(PAGE_W / 2, PAGE_H - 130, t["warez_sub"])

    # Split releases in half, 8 above + 8 below the URL centerpiece
    releases = t["warez_releases"]
    half = len(releases) // 2
    upper, lower = releases[:half], releases[half:]
    line_h = 13

    def _draw_panel(top, items, header_text, right_tag):
        h = 24 + len(items) * line_h + 8
        bot = top - h
        c.setFillColor(BG_PANEL)
        c.rect(panel_x, bot, panel_w, h, fill=1, stroke=0)
        c.setStrokeColor(NEON_GREEN)
        c.setStrokeAlpha(0.35)
        c.setLineWidth(0.4)
        c.rect(panel_x, bot, panel_w, h, fill=0, stroke=1)
        c.setStrokeAlpha(1)
        c.setFillColor(NEON_GREEN)
        c.setFont(MONO_BOLD, 8)
        c.drawString(panel_x + 12, top - 15, header_text)
        c.drawRightString(panel_x + panel_w - 12, top - 15, right_tag)
        c.setStrokeColor(NEON_GREEN)
        c.setStrokeAlpha(0.25)
        c.setLineWidth(0.3)
        c.line(panel_x + 12, top - 22, panel_x + panel_w - 12, top - 22)
        c.setStrokeAlpha(1)
        c.setFont(MONO, 8)
        ry = top - 35
        tag_x = panel_x + panel_w - 90
        fmt_x = panel_x + panel_w - 14
        for idx, (name, tag, fmt) in enumerate(items):
            if idx % 2 == 1:
                c.setFillColor(HexColor("#0a0a0a"))
                c.rect(panel_x + 4, ry - 3, panel_w - 8, line_h, fill=1, stroke=0)
            c.setFillColor(TEXT)
            c.drawString(panel_x + 14, ry, name)
            c.setFillColor(NEON_PINK)
            c.drawString(tag_x, ry, tag)
            c.setFillColor(NEON_CYAN)
            c.drawRightString(fmt_x, ry, fmt)
            ry -= line_h
        return bot

    # Upper release panel
    up_top = PAGE_H - 155
    up_bot = _draw_panel(up_top, upper, t["warez_releases_header"], "// 18 · 2026-04")

    # URL centerpiece — the hero
    url_top = up_bot - 22
    url_h = 130
    url_bot = url_top - url_h

    c.setFillColor(BG_PANEL)
    c.rect(panel_x, url_bot, panel_w, url_h, fill=1, stroke=0)
    c.setStrokeColor(NEON_GREEN)
    c.setStrokeAlpha(0.75)
    c.setLineWidth(1.2)
    c.rect(panel_x, url_bot, panel_w, url_h, fill=0, stroke=1)
    c.setStrokeAlpha(1)

    # Corner ticks
    c.setStrokeColor(NEON_GREEN)
    c.setLineWidth(2.0)
    for (cx, cy) in [
        (panel_x, url_bot),
        (panel_x + panel_w, url_bot),
        (panel_x, url_bot + url_h),
        (panel_x + panel_w, url_bot + url_h),
    ]:
        c.line(cx - 8, cy, cx + 8, cy)
        c.line(cx, cy - 8, cx, cy + 8)

    # HUGE URL — the hero (fully qualified, so readers grok it's a URL)
    url_center_y = url_bot + url_h / 2 - 13
    c.setFillColor(NEON_PINK)
    c.setFillAlpha(0.35)
    c.setFont(SANS_BOLD, 36)
    c.drawCentredString(PAGE_W / 2 + 3, url_center_y - 2, t["warez_url"])
    c.setFillAlpha(1)
    c.setFillColor(NEON_GREEN)
    c.drawCentredString(PAGE_W / 2, url_center_y, t["warez_url"])

    # Lower release panel
    low_top = url_bot - 22
    low_bot = _draw_panel(low_top, lower, t["warez_releases_header"], "// cont.")

    # Bottom deco bar
    c.saveState()
    c.setFillColor(NEON_GREEN)
    for i in range(int(panel_w / (bw + 1))):
        hh = random.choice([4, 6, 8, 10, 8, 6, 4])
        c.rect(panel_x + i * (bw + 1), 50, bw, hh, fill=1, stroke=0)
    c.restoreState()

    # Page num corner
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 7)
    c.drawRightString(PAGE_W - 15, 20, f"// PG {page_num:02d}")


def page_agent(c, t, page_num):
    fill_bg(c, BG_DARK)
    draw_header_bar(c, page_num, "COMMENTARY // CULTURE")

    # Kicker
    c.setFillColor(NEON_CYAN)
    c.setFont(MONO_BOLD, 10)
    c.drawString(25, PAGE_H - 50, t["agent_kicker"])

    # Title
    c.setFillColor(TEXT)
    c.setFont(SANS_BOLD, 42)
    c.drawString(25, PAGE_H - 95, t["agent_title_a"])
    c.setFillColor(NEON_CYAN)
    c.drawString(25, PAGE_H - 135, t["agent_title_b"])

    # Subhead
    c.setFillColor(TEXT_DIM)
    c.setFont(SERIF_ITAL, 11.5)
    sub_lines = wrap_text(t["agent_sub"], PAGE_W - 50, SERIF_ITAL, 11.5, c)
    sy = PAGE_H - 155
    for line in sub_lines:
        c.drawString(25, sy, line)
        sy -= 14

    c.setStrokeColor(NEON_CYAN)
    c.setLineWidth(0.5)
    c.line(25, sy - 2, PAGE_W - 25, sy - 2)

    # Two-column body with a right-side panel on top-right
    col_w = (PAGE_W - 70) / 2
    body_top = sy - 18
    body_bottom = 50  # leave space above footer

    # Right panel: post_agent.diff — top of right column
    panel_x = 25 + col_w + 20
    panel_y = body_top
    panel_w = col_w
    rows = len(t["agent_panel_rows"])
    row_h = 11.5
    panel_h = 26 + rows * row_h + 22

    c.setFillColor(BG_PANEL)
    c.rect(panel_x, panel_y - panel_h, panel_w, panel_h, fill=1, stroke=0)
    c.setFillColor(NEON_CYAN)
    c.setFont(MONO_BOLD, 10)
    c.drawString(panel_x + 10, panel_y - 16, t["agent_panel_header"])
    c.setStrokeColor(NEON_CYAN)
    c.setLineWidth(0.3)
    c.line(panel_x + 10, panel_y - 22, panel_x + panel_w - 10, panel_y - 22)

    ry = panel_y - 38
    state_color = {
        "SIGNAL → NOISE":    NEON_RED,
        "SIGNAL → RAUSCHEN": NEON_RED,
        "AUTO-CLEARED":      NEON_RED,
        "AUTO-GEKLÄRT":      NEON_RED,
        "STILL HARD":        NEON_YELLOW,
        "BLEIBT SCHWER":     NEON_YELLOW,
        "UNTOUCHED":         NEON_GREEN,
        "UNBERÜHRT":         NEON_GREEN,
        "NEW SKILL":         NEON_CYAN,
        "NEUE SKILL":        NEON_CYAN,
    }
    for label, state in t["agent_panel_rows"]:
        c.setFillColor(TEXT)
        c.setFont(MONO, 8)
        c.drawString(panel_x + 12, ry, label)
        c.setFillColor(state_color.get(state, TEXT_DIM))
        c.setFont(MONO_BOLD, 7.5)
        c.drawRightString(panel_x + panel_w - 12, ry, state)
        ry -= row_h

    # separator + foot text
    c.setStrokeColor(NEON_CYAN)
    c.setStrokeAlpha(0.3)
    c.setLineWidth(0.2)
    c.line(panel_x + 10, ry + 4, panel_x + panel_w - 10, ry + 4)
    c.setStrokeAlpha(1)
    c.setFillColor(TEXT_DIM)
    c.setFont(SERIF_ITAL, 7)
    foot_lines = wrap_text(t["agent_panel_foot"], panel_w - 16, SERIF_ITAL, 7, c)
    fy = ry - 10
    for line in foot_lines:
        c.drawString(panel_x + 12, fy, line)
        fy -= 9

    # Fork-diagram art below panel
    art_y = min(fy, panel_y - panel_h) - 14
    art_bottom = draw_fork_diagram(c, panel_x, art_y, panel_w,
                                   t["agent_fork_rows"], t["agent_fork_label"])
    # preserve legacy name for subsequent right_body_top calc
    pq_y = art_bottom + 46

    # Left column: full body flow (may wrap into right column below panel if long)
    # Strategy: text flows top-to-bottom in left column; if it overflows, continue
    # in right column below panel+pullquote.
    left_x = 25
    right_x = panel_x
    split_point = t["agent_body"].rfind("\n\n", 0, int(len(t["agent_body"]) * 0.80))
    if split_point < 0:
        split_point = len(t["agent_body"]) // 2
    body_l = t["agent_body"][:split_point].strip()
    body_r = t["agent_body"][split_point:].strip()

    # Left flows from body_top down to body_bottom
    draw_body_text(c, body_l, left_x, body_top, col_w,
                   font=SERIF, size=8.0, leading=11)

    # Right continues under the panel + pullquote
    right_body_top = pq_y - 56
    draw_body_text(c, body_r, right_x, right_body_top, col_w,
                   font=SERIF, size=8.0, leading=11)

    draw_footer(c, page_num, "FLAGS WITHOUT HUMANS")


def page_vitamins(c, t, page_num):
    fill_bg(c, BG_DARK)
    draw_header_bar(c, page_num, "HEALTH")

    c.setFillColor(NEON_YELLOW)
    c.setFont(MONO_BOLD, 10)
    c.drawString(25, PAGE_H - 50, t["vit_kicker"])

    c.setFillColor(TEXT)
    c.setFont(SANS_BOLD, 42)
    c.drawString(25, PAGE_H - 95, t["vit_title_a"])
    c.setFillColor(NEON_YELLOW)
    c.drawString(25, PAGE_H - 135, t["vit_title_b"])

    c.setFillColor(TEXT_DIM)
    c.setFont(SERIF_ITAL, 11.5)
    sub_lines = wrap_text(t["vit_sub"], PAGE_W - 50, SERIF_ITAL, 11.5, c)
    sy = PAGE_H - 155
    for line in sub_lines:
        c.drawString(25, sy, line)
        sy -= 14

    c.setStrokeColor(NEON_YELLOW)
    c.setLineWidth(0.5)
    c.line(25, sy - 2, PAGE_W - 25, sy - 2)

    body_w = PAGE_W - 280
    draw_body_text(c, t["vit_body"], 25, sy - 18, body_w,
                   font=SERIF, size=9.5, leading=13)

    # rezept panel (top right)
    px = PAGE_W - 245
    py = sy - 18
    pw = 220
    ph = 130
    c.setFillColor(BG_PANEL)
    c.rect(px, py - ph, pw, ph, fill=1, stroke=0)

    c.setFillColor(NEON_YELLOW)
    c.setFont(MONO_BOLD, 10)
    c.drawString(px + 10, py - 15, t["vit_panel_header"])
    c.setStrokeColor(NEON_YELLOW)
    c.setLineWidth(0.3)
    c.line(px + 10, py - 20, px + pw - 10, py - 20)

    ly = py - 36
    for name, price in t["vit_panel_lines"]:
        c.setFillColor(TEXT)
        c.setFont(SANS, 9)
        c.drawString(px + 12, ly, name)
        c.setFillColor(NEON_GREEN)
        c.setFont(MONO_BOLD, 9)
        c.drawRightString(px + pw - 12, ly, price)
        ly -= 13

    c.setStrokeColor(NEON_YELLOW)
    c.setStrokeAlpha(0.3)
    c.line(px + 10, ly + 4, px + pw - 10, ly + 4)
    c.setStrokeAlpha(1)
    ly -= 6
    label, val = t["vit_panel_total"]
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO_BOLD, 9)
    c.drawString(px + 12, ly, label)
    c.setFillColor(NEON_GREEN)
    c.drawRightString(px + pw - 12, ly, val)

    # depot plan panel (middle right)
    dy = py - ph - 18
    dh = 100
    c.setFillColor(BG_PANEL)
    c.rect(px, dy - dh, pw, dh, fill=1, stroke=0)
    c.setFillColor(NEON_CYAN)
    c.setFont(MONO_BOLD, 10)
    c.drawString(px + 10, dy - 15, t["vit_d3_header"])
    c.setStrokeColor(NEON_CYAN)
    c.setLineWidth(0.3)
    c.line(px + 10, dy - 20, px + pw - 10, dy - 20)

    dly = dy - 36
    for period, dose in t["vit_d3_lines"]:
        c.setFillColor(TEXT_DIM)
        c.setFont(MONO, 9)
        c.drawString(px + 12, dly, period)
        c.setFillColor(NEON_CYAN)
        c.setFont(MONO_BOLD, 9)
        c.drawRightString(px + pw - 12, dly, dose)
        dly -= 14

    dly -= 2
    c.setFillColor(TEXT_DIM)
    c.setFont(SERIF_ITAL, 7.5)
    for line in wrap_text(t["vit_d3_note"], pw - 20, SERIF_ITAL, 7.5, c):
        c.drawString(px + 12, dly, line)
        dly -= 10

    # diff panel (bottom right)
    ry = dy - dh - 18
    rh = 75
    c.setFillColor(BG_PANEL)
    c.rect(px, ry - rh, pw, rh, fill=1, stroke=0)
    c.setFillColor(NEON_PINK)
    c.setFont(MONO_BOLD, 10)
    c.drawString(px + 10, ry - 15, t["vit_rant_header"])
    c.setStrokeColor(NEON_PINK)
    c.setLineWidth(0.3)
    c.line(px + 10, ry - 20, px + pw - 10, ry - 20)

    rly = ry - 36
    for sign, text, col in t["vit_rant_lines"]:
        c.setFillColor(col)
        c.setFont(MONO_BOLD, 9)
        c.drawString(px + 12, rly, sign)
        c.setFillColor(TEXT)
        c.setFont(SANS, 8.5)
        for line in wrap_text(text, pw - 30, SANS, 8.5, c):
            c.drawString(px + 24, rly, line)
            rly -= 11

    draw_footer(c, page_num, "PLOP // GLAS WASSER // WEITER")


def page_eof(c, t, page_num):
    fill_bg(c, BG_DARK)
    # Nothing fancy. Big EOF. Blinking cursor (represented as block).
    c.setFillColor(TEXT)
    c.setFont(SANS_BOLD, 220)
    c.drawCentredString(PAGE_W / 2, PAGE_H / 2 - 10, "EOF")

    # cursor block to the right of the F
    txt_w = c.stringWidth("EOF", SANS_BOLD, 220)
    cursor_x = PAGE_W / 2 + txt_w / 2 + 15
    cursor_y = PAGE_H / 2 - 10
    c.setFillColor(NEON_GREEN)
    c.rect(cursor_x, cursor_y, 35, 110, fill=1, stroke=0)

    # small footer text
    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 9)
    c.drawCentredString(PAGE_W / 2, 75, "// " + t["eof_footer"])

    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 8)
    c.drawCentredString(PAGE_W / 2, 40, f"planet_express_2026_04  //  lang={t['lang']}")


# ================= BUILD =================
def build(t):
    random.seed(1337)
    out = t["filename"]
    c = canvas.Canvas(out, pagesize=A4)
    c.setTitle(f"Planet Express — 04/2026 ({t['lang'].upper()})")
    c.setAuthor("anon0x01")
    c.setSubject(t["tagline_l"])

    pages = [
        (page_cover,    (t,)),
        (page_axios_1,  (t, 2)),
        (page_axios_2,  (t, 3)),
        (page_ctf_1,    (t, 4)),
        (page_ctf_2,    (t, 5)),
        (page_nmap,     (t, 6)),
        (page_terminal, (t, 7)),
        (page_warez,    (t, 8)),
        (page_agent,    (t, 9)),
        (page_vitamins, (t, 10)),
        (page_eof,      (t, 11)),
    ]
    for fn, args in pages:
        fn(c, *args)
        c.showPage()
    c.save()
    print(f"built: {out}  ({os.path.getsize(out)//1024} KiB)")


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