"""考察記事のサムネイル(問いを文字で組んだ形)を HTML で書き出す。 考察記事は Gemini の絵(4コマ)ではなく、記事の問いを文字で組んだサムネイルにする(2026-10-26 から。採用は 3-B「3段階」)。 問いと強く見せる一語の正は tools/deep_dive_thumbs.json(記事1本=1項目)。 question : 記事の問い。縦書きで改行したい所に「/」。1行9字まで(数字の並びは1字、英字は1字ずつ数える) key : 強く見せる一語。問いの1行の中にある語 layout : 組み(0 縦・右寄せ/1 横・左下/2 縦・左寄せ/3 横・右上)。記事ごとに固定し、新しい記事が増えても既存の見え方は変えない 使い方: python3 tools/deep_dive_thumb.py # 全部の考察記事のサムネイルを public/thumbs/LOG{N}.html に書き出す python3 tools/deep_dive_thumb.py --add "<問い>" "<一語>" # 新しい考察記事を足して書き出す(組みは前の記事の続きを自動で決める) python3 tools/deep_dive_thumb.py --batch [--plan p3b] # 確認用:案ごとに書き出す(出力先は JSON の "out"/{案}/) 書き出したあと、SNS・検索結果用の画像は python3 tools/render_og.py LOG{N}(クラウドの作業環境で)。 案(組み方)。確認用ページ /preview/deep-dive-thumbs で比べたもの plain : 問いを縦書きで1つ置くだけ(最初の試作) p1 : 一語を大きく。問いの中の一語を絵のように大きく置き、問いは小さく添える p2 : 黒い面で分ける。黒い面に一語を白で抜き、紙の面に問いを置く p3 : 文の強弱。問いの文をそのまま組み、一語だけ大きく、残りは小さくする p3a : 案3の強弱を控えめに(一語は今の半分ほど、文は大きめ) p3b : 案3を3段階に(一語・問いの結び・残り)← 採用 p3c : 案3を太さだけで差をつける(大きさは全部同じ、一語は極太・残りは極細) 色は紙と墨の2色だけ。書体は明朝(考えのページ /thinking とバナー ThinkingBanner にそろえる)。 """ import html import json import re import sys from pathlib import Path PAPER = "#f5f3ee" INK = "#22201c" MAX_CHARS = 9 # 英字を縦に1字ずつ立てたので「フィジカルAIは、」が9字になる PARTICLES = set("のにはをでがとも") # 一語の後ろで改行してよい助詞 PLANS = ["plain", "p1", "p2", "p3", "p3a", "p3b", "p3c"] HEAD = """ LOG{log} thumbnail
{body}
""" def vlen(s: str) -> float: """縦に並べたときの長さ(字数)。縦中横にする1〜3字の数字は1字に数え、英字は1字ずつ数える""" return len(re.sub(r"[0-9]{1,3}", "x", s)) def hlen(s: str) -> float: """横に並べたときの長さ(字数)。英数字は半分強の幅で数える""" return sum(0.6 if re.match(r"[A-Za-z0-9]", ch) else 1 for ch in s) def esc(s: str, vertical: bool) -> str: s = html.escape(s) if vertical: # 英字(AI など)は1字の幅に収まらず列からはみ出すので、縦に1字ずつ立てて組む s = re.sub(r"([A-Za-z]+)", r'\1', s) # 縦書きの中で、(英字の後に入れる。先に入れると、印の中の英字まで囲んでしまう)1〜3字の数字(4・70 など)は横に倒さず、横に並べて1字分に組む(縦中横) s = re.sub(r"(?\1', s) return s def lines_of(q: str) -> list[str]: return [l for l in q.split("/") if l] def hlines(q: str) -> list[str]: """横書きのときの行。縦書き用の短い行を2つずつつなぐ""" ls = lines_of(q) return ["".join(ls[i:i + 2]) for i in range(0, len(ls), 2)] def small_v(q: str) -> str: return "
".join(esc(l, True) for l in lines_of(q)) def small_h(q: str) -> str: return "
".join(esc(l, False) for l in hlines(q)) # ── plain:問いを縦書きで1つ ────────────────────────────── def plain(it, n): css = " .q { right: 10cqw; top: 6.5cqw; font-size: 5.3cqw; line-height: 1.75; letter-spacing: .08em; font-weight: 700; }" return css, f'

{small_v(it["question"])}

' # ── p1:一語を大きく ───────────────────────────────────── def p1(it, n): key, q = it["key"], it["question"] kv = round(min(22, 46 / vlen(key)), 2) # 縦に置くときの大きさ(高さ 56.25cqw に収める) kh = round(min(26, 84 / hlen(key)), 2) # 横に置くときの大きさ(幅 100cqw に収める) s = 3.3 lay = n % 4 if lay == 0: # 一語を縦に右端、問いを縦にその左 css = f""" .k {{ right: 7cqw; top: 4.5cqw; font-size: {kv}cqw; }} .s {{ right: {7 + kv + 5}cqw; top: 6cqw; font-size: {s}cqw; line-height: 1.9; }}""" body = f'

{esc(key, True)}

{small_v(q)}

' elif lay == 1: # 一語を横に下へ大きく(少し下にはみ出す)、問いを横に左上 css = f""" .k {{ left: 4.5cqw; bottom: -{kh * 0.12:.2f}cqw; font-size: {kh}cqw; }} .s {{ left: 6cqw; top: 6cqw; font-size: {s}cqw; line-height: 1.8; }}""" body = f'

{esc(key, False)}

{small_h(q)}

' elif lay == 2: # 一語を横に右上、問いを縦に左 css = f""" .k {{ right: 5cqw; top: 5cqw; font-size: {min(kh, 22)}cqw; text-align: right; }} .s {{ left: 6cqw; top: 6cqw; font-size: {s}cqw; line-height: 1.9; }}""" body = f'

{esc(key, False)}

{small_v(q)}

' else: # 一語を縦に左、問いを横に右下 css = f""" .k {{ left: 6cqw; top: 4.5cqw; font-size: {kv}cqw; }} .s {{ right: 6cqw; bottom: 6cqw; font-size: {s}cqw; line-height: 1.8; text-align: right; }}""" body = f'

{esc(key, True)}

{small_h(q)}

' return css, body # ── p2:黒い面で分ける ─────────────────────────────────── def p2(it, n): key, q = it["key"], it["question"] s = 3.4 lay = n % 4 if lay == 0: # 黒い面を右に縦長、一語は縦 w = 36 kv = round(min(19, 44 / vlen(key), w * 0.62), 2) css = f""" .bk {{ right: 0; top: 0; bottom: 0; width: {w}cqw; display: flex; align-items: center; justify-content: center; }} .k {{ font-size: {kv}cqw; }} .s {{ right: {w + 7}cqw; top: 6cqw; font-size: {s}cqw; line-height: 1.9; }}""" body = f'

{esc(key, True)}

{small_v(q)}

' elif lay == 1: # 黒い面を下に帯、一語は横 hgt = 25 kh = round(min(17, 84 / hlen(key)), 2) css = f""" .bk {{ left: 0; right: 0; bottom: 0; height: {hgt}cqw; display: flex; align-items: center; padding-left: 6cqw; }} .k {{ font-size: {kh}cqw; }} .s {{ left: 6cqw; top: 6cqw; font-size: {s}cqw; line-height: 1.8; }}""" body = f'

{esc(key, False)}

{small_h(q)}

' elif lay == 2: # 黒い面を左に、一語は縦。問いは右に縦 w = 32 kv = round(min(17, 44 / vlen(key), w * 0.6), 2) css = f""" .bk {{ left: 0; top: 0; bottom: 0; width: {w}cqw; display: flex; align-items: center; justify-content: center; }} .k {{ font-size: {kv}cqw; }} .s {{ right: 8cqw; top: 6cqw; font-size: {s}cqw; line-height: 1.9; }}""" body = f'

{esc(key, True)}

{small_v(q)}

' else: # 黒い面を右上に、一語は横。問いは左下に横 w, hgt = 46, 30 kh = round(min(15, (w - 8) / hlen(key)), 2) css = f""" .bk {{ right: 0; top: 0; width: {w}cqw; height: {hgt}cqw; display: flex; align-items: center; justify-content: center; }} .k {{ font-size: {kh}cqw; }} .s {{ left: 6cqw; bottom: 6cqw; font-size: {s}cqw; line-height: 1.8; }}""" body = f'

{esc(key, False)}

{small_h(q)}

' return css, body # ── p3:文の強弱 ───────────────────────────────────────── # 強弱の付け方(大きさと太さ)だけを変えた型。組み(位置と縦・横)は4通りを順に回す # small: 小さい文字の大きさ(縦, 横)/ big: 一語の大きさの上限 / w: 太さ(一語, 残り) # mid: 問いの結びの行を中くらいにする(大きさ, 太さ)。None なら2段階 P3_STYLES = { "p3": dict(small=(3.6, 3.8), big=(13, 15), w=(900, 400), mid=None), # 今の案3:一語を大きく、残りを小さく "p3a": dict(small=(4.4, 4.6), big=(7.5, 8.5), w=(700, 400), mid=None), # 差を控えめに:一語は今の半分ほど、文は大きめ "p3b": dict(small=(4.2, 4.4), big=(16, 18), w=(900, 300), mid=(6.2, 700), pad=4.5), # 3段階:一語・問いの結び・残り。余白を詰め、文字を大きく "p3c": dict(small=(5.2, 5.0), big=(5.2, 5.0), w=(900, 200), mid=None), # 太さだけで差:大きさは全部同じ、一語は極太・残りは極細 } def p3_style(name): st = P3_STYLES[name] def render_p3(it, n): key, q = it["key"], it["question"] lay = n % 4 vertical = lay in (0, 2) ls = lines_of(q) if vertical else hlines(q) if not any(key in l for l in ls): raise SystemExit(f"LOG{it['log']}: key「{key}」が問いの行の中で切れています。/ の位置を直してください") small = st["small"][0 if vertical else 1] L = vlen if vertical else hlen pad = st.get("pad") # 外側の余白(cqw)。None なら案3の標準 room = (56.25 - pad * 2 if pad else 46) if vertical else (100 - pad * 2 if pad else 86) # 1行が使える長さ k = 1.06 if vertical else 1.0 cap = st["big"][0 if vertical else 1] def fit(ls): ki = next(i for i, l in enumerate(ls) if key in l) rest = ls[ki].replace(key, "", 1) return ki, round(min(cap, (room - L(rest) * small * k) / (L(key) * k)), 2) ki, big = fit(ls) if big < cap * 0.8: # 一語が上限の大きさまで届かないときは、一語の行を短くするため改行する(縦書き・横書きとも) # 切るのは「一語+助詞1字(+読点)」の後ろだけ。語の途中で切らない。残りが3字未満なら切らない # 例:フィジカルAIにあるものは何か。 → フィジカルAIに/あるものは何か。 4万円の教材と、 → 4万円の/教材と、 a, b = ls[ki].split(key, 1) if b[:1] in PARTICLES: n = 2 if b[1:2] == "、" else 1 head, tail = key + b[:n], b[n:] if len(tail) >= 3: ls = ls[:ki] + [x for x in (a + head, tail) if x] + ls[ki + 1:] ki, big = fit(ls) # 中くらいにする行:問いの結び(最後の行)。一語がその行にあれば最初の行 mi = None if st["mid"] and len(ls) > 1: mi = len(ls) - 1 if ki != len(ls) - 1 else 0 wb, ws = st["w"] # 大きい一語と中くらいの行は、その文字の大きさに合わせて行(縦書きでは列)が広がり、前後の行との間が開く。行の高さを詰める lb, lm = "line-height:1.12;", "line-height:1.25;" def line(i, l): if i == ki: a, b = l.split(key, 1) return f'{esc(a, vertical)}{esc(key, vertical)}{esc(b, vertical)}' if i == mi: ms = round(min(st["mid"][0], room / (L(l) * k)), 2) return f'{esc(l, vertical)}' return esc(l, vertical) inner = "
".join(line(i, l) for i, l in enumerate(ls)) pv, ph = (pad, pad) if pad else (5, 6) # 縦の行の上の余白、横の行の端の余白 pside = pad + 1.5 if pad else 7 # 縦書きの端。大きい英数字(70 など)が縦中横で幅を取るので、横書きより少し広く pos = {0: f"right: {pside}cqw; top: {pv}cqw;", # 縦、右寄せ 1: f"left: {ph}cqw; bottom: {ph}cqw;", # 横、左下 2: f"left: {pside}cqw; top: {pv}cqw;", # 縦、左寄せ 3: f"right: {ph}cqw; top: {ph}cqw; text-align: right;"}[lay] # 横、右上・右揃え css = f" .t {{ {pos} font-size: {small}cqw; line-height: 1.55; letter-spacing: .04em; font-weight: {ws}; }}" if vertical: # 縦書きで大きさの違う文字が同じ列に並ぶと、小さい文字が列の右に寄る。列の中心にそろえる css += "\n .t > span[style] { vertical-align: middle; }" return css, f'

{inner}

' return render_p3 RENDER = {"plain": plain, "p1": p1, "p2": p2, **{k: p3_style(k) for k in P3_STYLES}} def render(it: dict, plan: str, n: int) -> str: longest = max(vlen(l) for l in lines_of(it["question"])) if longest > MAX_CHARS: raise SystemExit(f"LOG{it['log']}: 1行が{longest:g}字あります。8字までになるよう「/」で区切ってください") css, body = RENDER[plan](it, n) return HEAD.format(log=it["log"], paper=PAPER, ink=INK, css=css, body=body) SITE = Path(__file__).resolve().parent.parent DATA = SITE / "tools/deep_dive_thumbs.json" ADOPTED = "p3b" def main(argv): if argv and argv[0] == "--batch": conf = json.loads(Path(argv[1]).read_text()) plans = [argv[argv.index("--plan") + 1]] if "--plan" in argv else PLANS for plan in plans: out = Path(conf["out"]) / plan out.mkdir(parents=True, exist_ok=True) for n, it in enumerate(conf["items"]): (out / f"LOG{it['log']}.html").write_text(render(it, plan, it.get("layout", n))) print(plan, len(conf["items"])) return conf = json.loads(DATA.read_text()) items = conf["items"] if argv and argv[0] == "--add": log, question, key = int(argv[1]), argv[2], argv[3] items = [it for it in items if it["log"] != log] newest = max(items, key=lambda it: it["log"]) # 一覧は LOG の大きい順に並ぶ。いちばん新しい記事の組みの1つ前を使うと、並んだときに4通りが順に回る items.append({"log": log, "question": question, "key": key, "layout": (newest["layout"] - 1) % 4}) items.sort(key=lambda it: -it["log"]) conf["items"] = items render(items[0] if items[0]["log"] == log else next(it for it in items if it["log"] == log), ADOPTED, 0) # 1行の字数などを先に確かめる DATA.write_text(json.dumps(conf, ensure_ascii=False, indent=2) + "\n") out = SITE / "public/thumbs" out.mkdir(parents=True, exist_ok=True) for it in items: (out / f"LOG{it['log']}.html").write_text(render(it, ADOPTED, it["layout"])) print(f"書き出し {len(items)} 件 → public/thumbs/") if __name__ == "__main__": main(sys.argv[1:])