0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

note記事の表がすぐ崩れる問題を、ヘッドレスChromeで解決する

0
Posted at

前回、headless Chrome自動化をバンドル分割した話を書きました。今回はその中の1本、**note本文に混ざるMarkdownの表だけをheadless Chromeで画像に焼いて差し替えるmd-table-to-image.py**を掘り下げます。

私はClaude Codeに毎日note向けの記事とサムネをストックさせています(note-daily-stock.sh / maker-daily-stock.sh)。この生成パイプラインで地味に一番厄介だったのが、本文中に表が入るとほぼ確実に崩れる問題でした。

困りごと:Markdownの表をそのまま貼れない

note.comもSolomaker(つくったアプリの入稿先。エディタはTipTap/ProseMirror)も、Markdownをそのまま読み込む口はありません。パイプライン側でMarkdown→HTMLに変換してからクリップボード貼り付け用HTMLを作っています(maker-daily-stock.shwrite_solomaker_html)。ところがこの変換器、表(|...|)を扱えません。スクリプト内のコメントがそのまま原因を言い当てています。

# 2026-07-04: 表は画像で出力する方針。HTML変換は表を扱えないので、変換前にmd表を画像化する。
# 生成PNGはTSU_DIRに置く。fail-safe(失敗しても本文は生きる)。
TBL_TOOL="$HOME/.claude/scripts/md-table-to-image.py"
if [ -f "$TBL_TOOL" ]; then
  N_TBL=$(run_to 180 python3 "$TBL_TOOL" "$BUILD" --outdir "$TSU_DIR" --prefix "$NO-$SLUG" 2>>"$LOG" || echo 0)
  [ "${N_TBL:-0}" -gt 0 ] 2>/dev/null && log "表を画像化: $N_TBL 個 ($NO-$SLUG)"
fi

note-daily-stock.sh側もまったく同じ構図です。表を含む記事はMarkdown→HTML変換の前に、表だけを画像に変換してMarkdownの参照に置き換えておく。これが唯一の解決策でした。

仕組み:表だけをheadless Chromeで描いてPNG化する

md-table-to-image.pyの役割は、スクリプト冒頭のdocstringに要約されています。

"""Markdown本文中の表(| ... |)を画像(PNG)に変換し、本文の表を画像参照に置換する。

note等マークダウン表を貼れない媒体向け。headless Chromeで整形HTMLをスクショする
(gen_note_thumbs.py と同じ描画経路=フォント/折返し/コード表示が綺麗)。
"""

サムネ生成と同じ描画経路を使っているのがポイントです。フォントや折り返し、インラインコードの見え方を、記事のサムネと表画像で揃えられます。

処理の流れは3段階です。

  1. find_tables() — 本文行から表ブロックを検出する
  2. rows_to_table_html() — 表の行データを整形HTMLに変換する
  3. render_png() — headless Chromeでそのwebページをスクリーンショットする

表検出:コードフェンス内の|を誤検出しない

一番最初につまずいたのがここです。表の開始条件は「|で始まる行の次に|---|区切り行がある」ですが、これだけだとコードフェンス内でシェルのパイプやMarkdownの表記例をそのまま書いた箇所まで表として拾ってしまいます。技術記事のnote本文には```で囲んだコマンド例が普通に出てくるので、これは実害が出ました。

対策はフェンスの開閉状態を明示的に追跡することです。

FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")

def find_tables(lines):
    """本文行から連続する表ブロックを検出。返り値: [(start_idx, end_idx_exclusive, [rows])]。"""
    tables = []
    i = 0
    n = len(lines)
    fence = None
    while i < n:
        fence_match = FENCE_RE.match(lines[i])
        if fence is not None:
            if (
                fence_match
                and fence_match.group(1)[0] == fence[0]
                and len(fence_match.group(1)) >= fence[1]
                and not fence_match.group(2).strip()
            ):
                fence = None
            i += 1
            continue
        if fence_match:
            marker = fence_match.group(1)
            fence = (marker[0], len(marker))
            i += 1
            continue
        # 表の開始: | を含みヘッダ、次行が区切り(|---|)
        if lines[i].lstrip().startswith("|") and i + 1 < n and re.match(
            r"^\s*\|[\s:|-]+\|\s*$", lines[i + 1]
        ):
            start = i
            ...

fence変数がNoneでない間(=フェンスの中にいる間)は、行の中身を一切見ずにスキップします。フェンスの開始マーカーは`~の3文字以上、閉じマーカーは同じ文字種で同じ長さ以上という条件も、CommonMarkのフェンス仕様に合わせて律儀に見ています。これで、記事中のコード例に|があっても表として誤爆しなくなりました。

整形HTML→PNG

検出した表はrows_to_table_html()で見た目の良いHTMLに変換します。セル内の`code`**bold**はエスケープしてから復元する形でHTML化しています。

def md_inline_to_html(text: str) -> str:
    """セル内のインライン記法(コード/太字)をHTML化。まずエスケープしてから復元。"""
    text = html.escape(text)
    text = re.sub(r"`([^`]+)`", lambda m: f"<code>{m.group(1)}</code>", text)
    text = re.sub(r"\*\*([^*]+)\*\*", lambda m: f"<strong>{m.group(1)}</strong>", text)
    return text

生成したHTMLはこのテンプレートに埋め込みます(一部抜粋)。

HTML_TMPL = """<!doctype html><html lang="ja"><head><meta charset="utf-8"><style>
  table {{ border-collapse:separate; border-spacing:0; width:{width}px;
    border:1px solid #e6e3ee; border-radius:14px; overflow:hidden;
    box-shadow:0 6px 24px #1a103508; }}
  thead th {{ background:#f6f3fb; color:#3a3350; font-weight:800; font-size:26px; ... }}
  tbody tr:nth-child(even) td {{ background:#faf9fd; }}
</style></head><body>{table_html}{footer}</body></html>"""

note本文は白背景なので、サムネのダーク系とは別系統の「明るく上品」なトーンにしてあります。

落とし穴:--screenshotはウィンドウの高さしか撮らない

ここが一番踏んだ落とし穴です。headless Chromeの--screenshotページのコンテンツにフィットした高さではなく、指定した--window-sizeの高さをそのまま撮ります。最初は高さを2000px固定で決め打ちしていましたが、コード直下のコメントにその代償が残っています。

# ⚠️headless=new の --screenshot は「ウィンドウの高さ」を撮る(コンテンツfitしない)。
# 2000固定だと表の下に巨大な白余白が残る(2026-08-11: 公開画像が1760x4000)。
# documentElement.scrollHeight はbodyの下paddingを取りこぼすので、
# inline-blockなbody自身の実高さ(padding込み)を測る。

2026年8月11日、実際に1760×4000のPNGが公開されていたことがあります。表の中身は上のほうの一部だけで、下は真っ白な余白という間抜けな画像でした。document.documentElement.scrollHeightで測ろうとしても、body側のpaddingが乗らないinline-blockレイアウトだと数値がズレます。

解決策は「実測した高さをdocument.titleに書き込んで、別プロセスから読み出す」という2パス構成です。

def render_png(rows, outpng: Path, footer: bool = True) -> bool:
    table_html, width = rows_to_table_html(rows)
    footer_html = '<div class="cap">bokuwalily.com</div>' if footer else ''
    doc = HTML_TMPL.format(table_html=table_html, width=width, footer=footer_html).replace(
        "</head>", "<title>0</title></head>"
    ).replace(
        "</body>",
        "<script>document.title = Math.ceil(document.body.getBoundingClientRect().height);</script></body>",
    )
    ...
    height = 2000
    try:
        measured = subprocess.run(
            [
                CHROME, "--headless=new", "--use-mock-keychain", "--password-store=basic",
                "--disable-gpu", "--hide-scrollbars", "--force-device-scale-factor=2", "--dump-dom",
                f"--window-size={width + 80},200", f"file://{htmlpath}",
            ],
            check=True, capture_output=True, text=True, timeout=120,
        )
        match = re.search(r"<title>\s*(\d+)\s*</title>", measured.stdout)
        if match:
            height = max(200, min(4000, int(match.group(1))))
    except Exception:
        pass
    subprocess.run(
        [
            CHROME, "--headless=new", "--use-mock-keychain", "--password-store=basic",
            "--disable-gpu", "--hide-scrollbars", "--force-device-scale-factor=2",
            f"--screenshot={outpng}", f"file://{htmlpath}",
            f"--window-size={width + 80},{height}",
        ],
        check=True, capture_output=True, timeout=120,
    )

1回目は--dump-domだけを撮る軽い実行で、<script>document.body.getBoundingClientRect().heightを計算してdocument.titleに書き込んだ結果を、DOMダンプの<title>タグから正規表現で拾います。2回目はその実測高さを--window-sizeに渡して、本番のスクリーンショットを撮る。Chromeに直接高さを聞く手段が用意されていないので、タイトルという横道からJSの計算結果を持ち帰る、という力技です。高さは200〜4000にクランプして、極端な値で暴走しないようにしてあります。

--screenshotはビューポートを撮るのであって、コンテンツの高さには合わせてくれません。headless Chromeで可変長コンテンツを画像化するなら、「1回描画して実測 → 実測値でもう一回描画」の2パスがほぼ必須です。今回はdocument.titleをJS→シェルの受け渡し役に流用しましたが、他に確実な手段が見当たりませんでした。

冪等設計:変換済みなら0を返して素通り

このスクリプトはdocstringに明記してある通り、既に画像化済み(表がMarkdownから消えている)なら何もしません

使い方:
  # 冪等: 既に画像化済み(表が消えている)なら何もしない。表が残っていれば処理。
出力:
  <outdir>/<prefix>-tableN.png を作り、本文の該当表を ![表N](<prefix>-tableN.png) に置換。
戻り値: 変換した表の数を stdout に出す。0=表なし(冪等スキップ含む)。

process_file()find_tables()の戻り値が空なら即return 0します。表を画像参照に置換した後の本文には、もう|...|の表構造が残っていないので、同じ記事に対して2回呼んでも2回目は何も起きません。パイプライン側が失敗してリトライしたり、同じ記事に対して手動で再実行したりしても安全、という設計です。

パイプライン統合:失敗しても本文は生かすfail-safe

note-daily-stock.shmaker-daily-stock.shのどちらでも、この変換は本文が完成した後・HTML変換の前というピンポイントの位置に差し込まれています。

# 2026-07-04: noteはMarkdown表を貼れない → 本文中の表を画像化して画像参照に置換する。
# 全工程fail-safe(失敗しても本文はそのまま生きる)。生成PNGは記事と同じ場所に置く。
TBL_TOOL="$HOME/.claude/scripts/md-table-to-image.py"
if [ -f "$TBL_TOOL" ]; then
  N_TBL=$(run_to 180 python3 "$TBL_TOOL" "$OUT" --prefix "$NO-$SLUG" 2>>"$LOG" || echo 0)
  [ "${N_TBL:-0}" -gt 0 ] 2>/dev/null && log "表を画像化: $N_TBL 個 ($NO-$SLUG)"
fi

run_to 180 ... || echo 0という書き方がfail-safeの要です。タイムアウトやChrome起動失敗でこのステップ自体がコケても、シェル側は0を受け取るだけで記事生成全体は止まりません。表だけがMarkdownのまま残って多少崩れるかもしれませんが、記事本文と他の工程(サムネ生成、フッター付与、キュー更新)は生き続けます。

スクリプト内部にも同じ思想でもう一段のfail-safeがあります。process_file()は複数の表を後ろから順に処理しますが、1つの表の描画に失敗しても、その表だけ元のMarkdownを残して次の表に進みます

for idx, (start, end, rows) in reversed(list(enumerate(tables, 1))):
    if len(rows) < 2:
        continue
    outpng = outdir / f"{prefix}-table{idx}.png"
    if not render_png(rows, outpng, footer=footer):
        sys.stderr.write(f"[md-table-to-image] 表{idx}の描画失敗。本文は維持。\n")
        continue
    ref = f"![表{idx}]({outpng.name})"
    lines[start:end] = [ref]
    converted += 1

「1個ダメなら全部ダメ」にしないのは地味に効きます。3つ表がある記事で1つだけChromeが機嫌を損ねても、残り2つは画像化され、失敗した1つだけがMarkdownのまま公開される――多少見た目は崩れますが、記事自体が消えるよりずっとましという判断です。

なお同じディレクトリには余白トリミング用の_autocrop.py(左上ピクセル色を背景とみなしてbboxで切り詰める)も置いてありますが、grepで確認した限り現状どのパイプラインからも呼ばれていません。高さ実測の2パス構成で余白問題の大半が解決したため、今は「必要になったときに手で使うスペア」の位置づけです。

踏んだ落とし穴

  • --screenshotはウィンドウ高さを撮る。コンテンツにフィットしない → 2000px固定だと下に巨大な白余白(2026-08-11: 実際に1760×4000の画像を公開してしまった) → document.titleに実測高さを埋め込む2パス測定で解決
  • documentElement.scrollHeightはbodyのpaddingを取りこぼすinline-blockなbody自身のgetBoundingClientRect().heightを測る
  • コードフェンス内の|をそのまま拾うと表として誤検出する → フェンスの開閉状態を追跡してフェンス内をスキップ
  • 1つの表の描画失敗で記事全体を止めたくないrender_png()単位でtry/exceptし、失敗した表だけ元のMarkdownを残して処理を続行
  • HTML変換ステップ自体が表を扱えない → 変換の前段で表だけを画像化し、Markdownの参照に置き換えてから渡す

まとめ

  • note.comやSolomaker(tsukutta)のMarkdown→HTML変換は表を扱えない。表だけ画像に焼いて事前に置換するのが現実解
  • headless Chromeの--screenshotウィンドウ高さしか撮らないので、可変長コンテンツは「測る→本番描画」の2パスが要る。document.titleはJS→シェルの受け渡しに使える
  • 表検出はフェンスの開閉状態を明示的に追跡しないと、コード例の|を誤検出する
  • 変換済みなら表がもう本文にないので0を返して即終了。この冪等性が再実行やリトライを安全にする
  • fail-safeは2段構え。表1個の失敗は握りつぶして次へ変換ステップ全体の失敗はパイプラインを止めずに本文をそのまま生かす

次回は、このnote/maker自動生成パイプライン自体――題材の重複排除や品質ゲート、検疫キューの設計あたりを書く予定です。


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?