Files
manga-translator/manga-renderer.py
Guillem Hernandez Sola 832d699917 Added all
2026-04-23 19:28:39 +02:00

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()