Refine rendering and packing logic
This commit is contained in:
@@ -44,6 +44,7 @@ The final PDF is written to `print/`. Temporary rendered images are written to `
|
|||||||
- `--render-only`: render card and board PNGs, but skip page PNGs and PDF creation. Default: off.
|
- `--render-only`: render card and board PNGs, but skip page PNGs and PDF creation. Default: off.
|
||||||
- `--output NAME`: write the PDF as `print/NAME`; `.pdf` is added if missing. Default filename: `blockminers_printable.pdf`.
|
- `--output NAME`: write the PDF as `print/NAME`; `.pdf` is added if missing. Default filename: `blockminers_printable.pdf`.
|
||||||
- `--rent-raised N`: print `N` copies of `Rent raised` and matching backs. Default: `3`.
|
- `--rent-raised N`: print `N` copies of `Rent raised` and matching backs. Default: `3`.
|
||||||
|
- `--text-supersampling N`: render text at `N` times final resolution before downscaling. Default: `3`.
|
||||||
- `--keep-temp`: keep `print/temp/` after PDF creation. Default: delete temp files after PDF creation.
|
- `--keep-temp`: keep `print/temp/` after PDF creation. Default: delete temp files after PDF creation.
|
||||||
- `--starter-colors {separate,same}`: use separate starter badge colors or one repeated color per starter deck. Default: `separate`.
|
- `--starter-colors {separate,same}`: use separate starter badge colors or one repeated color per starter deck. Default: `separate`.
|
||||||
|
|
||||||
|
|||||||
@@ -94,12 +94,6 @@ def cover_resize(img: Image.Image, size: tuple[int, int]) -> Image.Image:
|
|||||||
return resized.crop((left, top, left + target_w, top + target_h))
|
return resized.crop((left, top, left + target_w, top + target_h))
|
||||||
|
|
||||||
|
|
||||||
def render_work_scale(meta: dict, target_size: tuple[int, int]) -> float:
|
|
||||||
sx = target_size[0] / float(meta["cardWidth"])
|
|
||||||
sy = target_size[1] / float(meta["cardHeight"])
|
|
||||||
return max(2.0, sx, sy)
|
|
||||||
|
|
||||||
|
|
||||||
def meta_unit_scale(meta: dict) -> float:
|
def meta_unit_scale(meta: dict) -> float:
|
||||||
return float(meta.get("cardWidth", 825)) / 825.0
|
return float(meta.get("cardWidth", 825)) / 825.0
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .assets import load_json, load_rgb
|
|
||||||
from .settings import BOARD_DECK, DECKS_DIR, BuildConfig
|
from .settings import BOARD_DECK, DECKS_DIR, BuildConfig
|
||||||
|
|
||||||
ORIGINAL_BOARD_MM = (88.9, 139.7)
|
ORIGINAL_BOARD_MM = (88.9, 139.7)
|
||||||
@@ -45,17 +44,6 @@ def board_target_mm_for_mode(
|
|||||||
|
|
||||||
return BOARD_SIZE_OPTIONS_MM.get(board_mode, ORIGINAL_BOARD_MM)
|
return BOARD_SIZE_OPTIONS_MM.get(board_mode, ORIGINAL_BOARD_MM)
|
||||||
|
|
||||||
|
|
||||||
def board_aspect_ratio(board_path: Path) -> float:
|
|
||||||
deck_json = board_path.parent / "deck.json"
|
|
||||||
if deck_json.exists():
|
|
||||||
meta = load_json(deck_json)
|
|
||||||
if meta.get("cardWidth") and meta.get("cardHeight"):
|
|
||||||
return float(meta["cardWidth"]) / float(meta["cardHeight"])
|
|
||||||
with load_rgb(board_path) as img:
|
|
||||||
return img.width / img.height
|
|
||||||
|
|
||||||
|
|
||||||
def board_target_mm(board_path: Path, config: BuildConfig) -> tuple[float, float]:
|
def board_target_mm(board_path: Path, config: BuildConfig) -> tuple[float, float]:
|
||||||
return board_target_mm_for_mode(board_path, config.board_mode, config.board_custom_mm)
|
return board_target_mm_for_mode(board_path, config.board_mode, config.board_custom_mm)
|
||||||
|
|
||||||
@@ -78,15 +66,3 @@ def board_size_fits_paper(
|
|||||||
if not (fits_upright or fits_rotated):
|
if not (fits_upright or fits_rotated):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def board_mode_fits_paper(board_mode: str, paper_mm: tuple[float, float], margin_mm: float) -> bool:
|
|
||||||
return board_size_fits_paper(board_mode, None, paper_mm, margin_mm)
|
|
||||||
|
|
||||||
|
|
||||||
def crop_board_bleed(img):
|
|
||||||
target_w = round(img.width * (3.5 / 3.75))
|
|
||||||
target_h = round(img.height * (5.5 / 5.75))
|
|
||||||
left = max(0, (img.width - target_w) // 2)
|
|
||||||
top = max(0, (img.height - target_h) // 2)
|
|
||||||
return img.crop((left, top, left + target_w, top + target_h))
|
|
||||||
|
|||||||
+269
-344
@@ -1,26 +1,27 @@
|
|||||||
|
import math
|
||||||
import shutil
|
import shutil
|
||||||
from concurrent.futures import ProcessPoolExecutor
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PIL import Image, ImageChops, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
from .assets import cover_resize, find_art_for_json, hex_color, load_json, load_rgb, meta_unit_scale, render_work_scale, safe_name, save_temp_png
|
from .assets import cover_resize, find_art_for_json, hex_color, load_json, load_rgb, meta_unit_scale, safe_name, save_temp_png
|
||||||
from .boards import board_target_mm, crop_board_bleed
|
from .boards import board_target_mm
|
||||||
from .settings import (
|
from .settings import (
|
||||||
BASE_CARD_SIZE,
|
|
||||||
BASEMENT_SINGLE_STARTER_COLOR,
|
BASEMENT_SINGLE_STARTER_COLOR,
|
||||||
BOARD_DECK,
|
BOARD_DECK,
|
||||||
CARD_DECKS,
|
CARD_DECKS,
|
||||||
CHARACTER_DECK,
|
CHARACTER_DECK,
|
||||||
CHARACTER_EDGE_REPLACE_PX,
|
|
||||||
CHECK_COLORS,
|
CHECK_COLORS,
|
||||||
|
DEFAULT_SOURCE_CROP_PX,
|
||||||
|
DECK_GROUPS,
|
||||||
DECKS_DIR,
|
DECKS_DIR,
|
||||||
FIXED_CROP,
|
FIXED_CROP,
|
||||||
OFFICE_SINGLE_STARTER_COLOR,
|
OFFICE_SINGLE_STARTER_COLOR,
|
||||||
PLAYER_COLORS,
|
PLAYER_COLORS,
|
||||||
RENDER_DIR,
|
RENDER_DIR,
|
||||||
SIDE_EDGE_REPLACE_BANDS,
|
|
||||||
STARTER_DECKS,
|
STARTER_DECKS,
|
||||||
|
TITLE_WIDTH_LIMITS,
|
||||||
TOP_BOTTOM_EDGE_REPLACE_PX,
|
TOP_BOTTOM_EDGE_REPLACE_PX,
|
||||||
BuildConfig,
|
BuildConfig,
|
||||||
PrintItem,
|
PrintItem,
|
||||||
@@ -39,7 +40,6 @@ from .text import (
|
|||||||
text_line_height,
|
text_line_height,
|
||||||
th,
|
th,
|
||||||
title_font_for_target_width,
|
title_font_for_target_width,
|
||||||
title_width_limit,
|
|
||||||
transformed_text_area,
|
transformed_text_area,
|
||||||
tw,
|
tw,
|
||||||
tx,
|
tx,
|
||||||
@@ -82,26 +82,13 @@ def draw_banner_text(
|
|||||||
def draw_banner(
|
def draw_banner(
|
||||||
image: Image.Image,
|
image: Image.Image,
|
||||||
meta: dict,
|
meta: dict,
|
||||||
text: str,
|
|
||||||
y_offset: int,
|
y_offset: int,
|
||||||
fill: tuple[int, int, int],
|
fill: tuple[int, int, int],
|
||||||
text_color: tuple[int, int, int] | None = None,
|
|
||||||
draw_text: bool = True,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
draw_banner_shape(image, meta, y_offset, fill)
|
draw_banner_shape(image, meta, y_offset, fill)
|
||||||
if not draw_text:
|
|
||||||
return
|
|
||||||
color = text_color or hex_color(meta.get("foregroundColor"), "#ffffff")
|
|
||||||
font = FONTS.title(int(meta.get("titleFontSize") or meta.get("fontSize") or 32))
|
|
||||||
scale = meta_unit_scale(meta)
|
|
||||||
x = int(meta["checkOffsetX"])
|
|
||||||
y = int(meta["checkOffsetY"]) + y_offset
|
|
||||||
w = int(meta["checkWidth"])
|
|
||||||
h = int(meta["checkHeight"])
|
|
||||||
draw_text_box(image, text, (x + round(50 * scale), y, w, h), font, color, 0.0, 0.5, paragraph_margin=0)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_starter_banner(image: Image.Image, meta: dict, y_offset: int, color: str, draw_text: bool = True) -> None:
|
def draw_starter_banner(image: Image.Image, meta: dict, y_offset: int, color: str) -> None:
|
||||||
draw = ImageDraw.Draw(image)
|
draw = ImageDraw.Draw(image)
|
||||||
scale = meta_unit_scale(meta)
|
scale = meta_unit_scale(meta)
|
||||||
x = int(meta["checkOffsetX"])
|
x = int(meta["checkOffsetX"])
|
||||||
@@ -110,14 +97,193 @@ def draw_starter_banner(image: Image.Image, meta: dict, y_offset: int, color: st
|
|||||||
h = int(meta["checkHeight"])
|
h = int(meta["checkHeight"])
|
||||||
black = (0, 0, 0)
|
black = (0, 0, 0)
|
||||||
banner = hex_color(color)
|
banner = hex_color(color)
|
||||||
draw_banner(image, meta, "STARTER", y_offset, black, text_color=(255, 255, 255), draw_text=draw_text)
|
draw_banner(image, meta, y_offset, black)
|
||||||
draw_polygon(draw, [(x * 2 + w - round(32 * scale), y), (x * 2 + w - round(26 * scale), y), (x + w, y + h), (x + w - round(6 * scale), y + h)], banner)
|
draw_polygon(draw, [(x * 2 + w - round(32 * scale), y), (x * 2 + w - round(26 * scale), y), (x + w, y + h), (x + w - round(6 * scale), y + h)], banner)
|
||||||
draw_polygon(draw, [(x * 2 + w - round(50 * scale), y), (x * 2 + w - round(38 * scale), y), (x + w - round(12 * scale), y + h), (x + w - round(24 * scale), y + h)], banner)
|
draw_polygon(draw, [(x * 2 + w - round(50 * scale), y), (x * 2 + w - round(38 * scale), y), (x + w - round(12 * scale), y + h), (x + w - round(24 * scale), y + h)], banner)
|
||||||
draw_polygon(draw, [(x, y), (x * 2 + w - round(30 * scale), y), (x * 2 + w - round(30 * scale), y + round(4 * scale)), (x, y + round(4 * scale))], banner)
|
draw_polygon(draw, [(x, y), (x * 2 + w - round(30 * scale), y), (x * 2 + w - round(30 * scale), y + round(4 * scale)), (x, y + round(4 * scale))], banner)
|
||||||
if not draw_text:
|
|
||||||
return
|
|
||||||
font = FONTS.title(int(meta.get("titleFontSize") or meta.get("fontSize") or 32))
|
def card_source_crop(config: BuildConfig) -> tuple[float, float, float, float]:
|
||||||
draw_text_box(image, "STARTER", (x + round(50 * scale), y, w, h), font, (255, 255, 255), 0.0, 0.5, paragraph_margin=0)
|
if config.crop_black_borders:
|
||||||
|
return tuple(float(value) for value in FIXED_CROP)
|
||||||
|
crop = float(DEFAULT_SOURCE_CROP_PX)
|
||||||
|
return crop, crop, crop, crop
|
||||||
|
|
||||||
|
|
||||||
|
def board_source_crop() -> tuple[float, float, float, float]:
|
||||||
|
crop = float(DEFAULT_SOURCE_CROP_PX)
|
||||||
|
return crop, crop, crop, crop
|
||||||
|
|
||||||
|
|
||||||
|
def bleed_px(config: BuildConfig) -> int:
|
||||||
|
return mm_to_px(config.bleed_mm, config.dpi)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_source_dirty_pixels(img: Image.Image, crop: tuple[float, float, float, float]) -> Image.Image:
|
||||||
|
out = img.copy()
|
||||||
|
width, height = out.size
|
||||||
|
left_crop, top_crop, right_crop, bottom_crop = crop
|
||||||
|
crop_height = height - top_crop - bottom_crop
|
||||||
|
|
||||||
|
top_ref = min(height - 1, math.ceil(top_crop) + TOP_BOTTOM_EDGE_REPLACE_PX)
|
||||||
|
if top_ref > 0:
|
||||||
|
row = out.crop((0, top_ref, width, top_ref + 1))
|
||||||
|
for y in range(0, top_ref):
|
||||||
|
out.paste(row, (0, y))
|
||||||
|
|
||||||
|
bottom_ref = max(0, height - math.ceil(bottom_crop) - TOP_BOTTOM_EDGE_REPLACE_PX - 1)
|
||||||
|
if bottom_ref < height - 1:
|
||||||
|
row = out.crop((0, bottom_ref, width, bottom_ref + 1))
|
||||||
|
for y in range(bottom_ref + 1, height):
|
||||||
|
out.paste(row, (0, y))
|
||||||
|
|
||||||
|
dirty_pixels_by_segment = [10, 3, 1, 3, 10]
|
||||||
|
transitions = [0]
|
||||||
|
for index in range(1, len(dirty_pixels_by_segment)):
|
||||||
|
transitions.append(round(top_crop + crop_height * index / len(dirty_pixels_by_segment)))
|
||||||
|
transitions.append(height)
|
||||||
|
|
||||||
|
for index, dirty_pixels in enumerate(dirty_pixels_by_segment):
|
||||||
|
segment_top = max(0, transitions[index])
|
||||||
|
segment_bottom = min(height, transitions[index + 1])
|
||||||
|
if segment_bottom <= segment_top:
|
||||||
|
continue
|
||||||
|
ref_left = min(width - 1, math.ceil(left_crop) + dirty_pixels)
|
||||||
|
ref_right = max(0, width - math.ceil(right_crop) - dirty_pixels - 1)
|
||||||
|
left_strip = out.crop((ref_left, segment_top, ref_left + 1, segment_bottom)).resize((ref_left, segment_bottom - segment_top))
|
||||||
|
right_width = width - ref_right - 1
|
||||||
|
if ref_left > 0:
|
||||||
|
out.paste(left_strip, (0, segment_top))
|
||||||
|
if right_width > 0:
|
||||||
|
right_strip = out.crop((ref_right, segment_top, ref_right + 1, segment_bottom)).resize((right_width, segment_bottom - segment_top))
|
||||||
|
out.paste(right_strip, (ref_right + 1, segment_top))
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def transformed_print_image(
|
||||||
|
source: Image.Image,
|
||||||
|
target_size: tuple[int, int],
|
||||||
|
config: BuildConfig,
|
||||||
|
crop: tuple[float, float, float, float],
|
||||||
|
) -> tuple[Image.Image, int]:
|
||||||
|
target_w, target_h = target_size
|
||||||
|
left_crop, top_crop, right_crop, bottom_crop = crop
|
||||||
|
crop_w = float(source.width) - left_crop - right_crop
|
||||||
|
crop_h = float(source.height) - top_crop - bottom_crop
|
||||||
|
sx = float(target_w) / crop_w
|
||||||
|
sy = float(target_h) / crop_h
|
||||||
|
bleed = bleed_px(config)
|
||||||
|
source_bleed_x = float(bleed) / sx
|
||||||
|
source_bleed_y = float(bleed) / sy
|
||||||
|
bleed_left = left_crop - source_bleed_x
|
||||||
|
bleed_top = top_crop - source_bleed_y
|
||||||
|
bleed_right = left_crop + crop_w + source_bleed_x
|
||||||
|
bleed_bottom = top_crop + crop_h + source_bleed_y
|
||||||
|
full_size = (target_w + 2 * bleed, target_h + 2 * bleed)
|
||||||
|
transformed = source.convert("RGB").transform(
|
||||||
|
full_size,
|
||||||
|
Image.Transform.EXTENT,
|
||||||
|
(bleed_left, bleed_top, bleed_right, bleed_bottom),
|
||||||
|
Image.Resampling.BICUBIC,
|
||||||
|
)
|
||||||
|
return transformed, bleed
|
||||||
|
|
||||||
|
|
||||||
|
def text_layout_for_full_bleed(meta: dict, target_size: tuple[int, int], crop: tuple[float, float, float, float], config: BuildConfig) -> tuple[dict, int]:
|
||||||
|
target_w, target_h = target_size
|
||||||
|
left_crop, top_crop, right_crop, bottom_crop = crop
|
||||||
|
crop_w = float(meta["cardWidth"]) - left_crop - right_crop
|
||||||
|
crop_h = float(meta["cardHeight"]) - top_crop - bottom_crop
|
||||||
|
sx = float(target_w) / crop_w
|
||||||
|
sy = float(target_h) / crop_h
|
||||||
|
bleed = bleed_px(config)
|
||||||
|
bleed_left = left_crop - float(bleed) / sx
|
||||||
|
bleed_top = top_crop - float(bleed) / sy
|
||||||
|
ss = config.text_supersampling
|
||||||
|
layout = standard_text_layout(
|
||||||
|
meta,
|
||||||
|
(target_w + 2 * bleed, target_h + 2 * bleed),
|
||||||
|
False,
|
||||||
|
crop_box=(bleed_left, bleed_top, 0.0, 0.0),
|
||||||
|
scale=(sx * ss, sy * ss),
|
||||||
|
)
|
||||||
|
layout["target_w"] = (target_w + 2 * bleed) * ss
|
||||||
|
layout["target_h"] = (target_h + 2 * bleed) * ss
|
||||||
|
layout["crop_w"] = crop_w
|
||||||
|
layout["crop_h"] = crop_h
|
||||||
|
return layout, bleed
|
||||||
|
|
||||||
|
|
||||||
|
def draw_corner_bleed_marks(img: Image.Image, config: BuildConfig, group: str, is_character: bool) -> Image.Image:
|
||||||
|
bleed = bleed_px(config)
|
||||||
|
if bleed <= 0:
|
||||||
|
return img
|
||||||
|
out = img.copy()
|
||||||
|
draw = ImageDraw.Draw(out)
|
||||||
|
size = 2 * bleed
|
||||||
|
if out.width < size or out.height < size:
|
||||||
|
return out
|
||||||
|
|
||||||
|
def color_for(corner: str) -> tuple[int, int, int]:
|
||||||
|
if is_character or not config.crop_black_borders:
|
||||||
|
return (255, 255, 255)
|
||||||
|
return (0, 0, 0) if corner.startswith("top") else (255, 255, 255)
|
||||||
|
|
||||||
|
w, h = out.size
|
||||||
|
b = bleed
|
||||||
|
|
||||||
|
def fill_top_left(color: tuple[int, int, int]) -> None:
|
||||||
|
draw.rectangle((0, 0, 2 * b - 1, b - 1), fill=color)
|
||||||
|
draw.rectangle((0, b, b - 1, 2 * b - 1), fill=color)
|
||||||
|
for y in range(b):
|
||||||
|
draw.line((b, b + y, 2 * b - y - 1, b + y), fill=color)
|
||||||
|
|
||||||
|
def fill_top_right(color: tuple[int, int, int]) -> None:
|
||||||
|
draw.rectangle((w - 2 * b, 0, w - 1, b - 1), fill=color)
|
||||||
|
draw.rectangle((w - b, b, w - 1, 2 * b - 1), fill=color)
|
||||||
|
for y in range(b):
|
||||||
|
draw.line((w - 2 * b + y, b + y, w - b - 1, b + y), fill=color)
|
||||||
|
|
||||||
|
def fill_bottom_left(color: tuple[int, int, int]) -> None:
|
||||||
|
draw.rectangle((0, h - b, 2 * b - 1, h - 1), fill=color)
|
||||||
|
draw.rectangle((0, h - 2 * b, b - 1, h - b - 1), fill=color)
|
||||||
|
for y in range(b):
|
||||||
|
draw.line((b, h - 2 * b + y, b + y, h - 2 * b + y), fill=color)
|
||||||
|
|
||||||
|
def fill_bottom_right(color: tuple[int, int, int]) -> None:
|
||||||
|
draw.rectangle((w - 2 * b, h - b, w - 1, h - 1), fill=color)
|
||||||
|
draw.rectangle((w - b, h - 2 * b, w - 1, h - b - 1), fill=color)
|
||||||
|
for y in range(b):
|
||||||
|
draw.line((w - b - 1 - y, h - 2 * b + y, w - b - 1, h - 2 * b + y), fill=color)
|
||||||
|
|
||||||
|
for corner, fill in (
|
||||||
|
("top_left", fill_top_left),
|
||||||
|
("top_right", fill_top_right),
|
||||||
|
("bottom_left", fill_bottom_left),
|
||||||
|
("bottom_right", fill_bottom_right),
|
||||||
|
):
|
||||||
|
fill(color_for(corner))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def split_face_from_bleed(full: Image.Image, target_size: tuple[int, int], bleed: int) -> Image.Image:
|
||||||
|
return full.crop((bleed, bleed, bleed + target_size[0], bleed + target_size[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def save_print_images(
|
||||||
|
full: Image.Image,
|
||||||
|
face_out: Path,
|
||||||
|
bleed_out: Path,
|
||||||
|
target_size: tuple[int, int],
|
||||||
|
bleed: int,
|
||||||
|
config: BuildConfig,
|
||||||
|
) -> None:
|
||||||
|
face = split_face_from_bleed(full, target_size, bleed)
|
||||||
|
face_out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
bleed_out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
save_temp_png(face, face_out, config.dpi)
|
||||||
|
save_temp_png(full, bleed_out, config.dpi)
|
||||||
|
|
||||||
|
|
||||||
def render_card_image(
|
def render_card_image(
|
||||||
@@ -127,9 +293,10 @@ def render_card_image(
|
|||||||
front_template: Image.Image,
|
front_template: Image.Image,
|
||||||
starter_color: str | None,
|
starter_color: str | None,
|
||||||
target_size: tuple[int, int],
|
target_size: tuple[int, int],
|
||||||
crop_edges_before_text: bool = False,
|
config: BuildConfig,
|
||||||
) -> Image.Image:
|
) -> Image.Image:
|
||||||
layout = standard_text_layout(meta, target_size, crop_edges_before_text)
|
crop = card_source_crop(config)
|
||||||
|
layout, _ = text_layout_for_full_bleed(meta, target_size, crop, config)
|
||||||
source_canvas = Image.new("RGB", (int(meta["cardWidth"]), int(meta["cardHeight"])), (0, 0, 0)).convert("RGBA")
|
source_canvas = Image.new("RGB", (int(meta["cardWidth"]), int(meta["cardHeight"])), (0, 0, 0)).convert("RGBA")
|
||||||
banner_texts: list[tuple[str, int, tuple[int, int, int]]] = []
|
banner_texts: list[tuple[str, int, tuple[int, int, int]]] = []
|
||||||
|
|
||||||
@@ -140,11 +307,11 @@ def render_card_image(
|
|||||||
|
|
||||||
check_offset = 0
|
check_offset = 0
|
||||||
if starter_color is not None:
|
if starter_color is not None:
|
||||||
draw_starter_banner(source_canvas, meta, check_offset, starter_color, draw_text=False)
|
draw_starter_banner(source_canvas, meta, check_offset, starter_color)
|
||||||
banner_texts.append(("STARTER", check_offset, (255, 255, 255)))
|
banner_texts.append(("STARTER", check_offset, (255, 255, 255)))
|
||||||
check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"])
|
check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"])
|
||||||
if card.get("encounterThreshold") is not None:
|
if card.get("encounterThreshold") is not None:
|
||||||
draw_banner(source_canvas, meta, f"Threat {card['encounterThreshold']}", check_offset, hex_color("#ead500"), draw_text=False)
|
draw_banner(source_canvas, meta, check_offset, hex_color("#ead500"))
|
||||||
banner_texts.append((f"Threat {card['encounterThreshold']}", check_offset, hex_color(meta.get("foregroundColor"), "#ffffff")))
|
banner_texts.append((f"Threat {card['encounterThreshold']}", check_offset, hex_color(meta.get("foregroundColor"), "#ffffff")))
|
||||||
check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"])
|
check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"])
|
||||||
if card.get("checkLevel") is not None and card.get("checkType") is not None:
|
if card.get("checkLevel") is not None and card.get("checkType") is not None:
|
||||||
@@ -152,36 +319,40 @@ def render_card_image(
|
|||||||
draw_banner(
|
draw_banner(
|
||||||
source_canvas,
|
source_canvas,
|
||||||
meta,
|
meta,
|
||||||
f"{card['checkType']} {card['checkLevel']}",
|
|
||||||
check_offset,
|
check_offset,
|
||||||
check_color,
|
check_color,
|
||||||
draw_text=False,
|
|
||||||
)
|
)
|
||||||
banner_texts.append((f"{card['checkType']} {card['checkLevel']}", check_offset, hex_color(meta.get("foregroundColor"), "#ffffff")))
|
banner_texts.append((f"{card['checkType']} {card['checkLevel']}", check_offset, hex_color(meta.get("foregroundColor"), "#ffffff")))
|
||||||
check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"])
|
check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"])
|
||||||
if card.get("type") == "VIRUS":
|
if card.get("type") == "VIRUS":
|
||||||
draw_banner(source_canvas, meta, "VIRUS", check_offset, (255, 255, 255), text_color=hex_color(meta.get("foregroundColor"), "#000000"), draw_text=False)
|
draw_banner(source_canvas, meta, check_offset, (255, 255, 255))
|
||||||
banner_texts.append(("VIRUS", check_offset, hex_color(meta.get("foregroundColor"), "#000000")))
|
banner_texts.append(("VIRUS", check_offset, hex_color(meta.get("foregroundColor"), "#000000")))
|
||||||
|
|
||||||
source_canvas.alpha_composite(front_template.convert("RGBA"), (0, 0))
|
source_canvas.alpha_composite(front_template.convert("RGBA"), (0, 0))
|
||||||
|
|
||||||
if crop_edges_before_text:
|
source_without_text = source_canvas.convert("RGB")
|
||||||
canvas = replace_standard_card_edges(fixed_crop_card(source_canvas)).convert("RGBA")
|
if config.crop_black_borders:
|
||||||
else:
|
source_without_text = replace_source_dirty_pixels(source_without_text, crop)
|
||||||
canvas = source_canvas
|
if config.gamma_enabled:
|
||||||
canvas = canvas.convert("RGB").resize(target_size, Image.Resampling.LANCZOS).convert("RGBA")
|
source_without_text = apply_gamma(source_without_text, config.gamma, config.black_point)
|
||||||
|
canvas, _ = transformed_print_image(source_without_text, target_size, config, crop)
|
||||||
|
canvas = canvas.convert("RGBA")
|
||||||
|
ss = config.text_supersampling
|
||||||
|
text_layer = Image.new("RGBA", (canvas.width * ss, canvas.height * ss), (0, 0, 0, 0))
|
||||||
|
|
||||||
for banner_text, y_offset, text_color in banner_texts:
|
for banner_text, y_offset, text_color in banner_texts:
|
||||||
draw_banner_text(canvas, meta, layout, banner_text, y_offset, text_color)
|
draw_banner_text(text_layer, meta, layout, banner_text, y_offset, text_color)
|
||||||
|
|
||||||
title = card.get("title")
|
title = card.get("title")
|
||||||
if title:
|
if title:
|
||||||
max_title_width = tw(layout, title_width_limit(deck_dir, meta))
|
group = DECK_GROUPS.get(deck_dir.name, "")
|
||||||
|
ratio = TITLE_WIDTH_LIMITS.get(group, 0.94)
|
||||||
|
max_title_width = math.floor(float(layout["crop_w"]) * layout["sx"] * ratio)
|
||||||
font = title_font_for_target_width(str(title), meta, layout["sy"], max_title_width)
|
font = title_font_for_target_width(str(title), meta, layout["sy"], max_title_width)
|
||||||
title_color = hex_color(meta.get("titleColor"), "#000000")
|
title_color = hex_color(meta.get("titleColor"), "#000000")
|
||||||
title_y = ty(layout, int(meta["titleOffset"]))
|
title_y = ty(layout, int(meta["titleOffset"]))
|
||||||
title_box_h = th(layout, 16)
|
title_box_h = th(layout, 16)
|
||||||
draw_text_box(canvas, title, (0, title_y, target_size[0], title_box_h), font, title_color, 0.5, 0.5, 0, 0)
|
draw_text_box(text_layer, title, (0, title_y, text_layer.width, title_box_h), font, title_color, 0.5, 0.5, 0, 0)
|
||||||
|
|
||||||
fg = hex_color(meta.get("foregroundColor"), "#ffffff")
|
fg = hex_color(meta.get("foregroundColor"), "#ffffff")
|
||||||
text_left = int(meta["textPadding"])
|
text_left = int(meta["textPadding"])
|
||||||
@@ -194,10 +365,12 @@ def render_card_image(
|
|||||||
flavour_text = format_flavour_text(card.get("flavourText"))
|
flavour_text = format_flavour_text(card.get("flavourText"))
|
||||||
body_text = format_card_text(card.get("text"))
|
body_text = format_card_text(card.get("text"))
|
||||||
if body_text or flavour_text:
|
if body_text or flavour_text:
|
||||||
body_font = FONTS.text(font_size_y(layout, int(meta.get("fontSize") or 32)))
|
body_source_size = int(meta.get("fontSize") or 32)
|
||||||
flavour_font = FONTS.text(font_size_y(layout, int(meta.get("flavourTextFontSize") or 24)))
|
flavour_source_size = int(meta.get("flavourTextFontSize") or 24)
|
||||||
|
body_font = FONTS.text(font_size_y(layout, body_source_size), body_source_size)
|
||||||
|
flavour_font = FONTS.text(font_size_y(layout, flavour_source_size), flavour_source_size)
|
||||||
draw_body_and_flavour_text(
|
draw_body_and_flavour_text(
|
||||||
canvas,
|
text_layer,
|
||||||
body_text,
|
body_text,
|
||||||
flavour_text,
|
flavour_text,
|
||||||
(text_x, text_y, text_w, text_h),
|
(text_x, text_y, text_w, text_h),
|
||||||
@@ -209,13 +382,13 @@ def render_card_image(
|
|||||||
flavour_bottom_gap=flavour_bottom_gap,
|
flavour_bottom_gap=flavour_bottom_gap,
|
||||||
)
|
)
|
||||||
|
|
||||||
old_footer_font = FONTS.text(font_size_y(layout, 16))
|
old_footer_font = FONTS.text(font_size_y(layout, 16), 16)
|
||||||
small = FONTS.text(font_size_y(layout, 16 * 0.9))
|
small = FONTS.text(font_size_y(layout, 16 * 0.9), 16 * 0.9)
|
||||||
footer_top_compensation = round((text_line_height(old_footer_font) - text_line_height(small)) / 2)
|
footer_top_compensation = round((text_line_height(old_footer_font) - text_line_height(small)) / 2)
|
||||||
footer_y = ty(layout, int(meta["cardHeight"]) - 95) - footer_top_compensation
|
footer_y = ty(layout, int(meta["cardHeight"]) - 95) - footer_top_compensation
|
||||||
if config_project_version := card.get("_project_version"):
|
if config_project_version := card.get("_project_version"):
|
||||||
draw_text_box(
|
draw_text_box(
|
||||||
canvas,
|
text_layer,
|
||||||
str(config_project_version),
|
str(config_project_version),
|
||||||
(
|
(
|
||||||
tx(layout, int(meta["cardWidth"]) - 252),
|
tx(layout, int(meta["cardWidth"]) - 252),
|
||||||
@@ -231,7 +404,7 @@ def render_card_image(
|
|||||||
)
|
)
|
||||||
if card.get("credit"):
|
if card.get("credit"):
|
||||||
draw_text_box(
|
draw_text_box(
|
||||||
canvas,
|
text_layer,
|
||||||
str(card["credit"]),
|
str(card["credit"]),
|
||||||
(
|
(
|
||||||
tx(layout, 100),
|
tx(layout, 100),
|
||||||
@@ -245,230 +418,12 @@ def render_card_image(
|
|||||||
0.5,
|
0.5,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
text_layer = text_layer.resize(canvas.size, Image.Resampling.LANCZOS)
|
||||||
|
canvas.alpha_composite(text_layer)
|
||||||
|
canvas = draw_corner_bleed_marks(canvas.convert("RGB"), config, DECK_GROUPS.get(deck_dir.name, ""), False).convert("RGBA")
|
||||||
return canvas.convert("RGB")
|
return canvas.convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
def smart_trim(img: Image.Image) -> Image.Image:
|
|
||||||
rgb = img.convert("RGB")
|
|
||||||
diff = ImageChops.difference(rgb, Image.new("RGB", rgb.size, (0, 0, 0)))
|
|
||||||
bbox = diff.getbbox()
|
|
||||||
if bbox is None:
|
|
||||||
return rgb
|
|
||||||
rgb = rgb.crop(bbox)
|
|
||||||
|
|
||||||
def black_fraction_row(image: Image.Image, y: int, x0: int = 0, x1: int | None = None) -> float:
|
|
||||||
x1 = image.width if x1 is None else x1
|
|
||||||
if x1 <= x0:
|
|
||||||
return 0.0
|
|
||||||
pixels = image.crop((x0, y, x1, y + 1)).getdata()
|
|
||||||
return sum(1 for p in pixels if p == (0, 0, 0)) / (x1 - x0)
|
|
||||||
|
|
||||||
def black_fraction_col(image: Image.Image, x: int, y0: int = 0, y1: int | None = None) -> float:
|
|
||||||
y1 = image.height if y1 is None else y1
|
|
||||||
if y1 <= y0:
|
|
||||||
return 0.0
|
|
||||||
pixels = image.crop((x, y0, x + 1, y1)).getdata()
|
|
||||||
return sum(1 for p in pixels if p == (0, 0, 0)) / (y1 - y0)
|
|
||||||
|
|
||||||
changed = True
|
|
||||||
while changed and rgb.width > 1 and rgb.height > 1:
|
|
||||||
changed = False
|
|
||||||
if black_fraction_row(rgb, 0) >= 0.9:
|
|
||||||
rgb = rgb.crop((0, 1, rgb.width, rgb.height))
|
|
||||||
changed = True
|
|
||||||
if rgb.height > 1 and black_fraction_row(rgb, rgb.height - 1) >= 0.9:
|
|
||||||
rgb = rgb.crop((0, 0, rgb.width, rgb.height - 1))
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
changed = True
|
|
||||||
while changed and rgb.width > 1 and rgb.height > 1:
|
|
||||||
changed = False
|
|
||||||
y0 = int(rgb.height * 0.05)
|
|
||||||
y1 = rgb.height - y0
|
|
||||||
if black_fraction_col(rgb, 0, y0, y1) >= 0.05:
|
|
||||||
rgb = rgb.crop((1, 0, rgb.width, rgb.height))
|
|
||||||
changed = True
|
|
||||||
if rgb.width > 1 and black_fraction_col(rgb, rgb.width - 1, y0, y1) >= 0.05:
|
|
||||||
rgb = rgb.crop((0, 0, rgb.width - 1, rgb.height))
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
changed = True
|
|
||||||
while changed and rgb.width > 1 and rgb.height > 1:
|
|
||||||
changed = False
|
|
||||||
x0 = int(rgb.width * 0.05)
|
|
||||||
x1 = rgb.width - x0
|
|
||||||
if black_fraction_row(rgb, 0, x0, x1) >= 0.05:
|
|
||||||
rgb = rgb.crop((0, 1, rgb.width, rgb.height))
|
|
||||||
changed = True
|
|
||||||
if rgb.height > 1 and black_fraction_row(rgb, rgb.height - 1, x0, x1) >= 0.05:
|
|
||||||
rgb = rgb.crop((0, 0, rgb.width, rgb.height - 1))
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if rgb.height > 1:
|
|
||||||
x0 = int(rgb.width * 0.05)
|
|
||||||
x1 = max(x0 + 1, rgb.width - x0)
|
|
||||||
prev = row_brightness(rgb, 0, x0, x1)
|
|
||||||
cut = 0
|
|
||||||
for y in range(1, rgb.height):
|
|
||||||
current = row_brightness(rgb, y, x0, x1)
|
|
||||||
if current >= prev:
|
|
||||||
cut = y
|
|
||||||
prev = current
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
if cut:
|
|
||||||
rgb = rgb.crop((0, cut, rgb.width, rgb.height))
|
|
||||||
|
|
||||||
return rgb
|
|
||||||
|
|
||||||
|
|
||||||
def row_brightness(img: Image.Image, y: int, x0: int, x1: int) -> float:
|
|
||||||
pixels = img.crop((x0, y, x1, y + 1)).getdata()
|
|
||||||
count = max(1, x1 - x0)
|
|
||||||
return sum(0.299 * r + 0.587 * g + 0.114 * b for r, g, b in pixels) / count
|
|
||||||
|
|
||||||
|
|
||||||
def scaled_fixed_crop_box(img: Image.Image) -> tuple[int, int, int, int]:
|
|
||||||
sx = img.width / BASE_CARD_SIZE[0]
|
|
||||||
sy = img.height / BASE_CARD_SIZE[1]
|
|
||||||
left = round(FIXED_CROP[0] * sx)
|
|
||||||
top = round(FIXED_CROP[1] * sy)
|
|
||||||
right = img.width - round(FIXED_CROP[2] * sx)
|
|
||||||
bottom = img.height - round(FIXED_CROP[3] * sy)
|
|
||||||
return (left, top, right, bottom)
|
|
||||||
|
|
||||||
|
|
||||||
def fixed_crop_card(img: Image.Image) -> Image.Image:
|
|
||||||
return img.convert("RGB").crop(scaled_fixed_crop_box(img))
|
|
||||||
|
|
||||||
|
|
||||||
def cropped_card_scale(img: Image.Image) -> float:
|
|
||||||
cropped_base_w = BASE_CARD_SIZE[0] - FIXED_CROP[0] - FIXED_CROP[2]
|
|
||||||
cropped_base_h = BASE_CARD_SIZE[1] - FIXED_CROP[1] - FIXED_CROP[3]
|
|
||||||
return min(img.width / cropped_base_w, img.height / cropped_base_h)
|
|
||||||
|
|
||||||
|
|
||||||
def full_card_scale(img: Image.Image) -> float:
|
|
||||||
return min(img.width / BASE_CARD_SIZE[0], img.height / BASE_CARD_SIZE[1])
|
|
||||||
|
|
||||||
|
|
||||||
def scaled_card_px(img: Image.Image, px: int) -> int:
|
|
||||||
return max(1, round(px * cropped_card_scale(img)))
|
|
||||||
|
|
||||||
|
|
||||||
def scaled_full_card_px(img: Image.Image, px: int) -> int:
|
|
||||||
return max(1, round(px * full_card_scale(img)))
|
|
||||||
|
|
||||||
|
|
||||||
def replace_side_edge_band(out: Image.Image, y0: int, y1: int, px: int) -> None:
|
|
||||||
if y1 <= y0 or px <= 0:
|
|
||||||
return
|
|
||||||
px = min(px, out.width // 4)
|
|
||||||
if px <= 0:
|
|
||||||
return
|
|
||||||
out.paste(out.crop((px, y0, px + 1, y1)).resize((px, y1 - y0)), (0, y0))
|
|
||||||
out.paste(out.crop((out.width - px - 1, y0, out.width - px, y1)).resize((px, y1 - y0)), (out.width - px, y0))
|
|
||||||
|
|
||||||
|
|
||||||
def replace_standard_card_edges(img: Image.Image) -> Image.Image:
|
|
||||||
out = img.copy()
|
|
||||||
for start, end, band_px in SIDE_EDGE_REPLACE_BANDS:
|
|
||||||
y0 = round(out.height * start)
|
|
||||||
y1 = round(out.height * end)
|
|
||||||
replace_side_edge_band(out, y0, y1, scaled_card_px(out, band_px))
|
|
||||||
|
|
||||||
px = min(scaled_card_px(out, TOP_BOTTOM_EDGE_REPLACE_PX), out.height // 4)
|
|
||||||
if px > 0:
|
|
||||||
out.paste(out.crop((0, px, out.width, px + 1)).resize((out.width, px)), (0, 0))
|
|
||||||
out.paste(out.crop((0, out.height - px - 1, out.width, out.height - px)).resize((out.width, px)), (0, out.height - px))
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def replace_uniform_edges(img: Image.Image, px: int) -> Image.Image:
|
|
||||||
px = min(px, img.width // 4, img.height // 4)
|
|
||||||
if px <= 0:
|
|
||||||
return img
|
|
||||||
out = img.copy()
|
|
||||||
out.paste(out.crop((px, 0, px + 1, out.height)).resize((px, out.height)), (0, 0))
|
|
||||||
out.paste(out.crop((out.width - px - 1, 0, out.width - px, out.height)).resize((px, out.height)), (out.width - px, 0))
|
|
||||||
out.paste(out.crop((0, px, out.width, px + 1)).resize((out.width, px)), (0, 0))
|
|
||||||
out.paste(out.crop((0, out.height - px - 1, out.width, out.height - px)).resize((out.width, px)), (0, out.height - px))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def replace_character_edges(img: Image.Image) -> Image.Image:
|
|
||||||
px = scaled_full_card_px(img, CHARACTER_EDGE_REPLACE_PX)
|
|
||||||
return replace_uniform_edges(img, px)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_corner_triangles(img: Image.Image, size: int, color: tuple[int, int, int]) -> Image.Image:
|
|
||||||
size = min(size, img.width // 4, img.height // 4)
|
|
||||||
if size <= 0:
|
|
||||||
return img
|
|
||||||
out = img.copy()
|
|
||||||
draw = ImageDraw.Draw(out)
|
|
||||||
draw.polygon([(0, 0), (size, 0), (0, size)], fill=color)
|
|
||||||
draw.polygon([(out.width - 1, 0), (out.width - 1 - size, 0), (out.width - 1, size)], fill=color)
|
|
||||||
draw.polygon([(0, out.height - 1), (size, out.height - 1), (0, out.height - 1 - size)], fill=color)
|
|
||||||
draw.polygon(
|
|
||||||
[(out.width - 1, out.height - 1), (out.width - 1 - size, out.height - 1), (out.width - 1, out.height - 1 - size)],
|
|
||||||
fill=color,
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def character_bleed_crop(img: Image.Image, target_size: tuple[int, int], bleed_px: int) -> Image.Image:
|
|
||||||
crop_box = scaled_fixed_crop_box(img)
|
|
||||||
crop_w = crop_box[2] - crop_box[0]
|
|
||||||
crop_h = crop_box[3] - crop_box[1]
|
|
||||||
extra_x = round(bleed_px * crop_w / target_size[0])
|
|
||||||
extra_y = round(bleed_px * crop_h / target_size[1])
|
|
||||||
expanded = (
|
|
||||||
max(0, crop_box[0] - extra_x),
|
|
||||||
max(0, crop_box[1] - extra_y),
|
|
||||||
min(img.width, crop_box[2] + extra_x),
|
|
||||||
min(img.height, crop_box[3] + extra_y),
|
|
||||||
)
|
|
||||||
return img.convert("RGB").crop(expanded).resize(
|
|
||||||
(target_size[0] + 2 * bleed_px, target_size[1] + 2 * bleed_px),
|
|
||||||
Image.Resampling.LANCZOS,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_print_adjustments(
|
|
||||||
img: Image.Image,
|
|
||||||
config: BuildConfig,
|
|
||||||
target_size: tuple[int, int],
|
|
||||||
crop_mode: str = "board",
|
|
||||||
) -> Image.Image:
|
|
||||||
out = img.convert("RGB")
|
|
||||||
if crop_mode == "character_card":
|
|
||||||
if config.crop_black_borders:
|
|
||||||
out = fixed_crop_card(out)
|
|
||||||
else:
|
|
||||||
out = replace_character_edges(out)
|
|
||||||
elif crop_mode == "rendered_standard_card":
|
|
||||||
pass
|
|
||||||
elif config.crop_black_borders:
|
|
||||||
if crop_mode == "pre_cropped_standard_card":
|
|
||||||
pass
|
|
||||||
elif crop_mode == "standard_card":
|
|
||||||
out = fixed_crop_card(out)
|
|
||||||
out = replace_standard_card_edges(out)
|
|
||||||
else:
|
|
||||||
out = smart_trim(out)
|
|
||||||
out = out.resize(target_size, Image.Resampling.LANCZOS)
|
|
||||||
if crop_mode in {"rendered_standard_card", "standard_card", "pre_cropped_standard_card", "character_card"}:
|
|
||||||
triangle_color = (0, 0, 0) if config.crop_black_borders else (255, 255, 255)
|
|
||||||
out = draw_corner_triangles(out, mm_to_px(config.bleed_mm, config.dpi), triangle_color)
|
|
||||||
if crop_mode == "board" and config.clean_edge_mm > 0:
|
|
||||||
out = clean_edges(out, mm_to_px(config.clean_edge_mm, config.dpi))
|
|
||||||
if config.gamma_enabled:
|
|
||||||
out = apply_gamma(out, config.gamma, config.black_point)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def apply_gamma(img: Image.Image, gamma: float, black_point: float) -> Image.Image:
|
def apply_gamma(img: Image.Image, gamma: float, black_point: float) -> Image.Image:
|
||||||
scale = max(1e-9, 1.0 - black_point)
|
scale = max(1e-9, 1.0 - black_point)
|
||||||
lut = []
|
lut = []
|
||||||
@@ -478,37 +433,6 @@ def apply_gamma(img: Image.Image, gamma: float, black_point: float) -> Image.Ima
|
|||||||
return img.point(lut * 3)
|
return img.point(lut * 3)
|
||||||
|
|
||||||
|
|
||||||
def clean_edges(img: Image.Image, px: int) -> Image.Image:
|
|
||||||
if px <= 0:
|
|
||||||
return img
|
|
||||||
px = min(px, img.width // 4, img.height // 4)
|
|
||||||
if px <= 0:
|
|
||||||
return img
|
|
||||||
out = img.copy()
|
|
||||||
out.paste(out.crop((px, 0, px + 1, out.height)).resize((px, out.height)), (0, 0))
|
|
||||||
out.paste(out.crop((out.width - px - 1, 0, out.width - px, out.height)).resize((px, out.height)), (out.width - px, 0))
|
|
||||||
out.paste(out.crop((0, px, out.width, px + 1)).resize((out.width, px)), (0, 0))
|
|
||||||
out.paste(out.crop((0, out.height - px - 1, out.width, out.height - px)).resize((out.width, px)), (0, out.height - px))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def make_bleed(img: Image.Image, bleed_px: int) -> Image.Image:
|
|
||||||
if bleed_px <= 0:
|
|
||||||
return img
|
|
||||||
w, h = img.size
|
|
||||||
out = Image.new("RGB", (w + 2 * bleed_px, h + 2 * bleed_px))
|
|
||||||
out.paste(img, (bleed_px, bleed_px))
|
|
||||||
out.paste(img.crop((0, 0, 1, h)).resize((bleed_px, h)), (0, bleed_px))
|
|
||||||
out.paste(img.crop((w - 1, 0, w, h)).resize((bleed_px, h)), (bleed_px + w, bleed_px))
|
|
||||||
out.paste(img.crop((0, 0, w, 1)).resize((w, bleed_px)), (bleed_px, 0))
|
|
||||||
out.paste(img.crop((0, h - 1, w, h)).resize((w, bleed_px)), (bleed_px, bleed_px + h))
|
|
||||||
out.paste(img.crop((0, 0, 1, 1)).resize((bleed_px, bleed_px)), (0, 0))
|
|
||||||
out.paste(img.crop((w - 1, 0, w, 1)).resize((bleed_px, bleed_px)), (bleed_px + w, 0))
|
|
||||||
out.paste(img.crop((0, h - 1, 1, h)).resize((bleed_px, bleed_px)), (0, bleed_px + h))
|
|
||||||
out.paste(img.crop((w - 1, h - 1, w, h)).resize((bleed_px, bleed_px)), (bleed_px + w, bleed_px + h))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def render_standard_front_job(job: dict) -> Path:
|
def render_standard_front_job(job: dict) -> Path:
|
||||||
deck_dir = job["deck_dir"]
|
deck_dir = job["deck_dir"]
|
||||||
meta = job["meta"]
|
meta = job["meta"]
|
||||||
@@ -517,19 +441,18 @@ def render_standard_front_job(job: dict) -> Path:
|
|||||||
card_px = job["card_px"]
|
card_px = job["card_px"]
|
||||||
starter_color = job["starter_color"]
|
starter_color = job["starter_color"]
|
||||||
out = job["out"]
|
out = job["out"]
|
||||||
|
bleed_out = job["bleed_out"]
|
||||||
front_template = load_rgb(deck_dir / "front.png")
|
front_template = load_rgb(deck_dir / "front.png")
|
||||||
image = render_card_image(
|
full = render_card_image(
|
||||||
deck_dir,
|
deck_dir,
|
||||||
meta,
|
meta,
|
||||||
card,
|
card,
|
||||||
front_template,
|
front_template,
|
||||||
starter_color,
|
starter_color,
|
||||||
card_px,
|
card_px,
|
||||||
crop_edges_before_text=config.crop_black_borders,
|
config,
|
||||||
)
|
)
|
||||||
adjusted = apply_print_adjustments(image, config, card_px, "rendered_standard_card")
|
save_print_images(full, out, bleed_out, card_px, bleed_px(config), config)
|
||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
save_temp_png(adjusted, out, config.dpi)
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -547,7 +470,6 @@ def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem
|
|||||||
deck_dir = DECKS_DIR / deck_name
|
deck_dir = DECKS_DIR / deck_name
|
||||||
meta = load_json(deck_dir / "deck.json")
|
meta = load_json(deck_dir / "deck.json")
|
||||||
back_template = load_rgb(deck_dir / "back.png").convert("RGB")
|
back_template = load_rgb(deck_dir / "back.png").convert("RGB")
|
||||||
work_scale = render_work_scale(meta, card_px)
|
|
||||||
deck_cards = sorted(p for p in deck_dir.glob("*.json") if p.name != "deck.json")
|
deck_cards = sorted(p for p in deck_dir.glob("*.json") if p.name != "deck.json")
|
||||||
rendered_fronts: list[Path] = []
|
rendered_fronts: list[Path] = []
|
||||||
front_jobs: list[dict] = []
|
front_jobs: list[dict] = []
|
||||||
@@ -561,12 +483,13 @@ def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem
|
|||||||
copies = config.rent_raised_count
|
copies = config.rent_raised_count
|
||||||
is_starter = bool(card.get("isStarter"))
|
is_starter = bool(card.get("isStarter"))
|
||||||
colors = starter_colors_for_deck(group, deck_name, meta, config) if is_starter else [None]
|
colors = starter_colors_for_deck(group, deck_name, meta, config) if is_starter else [None]
|
||||||
rendered_by_color: dict[str | None, Path] = {}
|
rendered_by_color: dict[str | None, tuple[Path, Path]] = {}
|
||||||
for color_index, starter_color in enumerate(colors):
|
for color_index, starter_color in enumerate(colors):
|
||||||
out = rendered_by_color.get(starter_color)
|
rendered = rendered_by_color.get(starter_color)
|
||||||
if out is None:
|
if rendered is None:
|
||||||
out = RENDER_DIR / "cards" / group / "fronts" / f"{deck_name}_{safe_name(card.get('title', card_json.stem))}_{color_index}.png"
|
out = RENDER_DIR / "cards" / group / "fronts" / f"{deck_name}_{safe_name(card.get('title', card_json.stem))}_{color_index}.png"
|
||||||
rendered_by_color[starter_color] = out
|
bleed_out = RENDER_DIR / "cards" / group / "fronts" / "bleed" / f"{deck_name}_{safe_name(card.get('title', card_json.stem))}_{color_index}.png"
|
||||||
|
rendered_by_color[starter_color] = (out, bleed_out)
|
||||||
front_jobs.append(
|
front_jobs.append(
|
||||||
{
|
{
|
||||||
"deck_dir": deck_dir,
|
"deck_dir": deck_dir,
|
||||||
@@ -576,30 +499,36 @@ def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem
|
|||||||
"card_px": card_px,
|
"card_px": card_px,
|
||||||
"starter_color": starter_color,
|
"starter_color": starter_color,
|
||||||
"out": out,
|
"out": out,
|
||||||
|
"bleed_out": bleed_out,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
out, bleed_out = rendered
|
||||||
for _ in range(copies):
|
for _ in range(copies):
|
||||||
rendered_fronts.append(out)
|
rendered_fronts.append(out)
|
||||||
label = card.get("title", card_json.stem)
|
label = card.get("title", card_json.stem)
|
||||||
card_items.append(PrintItem(out, group, label, "front", is_starter, label.lower()))
|
card_items.append(PrintItem(out, group, label, "front", is_starter, label.lower(), bleed_out))
|
||||||
|
|
||||||
with ProcessPoolExecutor(max_workers=worker_count(len(front_jobs))) as executor:
|
with ProcessPoolExecutor(max_workers=worker_count(len(front_jobs))) as executor:
|
||||||
list(executor.map(render_standard_front_job, front_jobs))
|
list(executor.map(render_standard_front_job, front_jobs))
|
||||||
|
|
||||||
back_work = back_template.resize(
|
back_source = back_template
|
||||||
(round(back_template.width * work_scale), round(back_template.height * work_scale)),
|
if config.crop_black_borders:
|
||||||
Image.Resampling.LANCZOS,
|
back_source = replace_source_dirty_pixels(back_source, card_source_crop(config))
|
||||||
)
|
if config.gamma_enabled:
|
||||||
adjusted_back = apply_print_adjustments(back_work, config, card_px, "standard_card")
|
back_source = apply_gamma(back_source, config.gamma, config.black_point)
|
||||||
|
adjusted_back, back_bleed = transformed_print_image(back_source, card_px, config, card_source_crop(config))
|
||||||
|
adjusted_back = draw_corner_bleed_marks(adjusted_back, config, group, False)
|
||||||
back_out = RENDER_DIR / "cards" / group / "backs" / f"{deck_name}_back.png"
|
back_out = RENDER_DIR / "cards" / group / "backs" / f"{deck_name}_back.png"
|
||||||
back_out.parent.mkdir(parents=True, exist_ok=True)
|
back_bleed_out = RENDER_DIR / "cards" / group / "backs" / "bleed" / f"{deck_name}_back.png"
|
||||||
save_temp_png(adjusted_back, back_out, config.dpi)
|
save_print_images(adjusted_back, back_out, back_bleed_out, card_px, back_bleed, config)
|
||||||
for i in range(len(rendered_fronts)):
|
for i in range(len(rendered_fronts)):
|
||||||
card_items.append(PrintItem(back_out, group, f"{deck_name} back {i + 1}", "back", False, deck_name.lower()))
|
card_items.append(PrintItem(back_out, group, f"{deck_name} back {i + 1}", "back", False, deck_name.lower(), back_bleed_out))
|
||||||
|
|
||||||
char_dir = DECKS_DIR / CHARACTER_DECK
|
char_dir = DECKS_DIR / CHARACTER_DECK
|
||||||
char_meta = load_json(char_dir / "deck.json")
|
char_meta = load_json(char_dir / "deck.json")
|
||||||
char_layout = standard_text_layout(char_meta, card_px, config.crop_black_borders)
|
char_crop = card_source_crop(config)
|
||||||
|
char_layout, char_bleed = text_layout_for_full_bleed(char_meta, card_px, char_crop, config)
|
||||||
for card_json in sorted(p for p in char_dir.glob("*.json") if p.name != "deck.json"):
|
for card_json in sorted(p for p in char_dir.glob("*.json") if p.name != "deck.json"):
|
||||||
art = find_art_for_json(card_json)
|
art = find_art_for_json(card_json)
|
||||||
if art is None:
|
if art is None:
|
||||||
@@ -608,11 +537,16 @@ def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem
|
|||||||
face = cover_resize(load_rgb(art), (int(char_meta["artWidth"]), int(char_meta["artHeight"])))
|
face = cover_resize(load_rgb(art), (int(char_meta["artWidth"]), int(char_meta["artHeight"])))
|
||||||
canvas.alpha_composite(face, (int(char_meta["artOffsetX"]), int(char_meta["artOffsetY"])))
|
canvas.alpha_composite(face, (int(char_meta["artOffsetX"]), int(char_meta["artOffsetY"])))
|
||||||
character_source = canvas.convert("RGB")
|
character_source = canvas.convert("RGB")
|
||||||
adjusted = apply_print_adjustments(character_source, config, card_px, "character_card")
|
if config.gamma_enabled:
|
||||||
|
character_source = apply_gamma(character_source, config.gamma, config.black_point)
|
||||||
|
adjusted, char_bleed = transformed_print_image(character_source, card_px, config, char_crop)
|
||||||
|
adjusted = adjusted.convert("RGBA")
|
||||||
|
ss = config.text_supersampling
|
||||||
|
text_layer = Image.new("RGBA", (adjusted.width * ss, adjusted.height * ss), (0, 0, 0, 0))
|
||||||
if config.project_version:
|
if config.project_version:
|
||||||
small = FONTS.text(font_size_y(char_layout, 16))
|
small = FONTS.text(font_size_y(char_layout, 16), 16)
|
||||||
draw_text_box(
|
draw_text_box(
|
||||||
adjusted,
|
text_layer,
|
||||||
config.project_version,
|
config.project_version,
|
||||||
(
|
(
|
||||||
tx(char_layout, int(char_meta["cardWidth"]) - 252),
|
tx(char_layout, int(char_meta["cardWidth"]) - 252),
|
||||||
@@ -626,23 +560,12 @@ def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem
|
|||||||
0.5,
|
0.5,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
text_layer = text_layer.resize(adjusted.size, Image.Resampling.LANCZOS)
|
||||||
|
adjusted.alpha_composite(text_layer)
|
||||||
|
adjusted = draw_corner_bleed_marks(adjusted.convert("RGB"), config, "character", True)
|
||||||
out = RENDER_DIR / "cards" / "characters" / f"{safe_name(card_json.stem)}.png"
|
out = RENDER_DIR / "cards" / "characters" / f"{safe_name(card_json.stem)}.png"
|
||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
bleed_out = RENDER_DIR / "cards" / "characters" / "bleed" / f"{safe_name(card_json.stem)}.png"
|
||||||
save_temp_png(adjusted, out, config.dpi)
|
save_print_images(adjusted, out, bleed_out, card_px, char_bleed, config)
|
||||||
bleed_out = None
|
|
||||||
if config.crop_black_borders and config.bleed_mm > 0:
|
|
||||||
bleed_px = mm_to_px(config.bleed_mm, config.dpi)
|
|
||||||
bleed_img = character_bleed_crop(character_source, card_px, bleed_px)
|
|
||||||
corner_size = 2 * bleed_px
|
|
||||||
ImageDraw.Draw(bleed_img).rectangle(
|
|
||||||
(0, bleed_img.height - corner_size, corner_size - 1, bleed_img.height - 1),
|
|
||||||
fill=(0, 0, 0),
|
|
||||||
)
|
|
||||||
if config.gamma_enabled:
|
|
||||||
bleed_img = apply_gamma(bleed_img, config.gamma, config.black_point)
|
|
||||||
bleed_out = RENDER_DIR / "cards" / "characters" / "bleed" / f"{safe_name(card_json.stem)}.png"
|
|
||||||
bleed_out.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
save_temp_png(bleed_img, bleed_out, config.dpi)
|
|
||||||
# Character edges are unique and not cut-neighbor compatible, so each
|
# Character edges are unique and not cut-neighbor compatible, so each
|
||||||
# duplicate is treated as its own print group.
|
# duplicate is treated as its own print group.
|
||||||
card_items.append(PrintItem(out, f"character:{card_json.stem}:1", card_json.stem, "front", False, card_json.stem.lower(), bleed_out))
|
card_items.append(PrintItem(out, f"character:{card_json.stem}:1", card_json.stem, "front", False, card_json.stem.lower(), bleed_out))
|
||||||
@@ -652,12 +575,14 @@ def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem
|
|||||||
for board_path in sorted(board_dir.glob("*-jumbo-front.png")):
|
for board_path in sorted(board_dir.glob("*-jumbo-front.png")):
|
||||||
target_mm = board_target_mm(board_path, config)
|
target_mm = board_target_mm(board_path, config)
|
||||||
target_px = (mm_to_px(target_mm[0], config.dpi), mm_to_px(target_mm[1], config.dpi))
|
target_px = (mm_to_px(target_mm[0], config.dpi), mm_to_px(target_mm[1], config.dpi))
|
||||||
image = crop_board_bleed(load_rgb(board_path).convert("RGB"))
|
image = load_rgb(board_path).convert("RGB")
|
||||||
adjusted = apply_print_adjustments(image, config, target_px, "board")
|
if config.gamma_enabled:
|
||||||
|
image = apply_gamma(image, config.gamma, config.black_point)
|
||||||
|
adjusted, board_bleed = transformed_print_image(image, target_px, config, board_source_crop())
|
||||||
out = RENDER_DIR / "boards" / board_path.name
|
out = RENDER_DIR / "boards" / board_path.name
|
||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
bleed_out = RENDER_DIR / "boards" / "bleed" / board_path.name
|
||||||
save_temp_png(adjusted, out, config.dpi)
|
save_print_images(adjusted, out, bleed_out, target_px, board_bleed, config)
|
||||||
board_items.append(PrintItem(out, "board", board_path.stem, "board"))
|
board_items.append(PrintItem(out, "board", board_path.stem, "board", False, "", bleed_out))
|
||||||
|
|
||||||
return card_items, board_items
|
return card_items, board_items
|
||||||
|
|
||||||
|
|||||||
+20
-2
@@ -18,6 +18,7 @@ from .settings import (
|
|||||||
RENDER_DIR,
|
RENDER_DIR,
|
||||||
ROOT,
|
ROOT,
|
||||||
TEMP_DIR,
|
TEMP_DIR,
|
||||||
|
TEXT_SUPERSAMPLING,
|
||||||
BuildConfig,
|
BuildConfig,
|
||||||
mm_to_px,
|
mm_to_px,
|
||||||
)
|
)
|
||||||
@@ -45,8 +46,8 @@ def build_printable(config: BuildConfig) -> Path | None:
|
|||||||
|
|
||||||
cols, rows = grid_capacity(config)
|
cols, rows = grid_capacity(config)
|
||||||
print(f"Card grid: {cols} columns x {rows} rows on {config.paper_name.upper()} ({cols * rows} images/page)")
|
print(f"Card grid: {cols} columns x {rows} rows on {config.paper_name.upper()} ({cols * rows} images/page)")
|
||||||
next_page, deferred_character_items = render_card_pages(card_items, config, 1)
|
next_page, deferred_character_items, remaining_board_items = render_card_pages(card_items, config, 1, board_items)
|
||||||
next_page = render_board_pages(board_items, config, next_page, deferred_character_items)
|
next_page = render_board_pages(remaining_board_items, config, next_page, deferred_character_items)
|
||||||
pdf = write_pdf(config)
|
pdf = write_pdf(config)
|
||||||
if pdf:
|
if pdf:
|
||||||
print(f"Wrote {pdf.relative_to(ROOT)}")
|
print(f"Wrote {pdf.relative_to(ROOT)}")
|
||||||
@@ -123,6 +124,16 @@ def prompt_positive_int(label: str, default: int) -> int:
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def positive_int_arg(value: str) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except ValueError as error:
|
||||||
|
raise argparse.ArgumentTypeError("must be a positive integer") from error
|
||||||
|
if parsed <= 0:
|
||||||
|
raise argparse.ArgumentTypeError("must be a positive integer")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def prompt_nonnegative_float(label: str, default: float) -> float:
|
def prompt_nonnegative_float(label: str, default: float) -> float:
|
||||||
value = input(f"{label} [{default:g}]: ").strip()
|
value = input(f"{label} [{default:g}]: ").strip()
|
||||||
if not value:
|
if not value:
|
||||||
@@ -263,6 +274,7 @@ def default_config() -> BuildConfig:
|
|||||||
clean_edge_mm=0.35,
|
clean_edge_mm=0.35,
|
||||||
output_pdf_name="",
|
output_pdf_name="",
|
||||||
delete_temp=True,
|
delete_temp=True,
|
||||||
|
text_supersampling=TEXT_SUPERSAMPLING,
|
||||||
render_only=False,
|
render_only=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -328,6 +340,7 @@ def interactive_config(args: argparse.Namespace) -> BuildConfig:
|
|||||||
clean_edge_mm=default.clean_edge_mm,
|
clean_edge_mm=default.clean_edge_mm,
|
||||||
output_pdf_name=output_pdf_name,
|
output_pdf_name=output_pdf_name,
|
||||||
delete_temp=None,
|
delete_temp=None,
|
||||||
|
text_supersampling=args.text_supersampling or default.text_supersampling,
|
||||||
render_only=default.render_only,
|
render_only=default.render_only,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -352,6 +365,7 @@ def parse_args(argv=None) -> argparse.Namespace:
|
|||||||
parser.add_argument("--render-only", action="store_true", help="Render source card/board PNGs and skip page/PDF creation.")
|
parser.add_argument("--render-only", action="store_true", help="Render source card/board PNGs and skip page/PDF creation.")
|
||||||
parser.add_argument("--output", help="Output PDF filename under print/. Adds .pdf if missing.")
|
parser.add_argument("--output", help="Output PDF filename under print/. Adds .pdf if missing.")
|
||||||
parser.add_argument("--rent-raised", type=int, help="Number of Rent raised encounter cards to print.")
|
parser.add_argument("--rent-raised", type=int, help="Number of Rent raised encounter cards to print.")
|
||||||
|
parser.add_argument("--text-supersampling", type=positive_int_arg, metavar="N", help="Render text at N times final resolution before downscaling.")
|
||||||
parser.add_argument("--keep-temp", action="store_true", help="Keep temporary rendered PNGs under print/temp/.")
|
parser.add_argument("--keep-temp", action="store_true", help="Keep temporary rendered PNGs under print/temp/.")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--starter-colors",
|
"--starter-colors",
|
||||||
@@ -411,6 +425,7 @@ def apply_overrides(config: BuildConfig, args: argparse.Namespace) -> BuildConfi
|
|||||||
clean_edge_mm=config.clean_edge_mm,
|
clean_edge_mm=config.clean_edge_mm,
|
||||||
output_pdf_name=args.output if args.output is not None else config.output_pdf_name,
|
output_pdf_name=args.output if args.output is not None else config.output_pdf_name,
|
||||||
delete_temp=delete_temp,
|
delete_temp=delete_temp,
|
||||||
|
text_supersampling=args.text_supersampling or config.text_supersampling,
|
||||||
render_only=args.render_only or config.render_only,
|
render_only=args.render_only or config.render_only,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -435,6 +450,7 @@ def with_board_size(config: BuildConfig, board_mode: str, board_custom_mm: tuple
|
|||||||
clean_edge_mm=config.clean_edge_mm,
|
clean_edge_mm=config.clean_edge_mm,
|
||||||
output_pdf_name=config.output_pdf_name,
|
output_pdf_name=config.output_pdf_name,
|
||||||
delete_temp=config.delete_temp,
|
delete_temp=config.delete_temp,
|
||||||
|
text_supersampling=config.text_supersampling,
|
||||||
render_only=config.render_only,
|
render_only=config.render_only,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -475,6 +491,7 @@ def main(argv=None) -> None:
|
|||||||
clean_edge_mm=config.clean_edge_mm,
|
clean_edge_mm=config.clean_edge_mm,
|
||||||
output_pdf_name=config.output_pdf_name,
|
output_pdf_name=config.output_pdf_name,
|
||||||
delete_temp=config.delete_temp,
|
delete_temp=config.delete_temp,
|
||||||
|
text_supersampling=config.text_supersampling,
|
||||||
render_only=config.render_only,
|
render_only=config.render_only,
|
||||||
)
|
)
|
||||||
config = apply_overrides(config, args)
|
config = apply_overrides(config, args)
|
||||||
@@ -495,6 +512,7 @@ def main(argv=None) -> None:
|
|||||||
bleed: {config.bleed_mm} mm ({mm_to_px(config.bleed_mm, config.dpi)} px at {config.dpi} DPI)
|
bleed: {config.bleed_mm} mm ({mm_to_px(config.bleed_mm, config.dpi)} px at {config.dpi} DPI)
|
||||||
crop black borders: {'yes' if config.crop_black_borders else 'no'}
|
crop black borders: {'yes' if config.crop_black_borders else 'no'}
|
||||||
gamma: {'off' if not config.gamma_enabled else f'{config.gamma} with black point {config.black_point}'}
|
gamma: {'off' if not config.gamma_enabled else f'{config.gamma} with black point {config.black_point}'}
|
||||||
|
text supersampling: {config.text_supersampling}
|
||||||
output: {'rendered PNGs only' if config.render_only else 'rendered PNGs, page PNGs, and PDF'}
|
output: {'rendered PNGs only' if config.render_only else 'rendered PNGs, page PNGs, and PDF'}
|
||||||
pdf name: {(config.output_pdf_name.strip() or 'blockminers_printable.pdf') if not config.render_only else '<not written>'}
|
pdf name: {(config.output_pdf_name.strip() or 'blockminers_printable.pdf') if not config.render_only else '<not written>'}
|
||||||
temp files: {'kept for render-only output' if config.render_only else 'ask after build' if config.delete_temp is None else 'deleted after build' if config.delete_temp else 'kept under print/temp'}
|
temp files: {'kept for render-only output' if config.render_only else 'ask after build' if config.delete_temp is None else 'deleted after build' if config.delete_temp else 'kept under print/temp'}
|
||||||
|
|||||||
+706
-232
File diff suppressed because it is too large
Load Diff
+3
-9
@@ -61,17 +61,10 @@ CARD_SIZE_OPTIONS_MM = {
|
|||||||
TEXT_INDENT_CHARS = 2
|
TEXT_INDENT_CHARS = 2
|
||||||
ITALIC_SHEAR = 0.18
|
ITALIC_SHEAR = 0.18
|
||||||
BOLD_OFFSETS = ((1, 0), (0, 1), (1, 1))
|
BOLD_OFFSETS = ((1, 0), (0, 1), (1, 1))
|
||||||
BASE_CARD_SIZE = (825, 1125)
|
|
||||||
FIXED_CROP = (70, 73, 70, 73)
|
FIXED_CROP = (70, 73, 70, 73)
|
||||||
SIDE_EDGE_REPLACE_BANDS = (
|
|
||||||
(0.0, 0.2, 10),
|
|
||||||
(0.2, 0.4, 3),
|
|
||||||
(0.4, 0.6, 1),
|
|
||||||
(0.6, 0.8, 3),
|
|
||||||
(0.8, 1.0, 10),
|
|
||||||
)
|
|
||||||
TOP_BOTTOM_EDGE_REPLACE_PX = 10
|
TOP_BOTTOM_EDGE_REPLACE_PX = 10
|
||||||
CHARACTER_EDGE_REPLACE_PX = 3
|
DEFAULT_SOURCE_CROP_PX = 37.5
|
||||||
|
TEXT_SUPERSAMPLING = 3
|
||||||
MAX_WORKERS = 8
|
MAX_WORKERS = 8
|
||||||
PDF_COMPRESSION_LEVEL = 9
|
PDF_COMPRESSION_LEVEL = 9
|
||||||
|
|
||||||
@@ -96,6 +89,7 @@ class BuildConfig:
|
|||||||
clean_edge_mm: float
|
clean_edge_mm: float
|
||||||
output_pdf_name: str
|
output_pdf_name: str
|
||||||
delete_temp: bool | None
|
delete_temp: bool | None
|
||||||
|
text_supersampling: int
|
||||||
render_only: bool = False
|
render_only: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+74
-43
@@ -9,7 +9,6 @@ from PIL import Image, ImageChops, ImageDraw, ImageFont
|
|||||||
|
|
||||||
from .assets import meta_unit_scale
|
from .assets import meta_unit_scale
|
||||||
from .settings import (
|
from .settings import (
|
||||||
BASE_CARD_SIZE,
|
|
||||||
BOLD_OFFSETS,
|
BOLD_OFFSETS,
|
||||||
DECK_GROUPS,
|
DECK_GROUPS,
|
||||||
FIXED_CROP,
|
FIXED_CROP,
|
||||||
@@ -19,7 +18,6 @@ from .settings import (
|
|||||||
TEXT_AREA_BOTTOM_CROPPED_RATIO,
|
TEXT_AREA_BOTTOM_CROPPED_RATIO,
|
||||||
TEXT_AREA_TOP_CROPPED_RATIOS,
|
TEXT_AREA_TOP_CROPPED_RATIOS,
|
||||||
TEXT_INDENT_CHARS,
|
TEXT_INDENT_CHARS,
|
||||||
TITLE_WIDTH_LIMITS,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -29,14 +27,19 @@ class Fonts:
|
|||||||
self._consolas = FONTS_DIR / "consolas.ttf"
|
self._consolas = FONTS_DIR / "consolas.ttf"
|
||||||
self._unicode = Path("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf")
|
self._unicode = Path("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf")
|
||||||
self._cache: dict[tuple[str, int], ImageFont.FreeTypeFont | ImageFont.ImageFont] = {}
|
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()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
def title(self, java_size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
def title(self, java_size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||||
size = max(8, int(java_size / 3) * 2)
|
size = max(8, int(java_size / 3) * 2)
|
||||||
return self._font("xirod", self._xirod, size)
|
return self._font("xirod", self._xirod, size)
|
||||||
|
|
||||||
def text(self, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
def text(self, size: int, source_size: float | None = None) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||||
return self._font("consolas", self._consolas, max(8, size))
|
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:
|
def unicode_text(self, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||||
return self._font("unicode", self._unicode, max(8, size))
|
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)
|
self._cache[key] = ImageFont.truetype(str(path), size=size, layout_engine=ImageFont.Layout.RAQM)
|
||||||
else:
|
else:
|
||||||
self._cache[key] = ImageFont.load_default()
|
self._cache[key] = ImageFont.load_default()
|
||||||
|
self._families[id(self._cache[key])] = name
|
||||||
return self._cache[key]
|
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()
|
FONTS = Fonts()
|
||||||
|
|
||||||
@@ -119,7 +129,7 @@ def format_card_text(text: str | None) -> str:
|
|||||||
parts = line.split("//", 1)
|
parts = line.split("//", 1)
|
||||||
action = parts[0]
|
action = parts[0]
|
||||||
if not action.strip().endswith(":"):
|
if not action.strip().endswith(":"):
|
||||||
action = re.sub(r" *$", ";", action)
|
action = action.rstrip() + ";"
|
||||||
action = action.replace(";;", "; ").replace(":;", ":")
|
action = action.replace(";;", "; ").replace(":;", ":")
|
||||||
action = KEYWORD_RE.sub(r"<b>\1</b>", action)
|
action = KEYWORD_RE.sub(r"<b>\1</b>", action)
|
||||||
result = leading + action
|
result = leading + action
|
||||||
@@ -239,43 +249,50 @@ def text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont,
|
|||||||
if not text:
|
if not text:
|
||||||
return 0
|
return 0
|
||||||
styles = styles or set()
|
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
|
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):
|
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):
|
for cluster in text_clusters(segment):
|
||||||
layout_text = cluster_layout_text(cluster)
|
layout_text = cluster_layout_text(cluster)
|
||||||
if layout_text:
|
if layout_text:
|
||||||
width += draw.textlength(layout_text, font=segment_font)
|
width += draw.textlength(layout_text, font=measuring_font)
|
||||||
if is_zalgo_cluster(cluster):
|
if is_zalgo_cluster(cluster):
|
||||||
mark_count = sum(1 for char in cluster if unicodedata.combining(char))
|
mark_count = sum(1 for char in cluster if unicodedata.combining(char))
|
||||||
width += zalgo_extra_advance(segment_font, mark_count)
|
width += zalgo_extra_advance(measuring_font, mark_count)
|
||||||
if bold_offset:
|
return round(round(width) * scale + bold_offset)
|
||||||
width += bold_offset
|
|
||||||
return round(width)
|
|
||||||
|
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:
|
def text_line_height(font: ImageFont.ImageFont) -> int:
|
||||||
if hasattr(font, "getmetrics"):
|
source_size = FONTS.source_size(font)
|
||||||
ascent, descent = font.getmetrics()
|
scale = font_px(font) / max(1.0, source_size)
|
||||||
return max(1, ascent + descent + 2)
|
return max(1, round(round(source_size * 35 / 32) * scale))
|
||||||
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:
|
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)
|
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_w = int(meta["cardWidth"])
|
||||||
card_h = int(meta["cardHeight"])
|
card_h = int(meta["cardHeight"])
|
||||||
unit = meta_unit_scale(meta)
|
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)
|
left = round(FIXED_CROP[0] * unit)
|
||||||
top = round(FIXED_CROP[1] * unit)
|
top = round(FIXED_CROP[1] * unit)
|
||||||
right = round(FIXED_CROP[2] * unit)
|
right_crop = round(FIXED_CROP[2] * unit)
|
||||||
bottom = round(FIXED_CROP[3] * unit)
|
bottom_crop = round(FIXED_CROP[3] * unit)
|
||||||
else:
|
else:
|
||||||
left = top = right = bottom = 0
|
left = top = right_crop = bottom_crop = 0
|
||||||
visible_w = max(1, card_w - left - right)
|
visible_w = max(1.0, card_w - left - right_crop)
|
||||||
visible_h = max(1, card_h - top - bottom)
|
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 {
|
return {
|
||||||
"left": left,
|
"left": left,
|
||||||
"top": top,
|
"top": top,
|
||||||
"sx": target_size[0] / visible_w,
|
"sx": sx,
|
||||||
"sy": target_size[1] / visible_h,
|
"sy": sy,
|
||||||
|
"crop_w": visible_w,
|
||||||
|
"crop_h": visible_h,
|
||||||
"target_w": target_size[0],
|
"target_w": target_size[0],
|
||||||
"target_h": target_size[1],
|
"target_h": target_size[1],
|
||||||
}
|
}
|
||||||
@@ -368,7 +399,7 @@ def draw_styled_text(
|
|||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
ascent = font.getmetrics()[0] if hasattr(font, "getmetrics") else font_px(font)
|
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(
|
def draw_zalgo_cluster(
|
||||||
target: Image.Image,
|
target: Image.Image,
|
||||||
@@ -508,7 +539,7 @@ def wrap_runs(
|
|||||||
current_w += text_width(draw, token, font, styles)
|
current_w += text_width(draw, token, font, styles)
|
||||||
continue
|
continue
|
||||||
token_w = text_width(draw, token, font, styles)
|
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:
|
if current and should_wrap:
|
||||||
while current and current[-1][0].isspace():
|
while current and current[-1][0].isspace():
|
||||||
current.pop()
|
current.pop()
|
||||||
|
|||||||
Reference in New Issue
Block a user