Added good stuff

This commit is contained in:
Guillem Hernandez Sola
2026-04-11 14:34:18 +02:00
parent 555892348f
commit 727b052e93
5 changed files with 310 additions and 157 deletions

View File

@@ -18,29 +18,101 @@ FONT_FALLBACK = "/System/Library/Fonts/Helvetica.ttc"
FONT_COLOR = (0, 0, 0)
# ─────────────────────────────────────────────
# WORD-ONLY WRAP
#
# Breaks ONLY at space boundaries.
# Returns (lines, overflow) where overflow=True
# means a single word is wider than max_w at
# this font size → caller must try smaller.
# ─────────────────────────────────────────────
def wrap_text_words(draw, text, max_w, font):
"""
Word-wraps text to fit within max_w pixels.
Never inserts hyphens or breaks mid-word.
Returns:
(lines, overflow)
lines : list of strings, each ≤ max_w px wide
overflow : True if any single word exceeds max_w
"""
def measure(s):
bb = draw.textbbox((0, 0), s, font=font)
return bb[2] - bb[0]
words = text.split()
lines = []
current = ""
overflow = False
for word in words:
if measure(word) > max_w:
overflow = True
break
test = (current + " " + word).strip()
if measure(test) <= max_w:
current = test
else:
if current:
lines.append(current)
current = word
if not overflow and current:
lines.append(current)
return lines, overflow
# ─────────────────────────────────────────────
# PARSE output.txt
# ─────────────────────────────────────────────
def parse_translations(filepath):
"""
Parses output.txt → {bubble_id: translated_text}.
Only bubbles present in the file are returned.
Absent IDs are left completely untouched on the page.
Uses header line as column ruler to find the exact
char position of the TRANSLATED column.
Immune to commas, ellipses, spaces in translated text.
"""
translations = {}
header_pos = None
with open(filepath, "r", encoding="utf-8") as f:
for line in f:
line = line.rstrip("\n")
if not re.match(r"^\s*#\d+", line):
continue
parts = re.split(r" {2,}", line.strip())
if len(parts) < 3:
continue
bubble_id = int(re.sub(r"[^0-9]", "", parts[0]))
translated = parts[-1].strip()
if translated.startswith("["):
continue
translations[bubble_id] = translated
lines = f.readlines()
for raw_line in lines:
line = raw_line.rstrip("\n")
if re.match(r"^BUBBLE\s+ORIGINAL", line):
m = re.search(r"TRANSLATED", line)
if m:
header_pos = m.start()
print(f" TRANSLATED column at char {header_pos}")
continue
stripped = line.strip()
if re.match(r"^[─\-=]{3,}$", stripped):
continue
if stripped.startswith("") or stripped.startswith("Done"):
continue
if not re.match(r"^\s*#\d+", line):
continue
m_id = re.match(r"^\s*#(\d+)", line)
if not m_id:
continue
bubble_id = int(m_id.group(1))
if header_pos is not None and len(line) > header_pos:
translated = line[header_pos:].strip()
else:
parts = re.split(r" {2,}", stripped)
translated = parts[-1].strip() if len(parts) >= 3 else ""
if not translated or translated.startswith("["):
print(f" ⚠️ #{bubble_id}: no translation found")
continue
translations[bubble_id] = translated
print(f"{len(translations)} bubble(s) to translate: "
f"{sorted(translations.keys())}")
@@ -67,11 +139,6 @@ def load_bubble_boxes(filepath):
# SAMPLE BACKGROUND COLOR
# ─────────────────────────────────────────────
def sample_bubble_background(cv_image, bubble_data):
"""
Samples the dominant background color inside the bbox
by averaging the brightest 10% of pixels.
Returns (B, G, R).
"""
x = max(0, bubble_data["x"])
y = max(0, bubble_data["y"])
x2 = min(cv_image.shape[1], x + bubble_data["w"])
@@ -92,21 +159,9 @@ def sample_bubble_background(cv_image, bubble_data):
# ─────────────────────────────────────────────
# ERASE ORIGINAL TEXT
# Fills the tight OCR bbox with the sampled
# background color. No extra expansion —
# the bbox from bubbles.json is already the
# exact size of the red squares.
# ─────────────────────────────────────────────
def erase_bubble_text(cv_image, bubble_data,
bg_color=(255, 255, 255)):
"""
Fills the bubble bounding box with bg_color.
Args:
cv_image : BGR numpy array (modified in place)
bubble_data : Dict with 'x','y','w','h'
bg_color : (B,G,R) fill color
"""
img_h, img_w = cv_image.shape[:2]
x = max(0, bubble_data["x"])
y = max(0, bubble_data["y"])
@@ -116,14 +171,61 @@ def erase_bubble_text(cv_image, bubble_data,
# ─────────────────────────────────────────────
# FIT FONT SIZE
# LINE HEIGHT (tight)
#
# Uses actual ascender+descender of the font
# at the given size, with a minimal 1px gap.
# Much tighter than the old flat "+2" approach.
# ─────────────────────────────────────────────
def get_line_height(draw, font):
"""
Returns the line height in pixels for the given font.
Measured from actual glyph bounds of "Ay" (covers
ascenders and descenders) plus 1px breathing room.
"""
bb = draw.textbbox((0, 0), "Ay", font=font)
return (bb[3] - bb[1]) + 1
# ─────────────────────────────────────────────
# FIT FONT SIZE (dynamic ceiling, word-wrap)
#
# max_size is derived from the box itself:
# min(MAX_FONT_CAP, inner_h)
# so a tall box can use a large font and a
# small box won't waste iterations on huge sizes.
#
# Rejects a size if:
# • any single word is wider than inner_w, OR
# • total wrapped height exceeds inner_h
# ─────────────────────────────────────────────
MAX_FONT_CAP = 120 # absolute ceiling across all boxes
def fit_font_size(draw, text, max_w, max_h, font_path,
min_size=7, max_size=48):
min_size=7):
"""
Finds the largest font size where word-wrapped text
fits inside (max_w × max_h).
fits inside max_w × max_h with NO mid-word breaking.
max_size is computed dynamically as min(MAX_FONT_CAP, max_h)
so the search always starts from a sensible upper bound
relative to the actual box height.
Args:
draw : ImageDraw instance
text : Full text string
max_w : Available width in pixels
max_h : Available height in pixels
font_path : Path to .ttf (or None for PIL default)
min_size : Minimum font pt (default: 7)
Returns:
(font, lines)
"""
# Dynamic ceiling: no point trying a font taller than the box
max_size = min(MAX_FONT_CAP, max_h)
max_size = max(max_size, min_size) # safety: never below min
best_font = None
best_lines = [text]
@@ -134,38 +236,48 @@ def fit_font_size(draw, text, max_w, max_h, font_path,
except Exception:
font = ImageFont.load_default()
words, lines, current = text.split(), [], ""
for word in words:
test = (current + " " + word).strip()
bb = draw.textbbox((0, 0), test, font=font)
if (bb[2] - bb[0]) <= max_w:
current = test
else:
if current:
lines.append(current)
current = word
if current:
lines.append(current)
lines, overflow = wrap_text_words(draw, text, max_w, font)
lh = draw.textbbox((0, 0), "Ay", font=font)
line_h = (lh[3] - lh[1]) + 2
if line_h * len(lines) <= max_h:
if overflow:
continue # a word is wider than the box → too big
line_h = get_line_height(draw, font)
total_h = line_h * len(lines)
if total_h <= max_h:
best_font = font
best_lines = lines
break
break # largest size that fits — done
return best_font or ImageFont.load_default(), best_lines
# Guaranteed fallback at min_size
if best_font is None:
try:
best_font = (ImageFont.truetype(font_path, min_size)
if font_path else ImageFont.load_default())
except Exception:
best_font = ImageFont.load_default()
best_lines, _ = wrap_text_words(
draw, text, max_w, best_font)
if not best_lines:
best_lines = [text]
return best_font, best_lines
# ─────────────────────────────────────────────
# RENDER TEXT INTO BUBBLE
#
# Text is centered both horizontally and
# vertically inside the padded bbox.
# Line height uses get_line_height() (tight).
# ─────────────────────────────────────────────
def render_text_in_bubble(pil_image, bubble_data, text,
font_path, padding=8,
font_path, padding=6,
font_color=(0, 0, 0)):
"""
Renders translated text centered inside the tight bbox.
Font auto-sizes to fill the same w×h the original occupied.
Renders translated text centered inside the bbox.
Font auto-sizes to fill the box as much as possible.
Word-wrap only — no mid-word hyphens.
"""
x, y = bubble_data["x"], bubble_data["y"]
w, h = bubble_data["w"], bubble_data["h"]
@@ -174,17 +286,20 @@ def render_text_in_bubble(pil_image, bubble_data, text,
inner_w = max(1, w - padding * 2)
inner_h = max(1, h - padding * 2)
font, lines = fit_font_size(draw, text, inner_w, inner_h,
font_path)
font, lines = fit_font_size(
draw, text, inner_w, inner_h, font_path
)
lh_bb = draw.textbbox((0, 0), "Ay", font=font)
line_h = (lh_bb[3] - lh_bb[1]) + 2
line_h = get_line_height(draw, font)
total_h = line_h * len(lines)
# Center block vertically
start_y = y + padding + max(0, (inner_h - total_h) // 2)
for line in lines:
lb = draw.textbbox((0, 0), line, font=font)
line_w = lb[2] - lb[0]
bb = draw.textbbox((0, 0), line, font=font)
line_w = bb[2] - bb[0]
# Center each line horizontally
start_x = x + padding + max(0, (inner_w - line_w) // 2)
draw.text((start_x, start_y), line,
font=font, fill=font_color)
@@ -216,19 +331,9 @@ def render_translated_page(
font_path = FONT_PATH,
font_fallback = FONT_FALLBACK,
font_color = FONT_COLOR,
text_padding = 8,
text_padding = 6,
debug = False,
):
"""
Pipeline:
1. Parse translations (only present IDs processed)
2. Load bubble boxes from bubbles.json
3. Cross-check IDs — absent ones left untouched
4. Sample background color per bubble
5. Erase original text (fill tight bbox)
6. Render translated text sized to fit the bbox
7. Save output
"""
print("=" * 55)
print(" MANGA TRANSLATOR — RENDERER")
print("=" * 55)
@@ -271,7 +376,7 @@ def render_translated_page(
print("\n🎨 Sampling backgrounds...")
bg_colors = {}
for bid in to_process:
bg_bgr = sample_bubble_background(
bg_bgr = sample_bubble_background(
cv_image, bubble_boxes[bid])
bg_colors[bid] = bg_bgr
bg_rgb = (bg_bgr[2], bg_bgr[1], bg_bgr[0])
@@ -344,9 +449,9 @@ if __name__ == "__main__":
output_image = "page_translated.png",
translations_file = "output.txt",
bubbles_file = "bubbles.json",
font_path = "font.ttf",
font_path = "fonts/ComicRelief-Regular.ttf",
font_fallback = "/System/Library/Fonts/Helvetica.ttc",
font_color = (0, 0, 0),
text_padding = 8,
text_padding = 6,
debug = True,
)
)