Files

805 lines
29 KiB
Python

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)(?<!<b>)(?<![\w+.-])(" + "|".join(KEYWORDS) + r")(?![\w.-])(?!</b>)")
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"//( ?)(?!<i>)(.*)(?!</i>)", r"<i>//\1\2</i>", 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"<b>\1</b>", action)
result = leading + action
if len(parts) > 1:
comment = "//" + parts[1]
comment = re.sub(r"//( ?)(?!<i>)(.*)(?!</i>)", r"<i>//\1\2</i>", 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"<zalgo>(.*)</zalgo>", text, flags=re.DOTALL)
if zalgo:
return "<i>" + zalgo.group(1) + "</i>\n<i></i>"
if text.startswith("<i>"):
return text
text = re.sub(r"/\* *", "/*&nbsp;", text)
text = re.sub(r" *\*/", "&nbsp;*/", text)
return "<i>" + text.replace("\n", "</i>\n<i>") + "</i>"
def strip_font_tags(text: str) -> str:
text = re.sub(r"</?(font|p)[^>]*>", "", text)
text = re.sub(r"<hr\s*/?>", "\n<hr>\n", text)
text = text.replace("<br>", "\n").replace("<br/>", "\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>|<hr>)", 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 == "<b>":
styles.add("b")
elif low == "</b>":
styles.discard("b")
elif low == "<i>":
styles.add("i")
elif low == "</i>":
styles.discard("i")
elif low == "<hr>":
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)