292 lines
9.8 KiB
Python
292 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
manga-renderer.py
|
|
|
|
Inputs: 16_cleaned.png + bubbles.json + output.txt
|
|
Output: 16_translated.png
|
|
"""
|
|
|
|
import json
|
|
import textwrap
|
|
import cv2
|
|
import numpy as np
|
|
import os
|
|
import argparse
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
from typing import Dict, List, Tuple, Optional, Set, Any
|
|
|
|
# ============================================================
|
|
# CONFIG
|
|
# ============================================================
|
|
# Added System Fallbacks (macOS, Windows, Linux) so it never fails
|
|
FONT_CANDIDATES = [
|
|
"fonts/animeace2_reg.ttf",
|
|
"fonts/ComicNeue-Bold.ttf",
|
|
"/Library/Fonts/Arial.ttf", # macOS
|
|
"/System/Library/Fonts/Helvetica.ttc", # macOS
|
|
"C:\\Windows\\Fonts\\arial.ttf", # Windows
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" # Linux
|
|
]
|
|
|
|
DEFAULT_FONT_SIZE = 18
|
|
MIN_FONT_SIZE = 8
|
|
|
|
# Add any bubble IDs you do NOT want rendered here.
|
|
SKIP_BUBBLE_IDS: Set[int] = set()
|
|
|
|
# ============================================================
|
|
# FONT LOADER
|
|
# ============================================================
|
|
def load_font(path: str, size: int) -> Optional[ImageFont.FreeTypeFont]:
|
|
"""Try every face index in a .ttc collection. Validate with getbbox."""
|
|
indices = range(4) if path.lower().endswith(".ttc") else [0]
|
|
for idx in indices:
|
|
try:
|
|
font = ImageFont.truetype(path, size, index=idx)
|
|
font.getbbox("A") # raises if face metrics are broken
|
|
return font
|
|
except Exception:
|
|
continue
|
|
return None
|
|
|
|
def resolve_font_path() -> str:
|
|
"""Return the path for the first working candidate."""
|
|
for candidate in FONT_CANDIDATES:
|
|
if os.path.exists(candidate) and load_font(candidate, DEFAULT_FONT_SIZE) is not None:
|
|
print(f" ✅ Font loaded: {candidate}")
|
|
return candidate
|
|
print(" ⚠️ No TrueType font found — using Pillow bitmap fallback (Text may look small)")
|
|
return ""
|
|
|
|
# ============================================================
|
|
# PARSERS
|
|
# ============================================================
|
|
def parse_translations(filepath: str) -> Dict[int, str]:
|
|
"""Reads output.txt and returns {bubble_id: translated_text}."""
|
|
translations = {}
|
|
with open(filepath, "r", encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line.startswith("#"):
|
|
continue
|
|
parts = line.split("|")
|
|
if len(parts) < 9:
|
|
continue
|
|
try:
|
|
bid = int(parts[0].lstrip("#"))
|
|
translated = parts[8].strip() # Index 8 is TRANSLATED
|
|
if translated and translated != "-":
|
|
translations[bid] = translated
|
|
except ValueError:
|
|
continue
|
|
return translations
|
|
|
|
def parse_bubbles(filepath: str):
|
|
with open(filepath, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
# ============================================================
|
|
# DYNAMIC TEXT FITTING
|
|
# ============================================================
|
|
def get_original_font_size(bubble_data: dict, fallback_size: int = DEFAULT_FONT_SIZE) -> int:
|
|
box = bubble_data.get("box")
|
|
lines = bubble_data.get("lines", [])
|
|
|
|
if not box or not lines:
|
|
return fallback_size
|
|
|
|
line_count = len(lines)
|
|
estimated_line_height = box["h"] / max(1, line_count)
|
|
estimated_size = int(estimated_line_height * 0.85)
|
|
|
|
return max(MIN_FONT_SIZE, min(estimated_size, 60))
|
|
|
|
def fit_text_dynamically(
|
|
text: str,
|
|
font_path: str,
|
|
max_w: int,
|
|
max_h: int,
|
|
target_font_size: int
|
|
) -> Tuple[List[str], Any, int, int]:
|
|
font_size = target_font_size
|
|
|
|
if not font_path:
|
|
font = ImageFont.load_default()
|
|
chars_per_line = max(1, int(max_w / 6))
|
|
wrapped_lines = textwrap.wrap(text, width=chars_per_line)
|
|
return wrapped_lines, font, 4, 10
|
|
|
|
while font_size >= MIN_FONT_SIZE:
|
|
font = load_font(font_path, font_size)
|
|
if font is None:
|
|
font = ImageFont.load_default()
|
|
return [text], font, 4, 10
|
|
|
|
char_bbox = font.getbbox("A")
|
|
char_w = (char_bbox[2] - char_bbox[0]) or 10
|
|
chars_per_line = max(1, int((max_w * 0.95) / char_w))
|
|
|
|
wrapped_lines = textwrap.wrap(text, width=chars_per_line)
|
|
|
|
line_spacing = max(2, int(font_size * 0.15))
|
|
if hasattr(font, 'getmetrics'):
|
|
ascent, descent = font.getmetrics()
|
|
line_h = ascent + descent
|
|
else:
|
|
line_h = font_size
|
|
|
|
total_h = (line_h * len(wrapped_lines)) + (line_spacing * max(0, len(wrapped_lines) - 1))
|
|
|
|
max_line_w = 0
|
|
for line in wrapped_lines:
|
|
bbox = font.getbbox(line)
|
|
lw = bbox[2] - bbox[0]
|
|
max_line_w = max(max_line_w, lw)
|
|
|
|
if max_line_w <= max_w and total_h <= max_h:
|
|
return wrapped_lines, font, line_spacing, font_size
|
|
|
|
font_size -= 2
|
|
|
|
font = load_font(font_path, MIN_FONT_SIZE) or ImageFont.load_default()
|
|
char_bbox = font.getbbox("A") if hasattr(font, 'getbbox') else (0,0,6,10)
|
|
char_w = (char_bbox[2] - char_bbox[0]) or 6
|
|
chars_per_line = max(1, int(max_w / char_w))
|
|
wrapped_lines = textwrap.wrap(text, width=chars_per_line)
|
|
|
|
return wrapped_lines, font, max(2, int(MIN_FONT_SIZE * 0.15)), MIN_FONT_SIZE
|
|
|
|
# ============================================================
|
|
# RENDER
|
|
# ============================================================
|
|
def render_text(
|
|
image_bgr,
|
|
bubbles_data: Dict[str, dict],
|
|
translations: Dict[int, str],
|
|
font_path: str,
|
|
skip_ids: Set[int]
|
|
):
|
|
image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
|
pil_img = Image.fromarray(image_rgb)
|
|
draw = ImageDraw.Draw(pil_img)
|
|
|
|
rendered_count = 0
|
|
|
|
for bid_str, val in bubbles_data.items():
|
|
bid = int(bid_str)
|
|
|
|
if bid in skip_ids or bid not in translations:
|
|
continue
|
|
|
|
text = translations[bid]
|
|
box = val.get("box")
|
|
if not box:
|
|
continue
|
|
|
|
bx, by, bw, bh = box["x"], box["y"], box["w"], box["h"]
|
|
|
|
pad_x = int(bw * 0.1)
|
|
pad_y = int(bh * 0.1)
|
|
bx -= pad_x // 2
|
|
by -= pad_y // 2
|
|
bw += pad_x
|
|
bh += pad_y
|
|
|
|
target_size = get_original_font_size(val)
|
|
wrapped_lines, font, line_spacing, final_size = fit_text_dynamically(text, font_path, bw, bh, target_size)
|
|
|
|
if hasattr(font, 'getmetrics'):
|
|
ascent, descent = font.getmetrics()
|
|
line_h = ascent + descent
|
|
else:
|
|
line_h = final_size
|
|
|
|
total_text_height = (line_h * len(wrapped_lines)) + (line_spacing * max(0, len(wrapped_lines) - 1))
|
|
current_y = by + (bh - total_text_height) // 2
|
|
|
|
# --- SMART OUTLINE LOGIC ---
|
|
bg_type = val.get("background_type", "white")
|
|
|
|
# Only use a white outline if the background is complex (inpainted artwork).
|
|
# If it's a white bubble, or if we are using the tiny default font, disable the outline.
|
|
if bg_type == "complex" and font_path:
|
|
outline_thickness = max(1, int(final_size * 0.05))
|
|
else:
|
|
outline_thickness = 0
|
|
|
|
for i, line in enumerate(wrapped_lines):
|
|
if hasattr(font, 'getbbox'):
|
|
bbox = font.getbbox(line)
|
|
lw = bbox[2] - bbox[0]
|
|
else:
|
|
lw = len(line) * 6
|
|
|
|
current_x = bx + (bw - lw) // 2
|
|
|
|
draw.text(
|
|
(current_x, current_y),
|
|
line,
|
|
fill=(0, 0, 0),
|
|
font=font,
|
|
stroke_width=outline_thickness,
|
|
stroke_fill=(255, 255, 255)
|
|
)
|
|
|
|
current_y += line_h + line_spacing
|
|
|
|
rendered_count += 1
|
|
|
|
print(f" Rendered: {rendered_count} bubbles")
|
|
return cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
|
|
|
# ============================================================
|
|
# MAIN
|
|
# ============================================================
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Render translated text onto cleaned manga pages.")
|
|
parser.add_argument("-i", "--image", required=True, help="Path to the CLEANED manga image")
|
|
parser.add_argument("-j", "--json", required=True, help="Path to bubbles.json")
|
|
parser.add_argument("-t", "--txt", required=True, help="Path to output.txt")
|
|
parser.add_argument("-o", "--output", help="Path to save the final translated image")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if not os.path.exists(args.image):
|
|
print(f"❌ Error: Image file not found at {args.image}")
|
|
return
|
|
|
|
print(f"📂 Loading cleaned image: {args.image}")
|
|
image_bgr = cv2.imread(args.image)
|
|
|
|
print(f"📂 Loading translations: {args.txt}")
|
|
translations = parse_translations(args.txt)
|
|
|
|
print(f"📂 Loading bubble data: {args.json}")
|
|
bubbles_data = parse_bubbles(args.json)
|
|
|
|
print("🔍 Resolving font...")
|
|
font_path = resolve_font_path()
|
|
|
|
print("\n--- Rendering translated text ---")
|
|
final_bgr = render_text(
|
|
image_bgr=image_bgr,
|
|
bubbles_data=bubbles_data,
|
|
translations=translations,
|
|
font_path=font_path,
|
|
skip_ids=SKIP_BUBBLE_IDS
|
|
)
|
|
|
|
if args.output:
|
|
out_path = args.output
|
|
else:
|
|
base_name = args.image.replace("_cleaned", "")
|
|
base_name, ext = os.path.splitext(base_name)
|
|
out_path = f"{base_name}_translated{ext}"
|
|
|
|
print(f"\n💾 Saving final image to: {out_path}")
|
|
cv2.imwrite(out_path, final_bgr)
|
|
print("✅ Done!")
|
|
|
|
if __name__ == "__main__":
|
|
main() |