Spaces:
Running
Running
| # Core FreeType-based text rendering | |
| # Ported from manga_translator/rendering/text_render.py | |
| import os | |
| import re | |
| import cv2 | |
| import numpy as np | |
| import freetype | |
| import functools | |
| from pathlib import Path | |
| from typing import Tuple, Optional, List | |
| from ..utils import BASE_PATH, is_punctuation, get_logger | |
| logger = get_logger("text_render") | |
| try: | |
| from hyphen import Hyphenator | |
| from hyphen.dictools import LANGUAGES as HYPHENATOR_LANGUAGES | |
| from langcodes import standardize_tag | |
| HAS_HYPHEN = True | |
| try: | |
| HYPHENATOR_LANGUAGES.remove('fr') | |
| HYPHENATOR_LANGUAGES.append('fr_FR') | |
| except Exception: | |
| pass | |
| except ImportError: | |
| HAS_HYPHEN = False | |
| # --------------------------------------------------------------------------- | |
| # CJK horizontal-to-vertical character mapping | |
| # --------------------------------------------------------------------------- | |
| CJK_H2V = { | |
| "‥": "︰", "—": "︱", "―": "|", "–": "︲", "_": "︳", | |
| "(": "︵", ")": "︶", "(": "︵", ")": "︶", | |
| "{": "︷", "}": "︸", "〔": "︹", "〕": "︺", | |
| "【": "︻", "】": "︼", "《": "︽", "》": "︾", | |
| "〈": "︿", "〉": "﹀", "「": "﹁", "」": "﹂", | |
| "『": "﹃", "』": "﹄", "[": "﹇", "]": "﹈", | |
| "…": "⋮", "⋯": "︙", | |
| "\u201c": "﹁", "\u201d": "﹂", # " " | |
| "\u2018": "﹁", "\u2019": "﹂", # ' ' | |
| "~": "︴", "〜": "︴", "~": "︴", | |
| "!": "︕", "?": "︖", ".": "︒", "。": "︒", | |
| ";": "︔", ";": "︔", ":": "︓", ":": "︓", | |
| ",": "︐", ",": "︐", "-": "︲", "−": "︲", "・": "·", | |
| } | |
| CJK_V2H = {v: k for k, v in CJK_H2V.items()} | |
| def CJK_Compatibility_Forms_translate(cdpt: str, direction: int): | |
| if cdpt == 'ー' and direction == 1: | |
| return 'ー', 90 | |
| if cdpt in CJK_V2H and direction == 0: | |
| return CJK_V2H[cdpt], 0 | |
| if cdpt in CJK_H2V and direction == 1: | |
| return CJK_H2V[cdpt], 0 | |
| return cdpt, 0 | |
| def compact_special_symbols(text: str) -> str: | |
| text = text.replace('...', '…').replace('..', '…') | |
| text = re.sub(r'([^\w\s])[ \u3000]+', r'\1', text) | |
| return text | |
| # --------------------------------------------------------------------------- | |
| # Font management | |
| # --------------------------------------------------------------------------- | |
| FALLBACK_FONTS = [ | |
| os.path.join(BASE_PATH, 'fonts/Arial-Unicode-Regular.ttf'), | |
| # System fonts (filtered by os.path.isfile in set_font) | |
| 'C:/Windows/Fonts/arial.ttf', | |
| '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', | |
| os.path.join(BASE_PATH, 'fonts/msyh.ttc'), | |
| os.path.join(BASE_PATH, 'fonts/msgothic.ttc'), | |
| ] | |
| FONT_SELECTION: List[freetype.Face] = [] | |
| _font_cache = {} | |
| def _get_cached_font(path: str) -> freetype.Face: | |
| path = path.replace('\\', '/') | |
| if path not in _font_cache: | |
| _font_cache[path] = freetype.Face(Path(path).open('rb')) | |
| return _font_cache[path] | |
| def set_font(font_path: str): | |
| global FONT_SELECTION | |
| selection = ([font_path] + FALLBACK_FONTS) if font_path else FALLBACK_FONTS | |
| FONT_SELECTION = [_get_cached_font(p) for p in selection if os.path.isfile(p)] | |
| class _NS: | |
| """Minimal namespace for glyph caching.""" | |
| pass | |
| class Glyph: | |
| def __init__(self, glyph): | |
| self.bitmap = _NS() | |
| self.bitmap.buffer = glyph.bitmap.buffer | |
| self.bitmap.rows = glyph.bitmap.rows | |
| self.bitmap.width = glyph.bitmap.width | |
| self.advance = _NS() | |
| self.advance.x = glyph.advance.x | |
| self.advance.y = glyph.advance.y | |
| self.bitmap_left = glyph.bitmap_left | |
| self.bitmap_top = glyph.bitmap_top | |
| self.metrics = _NS() | |
| self.metrics.vertBearingX = glyph.metrics.vertBearingX | |
| self.metrics.vertBearingY = glyph.metrics.vertBearingY | |
| self.metrics.horiBearingX = glyph.metrics.horiBearingX | |
| self.metrics.horiBearingY = glyph.metrics.horiBearingY | |
| self.metrics.horiAdvance = glyph.metrics.horiAdvance | |
| self.metrics.vertAdvance = glyph.metrics.vertAdvance | |
| def get_char_glyph(cdpt: str, font_size: int, direction: int) -> Glyph: | |
| for i, face in enumerate(FONT_SELECTION): | |
| if face.get_char_index(cdpt) == 0 and i != len(FONT_SELECTION) - 1: | |
| continue | |
| if direction == 0: | |
| face.set_pixel_sizes(0, font_size) | |
| else: | |
| face.set_pixel_sizes(font_size, 0) | |
| face.load_char(cdpt) | |
| return Glyph(face.glyph) | |
| def get_char_border(cdpt: str, font_size: int, direction: int): | |
| for i, face in enumerate(FONT_SELECTION): | |
| if face.get_char_index(cdpt) == 0 and i != len(FONT_SELECTION) - 1: | |
| continue | |
| if direction == 0: | |
| face.set_pixel_sizes(0, font_size) | |
| else: | |
| face.set_pixel_sizes(font_size, 0) | |
| face.load_char(cdpt, freetype.FT_LOAD_DEFAULT | freetype.FT_LOAD_NO_BITMAP) | |
| return face.glyph.get_glyph() | |
| # --------------------------------------------------------------------------- | |
| # Colorization | |
| # --------------------------------------------------------------------------- | |
| def add_color(bw_char_map, color, stroke_char_map, stroke_color): | |
| if bw_char_map.size == 0: | |
| return np.zeros((bw_char_map.shape[0], bw_char_map.shape[1], 4), dtype=np.uint8) | |
| if stroke_color is None: | |
| x, y, w, h = cv2.boundingRect(bw_char_map) | |
| else: | |
| x, y, w, h = cv2.boundingRect(stroke_char_map) | |
| fg = np.zeros((h, w, 4), dtype=np.uint8) | |
| fg[:, :, 0] = color[0] | |
| fg[:, :, 1] = color[1] | |
| fg[:, :, 2] = color[2] | |
| fg[:, :, 3] = bw_char_map[y:y + h, x:x + w] | |
| if stroke_color is None: | |
| stroke_color = color | |
| bg = np.zeros((stroke_char_map.shape[0], stroke_char_map.shape[1], 4), dtype=np.uint8) | |
| bg[:, :, 0] = stroke_color[0] | |
| bg[:, :, 1] = stroke_color[1] | |
| bg[:, :, 2] = stroke_color[2] | |
| bg[:, :, 3] = stroke_char_map | |
| fg_alpha = fg[:, :, 3:4] / 255.0 | |
| bg_alpha = 1.0 - fg_alpha | |
| bg[y:y + h, x:x + w] = (fg_alpha * fg + bg_alpha * bg[y:y + h, x:x + w]).astype(np.uint8) | |
| return bg | |
| # --------------------------------------------------------------------------- | |
| # Character offset helpers | |
| # --------------------------------------------------------------------------- | |
| def get_char_offset_x(font_size: int, cdpt: str): | |
| c, _ = CJK_Compatibility_Forms_translate(cdpt, 0) | |
| glyph = get_char_glyph(c, font_size, 0) | |
| bm = glyph.bitmap | |
| if bm.rows * bm.width == 0 or len(bm.buffer) != bm.rows * bm.width: | |
| return glyph.advance.x >> 6 | |
| return glyph.metrics.horiAdvance >> 6 | |
| def get_string_width(font_size: int, text: str): | |
| return sum(get_char_offset_x(font_size, c) for c in text) | |
| # --------------------------------------------------------------------------- | |
| # Horizontal character rendering | |
| # --------------------------------------------------------------------------- | |
| def put_char_horizontal(font_size: int, cdpt: str, pen_l, canvas_text, canvas_border, border_size: int): | |
| pen = list(pen_l) | |
| cdpt, _ = CJK_Compatibility_Forms_translate(cdpt, 0) | |
| slot = get_char_glyph(cdpt, font_size, 0) | |
| bitmap = slot.bitmap | |
| if slot.metrics.horiAdvance: | |
| char_offset_x = slot.metrics.horiAdvance >> 6 | |
| elif slot.advance.x: | |
| char_offset_x = slot.advance.x >> 6 | |
| else: | |
| char_offset_x = font_size // 2 | |
| if bitmap.rows * bitmap.width == 0 or len(bitmap.buffer) != bitmap.rows * bitmap.width: | |
| return char_offset_x | |
| bm_char = np.array(bitmap.buffer, dtype=np.uint8).reshape((bitmap.rows, bitmap.width)) | |
| cx = pen[0] + slot.bitmap_left | |
| cy = pen[1] - slot.bitmap_top | |
| y0 = max(0, cy); x0 = max(0, cx) | |
| y1 = min(canvas_text.shape[0], cy + bitmap.rows) | |
| x1 = min(canvas_text.shape[1], cx + bitmap.width) | |
| if y0 < y1 and x0 < x1: | |
| sl = bm_char[y0 - cy:y1 - cy, x0 - cx:x1 - cx] | |
| if sl.size > 0: | |
| canvas_text[y0:y1, x0:x1] = sl | |
| if border_size > 0: | |
| _render_border_horizontal(cdpt, font_size, bitmap, cx, cy, canvas_border) | |
| return char_offset_x | |
| def _render_border_horizontal(cdpt, font_size, bitmap, cx, cy, canvas_border): | |
| glyph_b = get_char_border(cdpt, font_size, 0) | |
| stroker = freetype.Stroker() | |
| stroker.set(64 * max(int(0.07 * font_size), 1), | |
| freetype.FT_STROKER_LINEJOIN_ROUND, freetype.FT_STROKER_LINECAP_ROUND, 0) | |
| glyph_b.stroke(stroker, destroy=True) | |
| blyph = glyph_b.to_bitmap(freetype.FT_RENDER_MODE_NORMAL, freetype.Vector(0, 0), True) | |
| bm_b = blyph.bitmap | |
| if bm_b.rows * bm_b.width == 0 or len(bm_b.buffer) != bm_b.rows * bm_b.width: | |
| return | |
| arr = np.array(bm_b.buffer, dtype=np.uint8).reshape((bm_b.rows, bm_b.width)) | |
| # Center-align border with character | |
| bx = int(round(cx + bitmap.width / 2 - bm_b.width / 2)) | |
| by = int(round(cy + bitmap.rows / 2 - bm_b.rows / 2)) | |
| y0a = max(0, by); x0a = max(0, bx) | |
| y1a = min(canvas_border.shape[0], by + bm_b.rows) | |
| x1a = min(canvas_border.shape[1], bx + bm_b.width) | |
| if y0a < y1a and x0a < x1a: | |
| sl = arr[y0a - by:y1a - by, x0a - bx:x1a - bx] | |
| tgt = canvas_border[y0a:y1a, x0a:x1a] | |
| if sl.shape == tgt.shape: | |
| canvas_border[y0a:y1a, x0a:x1a] = cv2.add(tgt, sl) | |
| # --------------------------------------------------------------------------- | |
| # Vertical character rendering | |
| # --------------------------------------------------------------------------- | |
| def put_char_vertical(font_size: int, cdpt: str, pen_l, canvas_text, canvas_border, border_size: int): | |
| pen = list(pen_l) | |
| cdpt, _ = CJK_Compatibility_Forms_translate(cdpt, 1) | |
| slot = get_char_glyph(cdpt, font_size, 1) | |
| bitmap = slot.bitmap | |
| if bitmap.rows * bitmap.width == 0 or len(bitmap.buffer) != bitmap.rows * bitmap.width: | |
| if slot.metrics.vertAdvance: | |
| return slot.metrics.vertAdvance >> 6 | |
| return font_size | |
| char_offset_y = slot.metrics.vertAdvance >> 6 | |
| bm_char = np.array(bitmap.buffer, dtype=np.uint8).reshape((bitmap.rows, bitmap.width)) | |
| cx = pen[0] + (slot.metrics.vertBearingX >> 6) | |
| cy = pen[1] + (slot.metrics.vertBearingY >> 6) | |
| y0 = max(0, cy); x0 = max(0, cx) | |
| y1 = min(canvas_text.shape[0], cy + bitmap.rows) | |
| x1 = min(canvas_text.shape[1], cx + bitmap.width) | |
| if y0 < y1 and x0 < x1: | |
| sl = bm_char[y0 - cy:y1 - cy, x0 - cx:x1 - cx] | |
| if sl.size > 0: | |
| canvas_text[y0:y1, x0:x1] = sl | |
| if border_size > 0: | |
| _render_border_vertical(cdpt, font_size, bitmap, cx, cy, canvas_border) | |
| return char_offset_y | |
| def _render_border_vertical(cdpt, font_size, bitmap, cx, cy, canvas_border): | |
| glyph_b = get_char_border(cdpt, font_size, 1) | |
| stroker = freetype.Stroker() | |
| stroker.set(64 * max(int(0.07 * font_size), 1), | |
| freetype.FT_STROKER_LINEJOIN_ROUND, freetype.FT_STROKER_LINECAP_ROUND, 0) | |
| glyph_b.stroke(stroker, destroy=True) | |
| blyph = glyph_b.to_bitmap(freetype.FT_RENDER_MODE_NORMAL, freetype.Vector(0, 0), True) | |
| bm_b = blyph.bitmap | |
| if bm_b.rows * bm_b.width == 0 or len(bm_b.buffer) != bm_b.rows * bm_b.width: | |
| return | |
| arr = np.array(bm_b.buffer, dtype=np.uint8).reshape((bm_b.rows, bm_b.width)) | |
| bx = int(round(cx + bitmap.width / 2 - bm_b.width / 2)) | |
| by = int(round(cy + bitmap.rows / 2 - bm_b.rows / 2)) | |
| y0a = max(0, by); x0a = max(0, bx) | |
| y1a = min(canvas_border.shape[0], by + bm_b.rows) | |
| x1a = min(canvas_border.shape[1], bx + bm_b.width) | |
| if y0a < y1a and x0a < x1a: | |
| sl = arr[y0a - by:y1a - by, x0a - bx:x1a - bx] | |
| tgt = canvas_border[y0a:y1a, x0a:x1a] | |
| if sl.shape == tgt.shape: | |
| canvas_border[y0a:y1a, x0a:x1a] = cv2.add(tgt, sl) | |
| # --------------------------------------------------------------------------- | |
| # Layout calculation | |
| # --------------------------------------------------------------------------- | |
| def calc_vertical(font_size: int, text: str, max_height: int): | |
| line_text_list = [] | |
| line_height_list = [] | |
| line_str = "" | |
| line_height = 0 | |
| for cdpt in text: | |
| if line_height == 0 and cdpt == ' ': | |
| continue | |
| cdpt, _ = CJK_Compatibility_Forms_translate(cdpt, 1) | |
| ckpt = get_char_glyph(cdpt, font_size, 1) | |
| bm = ckpt.bitmap | |
| if bm.rows * bm.width == 0 or len(bm.buffer) != bm.rows * bm.width: | |
| char_offset_y = ckpt.metrics.vertBearingY >> 6 | |
| else: | |
| char_offset_y = ckpt.metrics.vertAdvance >> 6 | |
| if line_height + char_offset_y > max_height: | |
| line_text_list.append(line_str) | |
| line_height_list.append(line_height) | |
| line_str = "" | |
| line_height = 0 | |
| line_height += char_offset_y | |
| line_str += cdpt | |
| line_text_list.append(line_str) | |
| line_height_list.append(line_height) | |
| return line_text_list, line_height_list | |
| def select_hyphenator(lang: str): | |
| if not HAS_HYPHEN: | |
| return None | |
| lang = standardize_tag(lang) | |
| if lang not in HYPHENATOR_LANGUAGES: | |
| for avail in reversed(HYPHENATOR_LANGUAGES): | |
| if avail.startswith(lang): | |
| lang = avail | |
| break | |
| else: | |
| return None | |
| try: | |
| return Hyphenator(lang) | |
| except Exception: | |
| return None | |
| def _is_cjk_language(lang: str) -> bool: | |
| """Check if language should skip hyphenation (CJK, Thai, etc.).""" | |
| lang_lower = lang.lower() | |
| # CJK and other languages that don't use spaces/hyphenation | |
| cjk_codes = ['ja', 'jp', 'zh', 'ko', 'th', 'lo', 'my', 'km'] | |
| return any(lang_lower.startswith(code) for code in cjk_codes) | |
| def _syllabify_word(word: str, font_size: int, hyphenator=None, lang: str = 'en_US'): | |
| """ | |
| Smart word breaking that avoids crude character-level splits. | |
| Incorporates: | |
| - Language awareness (skip hyphenation for CJK) | |
| - Hyphenation for long words (10+ chars) | |
| - Natural grouping for mid-length words (4-9 chars) | |
| - Keeps short words intact (1-3 chars) | |
| """ | |
| # Skip hyphenation for CJK languages | |
| if _is_cjk_language(lang): | |
| return [word] | |
| # Keep short words intact | |
| if len(word) <= 3: | |
| return [word] | |
| # Try hyphenation for long words | |
| if hyphenator and len(word) >= 10 and len(word) <= 100: | |
| try: | |
| syls = hyphenator.syllables(word) | |
| if syls: # Only use if hyphenation succeeded | |
| return syls | |
| except Exception: | |
| pass | |
| # For 4-9 char words: prefer keeping whole or smart grouping | |
| if len(word) <= 9: | |
| # Simple heuristic: break after vowels when followed by consonants | |
| # This gives more natural breaks like "me-di-um" vs "m-e-d-i-u-m" | |
| result = [] | |
| current = "" | |
| vowels = "aeiouAEIOU" | |
| for i, c in enumerate(word): | |
| current += c | |
| # Break after vowel if next is consonant (and we have 2+ chars) | |
| if len(current) >= 2 and c in vowels and (i + 1 < len(word) and word[i+1] not in vowels): | |
| result.append(current) | |
| current = "" | |
| if current: | |
| result.append(current) | |
| # Only use smart grouping if it produces 2-4 clean chunks | |
| if result and 2 <= len(result) <= 4: | |
| return result | |
| # Otherwise, keep whole word (better than char-by-char) | |
| return [word] | |
| # For very long words without hyphenation: chunk into groups of 3-4 chars | |
| if len(word) > 15: | |
| chunk_size = 3 | |
| return [word[i:i+chunk_size] for i in range(0, len(word), chunk_size)] | |
| # Default: keep whole word | |
| return [word] | |
| def calc_horizontal(font_size: int, text: str, max_width: int, max_height: int, | |
| language: str = 'en_US', hyphenate: bool = True): | |
| max_width = max(max_width, 2 * font_size) | |
| whitespace_offset_x = get_char_offset_x(font_size, ' ') | |
| hyphen_offset_x = get_char_offset_x(font_size, '-') | |
| words = re.split(r'\s+', text) | |
| word_widths = [get_string_width(font_size, w) for w in words] | |
| # Expand width if overflow is unavoidable | |
| while True: | |
| max_lines = max_height // font_size + 1 | |
| expected = sum(word_widths) + max((len(word_widths) - 1) * whitespace_offset_x - (max_lines - 1) * hyphen_offset_x, 0) | |
| if max_width * max_lines < expected: | |
| m = max(np.sqrt(expected / (max_width * max_lines)), 1.05) | |
| max_width *= m | |
| max_height *= m | |
| else: | |
| break | |
| # Split into syllables using smart word breaking | |
| syllables = [] | |
| hyphenator = select_hyphenator(language) if hyphenate else None | |
| for word in words: | |
| # Use new smart syllabification | |
| syls = _syllabify_word(word, font_size, hyphenator, language) | |
| # Handle edge case: if a syllable is still too wide, split it | |
| normed = [] | |
| for s in syls: | |
| if get_string_width(font_size, s) > max_width: | |
| # Last resort: character-level split for extremely narrow boxes | |
| normed.extend(list(s)) | |
| else: | |
| normed.append(s) | |
| syllables.append(normed) | |
| line_words_list: list[list[int]] = [] | |
| line_width_list: list[int] = [] | |
| hyphenation_idx_list: list[int] = [] | |
| line_words: list[int] = [] | |
| line_width = 0 | |
| hyph_idx = 0 | |
| def break_line(): | |
| nonlocal line_words, line_width, hyph_idx | |
| line_words_list.append(line_words) | |
| line_width_list.append(line_width) | |
| hyphenation_idx_list.append(hyph_idx) | |
| line_words = [] | |
| line_width = 0 | |
| hyph_idx = 0 | |
| # Step 1: arrange without hyphenation | |
| i = 0 | |
| while True: | |
| if i >= len(words): | |
| if line_width > 0: | |
| break_line() | |
| break | |
| cw = whitespace_offset_x if line_width > 0 else 0 | |
| if line_width + cw + word_widths[i] <= max_width + hyphen_offset_x: | |
| line_words.append(i) | |
| line_width += cw + word_widths[i] | |
| i += 1 | |
| elif word_widths[i] > max_width: | |
| j = 0 | |
| hyph_idx = 0 | |
| while j < len(syllables[i]): | |
| sw = get_string_width(font_size, syllables[i][j]) | |
| if line_width + cw + sw <= max_width: | |
| cw += sw | |
| j += 1 | |
| hyph_idx = j | |
| else: | |
| if hyph_idx > 0: | |
| line_words.append(i) | |
| line_width += cw | |
| cw = 0 | |
| break_line() | |
| line_words.append(i) | |
| line_width += cw | |
| i += 1 | |
| else: | |
| break_line() | |
| # Step 2: Backwards hyphenation to fill lines | |
| def get_present_syllables_range(li, wp): | |
| while wp < 0: | |
| wp += len(line_words_list[li]) | |
| wi = line_words_list[li][wp] | |
| ss = 0 | |
| se = len(syllables[wi]) | |
| if li > 0 and wp == 0 and line_words_list[li - 1][-1] == wi: | |
| ss = hyphenation_idx_list[li - 1] | |
| if li < len(line_words_list) - 1 and wp == len(line_words_list[li]) - 1 \ | |
| and line_words_list[li + 1][0] == wi: | |
| se = hyphenation_idx_list[li] | |
| return ss, se | |
| max_lines = max_height // font_size + 1 | |
| if hyphenate and hyphenator and len(line_words_list) > max_lines: | |
| li = 0 | |
| while li < len(line_words_list) - 1: | |
| lw1 = line_words_list[li] | |
| lw2 = line_words_list[li + 1] | |
| left = max_width - line_width_list[li] | |
| first_word = True | |
| while lw2: | |
| wi = lw2[0] | |
| if first_word and wi == lw1[-1]: | |
| ss = hyphenation_idx_list[li] | |
| se = hyphenation_idx_list[li + 1] if (li < len(line_width_list) - 2 and wi == line_words_list[li + 2][0]) else len(syllables[wi]) | |
| else: | |
| left -= whitespace_offset_x | |
| ss = 0 | |
| se = len(syllables[wi]) if len(lw2) > 1 else hyphenation_idx_list[li + 1] | |
| first_word = False | |
| cw = 0 | |
| for si in range(ss, se): | |
| sw = get_string_width(font_size, syllables[wi][si]) | |
| if left > cw + sw: | |
| cw += sw | |
| else: | |
| if cw > 0: | |
| left -= cw | |
| line_width_list[li] = max_width - left | |
| hyphenation_idx_list[li] = si | |
| lw1.append(wi) | |
| break | |
| else: | |
| left -= cw | |
| line_width_list[li] = max_width - left | |
| lw1.append(wi) | |
| lw2.pop(0) | |
| continue | |
| break | |
| if not lw2: | |
| line_words_list.pop(li + 1) | |
| line_width_list.pop(li + 1) | |
| hyphenation_idx_list.pop(li) | |
| else: | |
| li += 1 | |
| # Step 3: Move tiny fragments between lines | |
| li = 0 | |
| while li < len(line_words_list) - 1: | |
| lw1 = line_words_list[li] | |
| lw2 = line_words_list[li + 1] | |
| if lw1[-1] == lw2[0]: | |
| ss1, se1 = get_present_syllables_range(li, -1) | |
| t1 = ''.join(syllables[lw1[-1]][ss1:se1]) | |
| ss2, se2 = get_present_syllables_range(li + 1, 0) | |
| t2 = ''.join(syllables[lw2[0]][ss2:se2]) | |
| w1 = get_string_width(font_size, t1) | |
| w2 = get_string_width(font_size, t2) | |
| if len(t2) == 1 or w2 < font_size: | |
| lw2.pop(0) | |
| line_width_list[li] += w2 | |
| line_width_list[li + 1] -= w2 + whitespace_offset_x | |
| elif len(t1) == 1 or w1 < font_size: | |
| lw1.pop(-1) | |
| line_width_list[li] -= w1 + whitespace_offset_x | |
| line_width_list[li + 1] += w1 | |
| if not lw1: | |
| line_words_list.pop(li); line_width_list.pop(li); hyphenation_idx_list.pop(li) | |
| elif not lw2: | |
| line_words_list.pop(li + 1); line_width_list.pop(li + 1); hyphenation_idx_list.pop(li) | |
| else: | |
| li += 1 | |
| # Step 4: Assemble | |
| use_hyphens = hyphenate and hyphenator and max_width > 1.5 * font_size and len(words) > 1 | |
| line_text_list = [] | |
| for i, line in enumerate(line_words_list): | |
| lt = '' | |
| for j, wi in enumerate(line): | |
| ss, se = get_present_syllables_range(i, j) | |
| lt += ''.join(syllables[wi][ss:se]) | |
| if not lt: | |
| continue | |
| if j == 0 and i > 0 and line_text_list[-1][-1] == '-' and lt[0] == '-': | |
| lt = lt[1:] | |
| line_width_list[i] -= hyphen_offset_x | |
| if j < len(line) - 1 and lt: | |
| lt += ' ' | |
| elif use_hyphens and se != len(syllables[wi]) and len(words[wi]) > 3 and lt[-1] != '-' \ | |
| and not (se < len(syllables[wi]) and not re.search(r'\w', syllables[wi][se][0])): | |
| lt += '-' | |
| line_width_list[i] += hyphen_offset_x | |
| line_width_list[i] = get_string_width(font_size, lt) | |
| line_text_list.append(lt) | |
| return line_text_list, line_width_list | |
| # --------------------------------------------------------------------------- | |
| # High-level text rendering | |
| # --------------------------------------------------------------------------- | |
| def put_text_vertical(font_size: int, text: str, h: int, alignment: str, | |
| fg, bg, line_spacing: int): | |
| text = compact_special_symbols(text) | |
| if not text: | |
| return None | |
| bg_size = int(max(font_size * 0.07, 1)) if bg is not None else 0 | |
| # Improved line spacing: 10% for vertical text | |
| line_spacing_ratio = line_spacing if line_spacing else 0.10 | |
| spacing_x = max(int(font_size * line_spacing_ratio), 2) # Minimum 2px | |
| num_char_y = max(h // font_size, 1) | |
| num_char_x = len(text) // num_char_y + 1 | |
| canvas_x = font_size * num_char_x + spacing_x * (num_char_x - 1) + (font_size + bg_size) * 2 | |
| canvas_y = font_size * num_char_y + (font_size + bg_size) * 2 | |
| line_text_list, line_height_list = calc_vertical(font_size, text, h) | |
| canvas_text = np.zeros((canvas_y, canvas_x), dtype=np.uint8) | |
| canvas_border = canvas_text.copy() | |
| pen_orig = [canvas_text.shape[1] - (font_size + bg_size), font_size + bg_size] | |
| for lt, lh in zip(line_text_list, line_height_list): | |
| pen_line = pen_orig.copy() | |
| if alignment == 'center': | |
| pen_line[1] += (max(line_height_list) - lh) // 2 | |
| elif alignment == 'right': | |
| pen_line[1] += max(line_height_list) - lh | |
| for c in lt: | |
| pen_line[1] += put_char_vertical(font_size, c, pen_line, canvas_text, canvas_border, border_size=bg_size) | |
| pen_orig[0] -= spacing_x + font_size | |
| canvas_border = np.clip(canvas_border, 0, 255) | |
| box = add_color(canvas_text, fg, canvas_border, bg) | |
| x, y, w, h = cv2.boundingRect(canvas_border) | |
| return box[y:y + h, x:x + w] | |
| def put_text_horizontal(font_size: int, text: str, width: int, height: int, alignment: str, | |
| reversed_direction: bool, fg, bg, | |
| lang: str = 'en_US', hyphenate: bool = True, line_spacing: int = 0): | |
| text = compact_special_symbols(text) | |
| if not text: | |
| return None | |
| bg_size = int(max(font_size * 0.07, 1)) if bg is not None else 0 | |
| # Improved line spacing: 15% of font size (professional typography standard) | |
| line_spacing_ratio = line_spacing if line_spacing else 0.15 | |
| spacing_y = max(int(font_size * line_spacing_ratio), 3) # Minimum 3px | |
| line_text_list, line_width_list = calc_horizontal(font_size, text, width, height, lang, hyphenate) | |
| canvas_w = max(line_width_list) + (font_size + bg_size) * 2 | |
| canvas_h = font_size * len(line_width_list) + spacing_y * (len(line_width_list) - 1) + (font_size + bg_size) * 2 | |
| canvas_text = np.zeros((canvas_h, canvas_w), dtype=np.uint8) | |
| canvas_border = canvas_text.copy() | |
| pen_orig = [font_size + bg_size, font_size + bg_size] | |
| if reversed_direction: | |
| pen_orig[0] = canvas_w - bg_size - 10 | |
| for lt, lw in zip(line_text_list, line_width_list): | |
| pen_line = pen_orig.copy() | |
| if alignment == 'center': | |
| pen_line[0] += (max(line_width_list) - lw) // 2 * (-1 if reversed_direction else 1) | |
| elif alignment == 'right' and not reversed_direction: | |
| pen_line[0] += max(line_width_list) - lw | |
| elif alignment == 'left' and reversed_direction: | |
| pen_line[0] -= max(line_width_list) - lw | |
| pen_line[0] = max(lw, pen_line[0]) | |
| for c in lt: | |
| if reversed_direction: | |
| cdpt, _ = CJK_Compatibility_Forms_translate(c, 0) | |
| g = get_char_glyph(cdpt, font_size, 0) | |
| pen_line[0] -= g.metrics.horiAdvance >> 6 | |
| offset_x = put_char_horizontal(font_size, c, pen_line, canvas_text, canvas_border, border_size=bg_size) | |
| if not reversed_direction: | |
| pen_line[0] += offset_x | |
| pen_orig[1] += spacing_y + font_size | |
| canvas_border = np.clip(canvas_border, 0, 255) | |
| box = add_color(canvas_text, fg, canvas_border, bg) | |
| x, y, w, h = cv2.boundingRect(canvas_border) | |
| return box[y:y + h, x:x + w] | |