From 18d7b5564dc147c8dd81112b85a53aca9174ff3d Mon Sep 17 00:00:00 2001 From: fmolinagomez <33826980+fmolinagomez@users.noreply.github.com> Date: Mon, 6 Oct 2025 16:15:04 +0200 Subject: [PATCH] Add configurable background colours for cards --- LWCProto.py | 426 +++++++++++++++++++++++----------- README.md | 16 +- add_images.py | 23 +- card_model.py | 42 ++++ cartas.json | 11 + draw_card.py | 21 ++ layout.py | 2 + tests/conftest.py | 7 + tests/test_card_background.py | 57 +++++ tests/test_card_footer.py | 56 +++++ tests/test_card_model.py | 5 + tests/test_cli_features.py | 30 +++ utils.py | 9 + 13 files changed, 566 insertions(+), 139 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..25256bd 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,6 +64,12 @@ El archivo de definicion de cartas es un archivo en jormato json con el siguente "text": "str", "colour": "#RRGGBB" }, + "background_color": "#RRGGBB", + "footer": { + "text": "str", + "color": "#RRGGBB", + "font_style": "normal | negrita | itálica" + }, "manaCost": "str", "power": int, "toughness": int, @@ -71,7 +81,9 @@ 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 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..e0a60b9 100644 --- a/card_model.py +++ b/card_model.py @@ -34,6 +34,10 @@ def __init__(self, name=None, db=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 @@ -99,6 +103,20 @@ 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})' @@ -111,6 +129,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 +160,21 @@ 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') diff --git a/cartas.json b/cartas.json index ff4c225..cdaa391 100644 --- a/cartas.json +++ b/cartas.json @@ -12,6 +12,11 @@ "text": "este es un texto de prueba", "colour": "#000000" }, + "background_color": "#F5F5F5", + "footer": { + "text": "footer carta 1", + "color": "#333333" + }, "manaCost": "5/6 * {r}", "image": "BarcoPirata.jpg" }, @@ -28,6 +33,12 @@ "text": "este es un texto de prueba para esta supercarta", "colour": "#FFFFFF" }, + "background_color": "#002244", + "footer": { + "text": "footer destacado", + "color": "#FFD700", + "font_style": "negrita" + }, "manaCost": "2", "power": 60, "toughness": 9, diff --git a/draw_card.py b/draw_card.py index 35847cb..2da4b45 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') @@ -107,6 +111,23 @@ def drawCard( ) ctx.show_text(card.manaCost) + # 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..23a1fea 100644 --- a/layout.py +++ b/layout.py @@ -21,6 +21,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) 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..462bb99 100644 --- a/tests/test_card_model.py +++ b/tests/test_card_model.py @@ -26,6 +26,7 @@ def test_load_with_optional_fields(self): "toughness": 3, "image": "wizard.png", "full_frame_image": True, + "background_color": "#123456", } card = CardModel() @@ -47,6 +48,8 @@ def test_load_with_optional_fields(self): 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 = { @@ -74,6 +77,8 @@ def test_load_with_defaults(self): 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 = { 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'