Files
blockminers-printer/scripts/text.py
T

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)