论文 → 漫画故事 (PDF)
当用户提供一篇论文(PDF、LaTeX、文本)并请求将其制作成漫画、故事、"comix 杂志"、卡通解释或有趣的视觉摘要时使用此技能。输出为一个 A4 PDF,约 8 页,完全通过代码(SVG + HTML → Chromium PDF)绘制。无需图像生成。
1. 阅读论文并制作事实清单(在脑内或草稿文件中)
用简单的语言提取:
- 论文研究的系统/设备/算法(这将成为英雄)。
- 问题或糟糕的指标(中断、误差、延迟、攻击……)——这将成为反派。
- 基线/经典方法和提出的方法(以及任何变体,例如 UCB、消融实验)。
- 核心思想、定理或洞见,以及一个日常比喻(拼图块、门、钥匙、道路……)。
- 决策规则或机制,如果有的话(阈值、开关、门)。
- 测试设置(参数、试验次数、信噪比……)。
- 关键数字、表格、鲁棒性检查、局限性、未来工作、作者和所属单位。
规则:严格复制论文中的数字。切勿编造结果、数据集名称或主张。如果某个简单值可以重新计算(例如维度或公式输出),使用 Python 重新计算以验证。保留论文自己的术语,但每个缩略词需解释一次。
2. 角色分配(引擎函数)
| 故事中的角色 | 引擎函数 | 典型用途 |
|---|---|---|
| 英雄(系统) | robot(...) | 颜色,胸前 label,antenna=True 在其头部绘制一串端口导轨 |
| 导师/叙述者 | scientist(...) | 解释理论;headwear="hijab" 或 "hair";point=True |
| 反派(问题) | monster(...) | 腹部 label,情绪为 angry / hurt |
| 基线/经典方法 | sergeant(...) | 纪律严明,持尺子,label 例如 "GRID"、"BP"、"ZF" |
| 提出/自适应方法 | explorer(...) | 手电筒光束;第二个变体使用 tool="lantern" 和另一种颜色 |
| 决策规则/阈值 | gatekeeper(...) | 标牌文本 label + sub(例如 "D","99") |
给英雄一个与主题相关的简短友好名称(流体天线用 FLUI,解码器用 DECO……)。选择一个朗朗上口的漫画标题(例如 "THE 50 DOORS")。
3. 故事板(默认 8 页弧线——根据论文调整)
- 封面:大标题,副标题 "How learned to ...",带有主要教训的标语,所有角色,致谢行包含完整论文标题、作者和所属单位。
- 第 1 章 世界和问题:英雄是谁,它必须做什么,它的限制是什么(预算、噪声、一条射频链路……),反派。一个展示物理模型的小画板。
- 第 2 章 两种方式:基线方法与提出的方法,每种方法如何工作(在
math标题中放置一个公式),以及比较的公平规则。 - 第 3 章 转折/关键洞见:一个惊喜(新想法失败或成功的地方),然后导师用黑板图表和表格解释理论。
- 第 4 章 机制:比喻面板(例如拼图块)、证据数字、决策规则(带有 YES/NO 分支的菱形)。
- 第 5 章 大测试:设置标题、记分牌表格、热图或图表、导师气泡中的关键结果。
- 第 6 章 鲁棒性:其他设置、灵敏度检查、诚实地解释 "only miss"。
- 结局:庆祝面板、"THE LESSON" 框、诚实的局限性 + 未来工作、"CHEAT SHEET"(关键数字)和 "WORDS TO KNOW"(术语表)。
短论文可能需要 6 页;长论文最多 10 页。每个章节页有 4–6 个面板。
写作规则:
- 简单的英语(B2 级别),友好,流畅:每个面板回答上一个面板的问题并提出下一个问题。
- 一个面板一个想法。气泡最多约 30 词;标题最多约 35 词。
- 使用
<b>加粗关键词和数字。公式仅放在C(..., cls="math")标题中,使用<sub>/<sup>。 - 每个数字都可追溯到论文。将说明性绘图标记为 "typical" 或 "example"。
- 导师气泡使用
cls="mentor";感叹使用cls="shout"。
4. 构建
- 将下面的引擎写入工作目录的
comic_engine.py(精确复制)。 - 编写
story.py,导入引擎,构建页面,调用render(pages, "<Title>_Comic_v1.pdf")。 - 运行它。
render还会在<name>_preview/中写入 2-up PNG 预览表。
要求:Python 的 playwright(Chromium)、numpy、PIL、pdftoppm(poppler)。字体 Bangers 和 Comic Neue 通过 npm pack @fontsource/... 一次性下载到 COMIC_FONT_DIR(默认为 /tmp/comic_fonts);如果离线,引擎回退到系统字体。如果 Playwright 的 Chromium 缺失,运行 python -m playwright install chromium。
布局速查表:
- 页面:A4 210 × 297 mm。可用区域 x = 7…203 mm,y = 16…289 mm(标题在上方)。边距 4 mm。全宽面板 w = 196;半宽 w = 96(x = 7 和 x = 107)。
- 在面板内部,SVG 单位 = 每毫米 4 个:一个 96 × 70 mm 的面板是 384 × 280 单位。将所有图形保持在内部(x < w·4,y < h·4)。
- 角色以其脚部在 (x, y) 绘制;将脚部放在 y ≈ h·4 − 8。s = 1 时的高度:机器人 ≈ 125 单位,科学家 ≈ 120,探索者 ≈ 120,中士 ≈ 115,看门人 ≈ 195;怪物居中,宽度 ≈ 150。
- 气泡/标题使用相对于面板的毫米单位。在 9.8 pt 下,一个 45 mm 宽的气泡每行约容纳 8 个词;每行高度约 4.3 mm。
- 尾巴:
bl br bc tl tr l r——尾巴必须指向说话者。 - 表格和框:
RAW(x, y, w, table(...)),或RAW(..., '<div class="box lesson"><div class="lt">THE LESSON</div>...</div>');带<table>的框制作速查表,带<p>的制作术语表。 - 面板内的横幅文本:将
'<div class="banner">THE GREAT TEST</div>'作为一项。
小示例(一页):
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. 视觉检查(始终执行)
使用阅读工具打开每个预览表,在交付前修复:
- 气泡或标题遮挡了面部或关键数字 → 将其移动。
- 图形被面板边缘裁剪或文本被底部裁剪 → 移动或缩小。
- 尾巴指向错误的角色 → 更改尾巴。
- 面板半空 → 添加角色、道具或更大的图表。
- 数字与论文匹配。 重新渲染直至干净(通常 2–3 轮)。
6. 交付
- 文件名
<Short_Title>_Comic_v1.pdf;每个后续修订版为 v2、v3…… - 使用 SendUserFile 发送。回复 3–5 行简短内容:标题、角色阵容、页面列表以及一个提议(另一种语言、其他角色名称、更多或更少的页面)。
- 对于阿拉伯语版本:在气泡/标题上添加
dir="rtl"并加载阿拉伯语字体(例如npm pack @fontsource/cairo)到 CSS 中;保持数字和公式从左到右。
引擎:comic_engine.py(精确写入此文件)
"""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"`
(由于篇幅限制,引擎的完整代码已省略。在实际文件中,请复制原始仓库中的完整 comic_engine.py 内容。)