Paper → Comic Story (PDF)
當使用者提供一篇論文(PDF、LaTeX、文字)並要求將其轉換為漫畫、故事、漫畫雜誌、卡通解說或有趣的視覺摘要時,請使用此技巧。輸出是一個 A4 PDF,約 8 頁,完全透過程式碼(SVG + HTML → Chromium PDF)繪製。無需圖片生成。
1. 閱讀論文並建立事實摘要(在腦中或草稿檔案中)
以簡單的詞彙提取:
- 論文研究的系統 / 設備 / 演算法(這將成為英雄)。
- 問題或不良指標(中斷、錯誤、延遲、攻擊...)——這將成為反派。
- 基線 / 經典方法和提出的方法(以及任何變體,例如 UCB、消融實驗)。
- 關鍵想法、定理或見解,以及一個日常生活比喻(拼圖碎片、門、鑰匙、道路...)。
- 決策規則或機制(閾值、開關、閘門)。
- 測試設定(參數、試驗次數、SNR...)。
- 標題數字、表格、穩健性檢查、限制、未來工作、作者和所屬機構。
規則:完全照抄論文中的數字。絕不虛構結果、資料集名稱或主張。如果一個簡單值可以重新計算(例如維度或公式輸出),請使用 Python 重新計算以確認。保留論文本身的術語,但每個縮寫需解釋一次。
2. 角色分配(引擎函式)
| 故事中的角色 | 引擎函式 | 典型用途 |
|---|---|---|
| 英雄(系統) | robot(...) | 顏色、胸部 label、antenna=True 在頭頂畫出一排連接埠 |
| 導師 / 旁白 | scientist(...) | 解釋理論;headwear="hijab" 或 "hair";point=True |
| 反派(問題) | monster(...) | 標籤在肚子上,表情 angry / hurt |
| 基線 / 經典方法 | sergeant(...) | 嚴謹、帶有尺、標籤例如 "GRID"、"BP"、"ZF" |
| 提出的 / 自適應方法 | explorer(...) | 手電筒光束;第二變體使用 tool="lantern" 和另一種顏色 |
| 決策規則 / 閾值 | gatekeeper(...) | 牌匾文字 label + sub(例如 "D"、"99") |
為英雄取一個與主題相關的簡短友好名稱(FLUI 代表流體天線,DECO 代表解碼器...)。選擇一個朗朗上口的漫畫標題(例如 "THE 50 DOORS")。
3. 分鏡腳本(預設 8 頁故事弧——根據論文調整)
- 封面:大標題、副標題 "How learned to ..."、帶有主要教訓的標語、所有角色、包含完整論文標題、作者、所屬機構的致謝行。
- 第 1 章 世界與問題:英雄是誰、它必須做什麼、限制它的因素(預算、噪音、一個 RF 鏈...)、反派。一個帶有物理模型的小圖板。
- 第 2 章 兩種觀點:基線方法與提出的方法,每種方法如何運作(在
math標題中放一個公式),以及比較的公平規則。 - 第 3 章 轉折 / 關鍵見解:一個驚喜(新想法失敗或成功的地方),然後導師用黑板圖表和一個表格解釋理論。
- 第 4 章 機制:比喻面板(例如拼圖碎片)、證據數字、帶有是/否分支的菱形決策規則。
- 第 5 章 大考驗:設定說明、記分板表格、熱力圖或圖表、導師對話框中的標題結果。
- 第 6 章 穩健性:其他設定、敏感度檢查、誠實解釋 "唯一失誤"。
- 結局:慶祝面板、教訓框、誠實的限制 + 未來工作、秘笈(關鍵數字)和詞彙表。
較短的論文可能需要 6 頁;較長的可能需要 10 頁。每個章節頁面有 4-6 個面板。
寫作規則:
- 簡單的英語(B2 等級),友好流暢:每個面板回答上一個面板的問題,並提出下一個問題。
- 每個面板一個想法。對話框最多約 30 個詞;標題最多約 35 個詞。
- 加粗(
<b>)關鍵詞和數字。公式僅在C(..., cls="math")標題中使用,搭配<sub>/<sup>。 - 每個數字都能在論文中追溯到。標記插圖為 "典型" 或 "範例"。
- 導師對話框使用
cls="mentor";驚嘆號使用cls="shout"。
4. 建置
- 將以下引擎寫入工作資料夾中的
comic_engine.py(完全複製)。 - 撰寫
story.py,匯入引擎、建置頁面,並呼叫render(pages, "<Title>_Comic_v1.pdf")。 - 執行它。
render也會在<name>_preview/中寫入 2 合 1 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 單位 = 每 mm 4:一個 96 × 70 mm 的面板是 384 × 280 單位。將所有圖形保持在內部(x < w·4,y < h·4)。
- 角色以腳部在 (x, y) 繪製;將腳部放在 ≈ h·4 − 8。縮放比例 s = 1:機器人大約 125 單位,科學家約 120,探險家約 120,軍士約 115,守門人約 195;怪物居中且約 150 寬。
- 對話框/標題使用相對於面板的 mm。在 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">教訓</div>...</div>');帶有<table>的方框製作秘笈,帶有<p>的方框製作詞彙表。 - 面板內的橫幅文字:
'<div class="banner">大考驗</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, "在一個充滿無線電波的城市裡住著 <b>FLUI</b>..."),
B(100, 44, 92, "我有 <b>50 個連接埠</b>,但只有 <b>一個</b> 無線電鏈!", "tl")]))
p.append(panel(7, 90, 96, 70, scientist(78, 270, 1.4, point=True) + monster(290, 110, 1.05),
[B(3, 3, 50, "選錯連接埠,<b>中斷怪獸</b>就會贏。", "br", "mentor"),
B(42, 50, 50, "低於閾值?你就是我的了!", "tr", "shout")]))
pages = [cover("THE 50 DOORS", "一隻單耳天線如何學會聰明地觀看", art,
"看對地方...<br>但只在需要時!", credit_html),
page(2, "第 1 章 · 一隻耳朵,五十道門", 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",並在 CSS 中載入阿拉伯字型(例如npm pack @fontsource/cairo);保持數字和公式為 LTR。
引擎:comic_engine.py(完全照抄這個檔案)
"""comic_engine.py — reusable engine for comic-magazine explainers of research papers.
座標:每個面板以 mm 為單位放置在 A4 頁面上(210 x 297 mm)。在面板內部,
SVG 繪圖使用 U = 4 units per mm(一個 96 x 70 mm 的面板具有 viewBox 0 0 384 280)。
角色以其 FOOT 在 (x, y) 處繪製;s = 縮放比例(1.0 大約 30 mm 高)。
"""
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("字型下載失敗,使用備用方案:", 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...'''