"""縦長4コマの英語版を作る(2026-10-31・第133話のやり方を全話に広げたもの) python3 en_manga.py <日本語のコマのフォルダ> <出力フォルダ> <フォントのフォルダ> [--cover <日本語の表紙>] - spec の bubbles:吹き出しの白い内側の1点(seed)から吹き出しを見つけ、中の日本語を消して英語を組む (clip があれば、その四角の中に組む。2つの吹き出しが1つの白い領域でつながっているとき) - spec の signs:絵に描かれた日本語の字(名札・画面・張り紙)を、まわりの色で消して英語を置く - 吹き出しと字の外の画素は変えない(変わったら止める) - 表紙:日本語版の表紙の絵(タイトルの下)を使い、上に EPISODE N と大きな一言を Roboto Mono で組む(今までの英語版の表紙と同じ形) フォント:吹き出しは Comic Neue Bold(今までの英語版マンガに合わせた)、表紙は Roboto Mono Bold、画面などの字は Inter SemiBold """ import json, os, sys from collections import deque import numpy as np from PIL import Image, ImageDraw, ImageFont def font_path(fdir, *names): for n in names: p = os.path.join(fdir, n) if os.path.exists(p): return p raise SystemExit(f'フォントがない: {names} in {fdir}') def flood(mask, seed): H, W = mask.shape m = np.zeros((H, W), bool) x, y = seed if not mask[y, x]: return m q = deque([(x, y)]); m[y, x] = True while q: x, y = q.popleft() for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): xx, yy = x + dx, y + dy if 0 <= xx < W and 0 <= yy < H and mask[yy, xx] and not m[yy, xx]: m[yy, xx] = True; q.append((xx, yy)) return m def dilate(m, r): out = m.copy() for _ in range(r): o = out.copy() o[1:] |= out[:-1]; o[:-1] |= out[1:]; o[:, 1:] |= out[:, :-1]; o[:, :-1] |= out[:, 1:] out = o return out def erode(m, r): return ~dilate(~m, r) def fill_holes(comp): ys, xs = np.nonzero(comp) y0, y1, x0, x1 = ys.min(), ys.max(), xs.min(), xs.max() sub = comp[y0:y1 + 1, x0:x1 + 1] pad = np.zeros((sub.shape[0] + 2, sub.shape[1] + 2), bool); pad[1:-1, 1:-1] = sub outside = flood(~pad, (0, 0)) full = np.zeros_like(comp); full[y0:y1 + 1, x0:x1 + 1] = ~outside[1:-1, 1:-1] return full def touches_border(m): return m[0].any() or m[-1].any() or m[:, 0].any() or m[:, -1].any() def find_bubble(g, seed): """seed のある白い領域を吹き出しとして返す。縁が破線で外とつながるときは、縁の線を太らせて閉じる""" x, y = seed # seed が字の上なら近くの白へずらす if g[y, x] <= 230: for r in range(1, 40): hit = [(x + dx, y + dy) for dx in range(-r, r + 1) for dy in (-r, r)] + [(x + dx, y + dy) for dy in range(-r, r + 1) for dx in (-r, r)] hit = [(a, b) for a, b in hit if 0 <= a < g.shape[1] and 0 <= b < g.shape[0] and g[b, a] > 240] if hit: x, y = hit[0]; break for r in (0, 2, 3, 4, 6, 8): white = g > 230 if r: white &= ~dilate(g < 160, r) if not white[y, x]: continue comp = flood(white, (x, y)) if not touches_border(comp) and comp.sum() < 0.35 * g.size: if r: # 太らせた分を戻す(吹き出しの内側の白いところまで) comp = dilate(comp, r) & (g > 200) & ~dilate(g < 160, 1) | comp return comp, r raise RuntimeError(f'吹き出しが閉じていない: seed {seed}') def wrap(draw, text, font, maxw): if '\n' in text: return text.split('\n') words = text.split(); lines = []; cur = '' for w in words: t = (cur + ' ' + w).strip() if draw.textlength(t, font=font) <= maxw or not cur: cur = t else: lines.append(cur); cur = w lines.append(cur) return lines def fit_text(draw, text, fpath, inside, smax=44, smin=15): ys, xs = np.nonzero(inside) spans = {} for yy in np.unique(ys): r = xs[ys == yy]; spans[int(yy)] = (int(r.min()), int(r.max())) widths = np.array([spans[y][1] - spans[y][0] for y in sorted(spans)]) wmax = widths.max() core = [y for y in sorted(spans) if spans[y][1] - spans[y][0] >= 0.6 * wmax] cy = (core[0] + core[-1]) / 2; cx = float(np.mean([(spans[y][0] + spans[y][1]) / 2 for y in core])) for keep_breaks in (True, False): t = text if keep_breaks else text.replace('\n', ' ') for size in range(smax, smin - 1, -1): pad = max(10, min(22, int(size * 0.5))) font = ImageFont.truetype(fpath, size); lh = int(size * 1.18) for maxw in range(int(wmax), 40, -12): lines = wrap(draw, t, font, maxw) H = lh * len(lines); top = cy - H / 2 ok = True; place = [] for i, l in enumerate(lines): lw = draw.textlength(l, font=font); a = int(top + i * lh) x = cx - lw / 2 for yy in range(a - pad // 2, a + lh + pad // 2): if yy not in spans or spans[yy][0] + pad > x or spans[yy][1] - pad < x + lw: ok = False; break if not ok: break place.append((x, a, l)) if ok: return font, place, keep_breaks if '\n' in t: break # 改行を決めているときは幅を変えても同じ raise RuntimeError(f'収まらない: {text!r}') def bg_color(a, box): """字の地の色:明るい地なら明るいほう(80%点)、暗い地(黒い板に白い字)なら暗いほう(20%点)""" x0, y0, x1, y1 = box px = a[y0:y1, x0:x1].reshape(-1, 3).astype(int); lum = px.mean(1) q = 80 if lum.mean() >= 110 else 20 t = np.percentile(lum, q); sel = px[np.abs(lum - t) <= 12] return tuple(int(v) for v in np.median(sel if len(sel) else px, axis=0)) def render_panel(src, out, panel, fonts): im = Image.open(src).convert('RGB'); a0 = np.asarray(im); a = a0.copy() g = np.asarray(im.convert('L')).astype(int) allowed = np.zeros(g.shape, bool); jobs = []; report = [] done = {} for b in panel.get('bubbles', []): key = tuple(b['seed']) comp, r = find_bubble(g, b['seed']) ins = fill_holes(comp) sig = int(np.argmax(ins)) # 同じ吹き出しを2回消さない if sig not in done: er = erode(ins, 6) white = np.median(a[comp & (g > 240)], axis=0).astype(np.uint8) if (comp & (g > 240)).any() else np.array([255, 255, 255], np.uint8) left = int(((g < 200) & ins & ~er & ~dilate(~ins, 2)).sum()) a[er] = white; allowed |= ins; done[sig] = left area = ins.copy() if b.get('clip'): x0, y0, x1, y1 = b['clip']; c = np.zeros_like(area); c[y0:y1, x0:x1] = True; area &= c report.append(dict(kind='bubble', text=b['text'], closed_by=r, rim_dark=done[sig])) jobs.append(('bubble', b['text'], area, fonts['bubble'], report[-1])) for s in panel.get('signs', []): x0, y0, x1, y1 = s['box'] col = bg_color(a0, s['box']) a[y0:y1, x0:x1] = col m = np.zeros(g.shape, bool); m[y0:y1, x0:x1] = True; allowed |= m ink = (245, 245, 245) if sum(col) < 3 * 128 else (25, 25, 25) report.append(dict(kind='sign', text=s['text'], bg=col)) jobs.append(('sign', s['text'], m, fonts.get('sign_' + s.get('style', 'print')), (ink, report[-1]))) img = Image.fromarray(a); d = ImageDraw.Draw(img) for kind, text, area, fp, extra in jobs: if kind == 'bubble': rp = extra f, place, kept = fit_text(d, text, fp, area) for x, y, l in place: d.text((x, y), l, font=f, fill=(15, 15, 15)) rp.update(size=f.size, lines=[l for _, _, l in place], kept_breaks=kept) else: ink, rp = extra ys, xs = np.nonzero(area); w = xs.max() - xs.min() + 1; h = ys.max() - ys.min() + 1 size = 60 while size > 6: f = ImageFont.truetype(fp, size); lines = wrap(d, text, f, w - 4); lh = int(size * 1.1) if max(d.textlength(l, font=f) for l in lines) <= w - 4 and lh * len(lines) <= h: break size -= 1 top = (ys.min() + ys.max()) / 2 - lh * len(lines) / 2 - size * 0.1 layer = img.copy(); dl = ImageDraw.Draw(layer) for k, l in enumerate(lines): dl.text(((xs.min() + xs.max()) / 2 - d.textlength(l, font=f) / 2, top + k * lh), l, font=f, fill=ink) cur = np.asarray(img).copy(); cur[area] = np.asarray(layer)[area] # 字の四角の外には描かない img.paste(Image.fromarray(cur)); d = ImageDraw.Draw(img) rp.update(size=size) outa = np.asarray(img) changed = (np.abs(outa.astype(int) - a0.astype(int)).sum(2) > 0) outside = int((changed & ~allowed).sum()) if outside: raise RuntimeError(f'吹き出しと字の外が {outside} 画素変わった') img.save(out) return report def track_text(d, xy, text, font, track, fill): x, y = xy for ch in text: d.text((x, y), ch, font=font, fill=fill); x += d.textlength(ch, font=font) + track def track_len(d, text, font, track): return sum(d.textlength(ch, font=font) for ch in text) + track * (len(text) - 1) def make_cover(ja_cover, out, ep, line, fonts, art_src=None): """今までの英語版の表紙の形:地 #fdfdfd、左上に EPISODE N(小)、その下に大きな一言(Roboto Mono Bold・大文字)、下に絵""" W, H = 1080, 1350; BG = (253, 253, 253); INK = (36, 36, 36); X0 = 72; MAXW = W - X0 - 50 c = Image.new('RGB', (W, H), BG); d = ImageDraw.Draw(c) fp = fonts['cover'] fe = ImageFont.truetype(fp, 64); top_e = 68 - fe.getbbox('E')[1] track_text(d, (X0, top_e), f'EPISODE {ep}', fe, -1.5, INK) y_title = 68 + (fe.getbbox('E')[3] - fe.getbbox('E')[1]) + 34 words = line.upper().split() best = None for size in range(127, 59, -1): f = ImageFont.truetype(fp, size); tr = -2 * size / 127 lines = []; cur = '' for w in words: t = (cur + ' ' + w).strip() if track_len(d, t, f, tr) <= MAXW or not cur: cur = t else: lines.append(cur); cur = w lines.append(cur) if all(track_len(d, l, f, tr) <= MAXW for l in lines) and (len(lines) <= 2 or (len(lines) == 3 and size <= 100)): best = (size, f, tr, lines); break size, f, tr, lines = best cap = f.getbbox('H')[3] - f.getbbox('H')[1]; pitch = int(size * 0.97) for i, l in enumerate(lines): track_text(d, (X0 - 3, y_title + i * pitch - f.getbbox('H')[1]), l, f, tr, INK) text_bottom = y_title + (len(lines) - 1) * pitch + cap # 絵:日本語版の表紙のタイトルより下(art_src があればそれを使う) if art_src is not None: art = Image.open(art_src).convert('RGB') else: jc = Image.open(ja_cover).convert('RGB'); jg = np.asarray(jc.convert('L')) rows = (jg[:420] < 120).any(1); ys = np.nonzero(rows)[0] # タイトルの字のかたまり(上から、間が 30 行より広く空くまで) last = ys[0] for yy in ys[1:]: if yy - last > 30: break last = yy art = jc.crop((0, last + 30, W, H)) aa = np.asarray(art).astype(np.int16); aa[aa.min(2) > 232] = 253; art = Image.fromarray(aa.astype(np.uint8)) ag = np.asarray(art.convert('L')) < 235; ys = np.nonzero(ag.any(1))[0]; xs = np.nonzero(ag.any(0))[0] art = art.crop((xs.min(), ys.min(), xs.max() + 1, ys.max() + 1)) top = text_bottom + 56; A = H - top - 24 s = min(A / art.height, (W - 40) / art.width, 1.45) art = art.resize((round(art.width * s), round(art.height * s)), Image.LANCZOS) c.paste(art, ((W - art.width) // 2, top + (A - art.height))) c.save(out) return dict(lines=lines, size=size, art_scale=round(s, 3)) if __name__ == '__main__': spec = json.load(open(sys.argv[1])); src, outd, fdir = sys.argv[2:5] fonts = dict(bubble=font_path(fdir, 'ComicNeue-Bold.ttf', 'comic-neue-latin-700-normal.woff'), sign_print=font_path(fdir, 'Inter-SemiBold.ttf', 'inter-latin-600-normal.woff'), sign_hand=font_path(fdir, 'ComicNeue-Bold.ttf', 'comic-neue-latin-700-normal.woff'), cover=font_path(fdir, 'RobotoMono-700.woff', 'RobotoMono-Bold.ttf')) ep = spec['ep']; os.makedirs(outd, exist_ok=True); rep = {} for i, p in enumerate(spec['panels']): s = os.path.join(src, f'ep{ep}_p{i + 1}.png') if not os.path.exists(s): s = s[:-4] + '.webp' rep[f'P{i + 1}'] = render_panel(s, os.path.join(outd, f'ep{ep}_p{i + 1}_EN.png'), p, fonts) if '--cover' in sys.argv: jc = sys.argv[sys.argv.index('--cover') + 1] art = sys.argv[sys.argv.index('--art') + 1] if '--art' in sys.argv else None rep['cover'] = make_cover(jc, os.path.join(outd, f'ep{ep}_cover_EN.png'), ep, spec['cover_line'], fonts, art) print(json.dumps(rep, ensure_ascii=False))