Improve print layout options and character packing
This commit is contained in:
@@ -34,6 +34,7 @@ The final PDF is written to `print/`. Temporary rendered images are written to `
|
|||||||
- `--dpi {300,600}`: set output resolution. Default: `300`.
|
- `--dpi {300,600}`: set output resolution. Default: `300`.
|
||||||
- `--paper {a3,a4,legal,letter,tabloid}`: choose paper size. Default: `a3`.
|
- `--paper {a3,a4,legal,letter,tabloid}`: choose paper size. Default: `a3`.
|
||||||
- `--card-size SIZE`: choose card size. Use `bridge`, `mini-euro`, `poker`, `tarot`, or a custom size like `70.0x120.0` in millimeters. Default: `poker`.
|
- `--card-size SIZE`: choose card size. Use `bridge`, `mini-euro`, `poker`, `tarot`, or a custom size like `70.0x120.0` in millimeters. Default: `poker`.
|
||||||
|
- `--margin MM`: set page margin in millimeters. Default: `10`.
|
||||||
- `--crop`: crop black borders from cards. Default: off.
|
- `--crop`: crop black borders from cards. Default: off.
|
||||||
- `--no-gamma`: disable print-brightening gamma correction. Default: gamma correction on.
|
- `--no-gamma`: disable print-brightening gamma correction. Default: gamma correction on.
|
||||||
- `--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.
|
||||||
|
|||||||
@@ -633,6 +633,11 @@ def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem
|
|||||||
if config.crop_black_borders and config.bleed_mm > 0:
|
if config.crop_black_borders and config.bleed_mm > 0:
|
||||||
bleed_px = mm_to_px(config.bleed_mm, config.dpi)
|
bleed_px = mm_to_px(config.bleed_mm, config.dpi)
|
||||||
bleed_img = character_bleed_crop(character_source, card_px, bleed_px)
|
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:
|
if config.gamma_enabled:
|
||||||
bleed_img = apply_gamma(bleed_img, config.gamma, config.black_point)
|
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 = RENDER_DIR / "cards" / "characters" / "bleed" / f"{safe_name(card_json.stem)}.png"
|
||||||
|
|||||||
+27
-4
@@ -98,6 +98,21 @@ def prompt_positive_int(label: str, default: int) -> int:
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_nonnegative_float(label: str, default: float) -> float:
|
||||||
|
value = input(f"{label} [{default:g}]: ").strip()
|
||||||
|
if not value:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
parsed = float(value)
|
||||||
|
except ValueError:
|
||||||
|
print(f"Invalid number '{value}', using {default:g}.")
|
||||||
|
return default
|
||||||
|
if parsed < 0:
|
||||||
|
print(f"Unsupported value '{parsed:g}', using {default:g}.")
|
||||||
|
return default
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def prompt_bool(label: str, default: bool) -> bool:
|
def prompt_bool(label: str, default: bool) -> bool:
|
||||||
suffix = "Y/n" if default else "y/N"
|
suffix = "Y/n" if default else "y/N"
|
||||||
value = input(f"{label} [{suffix}]: ").strip().lower()
|
value = input(f"{label} [{suffix}]: ").strip().lower()
|
||||||
@@ -183,7 +198,7 @@ def default_config() -> BuildConfig:
|
|||||||
project_version=load_project_version(),
|
project_version=load_project_version(),
|
||||||
starter_unique_colors=True,
|
starter_unique_colors=True,
|
||||||
rent_raised_count=3,
|
rent_raised_count=3,
|
||||||
margin_mm=12.0,
|
margin_mm=10.0,
|
||||||
bleed_mm=1.0,
|
bleed_mm=1.0,
|
||||||
clean_edge_mm=0.35,
|
clean_edge_mm=0.35,
|
||||||
output_pdf_name="",
|
output_pdf_name="",
|
||||||
@@ -198,7 +213,8 @@ def interactive_config() -> BuildConfig:
|
|||||||
dpi = prompt_int("DPI", default.dpi, [300, 600])
|
dpi = prompt_int("DPI", default.dpi, [300, 600])
|
||||||
crop = prompt_bool("Crop black borders", default.crop_black_borders)
|
crop = prompt_bool("Crop black borders", default.crop_black_borders)
|
||||||
card_mm = prompt_card_size(default.card_mm)
|
card_mm = prompt_card_size(default.card_mm)
|
||||||
board_mode, custom_board = prompt_board_mode(PAPER_SIZES_MM[paper], default.margin_mm)
|
margin_mm = prompt_nonnegative_float("Page margin in mm", default.margin_mm)
|
||||||
|
board_mode, custom_board = prompt_board_mode(PAPER_SIZES_MM[paper], margin_mm)
|
||||||
unify_starter_colors = prompt_bool("Unify starter badge colors", not default.starter_unique_colors)
|
unify_starter_colors = prompt_bool("Unify starter badge colors", not default.starter_unique_colors)
|
||||||
rent_raised_count = prompt_positive_int("Number of Rent raised", default.rent_raised_count)
|
rent_raised_count = prompt_positive_int("Number of Rent raised", default.rent_raised_count)
|
||||||
gamma_enabled, gamma, black_point = prompt_gamma()
|
gamma_enabled, gamma, black_point = prompt_gamma()
|
||||||
@@ -217,7 +233,7 @@ def interactive_config() -> BuildConfig:
|
|||||||
project_version=default.project_version,
|
project_version=default.project_version,
|
||||||
starter_unique_colors=not unify_starter_colors,
|
starter_unique_colors=not unify_starter_colors,
|
||||||
rent_raised_count=rent_raised_count,
|
rent_raised_count=rent_raised_count,
|
||||||
margin_mm=default.margin_mm,
|
margin_mm=margin_mm,
|
||||||
bleed_mm=default.bleed_mm,
|
bleed_mm=default.bleed_mm,
|
||||||
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,
|
||||||
@@ -232,6 +248,7 @@ def parse_args(argv=None) -> argparse.Namespace:
|
|||||||
parser.add_argument("--dpi", type=int, choices=[300, 600], help="Override DPI.")
|
parser.add_argument("--dpi", type=int, choices=[300, 600], help="Override DPI.")
|
||||||
parser.add_argument("--paper", choices=sorted(PAPER_SIZES_MM), help="Override paper size.")
|
parser.add_argument("--paper", choices=sorted(PAPER_SIZES_MM), help="Override paper size.")
|
||||||
parser.add_argument("--card-size", metavar="SIZE", type=parse_card_size_arg, help="Override card size by name or WIDTHxHEIGHT in mm.")
|
parser.add_argument("--card-size", metavar="SIZE", type=parse_card_size_arg, help="Override card size by name or WIDTHxHEIGHT in mm.")
|
||||||
|
parser.add_argument("--margin", type=float, help="Page margin in millimeters.")
|
||||||
parser.add_argument("--crop", action="store_true", help="Crop black borders.")
|
parser.add_argument("--crop", action="store_true", help="Crop black borders.")
|
||||||
parser.add_argument("--no-gamma", action="store_true", help="Disable print brightening gamma.")
|
parser.add_argument("--no-gamma", action="store_true", help="Disable print brightening gamma.")
|
||||||
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.")
|
||||||
@@ -255,6 +272,11 @@ def apply_overrides(config: BuildConfig, args: argparse.Namespace) -> BuildConfi
|
|||||||
if args.rent_raised <= 0:
|
if args.rent_raised <= 0:
|
||||||
raise SystemExit("--rent-raised must be a positive non-zero integer.")
|
raise SystemExit("--rent-raised must be a positive non-zero integer.")
|
||||||
rent_raised_count = args.rent_raised
|
rent_raised_count = args.rent_raised
|
||||||
|
margin_mm = config.margin_mm
|
||||||
|
if args.margin is not None:
|
||||||
|
if args.margin < 0:
|
||||||
|
raise SystemExit("--margin must not be negative.")
|
||||||
|
margin_mm = args.margin
|
||||||
return BuildConfig(
|
return BuildConfig(
|
||||||
paper_name=paper_name,
|
paper_name=paper_name,
|
||||||
paper_mm=PAPER_SIZES_MM[paper_name],
|
paper_mm=PAPER_SIZES_MM[paper_name],
|
||||||
@@ -269,7 +291,7 @@ def apply_overrides(config: BuildConfig, args: argparse.Namespace) -> BuildConfi
|
|||||||
project_version=config.project_version,
|
project_version=config.project_version,
|
||||||
starter_unique_colors=(args.starter_colors == "separate") if args.starter_colors else config.starter_unique_colors,
|
starter_unique_colors=(args.starter_colors == "separate") if args.starter_colors else config.starter_unique_colors,
|
||||||
rent_raised_count=rent_raised_count,
|
rent_raised_count=rent_raised_count,
|
||||||
margin_mm=config.margin_mm,
|
margin_mm=margin_mm,
|
||||||
bleed_mm=config.bleed_mm,
|
bleed_mm=config.bleed_mm,
|
||||||
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,
|
||||||
@@ -294,6 +316,7 @@ def main(argv=None) -> None:
|
|||||||
card version: {config.project_version or '<none>'}
|
card version: {config.project_version or '<none>'}
|
||||||
starter badge colors: {'different player colors' if config.starter_unique_colors else 'same color per starter deck'}
|
starter badge colors: {'different player colors' if config.starter_unique_colors else 'same color per starter deck'}
|
||||||
Rent raised cards: {config.rent_raised_count}
|
Rent raised cards: {config.rent_raised_count}
|
||||||
|
margin: {config.margin_mm:g} mm
|
||||||
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}'}
|
||||||
|
|||||||
+195
-61
@@ -245,6 +245,42 @@ def character_item_bottom_left_corner(item: PrintItem, index: int, cols: int) ->
|
|||||||
return character_bottom_left_corner(index, rotation, cols)
|
return character_bottom_left_corner(index, rotation, cols)
|
||||||
|
|
||||||
|
|
||||||
|
def slot_corners(index: int, cols: int) -> set[tuple[int, int]]:
|
||||||
|
row, col = divmod(index, cols)
|
||||||
|
return {(row, col), (row, col + 1), (row + 1, col), (row + 1, col + 1)}
|
||||||
|
|
||||||
|
|
||||||
|
def character_bottom_left_corner_allowed(
|
||||||
|
page: list[PrintItem | None],
|
||||||
|
index: int,
|
||||||
|
item: PrintItem,
|
||||||
|
cols: int,
|
||||||
|
) -> bool:
|
||||||
|
item_corners = slot_corners(index, cols)
|
||||||
|
item_is_character = is_character_item(item)
|
||||||
|
item_bl = character_item_bottom_left_corner(item, index, cols) if item_is_character else None
|
||||||
|
item_key = character_duplicate_key(item) if item_is_character else None
|
||||||
|
|
||||||
|
for placed_index, placed in enumerate(page):
|
||||||
|
if placed is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
placed_corners = slot_corners(placed_index, cols)
|
||||||
|
placed_is_character = is_character_item(placed)
|
||||||
|
placed_bl = character_item_bottom_left_corner(placed, placed_index, cols) if placed_is_character else None
|
||||||
|
placed_key = character_duplicate_key(placed) if placed_is_character else None
|
||||||
|
|
||||||
|
if item_is_character and item_bl in placed_corners:
|
||||||
|
if not (placed_is_character and placed_bl == item_bl and placed_key == item_key):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if placed_is_character and placed_bl in item_corners:
|
||||||
|
if not (item_is_character and item_bl == placed_bl and item_key == placed_key):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def neighboring_slots(index: int, cols: int, total: int) -> list[tuple[int, str, str]]:
|
def neighboring_slots(index: int, cols: int, total: int) -> list[tuple[int, str, str]]:
|
||||||
neighbors: list[tuple[int, str, str]] = []
|
neighbors: list[tuple[int, str, str]] = []
|
||||||
if index % cols:
|
if index % cols:
|
||||||
@@ -259,16 +295,8 @@ def neighboring_slots(index: int, cols: int, total: int) -> list[tuple[int, str,
|
|||||||
|
|
||||||
|
|
||||||
def can_place_item(page: list[PrintItem | None], index: int, item: PrintItem, cols: int) -> bool:
|
def can_place_item(page: list[PrintItem | None], index: int, item: PrintItem, cols: int) -> bool:
|
||||||
if is_character_item(item):
|
if not character_bottom_left_corner_allowed(page, index, item, cols):
|
||||||
item_corner = character_item_bottom_left_corner(item, index, cols)
|
return False
|
||||||
item_key = character_duplicate_key(item)
|
|
||||||
for placed_index, placed in enumerate(page):
|
|
||||||
if placed is None or not is_character_item(placed):
|
|
||||||
continue
|
|
||||||
if character_item_bottom_left_corner(placed, placed_index, cols) != item_corner:
|
|
||||||
continue
|
|
||||||
if character_duplicate_key(placed) != item_key:
|
|
||||||
return False
|
|
||||||
|
|
||||||
for neighbor_index, item_side, neighbor_side in neighboring_slots(index, cols, len(page)):
|
for neighbor_index, item_side, neighbor_side in neighboring_slots(index, cols, len(page)):
|
||||||
neighbor = page[neighbor_index]
|
neighbor = page[neighbor_index]
|
||||||
@@ -428,10 +456,12 @@ def fill_bottom_rows_with_spaced_characters(
|
|||||||
if empty_rows <= 0:
|
if empty_rows <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
edge_margin = margin
|
||||||
|
top_slack = max(0, y0 - edge_margin)
|
||||||
area_left = margin
|
area_left = margin
|
||||||
area_top = y0 + first_empty_row * oriented_h
|
area_top = y0 + first_empty_row * oriented_h
|
||||||
area_w = page_w - 2 * margin
|
area_w = page_w - 2 * margin
|
||||||
area_h = page_h - min(margin, gap) - area_top
|
area_h = page_h - edge_margin - area_top + top_slack
|
||||||
if first_empty_row > 0:
|
if first_empty_row > 0:
|
||||||
area_top += gap
|
area_top += gap
|
||||||
area_h -= gap
|
area_h -= gap
|
||||||
@@ -471,6 +501,73 @@ def rects_conflict(rect: tuple[int, int, int, int], occupied: list[tuple[int, in
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def pack_spaced_items_in_free_space(
|
||||||
|
occupied: list[tuple[int, int, int, int]],
|
||||||
|
items: list[PrintItem],
|
||||||
|
config: BuildConfig,
|
||||||
|
) -> list[tuple[PrintItem, tuple[int, int, int, int], int]]:
|
||||||
|
if not items:
|
||||||
|
return []
|
||||||
|
|
||||||
|
page_w = mm_to_px(config.paper_mm[0], config.dpi)
|
||||||
|
page_h = mm_to_px(config.paper_mm[1], config.dpi)
|
||||||
|
margin = mm_to_px(config.margin_mm, config.dpi)
|
||||||
|
gap = mm_to_px(5.0, config.dpi)
|
||||||
|
card_w = mm_to_px(config.card_mm[0], config.dpi)
|
||||||
|
card_h = mm_to_px(config.card_mm[1], config.dpi)
|
||||||
|
oriented_w, oriented_h = card_h, card_w
|
||||||
|
orientations = ((oriented_w, oriented_h, 90), (card_w, card_h, 0))
|
||||||
|
best: list[tuple[tuple[int, int, int, int], int]] = []
|
||||||
|
seen: set[tuple[int, tuple[tuple[int, int, int, int], ...]]] = set()
|
||||||
|
|
||||||
|
def normalized(rects: list[tuple[int, int, int, int]]) -> tuple[tuple[int, int, int, int], ...]:
|
||||||
|
return tuple(sorted(rects))
|
||||||
|
|
||||||
|
def candidates(rects: list[tuple[int, int, int, int]]) -> list[tuple[int, int, int, int, int]]:
|
||||||
|
xs = {margin}
|
||||||
|
ys = {margin}
|
||||||
|
for left, top, right, bottom in rects:
|
||||||
|
for item_w, item_h, _ in orientations:
|
||||||
|
xs.add(right + gap)
|
||||||
|
xs.add(left - gap - item_w)
|
||||||
|
ys.add(bottom + gap)
|
||||||
|
ys.add(top - gap - item_h)
|
||||||
|
|
||||||
|
out: list[tuple[int, int, int, int, int]] = []
|
||||||
|
for item_w, item_h, rotation in orientations:
|
||||||
|
for top in sorted(ys):
|
||||||
|
if top < margin or top + item_h > page_h - margin:
|
||||||
|
continue
|
||||||
|
for left in sorted(xs):
|
||||||
|
if left < margin or left + item_w > page_w - margin:
|
||||||
|
continue
|
||||||
|
rect = (left, top, left + item_w, top + item_h)
|
||||||
|
if not rects_conflict(rect, rects, gap):
|
||||||
|
out.append((*rect, rotation))
|
||||||
|
return sorted(out, key=lambda value: (value[1], value[0], value[4]))
|
||||||
|
|
||||||
|
def dfs(rects: list[tuple[int, int, int, int]], placements: list[tuple[tuple[int, int, int, int], int]]) -> bool:
|
||||||
|
nonlocal best
|
||||||
|
if len(placements) > len(best):
|
||||||
|
best = list(placements)
|
||||||
|
if len(best) == len(items):
|
||||||
|
return True
|
||||||
|
|
||||||
|
state = (len(placements), normalized(rects))
|
||||||
|
if state in seen:
|
||||||
|
return False
|
||||||
|
seen.add(state)
|
||||||
|
|
||||||
|
for left, top, right, bottom, rotation in candidates(rects):
|
||||||
|
rect = (left, top, right, bottom)
|
||||||
|
if dfs(rects + [rect], placements + [(rect, rotation)]):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
dfs(list(occupied), [])
|
||||||
|
return [(items[index], rect, rotation) for index, (rect, rotation) in enumerate(best)]
|
||||||
|
|
||||||
|
|
||||||
def shift_rect(rect: tuple[int, int, int, int], dx: int, dy: int) -> tuple[int, int, int, int]:
|
def shift_rect(rect: tuple[int, int, int, int], dx: int, dy: int) -> tuple[int, int, int, int]:
|
||||||
return rect[0] + dx, rect[1] + dy, rect[2] + dx, rect[3] + dy
|
return rect[0] + dx, rect[1] + dy, rect[2] + dx, rect[3] + dy
|
||||||
|
|
||||||
@@ -514,10 +611,8 @@ def fill_free_space_with_spaced_characters(
|
|||||||
page_w = mm_to_px(config.paper_mm[0], config.dpi)
|
page_w = mm_to_px(config.paper_mm[0], config.dpi)
|
||||||
page_h = mm_to_px(config.paper_mm[1], config.dpi)
|
page_h = mm_to_px(config.paper_mm[1], config.dpi)
|
||||||
margin = mm_to_px(config.margin_mm, config.dpi)
|
margin = mm_to_px(config.margin_mm, config.dpi)
|
||||||
edge_margin = min(margin, mm_to_px(5.0, config.dpi))
|
|
||||||
gap = mm_to_px(5.0, config.dpi)
|
|
||||||
card_w = mm_to_px(config.card_mm[0], config.dpi)
|
|
||||||
card_h = mm_to_px(config.card_mm[1], config.dpi)
|
card_h = mm_to_px(config.card_mm[1], config.dpi)
|
||||||
|
card_w = mm_to_px(config.card_mm[0], config.dpi)
|
||||||
oriented_w, oriented_h = card_h, card_w
|
oriented_w, oriented_h = card_h, card_w
|
||||||
grid_w = cols * oriented_w
|
grid_w = cols * oriented_w
|
||||||
grid_h = rows * oriented_h
|
grid_h = rows * oriented_h
|
||||||
@@ -538,41 +633,10 @@ def fill_free_space_with_spaced_characters(
|
|||||||
occupied.append((left, top, left + oriented_w, top + oriented_h))
|
occupied.append((left, top, left + oriented_w, top + oriented_h))
|
||||||
occupied.extend(rect for _, rect, _ in (page.extras or []))
|
occupied.extend(rect for _, rect, _ in (page.extras or []))
|
||||||
|
|
||||||
extras = list(page.extras or [])
|
placed = pack_spaced_items_in_free_space(occupied, remaining, config)
|
||||||
placed_count = 0
|
if placed:
|
||||||
while placed_count < len(remaining):
|
page.extras = list(page.extras or []) + placed
|
||||||
placed = False
|
remaining = remaining[len(placed):]
|
||||||
for item_w, item_h, rotation in ((oriented_w, oriented_h, 90), (card_w, card_h, 0)):
|
|
||||||
xs = {edge_margin, page_w - edge_margin - item_w}
|
|
||||||
ys = {edge_margin, page_h - edge_margin - item_h}
|
|
||||||
for left, top, right, bottom in occupied:
|
|
||||||
xs.add(right + gap)
|
|
||||||
xs.add(left - gap - item_w)
|
|
||||||
ys.add(bottom + gap)
|
|
||||||
ys.add(top - gap - item_h)
|
|
||||||
candidates = []
|
|
||||||
for y in sorted(ys):
|
|
||||||
if y < edge_margin or y + item_h > page_h - edge_margin:
|
|
||||||
continue
|
|
||||||
for x in sorted(xs):
|
|
||||||
if x < edge_margin or x + item_w > page_w - edge_margin:
|
|
||||||
continue
|
|
||||||
candidates.append((x, y, x + item_w, y + item_h))
|
|
||||||
for rect in sorted(candidates, key=lambda value: (value[1], value[0])):
|
|
||||||
if rects_conflict(rect, occupied, gap):
|
|
||||||
continue
|
|
||||||
extras.append((remaining[placed_count], rect, rotation))
|
|
||||||
occupied.append(rect)
|
|
||||||
placed_count += 1
|
|
||||||
placed = True
|
|
||||||
break
|
|
||||||
if placed:
|
|
||||||
break
|
|
||||||
if not placed:
|
|
||||||
break
|
|
||||||
if placed_count:
|
|
||||||
page.extras = extras
|
|
||||||
remaining = remaining[placed_count:]
|
|
||||||
|
|
||||||
return remaining
|
return remaining
|
||||||
|
|
||||||
@@ -590,7 +654,7 @@ def grouped_card_pages(items: list[PrintItem], config: BuildConfig) -> list[Card
|
|||||||
pages.extend(pack_cropped_standard_group(group_items, cols, rows))
|
pages.extend(pack_cropped_standard_group(group_items, cols, rows))
|
||||||
character_items = fill_bottom_rows_with_spaced_characters(pages, character_items, config)
|
character_items = fill_bottom_rows_with_spaced_characters(pages, character_items, config)
|
||||||
else:
|
else:
|
||||||
group_items = sorted(standard_items, key=item_order_key)
|
group_items = sorted(standard_items, key=noncropped_standard_item_order_key)
|
||||||
for i in range(0, len(group_items), per_page):
|
for i in range(0, len(group_items), per_page):
|
||||||
page = group_items[i : i + per_page]
|
page = group_items[i : i + per_page]
|
||||||
if i + per_page >= len(group_items):
|
if i + per_page >= len(group_items):
|
||||||
@@ -600,28 +664,80 @@ def grouped_card_pages(items: list[PrintItem], config: BuildConfig) -> list[Card
|
|||||||
last_page = pages[-1]
|
last_page = pages[-1]
|
||||||
if not last_page.spaced:
|
if not last_page.spaced:
|
||||||
filled_page, character_items = fill_page_with_characters(list(last_page.items), character_items, cols)
|
filled_page, character_items = fill_page_with_characters(list(last_page.items), character_items, cols)
|
||||||
pages[-1] = CardPage(filled_page)
|
if not character_items:
|
||||||
|
pages[-1] = CardPage(filled_page)
|
||||||
|
else:
|
||||||
|
character_items = sorted((item for item in items if item.group.startswith("character:")), key=item_order_key)
|
||||||
|
character_items = fill_free_space_with_spaced_characters(pages, character_items, config)
|
||||||
|
|
||||||
if character_items:
|
if character_items:
|
||||||
for chunk in chunk_spaced_cards(character_items, config):
|
pages.extend(pack_spaced_character_pages(character_items, config))
|
||||||
pages.append(CardPage(chunk, True))
|
|
||||||
return pages
|
return pages
|
||||||
|
|
||||||
|
|
||||||
def chunk_spaced_cards(items: list[PrintItem], config: BuildConfig) -> list[list[PrintItem]]:
|
def pack_spaced_character_pages(items: list[PrintItem], config: BuildConfig) -> list[CardPage]:
|
||||||
page_w = mm_to_px(config.paper_mm[0], config.dpi)
|
page_w = mm_to_px(config.paper_mm[0], config.dpi)
|
||||||
page_h = mm_to_px(config.paper_mm[1], config.dpi)
|
page_h = mm_to_px(config.paper_mm[1], config.dpi)
|
||||||
margin = mm_to_px(config.margin_mm, config.dpi)
|
margin = mm_to_px(config.margin_mm, config.dpi)
|
||||||
gap = mm_to_px(5.0, config.dpi)
|
gap = mm_to_px(5.0, config.dpi)
|
||||||
|
edge_margin = margin
|
||||||
card_w = mm_to_px(config.card_mm[0], config.dpi)
|
card_w = mm_to_px(config.card_mm[0], config.dpi)
|
||||||
card_h = mm_to_px(config.card_mm[1], config.dpi)
|
card_h = mm_to_px(config.card_mm[1], config.dpi)
|
||||||
oriented_w, oriented_h = card_h, card_w
|
oriented_w, oriented_h = card_h, card_w
|
||||||
usable_w = page_w - 2 * margin
|
remaining = list(items)
|
||||||
usable_h = page_h - 2 * margin
|
pages: list[CardPage] = []
|
||||||
cols = max(1, (usable_w + gap) // (oriented_w + gap))
|
|
||||||
rows = max(1, (usable_h + gap) // (oriented_h + gap))
|
while remaining:
|
||||||
per_page = max(1, cols * rows)
|
occupied: list[tuple[int, int, int, int]] = []
|
||||||
return [items[i : i + per_page] for i in range(0, len(items), per_page)]
|
extras: list[tuple[PrintItem, tuple[int, int, int, int], int]] = []
|
||||||
|
placed_count = 0
|
||||||
|
|
||||||
|
while placed_count < len(remaining):
|
||||||
|
best: tuple[int, int, tuple[int, int, int, int], int] | None = None
|
||||||
|
for item_w, item_h, rotation in ((oriented_w, oriented_h, 90), (card_w, card_h, 0)):
|
||||||
|
xs = {edge_margin, page_w - edge_margin - item_w}
|
||||||
|
ys = {edge_margin, page_h - edge_margin - item_h}
|
||||||
|
for left, top, right, bottom in occupied:
|
||||||
|
xs.add(right + gap)
|
||||||
|
xs.add(left - gap - item_w)
|
||||||
|
ys.add(bottom + gap)
|
||||||
|
ys.add(top - gap - item_h)
|
||||||
|
|
||||||
|
for top in sorted(ys):
|
||||||
|
if top < edge_margin or top + item_h > page_h - edge_margin:
|
||||||
|
continue
|
||||||
|
for left in sorted(xs):
|
||||||
|
if left < edge_margin or left + item_w > page_w - edge_margin:
|
||||||
|
continue
|
||||||
|
rect = (left, top, left + item_w, top + item_h)
|
||||||
|
if rects_conflict(rect, occupied, gap):
|
||||||
|
continue
|
||||||
|
score = (top, left)
|
||||||
|
if best is None or score < best[:2]:
|
||||||
|
best = (top, left, rect, rotation)
|
||||||
|
break
|
||||||
|
if best is not None and best[0] == top:
|
||||||
|
break
|
||||||
|
|
||||||
|
if best is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
_, _, rect, rotation = best
|
||||||
|
extras.append((remaining[placed_count], rect, rotation))
|
||||||
|
occupied.append(rect)
|
||||||
|
placed_count += 1
|
||||||
|
|
||||||
|
if placed_count == 0:
|
||||||
|
item = remaining[0]
|
||||||
|
left = (page_w - card_w) // 2
|
||||||
|
top = (page_h - card_h) // 2
|
||||||
|
extras.append((item, (left, top, left + card_w, top + card_h), 0))
|
||||||
|
placed_count = 1
|
||||||
|
|
||||||
|
pages.append(CardPage([], True, extras))
|
||||||
|
remaining = remaining[placed_count:]
|
||||||
|
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
def group_order_key(group: str) -> tuple[int, str]:
|
def group_order_key(group: str) -> tuple[int, str]:
|
||||||
@@ -643,6 +759,24 @@ def item_order_key(item: PrintItem) -> tuple[int, str, str, str]:
|
|||||||
return phase, item.sort_label or item.label.lower(), item.image_type, str(item.path)
|
return phase, item.sort_label or item.label.lower(), item.image_type, str(item.path)
|
||||||
|
|
||||||
|
|
||||||
|
def noncropped_standard_item_order_key(item: PrintItem) -> tuple[int, str, str, str]:
|
||||||
|
if item.image_type == "back":
|
||||||
|
phase = {"encounter": 5, "office": 6, "basement": 7}.get(item.group, 99)
|
||||||
|
elif item.group == "encounter":
|
||||||
|
phase = 0
|
||||||
|
elif item.group == "office" and item.is_starter:
|
||||||
|
phase = 1
|
||||||
|
elif item.group == "office":
|
||||||
|
phase = 2
|
||||||
|
elif item.group == "basement" and item.is_starter:
|
||||||
|
phase = 3
|
||||||
|
elif item.group == "basement":
|
||||||
|
phase = 4
|
||||||
|
else:
|
||||||
|
phase = 99
|
||||||
|
return phase, item.sort_label or item.label.lower(), item.image_type, str(item.path)
|
||||||
|
|
||||||
|
|
||||||
def draw_crop_lines(page: Image.Image, rect: tuple[int, int, int, int]) -> None:
|
def draw_crop_lines(page: Image.Image, rect: tuple[int, int, int, int]) -> None:
|
||||||
draw = ImageDraw.Draw(page)
|
draw = ImageDraw.Draw(page)
|
||||||
left, top, right, bottom = rect
|
left, top, right, bottom = rect
|
||||||
@@ -703,7 +837,7 @@ def render_card_pages(items: list[PrintItem], config: BuildConfig, start_index:
|
|||||||
|
|
||||||
occupied_rects = [rect for item, rect, _ in placements if item is not None]
|
occupied_rects = [rect for item, rect, _ in placements if item is not None]
|
||||||
occupied_rects.extend(rect for _, rect, _ in extras)
|
occupied_rects.extend(rect for _, rect, _ in extras)
|
||||||
dx, dy = centered_shift(occupied_rects, page_w, page_h, min(margin, gap or margin))
|
dx, dy = centered_shift(occupied_rects, page_w, page_h, margin)
|
||||||
if dx or dy:
|
if dx or dy:
|
||||||
placements = [(item, shift_rect(rect, dx, dy), rotate) for item, rect, rotate in placements]
|
placements = [(item, shift_rect(rect, dx, dy), rotate) for item, rect, rotate in placements]
|
||||||
extras = [(item, shift_rect(rect, dx, dy), rotate) for item, rect, rotate in extras]
|
extras = [(item, shift_rect(rect, dx, dy), rotate) for item, rect, rotate in extras]
|
||||||
|
|||||||
Reference in New Issue
Block a user