805 lines
29 KiB
Python
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"/\* *", "/* ", text)
|
|
text = re.sub(r" *\*/", " */", 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)
|