"""縦長4コマの英語版を作る（2026-10-31・第133話のやり方を全話に広げたもの）

  python3 en_manga.py <spec.json> <日本語のコマのフォルダ> <出力フォルダ> <フォントのフォルダ> [--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))
