論文 → コミックストーリー(PDF)
ユーザーが論文(PDF、LaTeX、テキスト)を提供し、コミック、ストーリー、「コミックマガジン」、漫画風の解説、楽しいビジュアル要約を依頼した場合に使用します。出力は、コード(SVG + HTML → Chromium PDF)で完全に描画された、A4サイズ約8ページのPDF1つです。画像生成は必要ありません。
1. 論文を読み、ファクトシートを作成する(頭の中で、またはスクラッチファイルに)
以下の情報を平易な言葉で抽出します。
- 論文が研究するシステム/デバイス/アルゴリズム(これが主人公になります)。
- 問題または悪い指標(アウテージ、エラー、遅延、攻撃...)— これが悪役になります。
- ベースライン/従来手法と提案手法(および任意のバリアント、例:UCB、アブレーション)。
- 主要なアイデア、定理、洞察、およびそれに対する日常的なメタファー(パズルのピース、ドア、鍵、道...)。
- 決定ルールまたはメカニズム(もしあれば)(しきい値、スイッチ、ゲート)。
- テスト設定(パラメータ、試行回数、SNR...)。
- 主要な数値、テーブル、ロバスト性チェック、限界、将来の研究、著者と所属。
ルール:論文の数値をそのままコピーします。結果、データセット名、主張を決して捏造しないでください。単純な値が再計算可能な場合(例:次元や数式の出力)、Pythonで再計算して確認します。論文の用語はそのまま使用しますが、各略語は一度説明します。
2. キャラクターをキャスティングする(エンジン関数)
| ストーリーでの役割 | エンジン関数 | 典型的な使用法 |
|---|---|---|
| 主人公(システム) | robot(...) | 色、胸の label、antenna=True で頭にポートのレールを描画 |
| メンター/ナレーター | scientist(...) | 理論を説明;headwear="hijab" または "hair";point=True |
| 悪役(問題) | monster(...) | お腹に label、ムード angry / hurt |
| ベースライン/従来手法 | sergeant(...) | 規律正しい、定規、label 例 "GRID"、"BP"、"ZF" |
| 提案/適応手法 | explorer(...) | 懐中電灯の光;第2バリアントは tool="lantern" と別の色 |
| 決定ルール/しきい値 | gatekeeper(...) | 銘板テキスト label + sub(例 "D"、"99") |
主人公には、トピックに関連した短く親しみやすい名前を付けます(流体アンテナならFLUI、デコーダならDECO...)。キャッチーなコミックタイトルを選びます(例:「THE 50 DOORS」)。
3. ストーリーボード(デフォルト8ページのアーク — 論文に合わせて調整)
- カバー:大きなタイトル、サブタイトル「<主人公>が...を学んだ方法」、主な教訓を伝えるタグライン、全キャラクター、論文の完全なタイトル、著者、所属を含むクレジット行。
- 第1章 世界と問題:主人公が誰か、何をすべきか、何がそれを制限するか(予算、ノイズ、1つのRFチェーン...)、悪役。物理モデルを示す小さなボード1つ。
- 第2章 二つの見方:ベースラインと提案手法、それぞれの仕組み(
mathキャプション内の数式1つ)、比較の公平なルール。 - 第3章 ひねり / 重要な洞察:驚き(新しいアイデアが失敗または成功する場面)、その後メンターが黒板の図とテーブルで理論を説明。
- 第4章 メカニズム:メタファーパネル(例:パズルのピース)、証拠となる数値、YES/NOの分岐を持つダイアモンド型の決定ルール。
- 第5章 大テスト:設定のキャプション、スコアボードテーブル、ヒートマップまたはチャート、メンターの吹き出しでの主要結果。
- 第6章 ロバスト性:その他の設定、感度チェック、「唯一の見逃し」を正直に説明。
- フィナーレ:祝賀パネル、「THE LESSON」ボックス、正直な限界 + 将来の研究、「CHEAT SHEET」(主要数値)、「WORDS TO KNOW」(用語集)。
短い論文は6ページで十分かもしれません。長いものは最大10ページまで。各章のページには4〜6個のパネルがあります。
執筆ルール:
- 簡単な英語(B2レベル)、親しみやすく、スムーズ:各パネルは前のパネルの質問に答え、次の質問を提起します。
- 1パネル1アイデア。吹き出しは最大約30語。キャプションは最大約35語。
- キーワードと数値は太字(
<b>)に。数式はC(..., cls="math")キャプション内のみ、<sub>/<sup>を使用。 - すべての数値は論文にトレース可能に。説明用のイラストは「典型的な」または「例」とマーク。
- メンターの吹き出しは
cls="mentor"を使用。感嘆文はcls="shout"を使用。
4. 構築
- 以下のエンジンをワーキングフォルダに
comic_engine.pyとして書き込みます(正確にコピー)。 - エンジンをインポートし、ページを構築し、
render(pages, "<Title>_Comic_v1.pdf")を呼び出すstory.pyを書きます。 - 実行します。
renderは2-upのPNGプレビューシートも<name>_preview/に書き込みます。
要件: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単位は1mmあたり4:96 × 70 mmのパネルは384 × 280ユニット。すべてのアートを内部に収めます(x < w·4、y < h·4)。
- キャラクターは (x, y) に足を置いて描画されます。足 ≈ h·4 − 8。s = 1での高さ:robot ≈ 125ユニット、scientist ≈ 120、explorer ≈ 120、sergeant ≈ 115、gatekeeper ≈ 195。monsterは中央揃えで幅約150。
- 吹き出し/キャプションはパネルを基準にmmを使用。9.8ptで幅45mmの吹き出しは1行あたり約8語を保持。各行の高さは約4.3mm。
- テール:
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>'。
小さな例(1ページ):
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. ビジュアルチェック(常に行う)
すべてのプレビューシートをReadツールで開き、配信前に修正します:
- 吹き出しやキャプションが顔や主要な数値を覆っている → 移動します。
- アートがパネルの端で切れている、またはテキストが下部で切れている → 移動または縮小します。
- テールが間違ったキャラクターを指している → テールを変更します。
- パネルが半分空っぽ → キャラクター、小道具、またはより大きなチャートを追加します。
- 数値が論文と一致していること。
きれいになるまで再レンダリングします(通常2〜3回)。
6. 配信
- ファイル名
<Short_Title>_Comic_v1.pdf。以降のリビジョンは v2、v3、... とします。 - SendUserFileで送信します。3〜5行の短い返信:タイトル、キャスト、ページリスト、および1つのオファー(別の言語、他のキャラクター名、ページ数の増減)。
- アラビア語版の場合:吹き出し/キャプションに
dir="rtl"を追加し、アラビア語フォント(例:npm pack @fontsource/cairo)をCSSにロードします。数値と数式はLTRのままにします。
エンジン: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
**[snip - 残りは元のSKILL.mdと同じPythonコードが続きます]**