Refine rendering and packing logic

This commit is contained in:
ajp_anton
2026-06-03 00:10:39 +00:00
parent 774de1c4f5
commit 1696476025
8 changed files with 1073 additions and 660 deletions
+74 -43
View File
@@ -9,7 +9,6 @@ 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,
@@ -19,7 +18,6 @@ from .settings import (
TEXT_AREA_BOTTOM_CROPPED_RATIO,
TEXT_AREA_TOP_CROPPED_RATIOS,
TEXT_INDENT_CHARS,
TITLE_WIDTH_LIMITS,
)
@@ -29,14 +27,19 @@ class Fonts:
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) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
return self._font("consolas", self._consolas, max(8, 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))
@@ -49,8 +52,15 @@ class Fonts:
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()
@@ -119,7 +129,7 @@ def format_card_text(text: str | None) -> str:
parts = line.split("//", 1)
action = parts[0]
if not action.strip().endswith(":"):
action = re.sub(r" *$", ";", action)
action = action.rstrip() + ";"
action = action.replace(";;", "; ").replace(":;", ":")
action = KEYWORD_RE.sub(r"<b>\1</b>", action)
result = leading + action
@@ -239,43 +249,50 @@ def text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont,
if not text:
return 0
styles = styles or set()
bold_offset = max(dx for dx, _ in BOLD_OFFSETS) if "b" in styles else 0
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=segment_font)
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(segment_font, mark_count)
if bold_offset:
width += bold_offset
return round(width)
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:
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)
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:
@@ -290,24 +307,38 @@ def title_font_for_target_width(title: str, meta: dict, font_scale: float, max_w
return FONTS.title(min_size)
def standard_text_layout(meta: dict, target_size: tuple[int, int], crop_edges: bool) -> dict:
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_edges:
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 = round(FIXED_CROP[2] * unit)
bottom = round(FIXED_CROP[3] * unit)
right_crop = round(FIXED_CROP[2] * unit)
bottom_crop = 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)
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": target_size[0] / visible_w,
"sy": target_size[1] / visible_h,
"sx": sx,
"sy": sy,
"crop_w": visible_w,
"crop_h": visible_h,
"target_w": target_size[0],
"target_h": target_size[1],
}
@@ -368,7 +399,7 @@ def draw_styled_text(
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 ()
bold_offsets = bold_offsets_for_font(font) if "b" in styles else ()
def draw_zalgo_cluster(
target: Image.Image,
@@ -508,7 +539,7 @@ def wrap_runs(
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
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()