import html import math import re import threading import unicodedata from pathlib import Path from PIL import Image, ImageChops, ImageDraw, ImageFont from .assets import meta_unit_scale from .settings import ( BOLD_OFFSETS, DECK_GROUPS, FIXED_CROP, FLAVOUR_BLOCK_BOTTOM_SHIFT_CROPPED_RATIO, FONTS_DIR, ITALIC_SHEAR, TEXT_AREA_BOTTOM_CROPPED_RATIO, TEXT_AREA_TOP_CROPPED_RATIOS, TEXT_INDENT_CHARS, ) class Fonts: def __init__(self) -> None: self._xirod = FONTS_DIR / "xirod.ttf" self._consolas = FONTS_DIR / "consolas.ttf" self._unicode = Path("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf") self._cache: dict[tuple[str, int], ImageFont.FreeTypeFont | ImageFont.ImageFont] = {} self._source_sizes: dict[int, float] = {} self._families: dict[int, str] = {} self._lock = threading.Lock() def title(self, java_size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: size = max(8, int(java_size / 3) * 2) return self._font("xirod", self._xirod, size) def text(self, size: int, source_size: float | None = None) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: font = self._font("consolas", self._consolas, max(8, size)) if source_size is not None: self._source_sizes[id(font)] = source_size return font def unicode_text(self, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: return self._font("unicode", self._unicode, max(8, size)) def _font(self, name: str, path: Path, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: key = (name, size) with self._lock: if key not in self._cache: if path.exists(): self._cache[key] = ImageFont.truetype(str(path), size=size, layout_engine=ImageFont.Layout.RAQM) else: self._cache[key] = ImageFont.load_default() self._families[id(self._cache[key])] = name return self._cache[key] def source_size(self, font: ImageFont.ImageFont) -> float: return self._source_sizes.get(id(font), float(font_px(font))) def family(self, font: ImageFont.ImageFont) -> str: return self._families.get(id(font), "") FONTS = Fonts() KEYWORDS = [ "Voice", "Mind", "Will", r"\+[0-9] Cards?", r"\+[0-9] Encounters?", r"\+[0-9] Blocks?", r"\+[0-9] Money", r"Effort [0-9]", "Blocks?", "Money", r"\+X", r"\-X", r"\+[0-9]", "-[0-9]", r"Reboot [0-9]?", r"Reveal [0-9]?", "Revealed", r"Distract [0-9]?", "Distracted", "Deleted", "Delete", "Draw [0-9] office cards", "Draw [0-9] office card", "Draw [0-9] basement cards", "Draw [0-9] basement card", "Draw [0-9] encounter cards", "Draw [0-9] encounter card", r"Escape [0-9]?", r"Escape [0-9]?", r"Extract [0-9]?", r"Browse [0-9]", "Infiltrating", "Infiltrates", "Infiltrated?", "Hacked", "Hacking", "Hack any company", "Hacks?", "Manipulates?", "Demoted", "Demote", "Promoted", "Promote", "Transferring", "Transferred", "Transfer", ] KEYWORD_RE = re.compile(r"(?i)(?)(?)") def format_card_text(text: str | None) -> str: if text is None: return "" lines: list[str] = [] for original in text.split("\n"): leading = re.match(r"^\s*", original).group(0) line = original.strip() if line.startswith("//"): lines.append(leading + re.sub(r"//( ?)(?!)(.*)(?!)", r"//\1\2", line)) continue parts = line.split("//", 1) action = parts[0] if not action.strip().endswith(":"): action = action.rstrip() + ";" action = action.replace(";;", "; ").replace(":;", ":") action = KEYWORD_RE.sub(r"\1", action) result = leading + action if len(parts) > 1: comment = "//" + parts[1] comment = re.sub(r"//( ?)(?!)(.*)(?!)", r"//\1\2", comment) result += " " + comment lines.append(result) return "\n".join(lines) def format_flavour_text(text: str | None) -> str: if not text: return "" zalgo = re.fullmatch(r"(.*)", text, flags=re.DOTALL) if zalgo: return "" + zalgo.group(1) + "\n" if text.startswith(""): return text text = re.sub(r"/\* *", "/* ", text) text = re.sub(r" *\*/", " */", text) return "" + text.replace("\n", "\n") + "" def strip_font_tags(text: str) -> str: text = re.sub(r"]*>", "", text) text = re.sub(r"", "\n
\n", text) text = text.replace("
", "\n").replace("
", "\n") return html.unescape(text) def parse_runs(text: str) -> list[tuple[str, set[str]]]: text = strip_font_tags(text) token_re = re.compile(r"(<\/?b>|<\/?i>|
)", re.I) parts = token_re.split(text) styles: set[str] = set() runs: list[tuple[str, set[str]]] = [] for part in parts: low = part.lower() if low == "": styles.add("b") elif low == "": styles.discard("b") elif low == "": styles.add("i") elif low == "": styles.discard("i") elif low == "
": runs.append(("\n---\n", set(styles))) elif part: runs.append((part, set(styles))) return runs def split_words(runs: list[tuple[str, set[str]]]) -> list[tuple[str, set[str]]]: out: list[tuple[str, set[str]]] = [] for text, styles in runs: for token in re.findall(r"\n|[ \t\r\f\v]+|[^ \t\r\f\v\n]+", text): out.append((token, styles)) return out def font_px(font: ImageFont.ImageFont) -> int: return int(getattr(font, "size", 16)) def cluster_has_unicode(cluster: str) -> bool: return any(ord(char) > 127 or unicodedata.combining(char) for char in cluster) def text_clusters(text: str) -> list[str]: clusters: list[str] = [] for char in text: if clusters and unicodedata.combining(char): clusters[-1] += char else: clusters.append(char) return clusters def text_segments(text: str, font: ImageFont.ImageFont) -> list[tuple[str, ImageFont.ImageFont]]: segments: list[tuple[str, ImageFont.ImageFont]] = [] current = "" current_font = font unicode_font = FONTS.unicode_text(font_px(font)) for cluster in text_clusters(text): segment_font = unicode_font if cluster_has_unicode(cluster) else font if current and segment_font is current_font: current += cluster else: if current: segments.append((current, current_font)) current = cluster current_font = segment_font if current: segments.append((current, current_font)) return segments def cluster_layout_text(cluster: str) -> str: if any(unicodedata.combining(char) for char in cluster): return "".join(char for char in cluster if not unicodedata.combining(char)) return cluster def is_zalgo_cluster(cluster: str) -> bool: return sum(1 for char in cluster if unicodedata.combining(char)) >= 4 def zalgo_extra_advance(font: ImageFont.ImageFont, mark_count: int) -> int: if mark_count <= 0: return 0 return max(2, round(math.sqrt(mark_count) * font_px(font) * 0.065)) def text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, styles: set[str] | None = None) -> int: if not text: return 0 styles = styles or set() bold_offsets = bold_offsets_for_font(font) if "b" in styles else () bold_offset = max((dx for dx, _ in bold_offsets), default=0) width = 0.0 family = FONTS.family(font) if family not in {"consolas", "unicode"}: for segment, segment_font in text_segments(text, font): for cluster in text_clusters(segment): layout_text = cluster_layout_text(cluster) if layout_text: width += draw.textlength(layout_text, font=segment_font) if is_zalgo_cluster(cluster): mark_count = sum(1 for char in cluster if unicodedata.combining(char)) width += zalgo_extra_advance(segment_font, mark_count) if bold_offset: width += bold_offset return round(width) source_size = FONTS.source_size(font) source_px = max(1, round(source_size)) scale = font_px(font) / max(1.0, source_size) source_font = FONTS.text(source_px, source_size) source_unicode_font = FONTS.unicode_text(source_px) for segment, segment_font in text_segments(text, font): measuring_font = source_unicode_font if segment_font is not font else source_font for cluster in text_clusters(segment): layout_text = cluster_layout_text(cluster) if layout_text: width += draw.textlength(layout_text, font=measuring_font) if is_zalgo_cluster(cluster): mark_count = sum(1 for char in cluster if unicodedata.combining(char)) width += zalgo_extra_advance(measuring_font, mark_count) return round(round(width) * scale + bold_offset) def bold_offsets_for_font(font: ImageFont.ImageFont) -> tuple[tuple[int, int], ...]: source_size = FONTS.source_size(font) scale = max(1, round(font_px(font) / max(1.0, source_size))) return tuple((dx * scale, dy * scale) for dx, dy in BOLD_OFFSETS) def text_line_height(font: ImageFont.ImageFont) -> int: source_size = FONTS.source_size(font) scale = font_px(font) / max(1.0, source_size) return max(1, round(round(source_size * 35 / 32) * scale)) def title_font_for_target_width(title: str, meta: dict, font_scale: float, max_width: int) -> ImageFont.ImageFont: source_base_size = int(meta.get("titleFontSize") or meta.get("fontSize") or 32) base_size = max(8, round(source_base_size * font_scale)) min_size = max(8, round(base_size * 0.55)) draw = ImageDraw.Draw(Image.new("RGB", (1, 1))) for size in range(base_size, min_size - 1, -1): font = FONTS.title(size) if draw.textlength(title, font=font) <= max_width: return font return FONTS.title(min_size) def standard_text_layout( meta: dict, target_size: tuple[int, int], crop_edges: bool, crop_box: tuple[float, float, float, float] | None = None, scale: tuple[float, float] | None = None, ) -> dict: card_w = int(meta["cardWidth"]) card_h = int(meta["cardHeight"]) unit = meta_unit_scale(meta) if crop_box is not None: left, top, right_crop, bottom_crop = crop_box elif crop_edges: left = round(FIXED_CROP[0] * unit) top = round(FIXED_CROP[1] * unit) right_crop = round(FIXED_CROP[2] * unit) bottom_crop = round(FIXED_CROP[3] * unit) else: left = top = right_crop = bottom_crop = 0 visible_w = max(1.0, card_w - left - right_crop) visible_h = max(1.0, card_h - top - bottom_crop) sx = target_size[0] / visible_w sy = target_size[1] / visible_h if scale is not None: sx, sy = scale return { "left": left, "top": top, "sx": sx, "sy": sy, "crop_w": visible_w, "crop_h": visible_h, "target_w": target_size[0], "target_h": target_size[1], } def tx(layout: dict, x: float) -> int: return round((x - layout["left"]) * layout["sx"]) def ty(layout: dict, y: float) -> int: return round((y - layout["top"]) * layout["sy"]) def tw(layout: dict, width: float) -> int: return max(1, round(width * layout["sx"])) def th(layout: dict, height: float) -> int: return max(1, round(height * layout["sy"])) def font_size_y(layout: dict, source_size: float) -> int: return max(8, round(source_size * layout["sy"])) def source_y_from_cropped_ratio(meta: dict, ratio: float) -> float: unit = meta_unit_scale(meta) crop_top = FIXED_CROP[1] * unit crop_bottom = FIXED_CROP[3] * unit cropped_h = float(meta["cardHeight"]) - crop_top - crop_bottom return crop_top + cropped_h * ratio def transformed_text_area(deck_dir: Path, meta: dict, layout: dict) -> tuple[int, int, int]: group = DECK_GROUPS.get(deck_dir.name, "") top_ratio = TEXT_AREA_TOP_CROPPED_RATIOS.get(group, TEXT_AREA_TOP_CROPPED_RATIOS["encounter"]) source_top = source_y_from_cropped_ratio(meta, top_ratio) source_bottom = source_y_from_cropped_ratio(meta, TEXT_AREA_BOTTOM_CROPPED_RATIO) previous_bottom = int(meta["textOffset"]) + int(meta["textHeight"]) - int(meta["textPadding"]) flavour_content_bottom = previous_bottom + ( source_y_from_cropped_ratio(meta, FLAVOUR_BLOCK_BOTTOM_SHIFT_CROPPED_RATIO) - source_y_from_cropped_ratio(meta, 0.0) ) source_bottom_gap = max(0.0, source_bottom - flavour_content_bottom) return ty(layout, source_top), ty(layout, source_bottom), th(layout, source_bottom_gap) def draw_styled_text( image: Image.Image, xy: tuple[int, int], text: str, font: ImageFont.ImageFont, color: tuple[int, int, int], styles: set[str], line_h: int, ) -> None: if not text: return ascent = font.getmetrics()[0] if hasattr(font, "getmetrics") else font_px(font) bold_offsets = bold_offsets_for_font(font) if "b" in styles else () def draw_zalgo_cluster( target: Image.Image, target_draw: ImageDraw.ImageDraw, pos: tuple[int, int], cluster: str, segment_font: ImageFont.ImageFont, fill: tuple[int, int, int], ) -> int: base = cluster_layout_text(cluster) or " " marks = [char for char in cluster if unicodedata.combining(char)] base_font = font if base.isascii() else segment_font base_w = max(1, round(target_draw.textlength(base, font=base_font))) advance_w = base_w + zalgo_extra_advance(base_font, len(marks)) def draw_base(base_pos: tuple[int, int]) -> None: if "i" not in styles: target_draw.text(base_pos, base, fill=fill, font=base_font, anchor="ls") return base_ascent = base_font.getmetrics()[0] if hasattr(base_font, "getmetrics") else font_px(base_font) pad = max(4, round(font_px(base_font) * 0.35)) src_w = base_w + pad * 2 src_h = font_px(base_font) * 3 src = Image.new("RGBA", (src_w, src_h), (0, 0, 0, 0)) src_draw = ImageDraw.Draw(src) src_draw.text((pad, pad + base_ascent), base, fill=(*fill, 255), font=base_font, anchor="ls") skew_w = src.width + round(src.height * ITALIC_SHEAR) skewed = src.transform((skew_w, src.height), Image.Transform.AFFINE, (1, ITALIC_SHEAR, -round(src.height * ITALIC_SHEAR), 0, 1, 0), Image.Resampling.BICUBIC) bbox = skewed.getbbox() if not bbox: return skewed = skewed.crop(bbox) paste_at = (base_pos[0], base_pos[1] - pad - base_ascent + bbox[1]) if target.mode == "RGBA": target.alpha_composite(skewed, paste_at) else: target.paste(skewed.convert("RGB"), paste_at, skewed.getchannel("A")) draw_base(pos) mark_font = FONTS.unicode_text(font_px(segment_font)) mark_ascent = mark_font.getmetrics()[0] if hasattr(mark_font, "getmetrics") else font_px(mark_font) above = 0 below = 0 step = max(2, round(font_px(segment_font) * 0.12)) center_x = pos[0] + base_w // 2 for mark in marks: combining = unicodedata.combining(mark) if combining in {0, 1, 202, 220, 240}: below += 1 y = pos[1] + below * step else: above += 1 y = pos[1] - above * step # Rendering a combining mark alone produces a dotted-circle placeholder # in many fonts. Render it attached to its base, subtract the base glyph, # then place the extracted mark so it can spill outside the text line. pad = max(8, font_px(segment_font) * 2) patch_size = max(32, font_px(segment_font) * 5) full = Image.new("L", (patch_size, patch_size), 0) base_mask = Image.new("L", (patch_size, patch_size), 0) full_draw = ImageDraw.Draw(full) base_draw = ImageDraw.Draw(base_mask) base_pos = (pad, pad + mark_ascent) full_draw.text(base_pos, base + mark, fill=255, font=mark_font, anchor="ls") base_draw.text(base_pos, base, fill=255, font=mark_font, anchor="ls") mark_mask = ImageChops.subtract(full, base_mask) bbox = mark_mask.getbbox() if bbox: mark_crop = mark_mask.crop(bbox) mark_img = Image.new("RGBA", mark_crop.size, (*fill, 0)) mark_img.putalpha(mark_crop) paste_at = (round(center_x - mark_crop.width / 2), round(y - mark_crop.height / 2)) if target.mode == "RGBA": target.alpha_composite(mark_img, paste_at) else: target.paste(mark_img.convert("RGB"), paste_at, mark_img.getchannel("A")) return advance_w def draw_run(target: Image.Image, pos: tuple[int, int]) -> None: target_draw = ImageDraw.Draw(target) cursor_x = pos[0] for segment, segment_font in text_segments(text, font): for cluster in text_clusters(segment): if is_zalgo_cluster(cluster): advance = draw_zalgo_cluster(target, target_draw, (cursor_x, pos[1]), cluster, segment_font, color) for dx, dy in bold_offsets: draw_zalgo_cluster(target, target_draw, (cursor_x + dx, pos[1] + dy), cluster, segment_font, color) cursor_x += advance else: target_draw.text((cursor_x, pos[1]), cluster, fill=color, font=segment_font, anchor="ls") for dx, dy in bold_offsets: target_draw.text((cursor_x + dx, pos[1] + dy), cluster, fill=color, font=segment_font, anchor="ls") cursor_x += text_width(target_draw, cluster, font, set()) has_zalgo = any(is_zalgo_cluster(cluster) for cluster in text_clusters(text)) if "i" not in styles or has_zalgo: draw_run(image, (xy[0], xy[1] + ascent)) return width = max(1, text_width(ImageDraw.Draw(image), text, font, styles)) pad = max(4, round(font_px(font) * 2.4)) src = Image.new("RGBA", (width + pad * 2, line_h + pad * 2), (0, 0, 0, 0)) draw_run(src, (pad, pad + ascent)) skew_w = src.width + round(src.height * ITALIC_SHEAR) skewed = src.transform((skew_w, src.height), Image.Transform.AFFINE, (1, ITALIC_SHEAR, -round(src.height * ITALIC_SHEAR), 0, 1, 0), Image.Resampling.BICUBIC) bbox = skewed.getbbox() if not bbox: return skewed = skewed.crop(bbox) paste_at = (xy[0], xy[1] - pad + bbox[1]) if image.mode == "RGBA": image.alpha_composite(skewed, paste_at) else: image.paste(skewed.convert("RGB"), paste_at, skewed.getchannel("A")) def wrap_runs( draw: ImageDraw.ImageDraw, runs: list[tuple[str, set[str]]], font: ImageFont.ImageFont, max_width: int, ) -> list[list[tuple[str, set[str]]]]: lines: list[list[tuple[str, set[str]]]] = [] current: list[tuple[str, set[str]]] = [] current_w = 0 for token, styles in split_words(runs): if token == "\n": lines.append(current) current = [] current_w = 0 continue if token.isspace(): if current: current.append((token, styles)) current_w += text_width(draw, token, font, styles) continue token_w = text_width(draw, token, font, styles) should_wrap = current and current[-1][0].isspace() and current_w + token_w > max_width if current and should_wrap: while current and current[-1][0].isspace(): current.pop() lines.append(current) current = [] current_w = 0 current.append((token, styles)) current_w += token_w if current or not lines: while current and current[-1][0].isspace(): current.pop() lines.append(current) return lines def draw_text_box( image: Image.Image, text: str, box: tuple[int, int, int, int], font: ImageFont.ImageFont, color: tuple[int, int, int], align_x: float, align_y: float, paragraph_margin: int = 10, indent_chars: int = TEXT_INDENT_CHARS, spacing_scale: float = 1.0, ) -> int: draw = ImageDraw.Draw(image) x, y, width, height = box paragraphs = text.split("\n") paragraph_lines: list[tuple[list[list[tuple[str, set[str]]]], int]] = [] total_h = 0 line_h = max(1, round(text_line_height(font) * spacing_scale)) paragraph_margin = max(0, round(paragraph_margin * spacing_scale)) indent_px = text_width(draw, " " * indent_chars, font, set()) for paragraph in paragraphs: indent = indent_px if paragraph.startswith("\t") else 0 paragraph = paragraph.lstrip("\t") if paragraph == "---": lines = [[("---", set())]] else: lines = wrap_runs(draw, parse_runs(paragraph), font, max(1, width - indent)) paragraph_lines.append((lines, indent)) total_h += len(lines) * line_h + paragraph_margin if paragraph_lines: total_h -= paragraph_margin cursor_y = y + int(height * align_y - total_h * align_y) for lines, indent in paragraph_lines: for line in lines: if len(line) == 1 and line[0][0] == "---": draw.line((x, cursor_y + line_h // 2, x + width, cursor_y + line_h // 2), fill=color, width=2) cursor_y += line_h continue line_text_w = sum(text_width(draw, run_text, font, styles) for run_text, styles in line) line_width = max(1, width - indent) cursor_x = x + indent + int(line_width * align_x - line_text_w * align_x) for run_text, styles in line: if not run_text: continue draw_styled_text(image, (cursor_x, cursor_y), run_text, font, color, styles, max(line_h, text_line_height(font))) cursor_x += text_width(draw, run_text, font, styles) cursor_y += line_h cursor_y += paragraph_margin return total_h def measure_wrapped_text_box( image: Image.Image, text: str, font: ImageFont.ImageFont, width: int, paragraph_margin: int = 10, indent_chars: int = TEXT_INDENT_CHARS, spacing_scale: float = 1.0, ) -> tuple[list[tuple[list[list[tuple[str, set[str]]]], int]], int, int]: draw = ImageDraw.Draw(image) paragraphs = text.split("\n") paragraph_lines: list[tuple[list[list[tuple[str, set[str]]]], int]] = [] total_h = 0 line_h = max(1, round(text_line_height(font) * spacing_scale)) paragraph_margin = max(0, round(paragraph_margin * spacing_scale)) indent_px = text_width(draw, " " * indent_chars, font, set()) for paragraph in paragraphs: indent = indent_px if paragraph.startswith("\t") else 0 paragraph = paragraph.lstrip("\t") lines = wrap_runs(draw, parse_runs(paragraph), font, max(1, width - indent)) paragraph_lines.append((lines, indent)) total_h += len(lines) * line_h + paragraph_margin if paragraph_lines: total_h -= paragraph_margin return paragraph_lines, total_h, line_h def draw_lines( image: Image.Image, paragraph_lines: list[tuple[list[list[tuple[str, set[str]]]], int]], x: int, y: int, width: int, font: ImageFont.ImageFont, color: tuple[int, int, int], line_h: int, paragraph_margin: int = 10, ) -> None: draw = ImageDraw.Draw(image) cursor_y = y for lines, indent in paragraph_lines: for line in lines: cursor_x = x + indent for run_text, styles in line: if not run_text: continue if len(line) == 1 and run_text == "---": draw.line((x, cursor_y + line_h // 2, x + width, cursor_y + line_h // 2), fill=color, width=2) continue draw_styled_text(image, (cursor_x, cursor_y), run_text, font, color, styles, max(line_h, text_line_height(font))) cursor_x += text_width(draw, run_text, font, styles) cursor_y += line_h cursor_y += paragraph_margin def flavour_rule_metrics(font: ImageFont.ImageFont, spacing_scale: float = 1.0) -> tuple[int, int, int]: scale = max(1.0, font.size / 24.0) if hasattr(font, "size") else 1.0 rule_gap = max(0, round(8 * scale * spacing_scale)) rule_h = max(1, round(1 * scale)) hook_h = max(1, round(4 * scale * spacing_scale)) return rule_gap, rule_h, hook_h def measure_flavour_block( image: Image.Image, flavour_text: str, font: ImageFont.ImageFont, width: int, paragraph_margin: int = 10, indent_chars: int = TEXT_INDENT_CHARS, spacing_scale: float = 1.0, bottom_gap: int = 0, ) -> dict: paragraph_lines, text_total_h, line_h = measure_wrapped_text_box( image, flavour_text, font, width, paragraph_margin, indent_chars, spacing_scale, ) scaled_paragraph_margin = max(0, round(paragraph_margin * spacing_scale)) scaled_bottom_gap = max(0, round(bottom_gap * spacing_scale)) rule_gap, rule_h, hook_h = flavour_rule_metrics(font, spacing_scale) total_h = max(rule_h, hook_h) + rule_gap + text_total_h + scaled_bottom_gap return { "paragraph_lines": paragraph_lines, "text_total_h": text_total_h, "line_h": line_h, "paragraph_margin": scaled_paragraph_margin, "bottom_gap": scaled_bottom_gap, "rule_gap": rule_gap, "rule_h": rule_h, "hook_h": hook_h, "total_h": total_h, } def draw_flavour_block( image: Image.Image, block: dict, x: int, top: int, width: int, font: ImageFont.ImageFont, color: tuple[int, int, int], ) -> None: draw = ImageDraw.Draw(image) rule_y = top rule_h = block["rule_h"] hook_h = block["hook_h"] draw.line((x, rule_y, x + width, rule_y), fill=color, width=rule_h) draw.line((x, rule_y, x, rule_y + hook_h), fill=color, width=rule_h) draw_lines( image, block["paragraph_lines"], x, rule_y + max(rule_h, hook_h) + block["rule_gap"], width, font, color, block["line_h"], block["paragraph_margin"], ) def draw_body_and_flavour_text( image: Image.Image, body_text: str, flavour_text: str, box: tuple[int, int, int, int], body_font: ImageFont.ImageFont, flavour_font: ImageFont.ImageFont, color: tuple[int, int, int], paragraph_margin: int, indent_chars: int = TEXT_INDENT_CHARS, flavour_bottom_gap: int = 0, ) -> None: x, y, width, height = box def measure(spacing_scale: float): body_lines: list[tuple[list[list[tuple[str, set[str]]]], int]] = [] body_h = 0 body_line_h = text_line_height(body_font) body_margin = max(0, round(paragraph_margin * spacing_scale)) if body_text: body_lines, body_h, body_line_h = measure_wrapped_text_box( image, body_text, body_font, width, paragraph_margin, indent_chars, spacing_scale, ) flavour_block = None flavour_h = 0 if flavour_text: flavour_block = measure_flavour_block( image, flavour_text, flavour_font, width, paragraph_margin, indent_chars, spacing_scale, flavour_bottom_gap, ) flavour_h = flavour_block["total_h"] return body_lines, body_h, body_line_h, body_margin, flavour_block, flavour_h body_lines, body_h, body_line_h, body_margin, flavour_block, flavour_h = measure(1.0) required_h = body_h + flavour_h spacing_scale = 1.0 if required_h <= 0 or required_h <= height else max(0.05, height / required_h) if spacing_scale < 1.0: body_lines, body_h, body_line_h, body_margin, flavour_block, flavour_h = measure(spacing_scale) for _ in range(3): scaled_required_h = body_h + flavour_h if scaled_required_h <= height or spacing_scale <= 0.05: break spacing_scale = max(0.05, spacing_scale * height / scaled_required_h * 0.99) body_lines, body_h, body_line_h, body_margin, flavour_block, flavour_h = measure(spacing_scale) if flavour_block is not None: flavour_top = y + height - flavour_h draw_flavour_block(image, flavour_block, x, flavour_top, width, flavour_font, color) body_area_h = max(1, flavour_top - y) else: body_area_h = height if body_text: body_y = y + max(0, round((body_area_h - body_h) / 2)) draw_lines(image, body_lines, x, body_y, width, body_font, color, body_line_h, body_margin)