Added good stuff
This commit is contained in:
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user