Communitygithub.com

mkaremm80/paper-comic

Turn any research paper into a comic-magazine style PDF story (A4, ~8 pages) that explains the paper smoothly with characters, panels and speech bubbles.

What is paper-comic?

paper-comic is a Claude Code agent skill that turn any research paper into a comic-magazine style PDF story (A4, ~8 pages) that explains the paper smoothly with characters, panels and speech bubbles.

Works with~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/mkaremm80/-my-claude-skills/tree/HEAD/skills/paper-comic

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

Paper → Comic Story (PDF)

Use this when the user gives a paper (PDF, LaTeX, text) and asks for a comic, a story, a "comix magazine", a cartoon explainer, or a fun visual summary of it. The output is one A4 PDF, about 8 pages, drawn fully in code (SVG + HTML → Chromium PDF). No image generation is needed.

1. Read the paper and make a fact sheet (in your head or a scratch file)

Extract, in simple words:

  • The system / device / algorithm the paper studies (this becomes the hero).
  • The problem or bad metric (outage, error, delay, attack...) — this becomes the villain.
  • The baseline / classic method and the proposed method (and any variant, e.g. UCB, ablation).
  • The key idea, theorem or insight, and one everyday metaphor for it (puzzle pieces, doors, keys, roads...).
  • The decision rule or mechanism, if any (thresholds, switches, gates).
  • Test setup (parameters, number of trials, SNRs...).
  • Headline numbers, tables, robustness checks, limits, future work, authors and affiliation.

Rules: copy numbers exactly as in the paper. Never invent results, names of datasets or claims. If a simple value can be recomputed (for example a dimension or a formula output), recompute it with Python to check. Keep the paper's own terms but explain each acronym once.

2. Cast the characters (engine functions)

Role in storyEngine functionTypical use
Hero (the system)robot(...)color, chest label, antenna=True draws a rail of ports on its head
Mentor / narratorscientist(...)explains theory; headwear="hijab" or "hair"; point=True
Villain (the problem)monster(...)label on belly, moods angry / hurt
Baseline / classic methodsergeant(...)disciplined, ruler, label e.g. "GRID", "BP", "ZF"
Proposed / adaptive methodexplorer(...)flashlight beam; second variant with tool="lantern" and another color
Decision rule / thresholdgatekeeper(...)plaque text label + sub (e.g. "D","99")

Give the hero a short friendly name linked to the topic (FLUI for a fluid antenna, DECO for a decoder...). Pick a catchy comic title (e.g. "THE 50 DOORS").

3. Storyboard (default 8-page arc — adapt to the paper)

  1. Cover: big title, subtitle "How learned to ...", tagline with the main lesson, all characters, credit line with the full paper title, authors, affiliation.
  2. Ch.1 The world and the problem: who the hero is, what it must do, what limits it (budget, noise, one RF chain...), the villain. One small board with the physical model.
  3. Ch.2 Two ways to look: baseline vs proposed method, how each works (one formula in a math caption), and the fair-play rules of the comparison.
  4. Ch.3 The twist / key insight: a surprise (where the new idea fails or wins), then the mentor explains the theory with a blackboard chart and a table.
  5. Ch.4 The mechanism: metaphor panels (e.g. puzzle pieces), evidence numbers, the decision rule as a diamond with YES/NO branches.
  6. Ch.5 The big test: setup caption, scoreboard table, heat map or chart, headline result in a mentor bubble.
  7. Ch.6 Robustness: other settings, sensitivity checks, the "only miss" explained honestly.
  8. Finale: celebration panel, THE LESSON box, honest limits + future work, CHEAT SHEET (key numbers) and WORDS TO KNOW (glossary).

A short paper may need 6 pages; a long one up to 10. Each chapter page has 4–6 panels.

Writing rules:

  • Simple English (B2 level), friendly, smooth: each panel answers the question from the last one and raises the next one.
  • One idea per panel. Bubbles at most ~30 words; captions at most ~35 words.
  • Bold (<b>) the key words and numbers. Formulas only in C(..., cls="math") captions, with <sub>/<sup>.
  • Every number traceable to the paper. Mark illustrative drawings as "typical" or "example".
  • The mentor bubbles use cls="mentor"; exclamations use cls="shout".

4. Build

  1. Write the engine below to comic_engine.py in a working folder (copy it exactly).
  2. Write story.py that imports the engine, builds the pages, and calls render(pages, "<Title>_Comic_v1.pdf").
  3. Run it. render also writes 2-up PNG preview sheets in <name>_preview/.

Requirements: Python with playwright (Chromium), numpy, PIL, pdftoppm (poppler). Fonts Bangers + Comic Neue are fetched once by npm pack @fontsource/... into COMIC_FONT_DIR (default /tmp/comic_fonts); if offline, the engine falls back to system fonts. If Playwright's Chromium is missing, run python -m playwright install chromium.

Layout cheat sheet:

  • Page: A4 210 × 297 mm. Usable area x = 7…203 mm, y = 16…289 mm (header above). Gutter 4 mm. Full-width panel w = 196; half w = 96 (x = 7 and x = 107).
  • Inside a panel, SVG units = 4 per mm: a 96 × 70 mm panel is 384 × 280 units. Keep all art inside (x < w·4, y < h·4).
  • Characters are drawn with feet at (x, y); put feet ≈ h·4 − 8. Heights at s = 1: robot ≈ 125 units, scientist ≈ 120, explorer ≈ 120, sergeant ≈ 115, gatekeeper ≈ 195; monster is centred and ≈ 150 wide.
  • Bubbles/captions use mm relative to the panel. At 9.8 pt a 45 mm wide bubble holds ~8 words per line; each line ≈ 4.3 mm high.
  • Tails: bl br bc tl tr l r — the tail must point to the speaker.
  • Tables and boxes: RAW(x, y, w, table(...)), or RAW(..., '<div class="box lesson"><div class="lt">THE LESSON</div>...</div>'); boxes with <table> make a cheat sheet, with <p> a glossary.
  • Banner text inside a panel: '<div class="banner">THE GREAT TEST</div>' as an item.

Small example (one page):

from comic_engine import *
p = []
p.append(panel(7, 16, 196, 70,
    robot(160, 272, 1.75, "happy") + dot_row(330, 80, 420, n=50, glow=31, h=26),
    [C(3, 3, 74, "In a city full of radio waves lives <b>FLUI</b>..."),
     B(100, 44, 92, "I have <b>50 ports</b> but only <b>one</b> radio chain!", "tl")]))
p.append(panel(7, 90, 96, 70, scientist(78, 270, 1.4, point=True) + monster(290, 110, 1.05),
    [B(3, 3, 50, "Pick a weak port and the <b>Outage Monster</b> wins.", "br", "mentor"),
     B(42, 50, 50, "Below the threshold? You are MINE!", "tr", "shout")]))
pages = [cover("THE 50 DOORS", "How a one-ear antenna learned <i>when</i> to look smart", art,
               "Look where it matters...<br>but only when it matters!", credit_html),
         page(2, "Chapter 1 · One Ear, Fifty Doors", p)]
render(pages, "The_50_Doors_Comic_v1.pdf")

5. Visual check (always)

Open every preview sheet with the Read tool and fix before delivering:

  • A bubble or caption covers a face or a key number → move it.
  • Art cut at the panel edge or text cut at the bottom → move or shrink.
  • Tail points to the wrong character → change the tail.
  • A panel is half empty → add a character, a prop, or a bigger chart.
  • Numbers match the paper. Re-render until clean (usually 2–3 rounds).

6. Deliver

  • File name <Short_Title>_Comic_v1.pdf; every later revision gets v2, v3, ...
  • Send it with SendUserFile. Reply in 3–5 short lines: the title, the cast, the page list, and one offer (another language, other character names, more or fewer pages).
  • For an Arabic version: add dir="rtl" on bubbles/captions and load an Arabic font (e.g. npm pack @fontsource/cairo) into the CSS; keep numbers and formulas LTR.

Engine: comic_engine.py (write this file exactly)

"""comic_engine.py — reusable engine for comic-magazine explainers of research papers.

Coordinates: each panel is placed in mm on an A4 page (210 x 297 mm). Inside a panel,
SVG drawing uses U = 4 units per mm (a 96 x 70 mm panel has viewBox 0 0 384 280).
Characters are drawn with their FEET at (x, y); s = scale (1.0 is about 30 mm tall).
"""
import os, subprocess, glob
import numpy as np

U = 4
INK = "#141414"
FONT_DIR = os.environ.get("COMIC_FONT_DIR", "/tmp/comic_fonts")


# ---------------------------------------------------------------- fonts
def ensure_fonts():
    """Download Bangers + Comic Neue (via npm) once. Falls back to system fonts if offline."""
    want = ["bangers-latin-400-normal.woff2", "comic-neue-latin-400-normal.woff2", "comic-neue-latin-700-normal.woff2"]
    found = {os.path.basename(p): p for p in glob.glob(f"{FONT_DIR}/**/*.woff2", recursive=True)}
    if all(w in found for w in want):
        return found
    os.makedirs(FONT_DIR, exist_ok=True)
    try:
        subprocess.run(["npm", "pack", "@fontsource/bangers", "@fontsource/comic-neue"], cwd=FONT_DIR,
                       check=True, capture_output=True)
        for t in glob.glob(f"{FONT_DIR}/*.tgz"):
            d = t[:-4]
            os.makedirs(d, exist_ok=True)
            subprocess.run(["tar", "xzf", t, "-C", d], check=True)
    except Exception as e:
        print("font download failed, using fallbacks:", e)
    return {os.path.basename(p): p for p in glob.glob(f"{FONT_DIR}/**/*.woff2", recursive=True)}


# ---------------------------------------------------------------- characters
def robot(x, y, s=1.0, mood="happy", arms="down", color="#29b6f6", label="1 RF", antenna=True, light="#e1f5fe"):
    """Hero robot (the system / device the paper studies). moods: happy worried surprised determined joy.
    arms: down up think. antenna=True draws a rail of ports on its head."""
    mouths = {
        "happy": f'<path d="M-11,-75 Q0,-64 11,-75" fill="none" stroke="{INK}" stroke-width="3" stroke-linecap="round"/>',
        "worried": f'<path d="M-10,-70 Q0,-78 10,-70" fill="none" stroke="{INK}" stroke-width="3" stroke-linecap="round"/>'
                   f'<path d="M-18,-97 L-6,-94 M18,-97 L6,-94" stroke="{INK}" stroke-width="3" stroke-linecap="round"/>',
        "surprised": f'<ellipse cx="0" cy="-72" rx="4.5" ry="6" fill="{INK}"/>',
        "determined": f'<path d="M-9,-72 L9,-72" stroke="{INK}" stroke-width="3" stroke-linecap="round"/>'
                      f'<path d="M-18,-96 L-6,-92 M18,-96 L6,-92" stroke="{INK}" stroke-width="3.5" stroke-linecap="round"/>',
        "joy": f'<path d="M-12,-76 Q0,-60 12,-76 Z" fill="#c62828" stroke="{INK}" stroke-width="2.5"/>',
    }
    a = {"up": "M-30,-52 L-48,-80 M30,-52 L48,-80", "think": "M-30,-52 L-44,-30 M30,-52 L22,-70"}.get(
        arms, "M-30,-52 L-44,-30 M30,-52 L44,-30")
    top = ""
    if antenna:
        dots = "".join(f'<circle cx="{-52 + k*11.5}" cy="-118" r="3" fill="#0277bd"/>' for k in range(10))
        top = (f'<rect x="-62" y="-125" width="124" height="14" rx="7" fill="#b2ebf2" stroke="{INK}" stroke-width="3"/>{dots}'
               f'<circle cx="{-52 + 6*11.5}" cy="-118" r="6" fill="#ffeb3b" stroke="{INK}" stroke-width="1.5"/>')
    else:
        top = f'<circle cx="0" cy="-122" r="7" fill="#ffeb3b" stroke="{INK}" stroke-width="2.5"/>'
    return f'''<g transform="translate({x},{y}) scale({s})">
<ellipse cx="0" cy="2" rx="40" ry="6" fill="#000" opacity=".15"/>
<rect x="-21" y="-14" width="13" height="14" rx="3" fill="#455a64" stroke="{INK}" stroke-width="3"/>
<rect x="8" y="-14" width="13" height="14" rx="3" fill="#455a64" stroke="{INK}" stroke-width="3"/>
<path d="{a}" stroke="{INK}" stroke-width="11" stroke-linecap="round" fill="none"/>
<path d="{a}" stroke="{color}" stroke-width="6" stroke-linecap="round" fill="none"/>
<rect x="-32" y="-64" width="64" height="52" rx="12" fill="{color}" stroke="{INK}" stroke-width="3"/>
<rect x="-17" y="-54" width="34" height="20" rx="4" fill="#01579b" stroke="{INK}" stroke-width="2"/>
<text x="0" y="-39.5" font-size="10" fill="#fff" text-anchor="middle" font-family="Bangers">{label}</text>
<rect x="-6" y="-116" width="12" height="12" fill="#90a4ae" stroke="{INK}" stroke-width="2.5"/>
<rect x="-31" y="-106" width="62" height="44" rx="15" fill="{light}" stroke="{INK}" stroke-width="3"/>
<ellipse cx="-11" cy="-87" rx="6" ry="8" fill="{INK}"/><ellipse cx="11" cy="-87" rx="6" ry="8" fill="{INK}"/>
<circle cx="-9" cy="-90" r="2.2" fill="#fff"/><circle cx="13" cy="-90" r="2.2" fill="#fff"/>
<circle cx="-22" cy="-76" r="4" fill="#f48fb1" opacity=".7"/><circle cx="22" cy="-76" r="4" fill="#f48fb1" opacity=".7"/>
{mouths[mood]}{top}</g>'''


def scientist(x, y, s=1.0, point=False, mood="smile", flip=False, scarf="#7e57c2", headwear="hijab",
              skin="#f1c27d", hair="#3e2723"):
    """Mentor / narrator who explains. headwear: 'hijab' or 'hair'. moods: smile open serious.
    point=True holds a pointer stick toward the upper right (flip=True mirrors)."""
    mouth = {"smile": f'<path d="M-6,-82 Q0,-77 6,-82" fill="none" stroke="{INK}" stroke-width="2.2" stroke-linecap="round"/>',
             "open": f'<path d="M-6,-83 Q0,-74 6,-83 Z" fill="#c62828" stroke="{INK}" stroke-width="2"/>',
             "serious": f'<path d="M-5,-81 L5,-81" stroke="{INK}" stroke-width="2.2" stroke-linecap="round"/>'}[mood]
    arm = (f'<path d="M18,-60 L40,-82" stroke="{INK}" stroke-width="11" stroke-linecap="round"/>'
           f'<path d="M18,-60 L40,-82" stroke="#fafafa" stroke-width="6" stroke-linecap="round"/>'
           f'<path d="M40,-82 L66,-108" stroke="#6d4c41" stroke-width="3.5" stroke-linecap="round"/>'
           f'<circle cx="40" cy="-82" r="5" fill="{skin}" stroke="{INK}" stroke-width="2"/>') if point else \
          (f'<path d="M20,-60 L28,-30" stroke="{INK}" stroke-width="11" stroke-linecap="round"/>'
           f'<path d="M20,-60 L28,-30" stroke="#fafafa" stroke-width="6" stroke-linecap="round"/>')
    if headwear == "hijab":
        head = (f'<path d="M-25,-92 Q-30,-66 -15,-62 L15,-62 Q30,-66 25,-92 Z" fill="{scarf}" stroke="{INK}" stroke-width="3"/>'
                f'<circle cx="0" cy="-94" r="25" fill="{scarf}" stroke="{INK}" stroke-width="3"/>')
    else:
        head = f'<path d="M-19,-86 Q-22,-116 0,-114 Q22,-116 19,-86 Q12,-104 0,-102 Q-12,-104 -19,-86 Z" fill="{hair}" stroke="{INK}" stroke-width="2.5"/>'
    fl = ' scale(-1,1)' if flip else ''
    body = f'''<ellipse cx="0" cy="2" rx="34" ry="6" fill="#000" opacity=".15"/>
<ellipse cx="-11" cy="-2" rx="10" ry="5" fill="#3e2723" stroke="{INK}" stroke-width="2"/>
<ellipse cx="11" cy="-2" rx="10" ry="5" fill="#3e2723" stroke="{INK}" stroke-width="2"/>
<path d="M-20,-60 L-28,-30" stroke="{INK}" stroke-width="11" stroke-linecap="round"/>
<path d="M-20,-60 L-28,-30" stroke="#fafafa" stroke-width="6" stroke-linecap="round"/>
<path d="M-27,-4 L-22,-66 Q0,-74 22,-66 L27,-4 Z" fill="#fafafa" stroke="{INK}" stroke-width="3"/>
<path d="M-9,-68 L0,-34 L9,-68 Z" fill="{scarf}" stroke="{INK}" stroke-width="2"/>
<path d="M0,-34 L0,-6" stroke="{INK}" stroke-width="2"/>
<rect x="9" y="-46" width="9" height="11" fill="#90caf9" stroke="{INK}" stroke-width="1.5"/>{arm}'''
    if headwear == "hijab":
        headgrp = head + f'<ellipse cx="0" cy="-89" rx="15" ry="17" fill="{skin}" stroke="{INK}" stroke-width="2.5"/>'
    else:
        headgrp = f'<ellipse cx="0" cy="-89" rx="15" ry="17" fill="{skin}" stroke="{INK}" stroke-width="2.5"/>' + \
                  f'<path d="M-17,-90 Q-20,-114 0,-112 Q20,-114 17,-90 Q12,-104 0,-103 Q-12,-104 -17,-90 Z" fill="{hair}" stroke="{INK}" stroke-width="2.5"/>'
    eyes = (f'<circle cx="-6.5" cy="-89" r="5.5" fill="#fff" fill-opacity=".5" stroke="{INK}" stroke-width="2"/>'
            f'<circle cx="6.5" cy="-89" r="5.5" fill="#fff" fill-opacity=".5" stroke="{INK}" stroke-width="2"/>'
            f'<path d="M-1,-89 L1,-89" stroke="{INK}" stroke-width="2"/>'
            f'<circle cx="-6.5" cy="-89" r="1.8" fill="{INK}"/><circle cx="6.5" cy="-89" r="1.8" fill="{INK}"/>')
    return f'<g transform="translate({x},{y}) scale({s}){fl}">{body}{headgrp}{eyes}{mouth}</g>'


def explorer(x, y, s=1.0, color="#43a047", label="EP", tool="flash", beam=110, mood="happy"):
    """Adaptive / clever method. tool: 'flash' (flashlight beam to the right, length=beam) or 'lantern' (with '?').
    moods: happy sad shout."""
    mouth = {"happy": f'<path d="M-6,-77 Q0,-71 6,-77" fill="none" stroke="{INK}" stroke-width="2.5" stroke-linecap="round"/>',
             "sad": f'<path d="M-6,-73 Q0,-79 6,-73" fill="none" stroke="{INK}" stroke-width="2.5" stroke-linecap="round"/>',
             "shout": f'<path d="M-7,-78 Q0,-66 7,-78 Z" fill="#c62828" stroke="{INK}" stroke-width="2"/>'}[mood]
    if tool == "flash":
        t = ((f'<polygon points="44,-56 {44+beam},-{56+beam*0.38} {44+beam},-{56-beam*0.38}" fill="#fff59d" opacity=".75"/>' if beam else "")
             + f'<rect x="26" y="-62" width="20" height="12" rx="3" fill="#616161" stroke="{INK}" stroke-width="2.5"/>')
    else:
        t = (f'<circle cx="40" cy="-50" r="{beam*0.35}" fill="#fff59d" opacity=".5"/>'
             f'<path d="M40,-72 L40,-64" stroke="{INK}" stroke-width="2.5"/>'
             f'<rect x="31" y="-64" width="18" height="22" rx="5" fill="#ffe082" stroke="{INK}" stroke-width="2.5"/>'
             f'<text x="40" y="-47" font-size="15" text-anchor="middle" font-family="Bangers" fill="{INK}">?</text>')
    fs = 17 if len(label) <= 3 else 12
    return f'''<g transform="translate({x},{y}) scale({s})">
<ellipse cx="0" cy="2" rx="30" ry="6" fill="#000" opacity=".15"/>{t}
<rect x="-16" y="-16" width="11" height="16" rx="3" fill="#33691e" stroke="{INK}" stroke-width="2.5"/>
<rect x="5" y="-16" width="11" height="16" rx="3" fill="#33691e" stroke="{INK}" stroke-width="2.5"/>
<path d="M-20,-50 L-32,-30 M20,-52 L30,-56" stroke="{INK}" stroke-width="10" stroke-linecap="round"/>
<path d="M-20,-50 L-32,-30 M20,-52 L30,-56" stroke="{color}" stroke-width="5.5" stroke-linecap="round"/>
<ellipse cx="0" cy="-40" rx="24" ry="28" fill="{color}" stroke="{INK}" stroke-width="3"/>
<text x="0" y="-31" font-size="{fs}" text-anchor="middle" font-family="Bangers" fill="#fff" stroke="{INK}" stroke-width=".8">{label}</text>
<circle cx="0" cy="-82" r="18" fill="#ffcc80" stroke="{INK}" stroke-width="3"/>
<circle cx="-6" cy="-84" r="2.6" fill="{INK}"/><circle cx="6" cy="-84" r="2.6" fill="{INK}"/>{mouth}
<ellipse cx="0" cy="-97" rx="30" ry="6.5" fill="#8d6e63" stroke="{INK}" stroke-width="2.5"/>
<path d="M-17,-98 Q0,-122 17,-98 Z" fill="#a1887f" stroke="{INK}" stroke-width="2.5"/>
<path d="M-16,-101 L16,-101" stroke="{color}" stroke-width="3"/></g>'''


def sergeant(x, y, s=1.0, mood="stern", color="#fb8c00", label="GRID"):
    """Baseline / classic / fixed method: disciplined soldier with a ruler. moods: stern smug."""
    mouth = {"stern": "", "smug": f'<path d="M-2,-71 Q6,-67 10,-72" fill="none" stroke="{INK}" stroke-width="2.2"/>'}[mood]
    ticks = "".join(f'<path d="M{18+k*8},-56 L{18+k*8},-{50 if k%2 else 47}" stroke="{INK}" stroke-width="1.5"/>' for k in range(9))
    fs = 12 if len(label) <= 5 else 9
    return f'''<g transform="translate({x},{y}) scale({s})">
<ellipse cx="0" cy="2" rx="34" ry="6" fill="#000" opacity=".15"/>
<rect x="-20" y="-14" width="13" height="14" rx="2" fill="#3e2723" stroke="{INK}" stroke-width="2.5"/>
<rect x="7" y="-14" width="13" height="14" rx="2" fill="#3e2723" stroke="{INK}" stroke-width="2.5"/>
<rect x="-27" y="-68" width="54" height="56" rx="7" fill="{color}" stroke="{INK}" stroke-width="3"/>
<rect x="-19" y="-40" width="38" height="13" rx="2" fill="#fff3e0" stroke="{INK}" stroke-width="2"/>
<text x="0" y="-29.5" font-size="{fs}" text-anchor="middle" font-family="Bangers" fill="{INK}">{label}</text>
<path d="M-27,-58 L-38,-34" stroke="{INK}" stroke-width="10" stroke-linecap="round"/>
<path d="M-27,-58 L-38,-34" stroke="{color}" stroke-width="5.5" stroke-linecap="round"/>
<rect x="10" y="-60" width="80" height="10" fill="#fff176" stroke="{INK}" stroke-width="2.5"/>{ticks}
<circle cx="22" cy="-55" r="6" fill="#ffcc80" stroke="{INK}" stroke-width="2"/>
<rect x="-20" y="-104" width="40" height="37" rx="10" fill="#ffcc80" stroke="{INK}" stroke-width="3"/>
<path d="M-12,-88 L-4,-88 M4,-88 L12,-88" stroke="{INK}" stroke-width="3" stroke-linecap="round"/>
<path d="M-12,-77 Q-6,-82 0,-78 Q6,-82 12,-77 Q6,-74 0,-76 Q-6,-74 -12,-77 Z" fill="#4e342e" stroke="{INK}" stroke-width="1.5"/>{mouth}
<rect x="-23" y="-114" width="46" height="13" rx="3" fill="#1565c0" stroke="{INK}" stroke-width="2.5"/>
<path d="M-23,-101 L30,-101 L24,-97 L-23,-97 Z" fill="#0d47a1" stroke="{INK}" stroke-width="2"/>
<circle cx="0" cy="-108" r="3.5" fill="#ffd54f" stroke="{INK}" stroke-width="1.2"/></g>'''


def monster(x, y, s=1.0, mood="angry", color="#546e7a", label=""):
    """Villain = the problem (outage, error, attack, noise...). Drawn CENTERED at (x, y), about 140 units wide.
    moods: angry (with lightning) hurt. label: short word written on its belly."""
    circles = [(0, 0, 34), (-34, 8, 26), (34, 8, 26), (-18, -24, 26), (20, -22, 28), (-52, 22, 16), (52, 22, 16), (0, 22, 28)]
    outline = "".join(f'<circle cx="{a}" cy="{b}" r="{r}" fill="{INK}" stroke="{INK}" stroke-width="7"/>' for a, b, r in circles)
    fill = "".join(f'<circle cx="{a}" cy="{b}" r="{r}" fill="{color}"/>' for a, b, r in circles)
    if mood == "angry":
        face = (f'<ellipse cx="-14" cy="-2" rx="9" ry="7" fill="#fff"/><ellipse cx="14" cy="-2" rx="9" ry="7" fill="#fff"/>'
                f'<circle cx="-12" cy="0" r="3.5" fill="#d50000"/><circle cx="12" cy="0" r="3.5" fill="#d50000"/>'
                f'<path d="M-26,-14 L-6,-6 M26,-14 L6,-6" stroke="{INK}" stroke-width="4.5" stroke-linecap="round"/>'
                f'<path d="M-16,20 L-10,13 L-4,20 L2,13 L8,20 L14,13 L18,20 Z" fill="#fff" stroke="{INK}" stroke-width="2"/>')
        bolt = f'<path d="M-6,46 L8,46 L0,62 L12,62 L-10,92 L-2,70 L-12,70 Z" fill="#ffeb3b" stroke="{INK}" stroke-width="2.5"/>'
    else:
        face = (f'<path d="M-20,-2 L-8,2 M-20,4 L-8,0 M20,-2 L8,2 M20,4 L8,0" stroke="{INK}" stroke-width="3" stroke-linecap="round"/>'
                f'<ellipse cx="0" cy="17" rx="7" ry="5" fill="{INK}"/>'
                f'<path d="M30,-30 q4,-8 8,0 q4,8 8,0" fill="none" stroke="#90a4ae" stroke-width="2"/>')
        bolt = ""
    lab = f'<text x="0" y="40" font-size="13" text-anchor="middle" font-family="Bangers" fill="#fff">{label}</text>' if label else ""
    return f'<g transform="translate({x},{y}) scale({s})">{bolt}{outline}{fill}{face}{lab}</g>'


def gatekeeper(x, y, s=1.0, label="D", sub="99"):
    """Decision rule / switch / threshold: a stone gate with eyes and a plaque. Feet at (x, y), ~190 units tall."""
    return f'''<g transform="translate({x},{y}) scale({s})">
<ellipse cx="0" cy="3" rx="80" ry="8" fill="#000" opacity=".15"/>
<rect x="-70" y="-120" width="28" height="120" fill="#9575cd" stroke="{INK}" stroke-width="3"/>
<rect x="42" y="-120" width="28" height="120" fill="#9575cd" stroke="{INK}" stroke-width="3"/>
<path d="M-74,-118 Q0,-178 74,-118 L74,-108 Q0,-160 -74,-108 Z" fill="#7e57c2" stroke="{INK}" stroke-width="3"/>
<path d="M-62,-100 L-50,-100 M-62,-70 L-50,-70 M-62,-40 L-50,-40 M50,-100 L62,-100 M50,-70 L62,-70 M50,-40 L62,-40" stroke="{INK}" stroke-width="2"/>
<rect x="-38" y="-176" width="76" height="40" rx="8" fill="#ffd54f" stroke="{INK}" stroke-width="3"/>
<text x="0" y="-147" font-size="28" text-anchor="middle" font-family="Bangers" fill="{INK}">{label}<tspan font-size="17" dy="5">{sub}</tspan></text>
<ellipse cx="-14" cy="-188" rx="9" ry="10" fill="#fff" stroke="{INK}" stroke-width="2.5"/>
<ellipse cx="14" cy="-188" rx="9" ry="10" fill="#fff" stroke="{INK}" stroke-width="2.5"/>
<circle cx="-12" cy="-186" r="4" fill="{INK}"/><circle cx="16" cy="-186" r="4" fill="{INK}"/></g>'''


# ---------------------------------------------------------------- props & mini charts (all in panel SVG units)
def dot_row(x, y, w, n=50, marked=(), color="#e53935", nums=(), h=16, glow=None):
    """A rail of n dots (ports, antennas, sensors, nodes, time slots...). marked = 1-based indices to highlight."""
    r = min(w / (n * 2.4), h * 0.36)
    out = [f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{h/2}" fill="#b2ebf2" stroke="{INK}" stroke-width="2.5"/>']
    for k in range(1, n + 1):
        cx, cy = dot_x(x, w, k, n, h), y + h / 2
        if k in marked:
            out.append(f'<circle cx="{cx}" cy="{cy}" r="{r*1.6}" fill="{color}" stroke="{INK}" stroke-width="1.6"/>')
        elif glow == k:
            out.append(f'<circle cx="{cx}" cy="{cy}" r="{r*1.7}" fill="#ffeb3b" stroke="{INK}" stroke-width="1.6"/>')
        else:
            out.append(f'<circle cx="{cx}" cy="{cy}" r="{r*0.8}" fill="#0277bd"/>')
        if k in nums:
            out.append(f'<text x="{cx}" y="{y+h+13}" font-size="11" text-anchor="middle" font-family="Comic Neue" font-weight="700">{k}</text>')
    return "".join(out)


def dot_x(x, w, k, n=50, h=16):
    return x + h / 2 + (k - 1) * (w - h) / (n - 1)


def burst(x, y, r, color="#ffeb3b", pts=14):
    p = []
    for i in range(pts * 2):
        a = np.pi * i / pts
        rr = r if i % 2 == 0 else r * 0.68
        p.append(f"{x + rr*np.cos(a):.1f},{y + rr*np.sin(a):.1f}")
    return f'<polygon points="{" ".join(p)}" fill="{color}" stroke="{INK}" stroke-width="3"/>'


def board(x, y, w, h):
    """Dark blackboard rectangle for charts/equations."""
    return f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="10" fill="#263238" stroke="{INK}" stroke-width="4"/>'


def text(x, y, t, size=16, color=INK, anchor="start", font="Comic Neue", weight=700):
    return f'<text x="{x}" y="{y}" font-size="{size}" fill="{color}" text-anchor="{anchor}" font-family="{font}" font-weight="{weight}">{t}</text>'


def bars(x, y, w, h, values, labels=None, colors=None, light=False, cut=None):
    """Simple bar chart. cut = index after which a dashed line is drawn (e.g. a threshold)."""
    vmax = max(values) or 1
    m = len(values)
    bw = w / m
    axis = "#fff" if light else INK
    out = [f'<line x1="{x}" y1="{y+h}" x2="{x+w}" y2="{y+h}" stroke="{axis}" stroke-width="2.5"/>']
    for k, v in enumerate(values):
        bh = max(v / vmax * h, 1.5)
        col = (colors[k] if colors else "#7e57c2")
        out.append(f'<rect x="{x+k*bw+bw*.12}" y="{y+h-bh}" width="{bw*.76}" height="{bh}" fill="{col}" stroke="{INK}" stroke-width="2"/>')
        if labels:
            out.append(text(x + k * bw + bw / 2, y + h + 14, labels[k], 11, axis, "middle"))
    if cut is not None:
        out.append(f'<path d="M{x+cut*bw},{y-4} L{x+cut*bw},{y+h}" stroke="#d81b60" stroke-width="2.5" stroke-dasharray="6 4"/>')
    return "".join(out)


def curve(x, y, w, h, ys, color="#d81b60", light=False, zero_line=True):
    """Line plot of ys (any range) scaled into the box."""
    ys = np.asarray(ys, float)
    lo, hi = ys.min(), ys.max()
    span = (hi - lo) or 1
    pts = " ".join(f"{x + i/(len(ys)-1)*w:.1f},{y + h - (v-lo)/span*h:.1f}" for i, v in enumerate(ys))
    axis = "#fff" if light else INK
    out = f'<line x1="{x}" y1="{y}" x2="{x}" y2="{y+h}" stroke="{axis}" stroke-width="2"/>'
    if zero_line and lo < 0 < hi:
        zy = y + h - (0 - lo) / span * h
        out += f'<line x1="{x}" y1="{zy}" x2="{x+w}" y2="{zy}" stroke="#90a4ae" stroke-width="1.5" stroke-dasharray="4 4"/>'
    return out + f'<polyline points="{pts}" fill="none" stroke="{color}" stroke-width="3.5"/>'


def heat(x, y, cw, ch, matrix, row_labels, col_labels, xlab="", ylab="", empty="—"):
    """Heat map; None cells are gray. Values colored yellow→magenta by size."""
    vals = [v for r in matrix for v in r if v is not None]
    vmax = max(vals) if vals else 1
    out = []
    for r, row in enumerate(matrix):
        out.append(text(x - 6, y + r * ch + ch * 0.65, row_labels[r], 13, anchor="end"))
        for c, v in enumerate(row):
            if v is None:
                fill, t, tc = "#e0e0e0", empty, "#757575"
            else:
                f = max(v, 0) / vmax
                col = np.array([255, 241, 118]) * (1 - f) + np.array([216, 27, 96]) * f
                fill, t, tc = "#%02x%02x%02x" % tuple(col.astype(int)), f"{v:g}", INK if f < .6 else "#fff"
            out.append(f'<rect x="{x+c*cw}" y="{y+r*ch}" width="{cw}" height="{ch}" fill="{fill}" stroke="{INK}" stroke-width="2"/>')
            out.append(text(x + c * cw + cw / 2, y + r * ch + ch * 0.64, t, 13, tc, "middle"))
    for c, lab in enumerate(col_labels):
        out.append(text(x + c * cw + cw / 2, y + len(matrix) * ch + 15, lab, 13, anchor="middle"))
    if xlab:
        out.append(text(x + len(col_labels) * cw / 2, y + len(matrix) * ch + 32, xlab, 13, anchor="middle", font="Bangers", weight=400))
    if ylab:
        cy = y + len(matrix) * ch / 2
        out.append(f'<text x="{x-34}" y="{cy}" font-size="13" text-anchor="middle" font-family="Bangers" transform="rotate(-90 {x-34} {cy})">{ylab}</text>')
    return "".join(out)


def puzzle(x, y, n, have, size=46, cols=3):
    """n puzzle pieces, the first `have` are filled (good metaphor for dimensions / unknowns / degrees of freedom)."""
    out = ""
    for k in range(n):
        cx, cy = x + (k % cols) * (size + 6), y + (k // cols) * (size + 6)
        f = "#7e57c2" if k < have else "#eceff1"
        dash = 'stroke-dasharray="6 4"' if k >= have else ""
        out += f'<rect x="{cx}" y="{cy}" width="{size}" height="{size}" rx="6" fill="{f}" stroke="{INK}" stroke-width="2.5" {dash}/>'
    return out


def diamond(x, y, t, w=75, h=60):
    """Decision diamond centred at (x, y)."""
    return (f'<polygon points="{x},{y-h} {x+w},{y} {x},{y+h} {x-w},{y}" fill="#ffd54f" stroke="{INK}" stroke-width="4"/>'
            + text(x, y + 9, t, 24, anchor="middle", font="Bangers", weight=400))


def chips(x, y, items, w=140, h=60, gap=20):
    """Row of coloured label chips: items = [(text, color), ...]."""
    out = ""
    for k, (t, c) in enumerate(items):
        cx = x + k * (w + gap)
        out += (f'<rect x="{cx}" y="{y}" width="{w}" height="{h}" rx="8" fill="{c}" stroke="{INK}" stroke-width="3"/>'
                + text(cx + w / 2, y + h / 2 + 7, t, 18, anchor="middle", font="Bangers", weight=400))
    return out


# ---------------------------------------------------------------- overlays (positions in mm, relative to the panel)
def B(x, y, w, html, tail="bl", cls=""):
    """Speech bubble. tail: bl br bc tl tr l r. cls: '' | 'mentor' (lilac) | 'shout' (yellow, Bangers) | 'small'."""
    return f'<div class="bubble t-{tail} {cls}" style="left:{x}mm;top:{y}mm;width:{w}mm">{html}</div>'


def C(x, y, w, html, cls=""):
    """Caption box. cls: '' (yellow) | 'white' | 'math' (formula box) | 'small'."""
    return f'<div class="cap {cls}" style="left:{x}mm;top:{y}mm;width:{w}mm">{html}</div>'


def SFX(x, y, t, size=16, color="#ffeb3b", rot=-8):
    return f'<div class="sfx" style="left:{x}mm;top:{y}mm;font-size:{size}pt;color:{color};transform:rotate({rot}deg)">{t}</div>'


def RAW(x, y, w, html, cls=""):
    """Free HTML block (tables, lists) positioned inside a panel."""
    return f'<div class="raw {cls}" style="left:{x}mm;top:{y}mm;width:{w}mm">{html}</div>'


def table(header, rows, row_colors=None, title="", foot=""):
    """Comic scoreboard table (HTML). Wrap with RAW(...)."""
    h = "".join(f"<th>{c}</th>" for c in header)
    body = ""
    for i, r in enumerate(rows):
        bg = f' style="background:{row_colors[i]}"' if row_colors else ""
        body += f"<tr{bg}>" + "".join(f"<td>{c}</td>" for c in r) + "</tr>"
    t = f'<div class="sbtitle">{title}</div>' if title else ""
    f = f'<div class="foot">{foot}</div>' if foot else ""
    return f'{t}<table class="score"><tr>{h}</tr>{body}</table>{f}'


def panel(x, y, w, h, svg="", items=(), bg="dots-blue"):
    """bg: dots-blue dots-yellow dots-pink dots-gray plain none."""
    return (f'<div class="panel {bg}" style="left:{x}mm;top:{y}mm;width:{w}mm;height:{h}mm">'
            f'<svg viewBox="0 0 {w*U} {h*U}" width="100%" height="100%">{svg}</svg>{"".join(items)}</div>')


def page(num, title, panels):
    head = f'<div class="head"><span class="ch">{title}</span><span class="pn">{num}</span></div>'
    return f'<section class="page">{head}{"".join(panels)}</section>'


def cover(title, subtitle, svg, tagline, credit, masthead="RESEARCH COMICS", issue="Issue #1", extra=()):
    """Full cover page. svg is drawn in a 196 x 283 mm frame (784 x 1132 units). Leave the top ~60 mm
    (title) and bottom ~25 mm (credit) free of important art."""
    items = [f'<div class="masthead">{masthead} <span>· {issue} ·</span></div>',
             f'<div class="bigtitle">{title}</div>', f'<div class="subtitle">{subtitle}</div>',
             f'<div class="tag" style="left:12mm;top:68mm">{tagline}</div>',
             f'<div class="basedon">{credit}</div>', *extra]
    bgsvg = ('<defs><radialGradient id="sun" cx="50%" cy="58%" r="70%"><stop offset="0" stop-color="#fff9c4"/>'
             '<stop offset=".45" stop-color="#4fc3f7"/><stop offset="1" stop-color="#0d47a1"/></radialGradient></defs>'
             f'<rect width="{196*U}" height="{283*U}" fill="url(#sun)"/>')
    return f'<section class="page cover">{panel(7, 7, 196, 283, bgsvg + svg, items, bg="none")}</section>'


# ---------------------------------------------------------------- render
def css(fonts):
    def src(name):
        p = fonts.get(name)
        return f"src: url(file://{p});" if p else "src: local('DejaVu Sans');"
    return f"""
@font-face {{ font-family: Bangers; {src('bangers-latin-400-normal.woff2')} }}
@font-face {{ font-family: 'Comic Neue'; font-weight: 400; {src('comic-neue-latin-400-normal.woff2')} }}
@font-face {{ font-family: 'Comic Neue'; font-weight: 700; {src('comic-neue-latin-700-normal.woff2')} }}
@page {{ size: 210mm 297mm; margin: 0; }}
* {{ box-sizing: border-box; }}
body {{ margin: 0; font-family: 'Comic Neue', 'DejaVu Sans', sans-serif; color: {INK}; }}
.page {{ position: relative; width: 210mm; height: 297mm; overflow: hidden; page-break-after: always; background: #fdf6e3; }}
.page.cover {{ background: {INK}; }}
.head {{ position: absolute; left: 7mm; right: 7mm; top: 5mm; height: 8mm; display: flex; justify-content: space-between;
        align-items: center; border-bottom: 1mm solid {INK}; }}
.head .ch {{ font-family: Bangers; font-size: 17pt; letter-spacing: .6pt; color: #d81b60; -webkit-text-stroke: .3pt {INK}; }}
.head .pn {{ font-family: Bangers; font-size: 14pt; background: {INK}; color: #ffeb3b; padding: 0 2.5mm; border-radius: 1mm; }}
.panel {{ position: absolute; border: .9mm solid {INK}; overflow: hidden; background: #fff; box-shadow: 1.2mm 1.2mm 0 {INK}; }}
.panel svg {{ position: absolute; inset: 0; }}
.dots-blue {{ background: radial-gradient(#b3e5fc 22%, transparent 24%) 0 0/3.2mm 3.2mm, #e1f5fe; }}
.dots-yellow {{ background: radial-gradient(#ffe082 22%, transparent 24%) 0 0/3.2mm 3.2mm, #fff8e1; }}
.dots-pink {{ background: radial-gradient(#f8bbd0 22%, transparent 24%) 0 0/3.2mm 3.2mm, #fce4ec; }}
.dots-gray {{ background: radial-gradient(#90a4ae 22%, transparent 24%) 0 0/3.2mm 3.2mm, #607d8b; }}
.plain, .none {{ background: #fff; }}
.bubble {{ position: absolute; background: #fff; border: .6mm solid {INK}; border-radius: 4mm; padding: 1.6mm 2.4mm;
          font-size: 9.8pt; line-height: 1.22; font-weight: 400; z-index: 3; }}
.bubble b, .cap b {{ font-weight: 700; }}
.small {{ font-size: 8.6pt !important; }}
.bubble.mentor {{ background: #f3e5f5; }}
.bubble.shout {{ background: #fff59d; font-family: Bangers; font-size: 13pt; letter-spacing: .4pt; border-width: .8mm; }}
.bubble::before, .bubble::after {{ content: ""; position: absolute; width: 0; height: 0; border: solid transparent; }}
.t-bl::before {{ left: 5mm; bottom: -4.6mm; border-width: 4.6mm 3mm 0 0; border-top-color: {INK}; }}
.t-bl::after  {{ left: 5.6mm; bottom: -3.4mm; border-width: 3.5mm 2.2mm 0 0; }}
.t-br::before {{ right: 5mm; bottom: -4.6mm; border-width: 4.6mm 0 0 3mm; border-top-color: {INK}; }}
.t-br::after  {{ right: 5.6mm; bottom: -3.4mm; border-width: 3.5mm 0 0 2.2mm; }}
.t-bc::before {{ left: 50%; bottom: -4.6mm; border-width: 4.6mm 2.4mm 0 2.4mm; border-top-color: {INK}; }}
.t-bc::after  {{ left: calc(50% + .6mm); bottom: -3.4mm; border-width: 3.5mm 1.8mm 0 1.8mm; }}
.t-tl::before {{ left: 5mm; top: -4.6mm; border-width: 0 3mm 4.6mm 0; border-bottom-color: {INK}; }}
.t-tl::after  {{ left: 5.6mm; top: -3.4mm; border-width: 0 2.2mm 3.5mm 0; }}
.t-tr::before {{ right: 5mm; top: -4.6mm; border-width: 0 0 4.6mm 3mm; border-bottom-color: {INK}; }}
.t-tr::after  {{ right: 5.6mm; top: -3.4mm; border-width: 0 0 3.5mm 2.2mm; }}
.t-l::before  {{ left: -4.6mm; top: 3mm; border-width: 3mm 4.6mm 0 0; border-right-color: {INK}; }}
.t-l::after   {{ left: -3.4mm; top: 3.6mm; border-width: 2.2mm 3.5mm 0 0; }}
.t-r::before  {{ right: -4.6mm; top: 3mm; border-width: 3mm 0 0 4.6mm; border-left-color: {INK}; }}
.t-r::after   {{ right: -3.4mm; top: 3.6mm; border-width: 2.2mm 0 0 3.5mm; }}
.t-bl::after, .t-br::after, .t-bc::after {{ border-top-color: #fff; }}
.t-tl::after, .t-tr::after {{ border-bottom-color: #fff; }}
.t-l::after {{ border-right-color: #fff; }} .t-r::after {{ border-left-color: #fff; }}
.mentor.t-bl::after, .mentor.t-br::after, .mentor.t-bc::after {{ border-top-color: #f3e5f5; }}
.mentor.t-tl::after, .mentor.t-tr::after {{ border-bottom-color: #f3e5f5; }}
.mentor.t-l::after {{ border-right-color: #f3e5f5; }} .mentor.t-r::after {{ border-left-color: #f3e5f5; }}
.shout.t-bl::after, .shout.t-br::after, .shout.t-bc::after {{ border-top-color: #fff59d; }}
.shout.t-tl::after, .shout.t-tr::after {{ border-bottom-color: #fff59d; }}
.shout.t-l::after {{ border-right-color: #fff59d; }} .shout.t-r::after {{ border-left-color: #fff59d; }}
.cap {{ position: absolute; background: #ffeb3b; border: .6mm solid {INK}; padding: 1.4mm 2.2mm; font-size: 9.4pt; line-height: 1.22; z-index: 3; }}
.cap.white {{ background: #fff; }}
.cap.math {{ background: #e8eaf6; font-size: 9.6pt; text-align: center; }}
.raw {{ position: absolute; z-index: 3; }}
.sfx {{ position: absolute; font-family: Bangers; -webkit-text-stroke: 1.1pt {INK}; letter-spacing: 1pt; z-index: 4; text-shadow: 1mm 1mm 0 {INK}; }}
.masthead {{ position: absolute; left: 0; right: 0; top: 0; height: 11mm; background: #d81b60; color: #fff; font-family: Bangers; font-size: 20pt;
            letter-spacing: 1.5pt; padding: 1mm 4mm; border-bottom: .9mm solid {INK}; z-index: 3; }}
.masthead span {{ font-size: 12pt; color: #ffeb3b; }}
.bigtitle {{ position: absolute; left: 0; right: 0; top: 14mm; text-align: center; font-family: Bangers; font-size: 64pt; color: #ffeb3b;
            -webkit-text-stroke: 2.6pt {INK}; text-shadow: 2mm 2mm 0 {INK}; letter-spacing: 3pt; transform: rotate(-3deg); z-index: 3; line-height: 1; }}
.subtitle {{ position: absolute; left: 22mm; right: 22mm; top: 45mm; text-align: center; font-size: 14pt; font-weight: 700; background: #fff;
            border: .7mm solid {INK}; padding: 1.5mm; transform: rotate(-1.5deg); z-index: 3; }}
.tag {{ position: absolute; font-family: Bangers; font-size: 17pt; background: #ffeb3b; border: .8mm solid {INK}; padding: 2mm 4mm;
       transform: rotate(-4deg); letter-spacing: .6pt; z-index: 3; box-shadow: 1.2mm 1.2mm 0 {INK}; max-width: 80mm; }}
.basedon {{ position: absolute; left: 0; right: 0; bottom: 0; background: #fff; border-top: .9mm solid {INK}; padding: 3mm 5mm;
           font-size: 9pt; line-height: 1.35; text-align: center; z-index: 3; }}
.banner {{ position: absolute; left: 0; right: 0; top: 4mm; text-align: center; font-family: Bangers; font-size: 30pt; color: #ffeb3b;
          -webkit-text-stroke: 1.4pt {INK}; text-shadow: 1.2mm 1.2mm 0 {INK}; letter-spacing: 2pt; z-index: 3; }}
.sbtitle {{ font-family: Bangers; font-size: 20pt; text-align: center; color: #d81b60; -webkit-text-stroke: .5pt {INK}; letter-spacing: 1pt; }}
table.score {{ width: 100%; border-collapse: collapse; font-size: 9.6pt; font-weight: 700; background: #fff; }}
table.score th {{ background: {INK}; color: #ffeb3b; font-family: Bangers; font-weight: 400; font-size: 11pt; letter-spacing: .5pt; padding: 1.2mm; }}
table.score td {{ border: .5mm solid {INK}; padding: 1.4mm 1.2mm; text-align: center; }}
table.score td:first-child {{ text-align: left; }}
.foot {{ font-size: 8pt; margin-top: 1.5mm; background: #fff; border: .4mm solid {INK}; padding: 1mm 1.5mm; }}
.box {{ background: #fff; border: .7mm solid {INK}; padding: 2mm 3.5mm; box-shadow: 1mm 1mm 0 {INK}; }}
.box .lt {{ font-family: Bangers; font-size: 19pt; color: #d81b60; letter-spacing: 1pt; -webkit-text-stroke: .4pt {INK}; text-align: center; }}
.box.lesson {{ font-size: 13pt; font-weight: 700; line-height: 1.5; text-align: center; }}
.box table {{ border-collapse: collapse; font-size: 10.2pt; font-weight: 700; }}
.box td {{ padding: 1.8mm 1mm; border-bottom: .3mm dashed #90a4ae; vertical-align: top; }}
.box td:first-child {{ font-family: Bangers; font-weight: 400; font-size: 13pt; color: #1565c0; white-space: nowrap; padding-right: 2.5mm; }}
.box p {{ margin: 2.2mm 0; font-size: 10pt; line-height: 1.25; }}
"""


def render(pages, pdf_path, preview=True):
    """pages: list of strings from cover()/page(). Writes the PDF and (optionally) 2-up PNG preview sheets."""
    from playwright.sync_api import sync_playwright
    fonts = ensure_fonts()
    html = f"<!doctype html><html><head><meta charset='utf-8'><style>{css(fonts)}</style></head><body>{''.join(pages)}</body></html>"
    base = os.path.splitext(pdf_path)[0]
    with open(base + ".html", "w") as f:
        f.write(html)
    with sync_playwright() as pw:
        b = pw.chromium.launch()
        pg = b.new_page()
        pg.goto(f"file://{os.path.abspath(base + '.html')}")
        pg.wait_for_timeout(800)
        pg.pdf(path=pdf_path, prefer_css_page_size=True, print_background=True)
        b.close()
    sheets = []
    if preview:
        from PIL import Image
        d = base + "_preview"
        os.makedirs(d, exist_ok=True)
        for f in glob.glob(f"{d}/*.png"):
            os.remove(f)
        subprocess.run(["pdftoppm", "-r", "80", "-png", pdf_path, f"{d}/pg"], check=True)
        ims = [Image.open(p) for p in sorted(glob.glob(f"{d}/pg-*.png"))]
        w, h = ims[0].size
        for j in range(0, len(ims), 2):
            c = Image.new("RGB", (w * 2, h), "white")
            c.paste(ims[j], (0, 0))
            if j + 1 < len(ims):
                c.paste(ims[j + 1], (w, 0))
            out = f"{d}/sheet{j//2 + 1}.png"
            c.save(out)
            sheets.append(out)
    print("PDF:", pdf_path, "| preview sheets:", sheets)
    return sheets

Individual skills in this repo

This repo contains 2 individual skills — each has its own dedicated page.

Related Skills