From 4a52fdfa6c1e79807368223a5178433100c41b1f Mon Sep 17 00:00:00 2001 From: fmolinagomez <33826980+fmolinagomez@users.noreply.github.com> Date: Mon, 6 Oct 2025 21:21:32 +0200 Subject: [PATCH] Refine command point shields and split values --- LWCProto.py | 426 +++++++++++++++++++++++----------- README.md | 19 +- add_images.py | 23 +- card_model.py | 99 +++++++- cartas.json | 15 +- draw_card.py | 83 ++++++- layout.py | 12 +- tests/conftest.py | 7 + tests/test_card_background.py | 57 +++++ tests/test_card_footer.py | 56 +++++ tests/test_card_model.py | 61 ++++- tests/test_cli_features.py | 30 +++ utils.py | 9 + 13 files changed, 735 insertions(+), 162 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_card_background.py create mode 100644 tests/test_card_footer.py create mode 100644 tests/test_cli_features.py create mode 100644 utils.py diff --git a/LWCProto.py b/LWCProto.py index d914da1..67cba06 100644 --- a/LWCProto.py +++ b/LWCProto.py @@ -1,106 +1,136 @@ #! /usr/bin/env python3 +import argparse import csv import os -import re -import numpy as np +import pathlib +from typing import Iterable, List, Optional, Sequence, Tuple import cairo -import argparse - +import numpy as np import layout +from card_model import CardDeck, CardModel from draw_card import drawCard -from card_model import CardModel -from card_model import CardDeck +from utils import slugify +def extant_file(path: str) -> str: + """Validate that a CLI argument points to an existing file.""" + if not os.path.exists(path): + raise argparse.ArgumentTypeError(f"{path} does not exist") + return path -def extant_file(x): - """ - 'Type' for argparse - checks that file exists but does not open. - """ - if not os.path.exists(x): - # Argparse uses the ArgumentTypeError to give a rejection message like: - # error: argument input: x does not exist - raise argparse.ArgumentTypeError("{0} does not exist".format(x)) - return x +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Deck Generator for Game Designers") + parser.add_argument('-d', '--deck', type=extant_file, help='csv file containing the deck', metavar="FILE") + parser.add_argument('-c', '--cards', type=extant_file, help='json file containing cards description', metavar="FILE", required=True) + parser.add_argument('-i', '--images', help='Add images to cards', action='store_true') + parser.add_argument('-r', '--rgb', help='Update layout card border colour with given R,G,B, only works with default layout', nargs=3, type=int) + parser.add_argument('-l', '--layout', help='Use a different layout than default', type=extant_file, metavar="FILE") + parser.add_argument('--single-card', help='Render each card as an individual 63x85mm PNG at 300 DPI', action='store_true') + parser.add_argument('-o', '--output-dir', help='Directory where generated decks will be stored', default='decks') -##### CLI args ##### -parser = argparse.ArgumentParser(description="Deck Generator for Game Designers") -parser.add_argument('-d', '--deck', type=extant_file, help='csv file containing the deck', metavar="FILE") -parser.add_argument('-c', '--cards', type=extant_file, help='json file containing cards description', metavar="FILE", required=True) + args = parser.parse_args() -parser.add_argument('-i', '--images', help='Add images to cards', action='store_true') -parser.add_argument('-r', '--rgb', help='Update layout card border colour with given R,G,B, only works with default layout', nargs=3, type=int) -parser.add_argument('-l', '--layout', help='Use a different layout than default', type=extant_file, metavar="FILE") -parser.add_argument('--single-card', help='Render each card as an individual 63x85mm PNG at 300 DPI', action='store_true') + if args.single_card and args.deck is not None: + parser.error('the --single-card option cannot be used together with --deck/-d') + if (not args.single_card) and args.deck is None: + parser.error('the --deck/-d option is required unless --single-card is specified') -args = parser.parse_args() + return args -handle_images = args.images -modify_layout = args.rgb -cards_file = args.cards -single_card_mode = args.single_card -deck_file = args.deck -if single_card_mode and deck_file is not None: - parser.error('the --single-card option cannot be used together with --deck/-d') +def chunk_cards(cards: Sequence[CardModel], chunk_size: int = 9) -> Iterable[List[CardModel]]: + for index in range(0, len(cards), chunk_size): + yield list(cards[index:index + chunk_size]) -if (not single_card_mode) and deck_file is None: - parser.error('the --deck/-d option is required unless --single-card is specified') -cards = CardDeck(cards_file) +def _require_image_helpers(*helpers) -> None: + if any(helper is None for helper in helpers): + raise RuntimeError( + 'Image helpers are not available. Make sure the --images flag ' + 'is used when requesting image processing.' + ) -nameList = [] -list_copy = [] -if single_card_mode: - deck_name = os.path.splitext(os.path.basename(cards_file))[0] - cardList = [] - for entry in cards.getDb().values(): - card = CardModel() - card.load(entry) - cardList.append(card) -else: - deck_name = os.path.basename(deck_file)[:-4] - with open(deck_file, encoding='utf-8') as csvFile: - reader = csv.reader(csvFile) - list_copy.append(reader.__next__()) - for row in reader: - list_copy.append(row) - nameList = nameList + [row[1]] * int(row[0]) +def build_card_list( + *, + cards: CardDeck, + cards_file: str, + deck_file: Optional[str], + single_card_mode: bool, +) -> Tuple[str, List[CardModel], Optional[List[Sequence[str]]]]: + card_db = cards.getDb() - cardList = [CardModel(name, cards.getDb()) for name in nameList] - pageList = [cardList[i:i+9] for i in range(0, len(cardList), 9)] + if single_card_mode: + deck_name = pathlib.Path(cards_file).stem + card_list: List[CardModel] = [] + for entry in card_db.values(): + card = CardModel() + card.load(entry) + card_list.append(card) + return deck_name, card_list, None -if handle_images or (modify_layout is not None): - from add_images import BaseImage + assert deck_file is not None + deck_path = pathlib.Path(deck_file) + deck_name = deck_path.stem + card_list: List[CardModel] = [] + deck_rows: List[Sequence[str]] = [] -if handle_images: - from add_images import addImage - from add_images import processImage - from add_images import load_full_frame_surface + with deck_path.open(encoding='utf-8') as csv_file: + reader = csv.reader(csv_file) -if not os.path.exists('decks'): - os.mkdir('decks') -if not os.path.exists(os.path.join('decks',deck_name)): - os.mkdir(os.path.join('decks',deck_name)) + try: + header = next(reader) + except StopIteration: + return deck_name, card_list, deck_rows -if single_card_mode: - cards_output_dir = os.path.join('decks', deck_name, 'cards') - os.makedirs(cards_output_dir, exist_ok=True) - single_dpi = layout.SINGLE_CARD_DPI + deck_rows.append(header) - def _slugify(value: str) -> str: - value = value.strip() - value = re.sub(r'\s+', '_', value) - value = re.sub(r'[^A-Za-z0-9_-]', '', value) - return value or 'card' + for row in reader: + deck_rows.append(row) + quantity = int(row[0]) + name = row[1] + for _ in range(quantity): + card_list.append(CardModel(name, card_db)) + + return deck_name, card_list, deck_rows + + +def ensure_output_directories( + base_dir: pathlib.Path, + deck_name: str, + single_card_mode: bool, +) -> Tuple[pathlib.Path, Optional[pathlib.Path]]: + deck_dir = base_dir / deck_name + deck_dir.mkdir(parents=True, exist_ok=True) + + cards_output_dir: Optional[pathlib.Path] = None + if single_card_mode: + cards_output_dir = deck_dir / 'cards' + cards_output_dir.mkdir(parents=True, exist_ok=True) + + return deck_dir, cards_output_dir + + +def render_single_cards( + card_list: Sequence[CardModel], + *, + deck_name: str, + cards_output_dir: pathlib.Path, + output_root: pathlib.Path, + handle_images: bool, + base_image_cls, + add_image_fn, + process_image_fn, + load_full_frame_surface_fn, +) -> None: + single_dpi = layout.SINGLE_CARD_DPI - for index, card in enumerate(cardList): + for index, card in enumerate(card_list): print(f'Card {index}: {card}') surf = layout.get_single_card_surface(single_dpi) ctx = cairo.Context(surf) @@ -108,12 +138,13 @@ def _slugify(value: str) -> str: card_matrix = layout.get_single_card_matrix(single_dpi) ctx.set_matrix(card_matrix) layout.clip_card(ctx) - ctx.set_source_rgb(1, 1, 1) - ctx.paint() - + if card.imageFullFrame: + ctx.set_source_rgb(1, 1, 1) + ctx.paint() if handle_images and card.imageFullFrame: - full_frame_surface = load_full_frame_surface(card, single_dpi) + _require_image_helpers(load_full_frame_surface_fn) + full_frame_surface = load_full_frame_surface_fn(card, single_dpi) if full_frame_surface is not None: ctx.save() ctx.identity_matrix() @@ -124,39 +155,81 @@ def _slugify(value: str) -> str: ctx.reset_clip() ctx.set_matrix(card_matrix) drawCard(card, ctx) - card_filename = f"{index:03d}_{_slugify(card.headerText)}.png" - output_path = os.path.join(cards_output_dir, card_filename) - surf.write_to_png(output_path) + + card_filename = f"{index:03d}_{slugify(card.headerText)}.png" + output_path = cards_output_dir / card_filename + surf.write_to_png(str(output_path)) if handle_images and not card.imageFullFrame: - processImage(card, deck_name, dpi=single_dpi) - baseImage = BaseImage(output_path) - updated_image = addImage(card, baseImage, deck_name, dpi=single_dpi) - baseImage.update(updated_image) - baseImage.save(output_path) -else: - for page_number in range(len(pageList)): + _require_image_helpers( + base_image_cls, + add_image_fn, + process_image_fn, + ) + process_image_fn(card, deck_name, dpi=single_dpi, output_root=output_root) + base_image = base_image_cls(str(output_path)) + updated_image = add_image_fn( + card, + base_image, + deck_name, + dpi=single_dpi, + output_root=output_root, + ) + base_image.update(updated_image) + base_image.save(str(output_path)) + + +def _apply_layout_colour_modification( + *, + base_image, + modify_layout: Sequence[int], +) -> None: + from PIL import Image + + temp = base_image.baseImage.convert('RGBA') + data = np.array(temp) + red, green, blue, alpha = data.T + for i in range(0, 63): + white_areas = (red == 190 + i) & (blue == 190 + i) & (green == 190 + i) + data[..., :-1][white_areas.T] = ( + modify_layout[0], + modify_layout[1], + modify_layout[2], + ) + base_image.update(Image.fromarray(data)) + + +def render_deck_pages( + card_list: Sequence[CardModel], + *, + deck_name: str, + deck_dir: pathlib.Path, + output_root: pathlib.Path, + handle_images: bool, + modify_layout: Optional[Sequence[int]], + base_image_cls, + add_image_fn, + process_image_fn, + load_full_frame_surface_fn, +) -> None: + for page_number, page in enumerate(chunk_cards(card_list)): print(f'Page {page_number}:') - page = pageList[page_number] surf = layout.getSurface() ctx = cairo.Context(surf) page_dpi = layout.get_surface_dpi(surf) - for i in range(len(page)): - card = page[i] - cardPos = (i % 3, i // 3) - print(cardPos) + for index, card in enumerate(page): + card_pos = (index % 3, index // 3) + print(card_pos) print(card) if handle_images and card.imageFullFrame: - full_frame_surface = load_full_frame_surface(card, page_dpi) + _require_image_helpers(load_full_frame_surface_fn) + full_frame_surface = load_full_frame_surface_fn(card, page_dpi) if full_frame_surface is not None: - card_origin_mm = layout.get_card_origin_mm(cardPos) - origin_px = layout.pair_mm_to_pixels( - card_origin_mm, - page_dpi, - ) + card_origin_mm = layout.get_card_origin_mm(card_pos) + origin_px = layout.pair_mm_to_pixels(card_origin_mm, page_dpi) ctx.save() ctx.identity_matrix() layout.clip_card_absolute(ctx, card_origin_mm, page_dpi) @@ -164,63 +237,150 @@ def _slugify(value: str) -> str: ctx.paint() ctx.restore() - mat = layout.getMatrix(*cardPos, surf) + mat = layout.getMatrix(*card_pos, surf) ctx.set_matrix(mat) drawCard(card, ctx) - output_path = f'decks/{deck_name}/{deck_name}_p{page_number}.png' - surf.write_to_png(output_path) - - if (modify_layout is not None): - from PIL import Image - - baseImage = BaseImage(output_path) - temp = baseImage.baseImage.convert('RGBA') - data = np.array(temp) - red, green, blue, alpha = data.T - for i in range(0,63): - white_areas = (red == 190+i) & (blue == 190+i) & (green == 190+i) - data[..., :-1][white_areas.T] = (modify_layout[0], modify_layout[1], modify_layout[2]) - baseImage.update(Image.fromarray(data)) - baseImage.save(output_path) + output_path = deck_dir / f'{deck_name}_p{page_number}.png' + surf.write_to_png(str(output_path)) + if modify_layout is not None: + if base_image_cls is None: + raise RuntimeError('Layout modifications require image helpers') + base_image = base_image_cls(str(output_path)) + _apply_layout_colour_modification( + base_image=base_image, + modify_layout=modify_layout, + ) + base_image.save(str(output_path)) - #import pdb;pdb.set_trace() if handle_images: - page_dpi = layout.get_surface_dpi(surf) needs_partial_images = any( card.image is not None and not card.imageFullFrame for card in page ) if needs_partial_images: - baseImage = BaseImage(output_path) - for i in range(len(page)): - card = page[i] + _require_image_helpers( + base_image_cls, + add_image_fn, + process_image_fn, + ) + base_image = base_image_cls(str(output_path)) + for index, card in enumerate(page): if card.image is None or card.imageFullFrame: continue - cardPos = (i % 3, i // 3) - card_origin_mm = layout.get_card_origin_mm(cardPos) + card_pos = (index % 3, index // 3) + card_origin_mm = layout.get_card_origin_mm(card_pos) image_position_mm = ( card_origin_mm[0] + layout.ART_OFFSET_MM[0], card_origin_mm[1] + layout.ART_OFFSET_MM[1], ) - processImage(card, deck_name, dpi=page_dpi) - baseImage.update( - addImage( + process_image_fn( + card, + deck_name, + dpi=page_dpi, + output_root=output_root, + ) + base_image.update( + add_image_fn( card, - baseImage, + base_image, deck_name, position_mm=image_position_mm, dpi=page_dpi, + output_root=output_root, ) ) - baseImage.save(output_path) - - -if not single_card_mode: - with open(f'decks/{deck_name}/{deck_name}.csv', 'w') as deck_copy: - filewriter = csv.writer(deck_copy) - for element in list_copy: - filewriter.writerow(element) + base_image.save(str(output_path)) + + +def write_deck_copy( + deck_rows: Sequence[Sequence[str]], + *, + deck_name: str, + deck_dir: pathlib.Path, +) -> None: + deck_copy_path = deck_dir / f'{deck_name}.csv' + with deck_copy_path.open('w', encoding='utf-8', newline='') as deck_copy: + writer = csv.writer(deck_copy) + for row in deck_rows: + writer.writerow(row) + + +def main() -> None: + args = parse_arguments() + + handle_images = args.images + modify_layout = args.rgb + cards_file = args.cards + single_card_mode = args.single_card + deck_file = args.deck + output_root = pathlib.Path(args.output_dir) + output_root.mkdir(parents=True, exist_ok=True) + + cards = CardDeck(cards_file) + deck_name, card_list, deck_rows = build_card_list( + cards=cards, + cards_file=cards_file, + deck_file=deck_file, + single_card_mode=single_card_mode, + ) + + deck_dir, cards_output_dir = ensure_output_directories( + output_root, + deck_name, + single_card_mode, + ) + + base_image_cls = add_image_fn = process_image_fn = load_full_frame_surface_fn = None + + if handle_images or (modify_layout is not None): + from add_images import BaseImage + + base_image_cls = BaseImage + + if handle_images: + from add_images import addImage, load_full_frame_surface, processImage + + add_image_fn = addImage + process_image_fn = processImage + load_full_frame_surface_fn = load_full_frame_surface + + if single_card_mode and cards_output_dir is not None: + render_single_cards( + card_list, + deck_name=deck_name, + cards_output_dir=cards_output_dir, + output_root=output_root, + handle_images=handle_images, + base_image_cls=base_image_cls, + add_image_fn=add_image_fn, + process_image_fn=process_image_fn, + load_full_frame_surface_fn=load_full_frame_surface_fn, + ) + else: + render_deck_pages( + card_list, + deck_name=deck_name, + deck_dir=deck_dir, + output_root=output_root, + handle_images=handle_images, + modify_layout=modify_layout, + base_image_cls=base_image_cls, + add_image_fn=add_image_fn, + process_image_fn=process_image_fn, + load_full_frame_surface_fn=load_full_frame_surface_fn, + ) + + if (not single_card_mode) and deck_rows is not None: + write_deck_copy( + deck_rows, + deck_name=deck_name, + deck_dir=deck_dir, + ) + + +if __name__ == '__main__': + main() diff --git a/README.md b/README.md index b16c238..089afd2 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ En principio todo lo que se necesita para ejecutar este script es python 3.9 ins ``` usage: LWCProto.py [-h] -d FILE -c FILE [-i] [-r RGB RGB RGB] [-l FILE] + [--single-card] [-o OUTPUT_DIR] Deck Generator for Game Designers @@ -38,7 +39,10 @@ optional arguments: -r RGB RGB RGB, --rgb RGB RGB RGB Update layout card border colour with given R,G,B, only works with default layout -l FILE, --layout FILE (==> Not ready yet) - Use a different layout than default + Use a different layout than default + --single-card Render each card as an individual 63x85mm PNG at 300 DPI + -o OUTPUT_DIR, --output-dir OUTPUT_DIR + Directory where generated decks will be stored ``` ### Archivo de definicion de cartas: @@ -60,7 +64,13 @@ El archivo de definicion de cartas es un archivo en jormato json con el siguente "text": "str", "colour": "#RRGGBB" }, - "manaCost": "str", + "background_color": "#RRGGBB", + "footer": { + "text": "str", + "color": "#RRGGBB", + "font_style": "normal | negrita | itálica" + }, + "commandPoints": int | "X/Y", "power": int, "toughness": int, "image": "str", @@ -71,7 +81,10 @@ El archivo de definicion de cartas es un archivo en jormato json con el siguente ``` El objeto `header` define el texto visible en la parte superior de la carta. El campo `color` ajusta el color del texto, mientras que los campos `banner` y `banner_color` permiten activar un recuadro de color sólido detrás del encabezado cuando sea necesario. El bloque `card_text` permite especificar el texto del cuerpo y el color con el que debe renderizarse. Para las imágenes puedes indicar un nombre de archivo directamente o un objeto con las claves `source` y `full_frame`. Cuando `full_frame_image` (o `full_frame` en el objeto de imagen) es `true`, la ilustración se ampliará para cubrir toda la carta; en caso contrario se mantendrá dentro del marco de arte. -Las imagenes deben almacenarse en el directorio "images" que se encuentra en la misma carpeta que LWCProto.py, el formato de las imagenes es indiferente y su tamaño tambien estas seran redimensionadas automaticamente para adaptarse al tamaño disponible en el layout +Puedes controlar el color de fondo del lienzo con el campo opcional `background_color`. Debe indicarse en formato hexadecimal (`#RRGGBB`) y solo se aplica cuando la carta no utiliza una imagen a pantalla completa (`full_frame_image: false`). +El valor `commandPoints` representa los puntos de mando de la carta. Se mostrará en negrita dentro de un escudo más pequeño con borde negro en la esquina superior derecha. Cuando el valor tenga el formato `X/Y`, se dibujará un segundo escudo del mismo tamaño a la izquierda, con fondo negro y el valor `Y` en blanco y negrita. +El bloque `footer` es opcional y permite mostrar una nota en la parte inferior de la carta. Puedes personalizar el texto, su color y el estilo de fuente (`normal`, `negrita` o `itálica`). Si no se especifica `font_style`, se utilizará `normal` por defecto. +Las imagenes deben almacenarse en el directorio "images" que se encuentra en la misma carpeta que LWCProto.py, el formato de las imagenes es indiferente y su tamaño tambien estas seran redimensionadas automaticamente para adaptarse al tamaño disponible en el layout. Puedes utilizar el argumento `--output-dir` para indicar otro directorio base donde almacenar las cartas generadas, lo que facilita mantener varios prototipos separados. ### Archivo de definicion del mazo diff --git a/add_images.py b/add_images.py index 34f9d77..c9e9519 100644 --- a/add_images.py +++ b/add_images.py @@ -1,6 +1,7 @@ import io import pathlib from functools import lru_cache +from typing import Union import cairo @@ -14,8 +15,20 @@ except AttributeError: _RESAMPLE = Image.LANCZOS -def _ensure_output_dir(deck: str) -> pathlib.Path: - path = pathlib.Path('decks') / deck / 'images' +def _ensure_output_dir(deck: str, root: Union[pathlib.Path, str] = 'decks') -> pathlib.Path: + """Return the directory used to store processed images for a deck. + + Parameters + ---------- + deck: + Name of the deck currently being processed. + root: + Base output directory where decks are stored. Defaults to ``decks`` + to remain backwards compatible with previous behaviour. + """ + + base_path = pathlib.Path(root) + path = base_path / deck / 'images' path.mkdir(parents=True, exist_ok=True) return path @@ -55,12 +68,13 @@ def processImage( *, size_mm=layout.ART_SIZE_MM, dpi: int = layout.SINGLE_CARD_DPI, + output_root: Union[pathlib.Path, str] = 'decks', ): if card.image is None: return size_px = layout.pair_mm_to_pixels(size_mm, dpi) - output_dir = _ensure_output_dir(deck) + output_dir = _ensure_output_dir(deck, output_root) destination = output_dir / str(card.image) if destination.exists(): @@ -87,12 +101,13 @@ def addImage( position_mm=None, size_mm=layout.ART_SIZE_MM, dpi: int = layout.SINGLE_CARD_DPI, + output_root: Union[pathlib.Path, str] = 'decks', ): if card.image is None: return base.get() - output_dir = _ensure_output_dir(deck) + output_dir = _ensure_output_dir(deck, output_root) image_path = output_dir / str(card.image) try: diff --git a/card_model.py b/card_model.py index a0bb756..87e64fa 100644 --- a/card_model.py +++ b/card_model.py @@ -29,11 +29,16 @@ def __init__(self, name=None, db=None): self.typeStr = "TYPE - SUBTYPE" self.cardText = "Some text" self.cardTextColour = "#000000" - self.manaCost = "\{W\}" + self.commandPoints = 0 + self.commandPointsSecondary = None self.power = None self.toughness = None self.image = None self.imageFullFrame = False + self.footerText = "" + self.footerColour = "#000000" + self.footerFontStyle = "normal" + self.backgroundColour = "#FFFFFF" if (name is not None) and (db is not None): # self.load(db[name][0]) For magic AllCards need this index @@ -71,10 +76,13 @@ def load(self, data: dict): self.cardText = "" self.cardTextColour = '#000000' - if ('manaCost' in data): - self.manaCost = data['manaCost'] - else: - self.manaCost = "" + command_points_value = data.get('commandPoints') + if command_points_value is None: + command_points_value = data.get('manaCost') + + primary, secondary = self._parse_command_points(command_points_value) + self.commandPoints = primary + self.commandPointsSecondary = secondary if 'power' in data: self.power = int(data['power']) @@ -99,8 +107,22 @@ def load(self, data: dict): self.imageFullFrame = image_full_frame + self.backgroundColour = data.get('background_color', '#FFFFFF') or '#FFFFFF' + + footer = data.get('footer') or {} + self.footerText = footer.get('text', '') or '' + self.footerColour = footer.get('color', '#000000') or '#000000' + footer_style = footer.get('font_style') + if footer_style is None: + footer_style = footer.get('style') + if footer_style is None: + footer_style = footer.get('font') + if footer_style is None: + footer_style = footer.get('tipo') + self.footerFontStyle = self._normalise_footer_style(footer_style) + def __str__(self): - return f'{self.headerText} - {self.manaCost} ({self.typeStr})' + return f'{self.headerText} - {self.get_command_points_display()} ({self.typeStr})' def get_text_color_rgb(self): return self._hex_to_rgb(self.cardTextColour, default=(0.0, 0.0, 0.0)) @@ -111,6 +133,12 @@ def get_header_text_color_rgb(self): def get_header_banner_color_rgb(self): return self._hex_to_rgb(self.headerBannerColour, default=(1.0, 1.0, 1.0)) + def get_footer_text_color_rgb(self): + return self._hex_to_rgb(self.footerColour, default=(0.0, 0.0, 0.0)) + + def get_background_color_rgb(self): + return self._hex_to_rgb(self.backgroundColour, default=(1.0, 1.0, 1.0)) + @staticmethod def _hex_to_rgb(colour: str, *, default): value = (colour or '').strip() @@ -136,3 +164,62 @@ def nameStr(self): @nameStr.setter def nameStr(self, value): self.headerText = value or '' + + @staticmethod + def _normalise_footer_style(value: str) -> str: + if not value: + return 'normal' + + normalised = value.strip().lower() + style_map = { + 'normal': 'normal', + 'bold': 'bold', + 'negrita': 'bold', + 'italic': 'italic', + 'italica': 'italic', + 'itálica': 'italic', + 'itálica': 'italic', + } + + return style_map.get(normalised, 'normal') + + def get_command_points_display(self) -> str: + if self.commandPointsSecondary is None: + return str(self.commandPoints) + + return f'{self.commandPoints}/{self.commandPointsSecondary}' + + @staticmethod + def _parse_command_points(value): + if value is None: + return (0, None) + + if isinstance(value, (int, float)): + return (int(value), None) + + text = str(value).strip() + if not text: + return (0, None) + + def _coerce(part, *, allow_none: bool = False): + digits = ''.join(ch for ch in part if ch.isdigit() or ch == '-') + if not digits: + return None if allow_none else 0 + + try: + return int(digits) + except ValueError: + return None if allow_none else 0 + + if '/' in text: + left, right = text.split('/', 1) + primary = _coerce(left) + secondary = _coerce(right, allow_none=True) + if primary is None: + primary = 0 + return (primary, secondary) + + primary = _coerce(text) + if primary is None: + primary = 0 + return (primary, None) diff --git a/cartas.json b/cartas.json index ff4c225..e0ba43c 100644 --- a/cartas.json +++ b/cartas.json @@ -12,7 +12,12 @@ "text": "este es un texto de prueba", "colour": "#000000" }, - "manaCost": "5/6 * {r}", + "background_color": "#F5F5F5", + "footer": { + "text": "footer carta 1", + "color": "#333333" + }, + "commandPoints": "12/4", "image": "BarcoPirata.jpg" }, "Carta2": { @@ -28,7 +33,13 @@ "text": "este es un texto de prueba para esta supercarta", "colour": "#FFFFFF" }, - "manaCost": "2", + "background_color": "#002244", + "footer": { + "text": "footer destacado", + "color": "#FFD700", + "font_style": "negrita" + }, + "commandPoints": 7, "power": 60, "toughness": 9, "image": "MinionNapoleonics.jpg", diff --git a/draw_card.py b/draw_card.py index 35847cb..51e749f 100644 --- a/draw_card.py +++ b/draw_card.py @@ -45,6 +45,10 @@ def drawCard( ctx.save() layout.clip_card(ctx) + if not card.imageFullFrame: + ctx.set_source_rgb(*card.get_background_color_rgb()) + ctx.paint() + ctx.select_font_face('serif') @@ -98,14 +102,77 @@ def drawCard( ctx.move_to(*layout.ptBL) ctx.show_text(ptStr) - # Draw Mana Cost - ctx.set_source_rgb(*body_color) - ctx.set_font_size(layout.nameH) - ctx.move_to( - layout.manaRight - ctx.text_extents(card.manaCost).width, - layout.nameBL[1] - ) - ctx.show_text(card.manaCost) + # Draw command points shield + ctx.save() + base_shield_x, shield_y = layout.commandPointsShieldTL + shield_width, shield_height = layout.commandPointsShieldSize + point_height = layout.commandPointsShieldPointHeight + gap = getattr(layout, "commandPointsShieldGap", 0.0) + + shield_values = [] + if card.commandPointsSecondary is not None: + secondary_x = base_shield_x - shield_width - gap + shield_values.append((card.commandPointsSecondary, secondary_x, True)) + + shield_values.append((card.commandPoints, base_shield_x, False)) + + ctx.select_font_face('serif', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) + for value, shield_x, invert in shield_values: + ctx.save() + ctx.move_to(shield_x, shield_y) + ctx.line_to(shield_x + shield_width, shield_y) + ctx.line_to(shield_x + shield_width, shield_y + shield_height * 0.65) + ctx.line_to(shield_x + shield_width / 2.0, shield_y + shield_height + point_height) + ctx.line_to(shield_x, shield_y + shield_height * 0.65) + ctx.close_path() + + if invert: + ctx.set_source_rgb(0.0, 0.0, 0.0) + ctx.fill_preserve() + ctx.set_line_width(layout.commandPointsBorderWidth) + ctx.set_source_rgb(0.0, 0.0, 0.0) + ctx.stroke() + text_color = (1.0, 1.0, 1.0) + else: + ctx.set_source_rgb(1.0, 1.0, 1.0) + ctx.fill_preserve() + ctx.set_line_width(layout.commandPointsBorderWidth) + ctx.set_source_rgb(0.0, 0.0, 0.0) + ctx.stroke() + text_color = (0.0, 0.0, 0.0) + + ctx.set_font_size(layout.commandPointsFontSize) + text = str(value) + extents = ctx.text_extents(text) + center_x = shield_x + shield_width / 2.0 + center_y = shield_y + (shield_height + point_height) / 2.0 + text_x = center_x - (extents.x_bearing + extents.width / 2.0) + text_y = center_y - (extents.y_bearing + extents.height / 2.0) + + ctx.set_source_rgb(*text_color) + ctx.move_to(text_x, text_y) + ctx.show_text(text) + ctx.restore() + + ctx.select_font_face('serif') + ctx.restore() + + # Draw footer text + if card.footerText: + footer_slant = cairo.FONT_SLANT_NORMAL + footer_weight = cairo.FONT_WEIGHT_NORMAL + + if card.footerFontStyle == 'italic': + footer_slant = cairo.FONT_SLANT_ITALIC + if card.footerFontStyle == 'bold': + footer_weight = cairo.FONT_WEIGHT_BOLD + + ctx.set_source_rgb(*card.get_footer_text_color_rgb()) + ctx.set_font_size(layout.footerH) + ctx.select_font_face('serif', footer_slant, footer_weight) + ctx.move_to(*layout.footerBL) + ctx.show_text(card.footerText) + ctx.select_font_face('serif') ctx.restore() diff --git a/layout.py b/layout.py index 20274f2..64f5888 100644 --- a/layout.py +++ b/layout.py @@ -11,9 +11,6 @@ # Card Measurements nameBL = (6,8.5) nameH = 2.5 -manaRight = 57 -mana0TL = (54,5.5) -manaOS = 3.5 typeBL = (6,52.5) typeH = 2 cardTextBL = (6,58) @@ -21,6 +18,8 @@ cardTextW = 51 ptBL = (50.5,82) ptH = 3 +footerBL = (6,82) +footerH = 1.8 # Artwork measurements (relative to a single card origin) ART_OFFSET_MM = (5.47, 10.933333333333332) @@ -37,6 +36,13 @@ SINGLE_CARD_DPI = 300 CARD_CORNER_RADIUS_MM = 3.0 +commandPointsShieldTL = (CARD_WIDTH_MM - 9.5, 4.0) +commandPointsShieldSize = (7.2, 8.0) +commandPointsShieldPointHeight = 2.4 +commandPointsFontSize = 3.6 +commandPointsBorderWidth = 0.45 +commandPointsShieldGap = 1.0 + def mm_to_pixels(value_mm: float, dpi: float) -> int: """Convert a millimetre measurement to whole pixels for a given DPI.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c2366bf --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +import pathlib +import sys + + +ROOT = pathlib.Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/tests/test_card_background.py b/tests/test_card_background.py new file mode 100644 index 0000000..eb003a5 --- /dev/null +++ b/tests/test_card_background.py @@ -0,0 +1,57 @@ +import pytest + +try: + import cairo # type: ignore +except Exception: # pragma: no cover - optional dependency missing + cairo = None + +from card_model import CardModel + +if cairo is not None: # pragma: no branch - conditional import for optional dependency + import layout + from draw_card import drawCard +else: # pragma: no cover - only triggered when cairo is missing + layout = None + drawCard = None + + +class TrackingCard(CardModel): + def __init__(self): + super().__init__() + self.background_calls = 0 + + def get_background_color_rgb(self): + self.background_calls += 1 + return super().get_background_color_rgb() + + +def _render_card(card: TrackingCard) -> TrackingCard: + assert cairo is not None and layout is not None and drawCard is not None + dpi = layout.SINGLE_CARD_DPI + surface = layout.get_single_card_surface(dpi) + ctx = cairo.Context(surface) + ctx.set_matrix(layout.get_single_card_matrix(dpi)) + drawCard(card, ctx) + return card + + +@pytest.mark.skipif(cairo is None, reason="pycairo is not available") +def test_draw_card_requests_background_colour_when_not_full_frame(): + card = TrackingCard() + card.backgroundColour = '#FF00FF' + card.imageFullFrame = False + + rendered = _render_card(card) + + assert rendered.background_calls >= 1 + + +@pytest.mark.skipif(cairo is None, reason="pycairo is not available") +def test_draw_card_skips_background_when_full_frame(): + card = TrackingCard() + card.backgroundColour = '#FF00FF' + card.imageFullFrame = True + + rendered = _render_card(card) + + assert rendered.background_calls == 0 diff --git a/tests/test_card_footer.py b/tests/test_card_footer.py new file mode 100644 index 0000000..90324b3 --- /dev/null +++ b/tests/test_card_footer.py @@ -0,0 +1,56 @@ +import pytest + +from card_model import CardModel + + +def _load_card(data): + card = CardModel() + card.load(data) + return card + + +def test_footer_defaults_to_empty_text_and_normal_style(): + card = _load_card({'type': 'Evento'}) + + assert card.footerText == '' + assert card.footerColour == '#000000' + assert card.footerFontStyle == 'normal' + + +@pytest.mark.parametrize( + 'style_value, expected', + [ + ('normal', 'normal'), + ('negrita', 'bold'), + ('bold', 'bold'), + ('italica', 'italic'), + ('itálica', 'italic'), + ], +) +def test_footer_style_is_normalised(style_value, expected): + card = _load_card({ + 'type': 'Evento', + 'footer': { + 'text': 'Referencia', + 'color': '#123456', + 'font_style': style_value, + }, + }) + + assert card.footerText == 'Referencia' + assert card.footerColour == '#123456' + assert card.footerFontStyle == expected + + +def test_footer_accepts_alternative_style_key(): + card = _load_card({ + 'type': 'Evento', + 'footer': { + 'text': 'Nota al pie', + 'color': '#654321', + 'tipo': 'negrita', + }, + }) + + assert card.footerFontStyle == 'bold' + assert card.get_footer_text_color_rgb() == pytest.approx((0x65 / 255, 0x43 / 255, 0x21 / 255)) diff --git a/tests/test_card_model.py b/tests/test_card_model.py index b502974..79c8e23 100644 --- a/tests/test_card_model.py +++ b/tests/test_card_model.py @@ -21,11 +21,12 @@ def test_load_with_optional_fields(self): "text": "Draw a card", "colour": "#112233", }, - "manaCost": "{1}{U}", + "commandPoints": 5, "power": 2, "toughness": 3, "image": "wizard.png", "full_frame_image": True, + "background_color": "#123456", } card = CardModel() @@ -42,11 +43,15 @@ def test_load_with_optional_fields(self): self.assertEqual(card.cardText, "Draw a card") self.assertEqual(card.cardTextColour, "#112233") self.assertEqual(card.get_text_color_rgb(), (0x11 / 255.0, 0x22 / 255.0, 0x33 / 255.0)) - self.assertEqual(card.manaCost, "{1}{U}") + self.assertEqual(card.commandPoints, 5) + self.assertIsNone(card.commandPointsSecondary) + self.assertEqual(card.get_command_points_display(), "5") self.assertEqual(card.power, 2) self.assertEqual(card.toughness, 3) self.assertEqual(card.image, "wizard.png") self.assertTrue(card.imageFullFrame) + self.assertEqual(card.backgroundColour, "#123456") + self.assertEqual(card.get_background_color_rgb(), (0x12 / 255.0, 0x34 / 255.0, 0x56 / 255.0)) def test_load_with_defaults(self): data = { @@ -69,11 +74,15 @@ def test_load_with_defaults(self): self.assertEqual(card.cardText, "") self.assertEqual(card.cardTextColour, "#000000") self.assertEqual(card.get_text_color_rgb(), (0.0, 0.0, 0.0)) - self.assertEqual(card.manaCost, "") + self.assertEqual(card.commandPoints, 0) + self.assertIsNone(card.commandPointsSecondary) + self.assertEqual(card.get_command_points_display(), "0") self.assertIsNone(card.power) self.assertIsNone(card.toughness) self.assertIsNone(card.image) self.assertFalse(card.imageFullFrame) + self.assertEqual(card.backgroundColour, "#FFFFFF") + self.assertEqual(card.get_background_color_rgb(), (1.0, 1.0, 1.0)) def test_load_supports_image_object(self): data = { @@ -93,6 +102,52 @@ def test_load_supports_image_object(self): self.assertEqual(card.image, "object.png") self.assertTrue(card.imageFullFrame) + def test_load_legacy_mana_cost_field(self): + data = { + "header": { + "text": "Legacy", + }, + "type": "Enchantment", + "manaCost": "12", + } + + card = CardModel() + card.load(data) + + self.assertEqual(card.commandPoints, 12) + self.assertIsNone(card.commandPointsSecondary) + + def test_load_coerces_string_command_points(self): + data = { + "header": { + "text": "Numbers", + }, + "type": "Instant", + "commandPoints": " CP: 08 ", + } + + card = CardModel() + card.load(data) + + self.assertEqual(card.commandPoints, 8) + self.assertIsNone(card.commandPointsSecondary) + + def test_load_parses_split_command_points(self): + data = { + "header": { + "text": "Split", + }, + "type": "Sorcery", + "commandPoints": "3/7", + } + + card = CardModel() + card.load(data) + + self.assertEqual(card.commandPoints, 3) + self.assertEqual(card.commandPointsSecondary, 7) + self.assertEqual(card.get_command_points_display(), "3/7") + def test_load_supports_legacy_name_field(self): data = { "name": "Legacy", diff --git a/tests/test_cli_features.py b/tests/test_cli_features.py new file mode 100644 index 0000000..9954f73 --- /dev/null +++ b/tests/test_cli_features.py @@ -0,0 +1,30 @@ +import sys +import pytest + +from utils import slugify + +try: + from add_images import _ensure_output_dir +except Exception: # pragma: no cover - optional dependency missing + _ensure_output_dir = None + + +@pytest.mark.parametrize( + "value, expected", + [ + ("Simple Name", "Simple_Name"), + (" leading and trailing ", "leading_and_trailing"), + ("special!*@#chars", "specialchars"), + ("", "card"), + ], +) +def test_slugify_generates_safe_names(value, expected): + assert slugify(value) == expected + +@pytest.mark.skipif(_ensure_output_dir is None, reason="Image processing dependencies are unavailable") +def test_ensure_output_dir_uses_custom_root(tmp_path): + output_dir = _ensure_output_dir('demo-deck', tmp_path) + expected_path = tmp_path / 'demo-deck' / 'images' + + assert output_dir == expected_path + assert output_dir.exists() diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..3820490 --- /dev/null +++ b/utils.py @@ -0,0 +1,9 @@ +import re + + +def slugify(value: str) -> str: + """Convert arbitrary text into a filesystem-friendly slug.""" + value = (value or '').strip() + value = re.sub(r'\s+', '_', value) + value = re.sub(r'[^A-Za-z0-9_-]', '', value) + return value or 'card'