Initial Blockminers printer
This commit is contained in:
+773
@@ -0,0 +1,773 @@
|
||||
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 (
|
||||
BASE_CARD_SIZE,
|
||||
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,
|
||||
TITLE_WIDTH_LIMITS,
|
||||
)
|
||||
|
||||
|
||||
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._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) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
return self._font("consolas", self._consolas, max(8, size))
|
||||
|
||||
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()
|
||||
return self._cache[key]
|
||||
|
||||
|
||||
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 = re.sub(r" *$", ";", action)
|
||||
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_offset = max(dx for dx, _ in BOLD_OFFSETS) if "b" in styles else 0
|
||||
width = 0.0
|
||||
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)
|
||||
|
||||
|
||||
def text_line_height(font: ImageFont.ImageFont) -> int:
|
||||
if hasattr(font, "getmetrics"):
|
||||
ascent, descent = font.getmetrics()
|
||||
return max(1, ascent + descent + 2)
|
||||
return max(1, font_px(font) + 2)
|
||||
|
||||
|
||||
TRIMMED_CARD_WIDTHS: dict[Path, int] = {}
|
||||
|
||||
|
||||
def trimmed_card_width(deck_dir: Path) -> int:
|
||||
deck_dir = deck_dir.resolve()
|
||||
if deck_dir not in TRIMMED_CARD_WIDTHS:
|
||||
TRIMMED_CARD_WIDTHS[deck_dir] = BASE_CARD_SIZE[0] - FIXED_CROP[0] - FIXED_CROP[2]
|
||||
return TRIMMED_CARD_WIDTHS[deck_dir]
|
||||
|
||||
|
||||
def title_width_limit(deck_dir: Path, meta: dict) -> int:
|
||||
group = DECK_GROUPS.get(deck_dir.name, "")
|
||||
ratio = TITLE_WIDTH_LIMITS.get(group, 0.94)
|
||||
crop_scale = meta_unit_scale(meta)
|
||||
return math.floor(trimmed_card_width(deck_dir) * crop_scale * ratio)
|
||||
|
||||
|
||||
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) -> dict:
|
||||
card_w = int(meta["cardWidth"])
|
||||
card_h = int(meta["cardHeight"])
|
||||
unit = meta_unit_scale(meta)
|
||||
if crop_edges:
|
||||
left = round(FIXED_CROP[0] * unit)
|
||||
top = round(FIXED_CROP[1] * unit)
|
||||
right = round(FIXED_CROP[2] * unit)
|
||||
bottom = round(FIXED_CROP[3] * unit)
|
||||
else:
|
||||
left = top = right = bottom = 0
|
||||
visible_w = max(1, card_w - left - right)
|
||||
visible_h = max(1, card_h - top - bottom)
|
||||
return {
|
||||
"left": left,
|
||||
"top": top,
|
||||
"sx": target_size[0] / visible_w,
|
||||
"sy": target_size[1] / 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 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_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)
|
||||
Reference in New Issue
Block a user