はじめに
最近、PMとして資料作成のお手伝いを依頼されました。
「このHTMLで作った資料、そのまま編集できるPowerPoint にして欲しいんだけど」
なるほど、と。
AI駆動でやっていい案件だったので、AIに頼んでサクッと終わるでしょ、と思っていた時期が、僕にもありました。
50枚を超えるスライド、複雑な表、ネストした図、インライン装飾、和文フォント前提のレイアウト。
これを「全部編集可能なネイティブPPT」にする仕事は、想像していた10倍の泥仕合でした。
これはAI(Claude Opus)と二人三脚で泥仕合を抜けて、最終的に再利用可能なスキルとして残すまでの実録です。
ハマった地雷は全部で8個。同じことをやろうとしている誰かの地雷除去マップになれば嬉しいです。
何が欲しかったのか
要件はシンプルでした。
テキストは全部編集できること。表は表として残っていること(画像はNG)。図は埋め込みでOK、編集まで求めない。レイアウトはHTMLとそっくりそのまま。
要するに、「ホームページの体裁の資料を、PowerPointで開いてそのまま使える状態にしてくれ」。
シンプル。
シンプルなはずだった。
最初の2回、どっちも一発でアウト
最初にAIが出してきた手は、HTMLをスクリーンショットしてPPTのスライド背景に1枚ずつ貼り付ける、というものでした。
開いてみる。
…全部画像じゃん。
テキストが一切編集できない。要件の一行目に正面から殴り返された格好です。馬鹿野郎、それは資料じゃない。ただの画像集だ。
「テキストが編集できないとダメです」と返すと、次にAIが出してきたのはCSSの数値から座標を計算するアプローチでした。fontSizeとline-heightからy座標を計算して、ネイティブのテキストボックスとして配置していく。理屈は通っている。
開いた。
崩れてる。
「崩れてます」と伝えると、AIが原因を掘って戻ってきました。
「HTMLとPPTで、同じフォント・同じサイズでも実描画が微妙に違います。ascender/descenderの扱い、CJK文字の行送り、PPTのテキストボックスのデフォルトpadding。要素1個あたり2pxのズレでも、上から積み上げれば最後は30pxズレます。計算で攻めるアプローチは、原理的に限界があります」
なるほどね。じゃあどうする、と聞いたら、AIが方針を根本から変えてきました。
転換点:「計算するな、実測しろ」
「HTMLをブラウザで実際に描画させて、各要素の座標を1個1個実測します。PPTはそのJSONを読んで、同じ座標に置きます」
これが効きました。
Playwrightで Chromium を立ち上げて、全要素の bbox と computed style とインライン装飾を JSON に吐き出す。SVGはcairosvgでPNGに変換。最後にpython-pptxでJSONを読みながら .pptx を組み立てる。3フェーズのパイプラインです。
HTML
│
├─ Phase 1: measure.py(Playwright で実測)
│ └─ 全要素の bbox + computed style + inline装飾を JSON に吐く
│
├─ Phase 2: convert_svgs.py(cairosvg)
│ └─ <svg> を1枚ずつ PNG に変換
│
└─ Phase 3: build.py(python-pptx)
└─ JSON と PNG から .pptx を構築
LibreOfficeで開くと、ほぼHTML通り。「これは行ける」と思いました。
…と思った瞬間、PowerPointで開いた瞬間に新しい地雷が次々と火を噴きました。
ここから本当の地獄が始まります。
地雷①:表の罫線が、消える
開いた瞬間、目を疑いました。
LibreOfficeで開いてた時はちゃんと罫線が出てたんです。同じファイル。同じパス。それをPowerPointで開いた瞬間に、罫線だけが、全部、消えている。
え?
調べたらOOXMLのスキーマ違反でした。
<a:tcPr>(テーブルセルのプロパティ)には子要素の並び順の規定があって、本来は罫線(lnL/lnR/lnT/lnB)を先に書いて、塗り(solidFill)を後に書かないといけない。
ところが python-pptx で cell.fill.solid() を呼ぶと solidFill が先に入ってしまう。その後で罫線を append すると、規定に違反した順序になる。
正しい順序: lnL → lnR → lnT → lnB → ... → solidFill
実際の順序: solidFill → lnL → lnR → ... ← 違反
ここがエグくて、LibreOfficeはこの違反を黙って許すんです。PowerPointは仕様通り厳密に解釈して、罫線を無視する。
つまり開発中ずっと、「動いて見える環境」で確認していたから気付かなかった。本番環境(PowerPoint)で開いた瞬間に初めて、罫線が消えていることがわかった。
寒気がしました。
修正は「セルを編集し終わったらtcPrの子要素をOOXML規定順に並べ直す」関数1個。
def reorder_tcPr(cell):
tc = cell._tc
tcPr = tc.find(qn('a:tcPr'))
if tcPr is None: return
order = ['lnL','lnR','lnT','lnB','lnTlToBr','lnBlToTr',
'cell3D','noFill','solidFill','gradFill',...,'extLst']
children = list(tcPr)
for c in children: tcPr.remove(c)
children.sort(key=lambda el: order.index(etree.QName(el).localname))
for c in children: tcPr.append(c)
各セルの最後に1回呼ぶだけで、一発で罫線が出ました。
教訓:検証環境は必ず納品先と同じものを使え。寛容な環境で確認すると、こういう穴をすり抜ける。
地雷②:図の中に、見たことのない四角が出る
罫線が直ったと思ったら、今度は図の一部に「☒」みたいな四角が描画されていました。
これ、見覚えがあります。フォントが対応していない文字の missing glyph アイコン。
調べると、cairosvgは Noto Sans CJK JP でレンダリングしているのですが、
-
⋮(U+22EE 縦三点リーダ) -
✕(U+2715 Heavy Multiplication X)
この2文字、Noto Sans CJK JP に収録されていない。
「じゃあ font-family のスタックで Noto Sans CJK JP, DejaVu Sans って書けばいいでしょ」と AI も最初は言ってきたんですが、cairosvgは先頭の1個しか見ない仕様で、これが効かない。tspanで分割しても、混在テキストはちゃんと描画してくれない。
最終的に取った戦略はシンプルで、事前に「見た目が同じ収録済み文字」へ置換する。
| 元の文字 | 置換先 | 理由 |
|---|---|---|
⋮ (U+22EE) |
︙ (U+FE19) |
見た目同一、CJK収録 |
✕ (U+2715) |
× (U+00D7) |
見た目ほぼ同一 |
ただこれだと「新しい記号が混ざってきたとき気付けない」というリスクがあるので、fontTools で実フォントの cmap を引いて、CJK未収録の非ASCII文字を自動検知して警告を出す仕組みも入れてもらいました。
WARN: still-unsupported glyphs detected: '⊕' (U+2295)
ログに出てくるので、人間の目視確認に頼らなくて済む。これは仕様書を自動生成するときに学んだ教訓と同じで、**「人間の注意力に頼った仕組みは破綻する」**んですよね。
地雷③:日付タグが、本文から離れて漂う
赤枠ピンク背景の小さなラベル。各スライドの冒頭に「5/12」とか入る更新日付タグです。
これが、本文と微妙にズレた位置に浮いている。
最初の実装は、このタグを「Rounded Rectangle Shape + テキストボックス」として、絶対座標で配置していました。Playwrightで測った位置にそのまま置く。それっぽく見える。
でも、本文がほんのちょっとズレた瞬間、絶対座標で打ったタグだけが取り残されて宙に浮く。
これがズレる理由でした。PPTのテキスト折り返しはHTMLと完全には一致しないので、本文側がほんの少し下にズレるだけで、絶対座標のタグだけ動かずに残ってしまう。
修正方針は、タグを独立したshapeにするのをやめて、親テキストのinline runとして埋め込むこと。
赤bold + Inter フォント + <a:highlight val="FEE2E2"/>(薄ピンク背景)を、run の XML に直接書き込む。
def set_run_highlight(run, color_hex):
rPr = run._r.get_or_add_rPr()
h = etree.SubElement(rPr, qn('a:highlight'))
clr = etree.SubElement(h, qn('a:srgbClr'))
clr.set('val', color_hex.lstrip('#'))
これで日付タグは本文の流れに乗って、絶対に離れなくなりました。
教訓:inline装飾はinlineで書け。shapeにしてはいけない。
地雷④:スライドによってヘッダーの高さが、なぜか違う
ほとんどのスライドで「優先度ラベル」が同じ位置にあるのに、一部のスライドだけ微妙に上にズレている。
「微妙」って表現が一番厄介で、見比べないと気付かない。でも見比べると、確実におかしい。
調べたら、HTML側に compact-light compact-medium compact-heavy compact-extreme という「密度修飾子クラス」が存在していました。情報量が多いスライドだけ padding を縮める仕掛け。
これが効いていると、ヘッダーのy座標がシフトする。Playwrightは正直に実測して JSON に書いてくる。だから PPT もその通り出力されて、スライドごとにバラつく。
修正方針はシンプルでした。compact-* の効果は完全に無視する。
build フェーズでヘッダー座標を強制的に上書きする。
HEADER_FIXED = {
'item-id-y': 28,
'priority-y': 22,
'priority-h': 25,
'action-title-y': 56,
}
compact の意図は「画面に詰め込みたい」だけど、PPTに書き出すときは見た目の統一感の方が大事。
これ、地味に重要なポイントで。「測定値が正しい」と「測定値が望ましい」は別物なんですよね。Playwrightは数字を取ってくるだけで、その数字が「人間が意図した値か」までは判断しない。判断するのはこっちの仕事。
地雷⑤:表のセルの中の表が、消える
これが一番やっかいでした。
ある複雑なセルの中身を見ると、「見出しテキスト + 日付タグ + ネストした表 + 警告バナー + 番号付きリスト」が全部詰まっている。HTMLでは普通に見えてるのに、PPTにするとネストした表だけが消えている。
理由を調べたら、衝撃の事実が出てきました。
python-pptx のネイティブテーブルは、セル内に別のテーブルを持てない。
…マジか。
無理矢理セル内に textContent を詰め込もうとすると、ネスト構造が消えて全部1つのテキストブロックに潰れる。当然レイアウトはぐちゃぐちゃ。
ここでAIが出してきたのが overlay戦略 でした。
「外側のセルは空のままにしておいて、中身の各要素をそれぞれ実測bboxの位置に個別配置します。ネストした表は別途ネイティブテーブルとして外側セルの上にz-orderで重ねます」
要は、表のセルの中に表を「入れる」のは諦めて、「重ねて見せる」発想です。
ただ、z-orderの順序を間違えると、外側セルの白塗りが中の要素を全部覆い隠してしまう。「中が見えない」「今度は外の表が消えた」と何度か往復しながら、最終的に7フェーズで描画順を整理しました。
1. 通常 shapes(背景)
2. lines
3. outer info-tables
4. inline-banner overlays
5. inner-tables
6. images
7. text(最前面)
教訓:ネイティブの制約を超える表現は、レイヤーを分けて重ねる。1つの構造で表現しようとすると死ぬ。
地雷⑥:フォントサイズがスライド毎に、地味に違う
「フォントサイズを全部揃えてくれ。SVG内が違うのは仕方ないけど」
要望を返すと、AIが調査しました。原因はHTMLのCSSで、ごく一部のスライドだけ fs=11 で上書きしている要素があったこと。Playwrightは正直に拾ってくるので、PPTにもそのまま反映される。
修正方針は地雷④と同じ思想でした。要素の「役割(role)」ごとに固定のフォントサイズテーブルを持って、測定値は無視して role の値で強制する。
ROLE_FONT_SIZE = {
'action-title': 22,
'lead-in': 12,
'section-title': 17,
'block-heading': 13,
'block-body': 12,
'li': 12,
}
これで「同じ意味の要素は必ず同じ大きさ」になりました。
地雷④と⑥で同じ教訓に2回ぶつかったのは偶然じゃなくて、「実測値をそのまま採用する」というアプローチには根本的な弱点があるんだ、と気付いたタイミングでした。実測は出発点。最終値は「意図」で決める。
地雷⑦・⑧:PPTのデフォルトを知らないとハマる系
残り2つは駆け足で。
地雷⑦:textboxの上に謎の空段落が入る
HTMLのインラインwhitespace(改行とインデント)がそのまま運ばれていた。text.replace(/\s+/g, ' ') で正規化して解決。
地雷⑧:優先度ラベルの文字が枠の上にズレる
textboxの vertical_anchor を MIDDLE に明示しないと、TOP がデフォルトで効いてしまう。1行追加で解決。
どっちも「PPTのデフォルト動作を知っていれば一瞬で気付く」系の地雷でした。
逆に言うと、知らないとなぜズレているのか永遠に悩む。
最終形:「Skill」として残す
8つの地雷を全部除去し終わって、ようやく完走できる pptx が出力できるようになりました。
ここで思ったんです。
「これ、忘れたくないな」と。
次に同じ仕事が来たとき、もう一度OOXMLのスキーマ違反を踏みたくない。cmapに収録されてない文字でハマりたくない。z-orderで何往復もしたくない。
そこで、ここまでのノウハウを Claude のスキル機能で再利用可能な形にまとめました。
こちらからフリーでダウンロード可能です。
https://github.com/enomoso-pm/structured-html-to-pptx
structured-html-to-pptx/
├── SKILL.md # スキル本体(8つの鉄則)
├── scripts/
│ ├── pipeline.py # 1コマンドで全部走らせる
│ ├── measure.py # Phase 1: Playwright 計測
│ ├── convert_svgs.py # Phase 2: SVG→PNG
│ └── build.py # Phase 3: python-pptx
├── references/
│ ├── design_spec.md # 設計仕様(色・フォント・寸法)
│ ├── troubleshooting.md # 症状→原因→対処を全列挙
│ └── html_format.md # 期待するHTML構造
└── samples/
└── sample_minimal.html
中でも一番重要なのが references/troubleshooting.md。
こう見える(症状) → 裏で何が起きてる(原因) → こう直す(対処) を、8個の地雷分すべて表形式で書き残しました。
python pipeline.py input.html -o output.pptx
これ1コマンドで完走するところまで仕上がっています。
次に同じパターンの仕事が来たとき、AIは SKILL.md と troubleshooting.md を読んで、今回踏んだ地雷を全部避けて走れる。
苦労を仕組みにして残す。これがやりたかったことでした。
学んだこと:AIは実装する、人間はレビューする
今回ずっとやっていたのは、要件を伝え、出てきたものを目視で確認し、「ここがおかしい」と返すことでした。
コードは書いていない。OOXMLのスキーマも、cmapの自動検知も、z-orderの重ね順も、全部AIが調べて、AIが実装した。
僕がやっていたのは、
- 何が崩れているかを目で確認する
- 「ここが要件を満たしていない」と具体的に伝える
- 原因の特定と修正方針はAIに任せる
- 再発防止を仕組みで残す
この4つだけ。
PMとしてAIと組むときの役割って、結局これなんだと思います。
AIは実装の腕力を持っている。判断の目は人間が持つ。 この役割分担が決まると、二人三脚は急に機能し始める。
逆に、人間が実装まで指示しようとすると、AIは「言われた通りにやるだけ」になって、悪くなる。
今回みたいに「計算で攻めるのは原理的に無理です」とAIが自分で結論を出してくれる関係を作れたのは、僕が実装に口を出さなかったからだと思っています。
おわりに
PowerPointの罫線が消える理由を、僕はもう思い出さなくていい。
それを思い出してくれる仕組みが、ちゃんと残っているから。
罫線が出ないとき。文字化けが起きたとき。タグの位置が浮いたとき。
そのときは OOXML schema を、cmap を、z-order を、開いてみてください。
答えは、そこに書いてあります。
同じ泥仕合をしている誰かの参考になれば。
それでは、また次の地雷でお会いしましょう。