diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 9b23ba1..bdf8a7b 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -23,9 +23,11 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install package - run: | - python -m pip install .[dev] + - name: Setup PDM + uses: pdm-project/setup-pdm@v4 + # You are now able to use PDM in your workflow + - name: Install packages + run: pdm install -G test --skip=post_install - name: Test with pytest run: | - python -m pytest tests/ + pdm test diff --git a/.gitignore b/.gitignore index b6e4761..1eb0568 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,8 @@ dmypy.json # Pyre type checker .pyre/ + +# MTG Cache +.cache_mtg/ +tests/outputs/ +tests/.test_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6b3064f..76c04cc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,46 +9,30 @@ repos: - id: check-executables-have-shebangs - id: check-shebang-scripts-are-executable - id: end-of-file-fixer + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v3.0.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [] # optional: list of Conventional Commits types to allow e.g. [feat, fix, ci, chore, test] - repo: https://github.com/asottile/pyupgrade rev: v3.15.0 hooks: - id: pyupgrade args: - "--py39-plus" - - repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.12.1 - hooks: - - id: black - - repo: https://github.com/PyCQA/isort - rev: 5.13.2 - hooks: - - id: isort - - repo: https://github.com/PyCQA/flake8 - rev: 6.1.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.9 hooks: - - id: flake8 - additional_dependencies: - - flake8-absolute-import - - flake8-annotations - - flake8-builtins - - flake8-comprehensions - - flake8-docstrings - - flake8-future-annotations - - flake8-no-implicit-concat - - flake8-print - - flake8-requirements - - flake8-simplify - - flake8-use-fstring - - flake8-use-pathlib - - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.1.0 - hooks: - - id: prettier - args: - - "--tab-width" - - "2" + # Run the linter. + - id: ruff + types_or: [python, pyi] + args: [--fix] + # Run the formatter. + - id: ruff-format + types_or: [python, pyi] - repo: https://github.com/shssoichiro/oxipng - rev: v9.0.0 + rev: v9.1.1 hooks: - id: oxipng args: diff --git a/README.md b/README.md index 92c9293..943a6c7 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,13 @@ Create a high quality printable PDF from your decklist or a list of cards you wa The Arena format is recommended, as it allows you to keep the same prints when moving decklists between multiple tools. There are even cases (i.e. tokens) where the name alone is not sufficient to uniquely specify a card. The Arena format helps in these case, as set and collector number are unique identifiers. - However, as tools often only work with one of these formats, `mtg-proxies` is a flexible as possible, even supporting mixed mode. - This is especially when you are making quick additions to a decklist and don't want to search for set and collector numbers. + However, as tools often only work with one of these formats, `mtg-proxies` is as flexible as possible, even supporting mixed mode. + This is especially useful when you are making quick additions to a decklist and don't want to search for set and collector numbers. The `convert.py` tool can be used to convert decklists between the two formats. - **Sanity checks and recommender engine** `mtg-proxies` warns you if you attempt to print a low-resolution scan and is able to offer alternatives. - The `convert.py` tool can automatically selects the best print for each card in a decklist with high accuracy, eliminating the need to manually select good prints. + The `convert.py` tool can automatically select the best print for each card in a decklist with high accuracy, eliminating the need to manually select good prints. - **Token support** The `tokens.py` tool appends the tokens created by the cards in a decklist to it, so you don't miss one accidentally. Caveat: This only works when Scryfall has the data on associated tokens. This is the case for cards printed or reprinted since Tenth Edition. @@ -34,53 +34,69 @@ Create a high quality printable PDF from your decklist or a list of cards you wa - **ManaStack and Archidekt integration** Directly use ManaStack and Archidekt deck ids as input for many functions instead of local decklist files. - Decks on Archidekt must be set to public to be read. + Decks on Archidekt must be set as public to be read. ## Usage 1. Clone or download this repo. -```bash -git clone https://github.com/DiddiZ/mtg-proxies.git -cd mtg-proxies -``` + ```bash + git clone https://github.com/DiddiZ/mtg-proxies.git + cd mtg-proxies + ``` 2. Install requirements. Requires at least [Python 3.9](https://www.python.org/downloads/). - -```bash -# On Linux, use `python3` instead of `python` -python -m pip install -e . -``` - -You can also use a [virtual environment](https://docs.python.org/3/library/venv.html). + You can use pip or PDM to install the dependencies. + + - Install using pip (you can use a [virtual environment](https://docs.python.org/3/library/venv.html)). + ```bash + # On Linux, use `python3` instead of `python` + python -m venv .venv # Create a virtual environment + source .venv/bin/activate # Activate the virtual environment + # on Windows, run .venv\Scripts\activate.bat or .venv\Scripts\Activate.ps1 + python -m pip install -e . # Install in editable mode + ``` + - Install using PDM (recommended) + ```bash + pdm install # creates a virtual env. and installs dependencies on its own + ``` 3. (Optional) Prepare your decklist in MtG Arena format. This is not required, but recommended as it allows for more control over the process. -```txt -COUNT FULL_NAME (SET) COLLECTOR_NUMBER -``` + ```txt + COUNT FULL_NAME (SET) COLLECTOR_NUMBER + ``` -E.g.: + E.g.: -```txt -1 Alela, Artful Provocateur (ELD) 324 -1 Korvold, Fae-Cursed King (ELD) 329 -1 Liliana, Dreadhorde General (WAR) 97 -1 Murderous Rider // Swift End (ELD) 287 -``` + ```txt + 1 Alela, Artful Provocateur (ELD) 324 + 1 Korvold, Fae-Cursed King (ELD) 329 + 1 Liliana, Dreadhorde General (WAR) 97 + 1 Murderous Rider // Swift End (ELD) 287 + ``` -Or use the `convert.py` tool to convert a plain decklist to Arena format: + Or use the `convert.py` tool to convert a plain decklist to Arena format: -```bash -python convert.py examples/decklist_text.txt examples/decklist.txt -``` + ```bash + python convert.py examples/decklist_text.txt examples/decklist.txt + ``` -4. Create a PDF file. +4. Assemble your proxies into a document. -```bash -python print.py examples/decklist.txt decklist.pdf -``` + To find out what options are available, run: + + ```bash + pdm run python print.py --help + ``` + + There are two commands available: `pdf` and `image`. + + ```python + pdm run python print.py image --help + pdm run python print.py pdf --help + ``` Examples: @@ -103,25 +119,15 @@ python -m pip install -e . ### print ```txt -pipenv run python print.py [-h] [--dpi DPI] decklist outfile +pdm run python print.py --help +Usage: print.py [OPTIONS] COMMAND [ARGS]... -Prepare a decklist for printing. +Options: + --help Show this message and exit. -positional arguments: - decklist_spec path to a decklist in text/arena format, or manastack:{manastack_id}, or archidekt:{archidekt_id} - outfile output file. Supports pdf, png and jpg. - -optional arguments: - -h, --help show this help message and exit - --dpi DPI dpi of output file (default: 300) - --paper WIDTHxHEIGHT paper size in inches or preconfigured format (default: a4) - --scale FLOAT scaling factor for printed cards (default: 1.0) - --border_crop PIXELS how much to crop inner borders of printed cards (default: 14) - --background COLOR background color, either by name or by hex code (e.g. black or "#ff0000", default: None) - --cropmarks, --no-cropmarks - add crop marks (default: True) - --faces {all,front,back} - which faces to print (default: all) +Commands: + image This command generates an image file at OUTPUT_FILE with the... + pdf This command generates a PDF document at OUTPUT_FILE with the... ``` ### convert diff --git a/deck_value.py b/deck_value.py index a44a40f..d85ebe0 100644 --- a/deck_value.py +++ b/deck_value.py @@ -2,7 +2,7 @@ import matplotlib.pyplot as plt -import scryfall +from mtgproxies import scryfall from mtgproxies.cli import parse_decklist_spec if __name__ == "__main__": diff --git a/mtgproxies/__init__.py b/mtgproxies/__init__.py index 1a6b38d..8bc331b 100644 --- a/mtgproxies/__init__.py +++ b/mtgproxies/__init__.py @@ -1,8 +1,5 @@ -from mtgproxies.print_cards import print_cards_fpdf, print_cards_matplotlib from mtgproxies.scans import fetch_scans_scryfall __all__ = [ "fetch_scans_scryfall", - "print_cards_matplotlib", - "print_cards_fpdf", ] diff --git a/mtgproxies/cli.py b/mtgproxies/cli.py index a57c074..0a605b4 100644 --- a/mtgproxies/cli.py +++ b/mtgproxies/cli.py @@ -4,16 +4,17 @@ from mtgproxies.decklists.decklist import Decklist -def parse_decklist_spec(decklist_spec: str, warn_levels=("ERROR", "WARNING", "COSMETIC")) -> Decklist: +def parse_decklist_spec(decklist_spec: str, cache_dir: Path, warn_levels=("ERROR", "WARNING", "COSMETIC")) -> Decklist: """Attempt to parse a decklist from different locations. Args: decklist_spec: File path or ManaStack id + cache_dir: Directory with cached files warn_levels: Levels of warnings to show """ print("Parsing decklist ...") if Path(decklist_spec).is_file(): # Decklist is file - decklist, ok, warnings = parse_decklist(decklist_spec) + decklist, ok, warnings = parse_decklist(decklist_spec, cache_dir=cache_dir) elif decklist_spec.lower().startswith("manastack:") and decklist_spec.split(":")[-1].isdigit(): # Decklist on Manastack manastack_id = decklist_spec.split(":")[-1] diff --git a/mtgproxies/decklists/archidekt/archidekt.py b/mtgproxies/decklists/archidekt/archidekt.py index 53338c1..3cefa4a 100644 --- a/mtgproxies/decklists/archidekt/archidekt.py +++ b/mtgproxies/decklists/archidekt/archidekt.py @@ -1,22 +1,28 @@ from __future__ import annotations +from pathlib import Path + import requests from mtgproxies.decklists import Decklist from mtgproxies.decklists.sanitizing import validate_card_name, validate_print -def parse_decklist(archidekt_id: str) -> tuple[Decklist, bool, list]: +def parse_decklist(archidekt_id: str, cache_dir: Path, zones: list[str] = None) -> tuple[Decklist, bool, list]: """Parse a decklist from manastack. Args: archidekt_id: Deck list id as shown in the deckbuilder URL + cache_dir: Directory to store cached card data zones: List of zones to include. Available are: `mainboard`, `commander`, `sideboard` and `maybeboard` """ decklist = Decklist() warnings = [] ok = True + if zones is not None: + raise NotImplementedError("Zones are not implemented for Archidekt decklists") + r = requests.get(f"https://archidekt.com/api/decks/{archidekt_id}/") if r.status_code != 200: raise (ValueError(f"Archidekt returned statuscode {r.status_code}")) @@ -31,11 +37,11 @@ def parse_decklist(archidekt_id: str) -> tuple[Decklist, bool, list]: card_name = item["card"]["oracleCard"]["name"] set_id = item["card"]["edition"]["editioncode"] collector_number = item["card"]["collectorNumber"] - if len(item["categories"]) > 0 and item["categories"][0] not in in_deck: + if item["categories"] is not None and len(item["categories"]) > 0 and item["categories"][0] not in in_deck: continue # Validate card name - card_name, warnings_name = validate_card_name(card_name) + card_name, warnings_name = validate_card_name(card_name, cache_dir=cache_dir) if card_name is None: decklist.append_comment(card_name) warnings.extend([(decklist.entries[-1], level, msg) for level, msg in warnings_name]) @@ -43,7 +49,7 @@ def parse_decklist(archidekt_id: str) -> tuple[Decklist, bool, list]: continue # Validate card print - card, warnings_print = validate_print(card_name, set_id, collector_number) + card, warnings_print = validate_print(card_name, set_id, collector_number, cache_dir=cache_dir) decklist.append_card(count, card) warnings.extend([(decklist.entries[-1], level, msg) for level, msg in warnings_name + warnings_print]) diff --git a/mtgproxies/decklists/decklist.py b/mtgproxies/decklists/decklist.py index 127c835..9f012e9 100644 --- a/mtgproxies/decklists/decklist.py +++ b/mtgproxies/decklists/decklist.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any -import scryfall +from mtgproxies import scryfall from mtgproxies.decklists.sanitizing import validate_card_name, validate_print @@ -117,7 +117,7 @@ def from_scryfall_ids(card_ids) -> Decklist: return decklist -def parse_decklist(filepath: str | Path) -> tuple[Decklist, bool, list]: +def parse_decklist(filepath: str | Path, cache_dir: Path) -> tuple[Decklist, bool, list]: """Parse card information from a decklist in text or MtG Arena (or mixed) format. E.g.: @@ -134,7 +134,7 @@ def parse_decklist(filepath: str | Path) -> tuple[Decklist, bool, list]: warnings: List of (entry, warning) tuples """ with open(filepath, encoding="utf-8") as f: - decklist, ok, warnings = parse_decklist_stream(f) + decklist, ok, warnings = parse_decklist_stream(f, cache_dir=cache_dir) # Use file name without extension as name decklist.name = Path(filepath).stem @@ -142,7 +142,7 @@ def parse_decklist(filepath: str | Path) -> tuple[Decklist, bool, list]: return decklist, ok, warnings -def parse_decklist_stream(stream) -> tuple[Decklist, bool, list]: +def parse_decklist_stream(stream, cache_dir: Path) -> tuple[Decklist, bool, list]: """Parse card information from a decklist in text or MtG Arena (or mixed) format from a stream. See: @@ -152,7 +152,7 @@ def parse_decklist_stream(stream) -> tuple[Decklist, bool, list]: warnings = [] ok = True for line in stream: - m = re.search(r"([0-9]+)\s+(.+?)(?:\s+\((\S*)\)\s+(\S+))?\s*$", line) + m = re.search(r"([0-9]+)x?\s+(.+?)(?:\s+\((\S*)\)\s+(\S+))?\s*$", line) if m: # Extract relevant data count = int(m.group(1)) @@ -161,7 +161,7 @@ def parse_decklist_stream(stream) -> tuple[Decklist, bool, list]: collector_number = m.group(4) # May be None # Validate card name - card_name, warnings_name = validate_card_name(card_name) + card_name, warnings_name = validate_card_name(card_name, cache_dir=cache_dir) if card_name is None: decklist.append_comment(line.rstrip()) warnings.extend([(decklist.entries[-1], level, msg) for level, msg in warnings_name]) @@ -169,7 +169,7 @@ def parse_decklist_stream(stream) -> tuple[Decklist, bool, list]: continue # Validate card print - card, warnings_print = validate_print(card_name, set_id, collector_number) + card, warnings_print = validate_print(card_name, set_id, collector_number, cache_dir=cache_dir) decklist.append_card(count, card) warnings.extend([(decklist.entries[-1], level, msg) for level, msg in warnings_name + warnings_print]) diff --git a/mtgproxies/decklists/sanitizing.py b/mtgproxies/decklists/sanitizing.py index 56737b5..3e95899 100644 --- a/mtgproxies/decklists/sanitizing.py +++ b/mtgproxies/decklists/sanitizing.py @@ -1,17 +1,18 @@ from functools import lru_cache +from pathlib import Path -import scryfall +from mtgproxies import scryfall from mtgproxies.format import format_print, format_token, listing @lru_cache(maxsize=None) -def card_names(): +def card_names(cache_dir: Path): """Sets of valid card names. Cached for performance. """ cards_by_name = { - card["name"].lower(): card["name"] for card in scryfall.get_cards() if card["layout"] not in ["art_series"] + card["name"].lower(): card["name"] for card in scryfall.get_cards(cache_dir=cache_dir) if card["layout"] not in ["art_series"] } double_faced_by_front = { name.split("//")[0].strip().lower(): name for name in cards_by_name.values() if "//" in name @@ -19,7 +20,7 @@ def card_names(): return cards_by_name, double_faced_by_front -def validate_card_name(card_name: str): +def validate_card_name(card_name: str, cache_dir: Path): """Validate card name against the Scryfall database. Returns: @@ -28,7 +29,7 @@ def validate_card_name(card_name: str): ok: whether the card could be found. """ # Unique names of all cards - cards_by_name, double_faced_by_front = card_names() + cards_by_name, double_faced_by_front = card_names(cache_dir=cache_dir) validated_name = None sanizized_name = scryfall.canonic_card_name(card_name) @@ -70,7 +71,7 @@ def get_print_warnings(card) -> list[str]: return warnings -def validate_print(card_name: str, set_id: str, collector_number: str): +def validate_print(card_name: str, set_id: str, collector_number: str, cache_dir: Path): """Validate a print against the Scryfall database. Assumes card name is valid. @@ -82,17 +83,17 @@ def validate_print(card_name: str, set_id: str, collector_number: str): warnings = [] if set_id is None: - card = scryfall.recommend_print(card_name=card_name) + card = scryfall.recommend_print(card_name=card_name, cache_dir=cache_dir) # Warn for tokens, as they are not unique by name if card["layout"] in ["token", "double_faced_token"]: warnings.append( ("WARNING", f"Tokens are not unique by name. Assuming '{card_name}' is a '{format_token(card)}'.") ) else: - card = scryfall.get_card(card_name, set_id, collector_number) + card = scryfall.get_card(card_name=card_name, cache_dir=cache_dir, set_id=set_id, collector_number=collector_number) if card is None: # No exact match # Find alternative print - card = scryfall.recommend_print(card_name=card_name) + card = scryfall.recommend_print(card_name=card_name, cache_dir=cache_dir) warnings.append( ( "WARNING", @@ -105,7 +106,7 @@ def validate_print(card_name: str, set_id: str, collector_number: str): quality_warnings = get_print_warnings(card) if len(quality_warnings) > 0: # Get recommendation - recommendation = scryfall.recommend_print(card) + recommendation = scryfall.recommend_print(current=card, cache_dir=cache_dir) # Format warnings string quality_warnings = listing(quality_warnings, ", ", " and ").capitalize() diff --git a/mtgproxies/dimensions.py b/mtgproxies/dimensions.py new file mode 100644 index 0000000..3a1e518 --- /dev/null +++ b/mtgproxies/dimensions.py @@ -0,0 +1,135 @@ +from collections.abc import Iterable +from logging import getLogger +from typing import Any, Literal + +import numpy as np +from nptyping import Float, NDArray, UInt +from nptyping.shape import Shape + + +logger = getLogger(__name__) + + +# TODO: Implement using pint +# MTG_CARD_INCHES: NDArray[Shape["2"], Float] = np.asarray([2.48425197, 3.46456693], dtype=float) +# MTG_CARD_MM: NDArray[Shape["2"], Float] = np.asarray([63.1, 88.0], dtype=float) +MTG_CARD_SIZE: dict[str, NDArray[Shape["2"], Float]] = { + "in": np.asarray([2.48425197, 3.46456693], dtype=float), + "mm": np.asarray([63.1, 88.0], dtype=float), + "cm": np.asarray([6.31, 8.8], dtype=float), +} + +# Paper sizes (sourced from the Adobe website) +PAPER_SIZE: dict[str, dict[str, NDArray[Shape["2"], Float]]] = { + "A0": { + "mm": np.asarray([841, 1189], dtype=float), + "in": np.asarray([33.1, 46.8], dtype=float), + "cm": np.asarray([84.1, 118.9], dtype=float), + }, + "A1": { + "mm": np.asarray([594, 841], dtype=float), + "in": np.asarray([23.4, 33.1], dtype=float), + "cm": np.asarray([59.4, 84.1], dtype=float), + }, + "A2": { + "mm": np.asarray([420, 594], dtype=float), + "in": np.asarray([16.5, 23.4], dtype=float), + "cm": np.asarray([42.0, 59.4], dtype=float), + }, + "A3": { + "mm": np.asarray([297, 420], dtype=float), + "in": np.asarray([11.7, 16.5], dtype=float), + "cm": np.asarray([29.7, 42.0], dtype=float), + }, + "A4": { + "mm": np.asarray([210, 297], dtype=float), + "in": np.asarray([8.3, 11.7], dtype=float), + "cm": np.asarray([21.0, 29.7], dtype=float), + }, + "A5": { + "mm": np.asarray([148, 210], dtype=float), + "in": np.asarray([5.8, 8.3], dtype=float), + "cm": np.asarray([14.8, 21.0], dtype=float), + }, + "A6": { + "mm": np.asarray([105, 148], dtype=float), + "in": np.asarray([4.1, 5.8], dtype=float), + "cm": np.asarray([10.5, 14.8], dtype=float), + }, + "A7": { + "mm": np.asarray([74, 105], dtype=float), + "in": np.asarray([2.9, 4.1], dtype=float), + "cm": np.asarray([7.4, 10.5], dtype=float), + }, + "A8": { + "mm": np.asarray([52, 74], dtype=float), + "in": np.asarray([2.0, 2.9], dtype=float), + "cm": np.asarray([5.2, 7.4], dtype=float), + }, + "A9": { + "mm": np.asarray([37, 52], dtype=float), + "in": np.asarray([1.5, 2.0], dtype=float), + "cm": np.asarray([3.7, 5.2], dtype=float), + }, + "A10": { + "mm": np.asarray([26, 37], dtype=float), + "in": np.asarray([1.0, 1.5], dtype=float), + "cm": np.asarray([2.6, 3.7], dtype=float), + }, +} + +UNITS_TO_MM = {"in": 25.4, "mm": 1.0, "cm": 10} + +UNITS_TO_IN = {"in": 1.0, "mm": 1 / 25.4, "cm": 10 / 25.4} + + +Units = Literal["in", "mm", "cm"] + + +def get_pixels_from_size_and_ppsu(ppsu: int, size: Iterable[float] | float) -> NDArray[Any, UInt]: + """Calculate size in pixels from size and DPI. + + The code assumes that everything is handled in milimetres. + + Args: + ppsu (int): Dots per inch. Dots here are pixels. + size (Iterable[float] | float): Value or iterable of values representing size in inches. + + Returns: + Iterable[int]: Sizes in pixels for each value in the input iterable. + """ + return (np.asarray(size, dtype=np.float32) * ppsu).round(decimals=0).astype(int) + + +def get_ppsu_from_size_and_pixels( + pixel_values: Iterable[int] | int, + size: Iterable[float] | float, +) -> int: + """Calculate PPSU (points per size unit) from size and amount of pixels. + + It calculates the PPSU by dividing the amount of pixels by the size in whatever units are used. + If multiple dimensions are provided, it averages over the DPIs for each dimension. + If the PPSUs differ, a warning is logged. + + Args: + pixel_values (Iterable[int] | int): Value or iterable of values representing size in pixels. + size (Iterable[float] | float): Value or iterable of values representing size in any units. + + Returns: + int: Dots per inch. Averages over the input values, if multiple dimensions are provided. + """ + ppsu = np.asarray(pixel_values, dtype=np.int32) / (np.asarray(size, dtype=np.float32)) + mean_ppsu = np.mean(ppsu) + if not np.allclose(ppsu, mean_ppsu, atol=1): + logger.warning(f"DPIs differ accross dimensions: {ppsu} = {pixel_values}/{size}") + return int(mean_ppsu.round(decimals=0)) + + +def parse_papersize_from_spec(spec: str, units: Units) -> NDArray[Shape["2"], Float]: + if spec in PAPER_SIZE: + if units in PAPER_SIZE[spec]: + return PAPER_SIZE[spec][units] + else: + raise ValueError(f"Units {units} not supported for papersize {spec}") + else: + raise ValueError(f"Paper size not supported: {spec}") diff --git a/mtgproxies/plotting/splitpages.py b/mtgproxies/plotting/splitpages.py index cf04f6d..24c8557 100644 --- a/mtgproxies/plotting/splitpages.py +++ b/mtgproxies/plotting/splitpages.py @@ -3,6 +3,7 @@ from pathlib import Path from types import TracebackType +import matplotlib import matplotlib.pyplot as plt @@ -16,8 +17,11 @@ def __init__(self, filename: Path | str) -> None: """Create a new SplitPages object.""" self.filename = Path(filename) self.pagecount = 0 + self.prev_mpl_backend = None def __enter__(self) -> SplitPages: + self.prev_mpl_backend = matplotlib.get_backend() + matplotlib.use('Agg') return self def __exit__( @@ -26,6 +30,7 @@ def __exit__( exc_val: BaseException | None, exc_tb: TracebackType | None, ): + matplotlib.use(self.prev_mpl_backend) pass def savefig(self, figure=None, **kwargs): @@ -38,4 +43,5 @@ def savefig(self, figure=None, **kwargs): filename = self.filename.parent / f"{self.filename.stem}_{self.pagecount:03}{self.filename.suffix}" figure.savefig(filename, **kwargs) + print(f"Saved to: {filename}") self.pagecount += 1 diff --git a/mtgproxies/print_cards.py b/mtgproxies/print_cards.py index 6033364..19065fd 100644 --- a/mtgproxies/print_cards.py +++ b/mtgproxies/print_cards.py @@ -1,177 +1,462 @@ from __future__ import annotations -from pathlib import Path +import abc +import math +from logging import getLogger +from typing import TYPE_CHECKING import matplotlib.pyplot as plt import numpy as np -from matplotlib.backends.backend_pdf import PdfPages -from matplotlib.patches import Rectangle +import PIL +from fpdf import FPDF +from PIL import ImageChops, ImageFilter +from PIL.Image import Image, Transpose from tqdm import tqdm -from mtgproxies.plotting import SplitPages +from mtgproxies.dimensions import UNITS_TO_IN, Units, get_pixels_from_size_and_ppsu, get_ppsu_from_size_and_pixels -image_size = np.array([745, 1040]) +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + from typing import Literal -def _occupied_space(cardsize, pos, border_crop: int, closed: bool = False): - return cardsize * (pos * image_size - np.clip(2 * pos - 1 - closed, 0, None) * border_crop) / image_size + from nptyping import Float, NDArray -def print_cards_matplotlib( - images: list[str | Path], - filepath: str | Path, - papersize=np.array([8.27, 11.69]), - cardsize=np.array([2.5, 3.5]), - border_crop: int = 14, - interpolation: str | None = "lanczos", - dpi: int = 600, - background_color=None, -): - """Print a list of cards to a pdf file. +logger = getLogger(__name__) - Args: - images: List of image files - filepath: Name of the pdf file - papersize: Size of the paper in inches. Defaults to A4. - cardsize: Size of a card in inches. - border_crop: How many pixel to crop from the border of each card. - """ - # Cards per figure - N = np.floor(papersize / cardsize).astype(int) - if N[0] == 0 or N[1] == 0: - raise ValueError(f"Paper size too small: {papersize}") - offset = (papersize - _occupied_space(cardsize, N, border_crop, closed=True)) / 2 - - # Ensure directory exists - filepath = Path(filepath) - filepath.parent.mkdir(parents=True, exist_ok=True) - - # Choose pdf of image saver - if filepath.suffix == ".pdf": - saver = PdfPages - else: - saver = SplitPages - - with saver(filepath) as saver, tqdm(total=len(images), desc="Plotting cards") as pbar: - while len(images) > 0: - fig = plt.figure(figsize=papersize) - ax = fig.add_axes([0, 0, 1, 1]) # ax covers the whole figure - # Background - if background_color is not None: - plt.gca().add_patch(Rectangle((0, 0), 1, 1, color=background_color, zorder=-1000)) - - for y in range(N[1]): - for x in range(N[0]): - if len(images) > 0: - img = plt.imread(images.pop(0)) - - # Crop left and top if not on border of sheet - left = border_crop if x > 0 else 0 - top = border_crop if y > 0 else 0 - img = img[top:, left:] - - # Compute extent - lower = (offset + _occupied_space(cardsize, np.array([x, y]), border_crop)) / papersize - upper = ( - offset - + _occupied_space(cardsize, np.array([x, y]), border_crop) - + cardsize * (image_size - [left, top]) / image_size - ) / papersize - extent = [lower[0], upper[0], 1 - upper[1], 1 - lower[1]] # flip y-axis - - plt.imshow( - img, - extent=extent, - aspect=papersize[1] / papersize[0], - interpolation=interpolation, - ) - pbar.update(1) - - plt.xlim(0, 1) - plt.ylim(0, 1) - - # Hide all axis ticks and labels - ax.axis("off") - - saver.savefig(dpi=dpi) - plt.close() - - -def print_cards_fpdf( - images: list[str | Path], - filepath: str | Path, - papersize=np.array([210, 297]), - cardsize=np.array([2.5 * 25.4, 3.5 * 25.4]), - border_crop: int = 14, - background_color: tuple[int, int, int] = None, - cropmarks: bool = True, -) -> None: - """Print a list of cards to a pdf file. - - Args: - images: List of image files - filepath: Name of the pdf file - papersize: Size of the paper in inches. Defaults to A4. - cardsize: Size of a card in inches. - border_crop: How many pixel to crop from the border of each card. - """ - from fpdf import FPDF +Bbox = tuple[float, float, float, float] # (x, y, width, height) +Lcoords = tuple[float, float, float, float] # (x0, y0, x1, y1) - # Cards per sheet - N = np.floor(papersize / cardsize).astype(int) - if N[0] == 0 or N[1] == 0: - raise ValueError(f"Paper size too small: {papersize}") - cards_per_sheet = np.prod(N) - offset = (papersize - _occupied_space(cardsize, N, border_crop, closed=True)) / 2 - # Ensure directory exists - filepath = Path(filepath) - filepath.parent.mkdir(parents=True, exist_ok=True) +def blend_patch_into_image(bbox: tuple[int, int, int, int], image: PIL.Image, patch: PIL.Image) -> PIL.Image: + """Blends an image patch into another image at the defined bounding box using an alpha mask. - # Initialize PDF - pdf = FPDF(orientation="P", unit="mm", format="A4") + The patch is inserted over the image based on the values in the alpha channel of the image. + The pixels are pasted only to fill in the transparent regions in the image (as described by the alpha channel) - for i, image in enumerate(tqdm(images, desc="Plotting cards")): - if i % cards_per_sheet == 0: # Startign a new sheet - pdf.add_page() - if background_color is not None: - pdf.set_fill_color(*background_color) - pdf.rect(0, 0, papersize[0], papersize[1], "F") + The edges of the original card are eroded away slightly to remove any black lining that would disturb the blend. + Then, the edge is blurred a little to improve the blending of the pixels that are filled in. - x = (i % cards_per_sheet) % N[0] - y = (i % cards_per_sheet) // N[0] + Args: + bbox (tuple[int, int, int, int]): The bounding box that further limits the area where pixels are being written + image (PIL.Image): The image to be written over. + patch (PIL.Image): The image patch to be pasted over the base image. - # Crop left and top if not on border of sheet - left = border_crop if x > 0 else 0 - top = border_crop if y > 0 else 0 + Returns: + A PIL image with the result + """ + size = min(image.size) + alpha = image.split()[3] + alpha = ImageChops.invert(alpha) + dill_size = size // 10 + 1 - (size // 10 % 2) # values identified manually + blur_size = size // 30 # values identified manually + print(dill_size, blur_size) + alpha = alpha.filter(ImageFilter.MaxFilter(dill_size)) + alpha = alpha.filter(ImageFilter.GaussianBlur(blur_size)) + # paste the blurred image where the alpha is 255 + image.paste(patch, mask=alpha.crop(bbox), box=bbox) + return image + + +def blend_flipped_stripe( + square_image: Image, + stripe_width_fraction: float, + flip_how: Literal["horizontal", "vertical"], + stripe_location: Literal["top", "bottom", "left", "right"], +) -> Image: + """Takes a leftmost stripe of a square image, flips it, and blends it back into the image. + + The stripe is blended using the alpha channel of the image to fill in the transparent regions + with the non-transparent regions of the flipped stripe. - if left == 0 and top == 0: - cropped_image = image + Args: + square_image: Image to process. + stripe_width_fraction: Fraction of the width/height of the square image to use as the stripe. + flip_how: How to flip the stripe. Either "horizontal" or "vertical". + stripe_location: Where to take the stripe from. Either "top", "bottom", "left", or "right" of the square + image. The stripe is adjacent to the edge. + + Returns: + The image with the flipped stripe blended in. + """ + corner_copy = square_image.copy() + width, height = corner_copy.size + if stripe_location in ["top", "bottom"]: + transpose_method = Transpose.FLIP_LEFT_RIGHT + elif stripe_location in ["left", "right"]: + transpose_method = Transpose.FLIP_TOP_BOTTOM + else: + raise ValueError(f"Invalid stripe_location: {stripe_location}") + + if flip_how == "horizontal": + if stripe_location == "top": + stripe_width = int(height * stripe_width_fraction) + bbox = (0, 0, width, stripe_width) + elif stripe_location == "bottom": + stripe_width = int(height * (1 - stripe_width_fraction)) + bbox = (0, stripe_width, width, height) else: - path = Path(image) - cropped_image = str(path.parent / (path.stem + f"_{left}_{top}" + path.suffix)) - if not Path(cropped_image).is_file(): - # Crop image - plt.imsave(cropped_image, plt.imread(image)[top:, left:]) - - # Compute extent - lower = offset + _occupied_space(cardsize, np.array([x, y]), border_crop) - size = cardsize * (image_size - [left, top]) / image_size - - # Plot image - pdf.image(cropped_image, x=lower[0], y=lower[1], w=size[0], h=size[1]) - - if cropmarks and ((i + 1) % cards_per_sheet == 0 or i + 1 == len(images)): - # If this was the last card on a page, add crop marks - pdf.set_line_width(0.05) - pdf.set_draw_color(255, 255, 255) - a = cardsize * (image_size - 2 * border_crop) / image_size - b = papersize - N * a - for x in range(N[0] + 1): - for y in range(N[1] + 1): - mark = b / 2 + a * [x, y] - pdf.line(mark[0] - 0.5, mark[1], mark[0] + 0.5, mark[1]) - pdf.line(mark[0], mark[1] - 0.5, mark[0], mark[1] + 0.5) - - tqdm.write(f"Writing to {filepath}") - pdf.output(filepath) + raise ValueError(f"Invalid stripe_location: {stripe_location} for flip_how: {flip_how}") + elif flip_how == "vertical": + if stripe_location == "left": + stripe_width = int(width * stripe_width_fraction) + bbox = (0, 0, stripe_width, height) + elif stripe_location == "right": + stripe_width = int(width * (1 - stripe_width_fraction)) + bbox = (stripe_width, 0, width, height) + else: + raise ValueError(f"Invalid stripe_location: {stripe_location} for flip_how: {flip_how}") + else: + raise ValueError(f"Invalid flip_how: {flip_how}") + + patch_inverted = corner_copy.crop(bbox).transpose(method=transpose_method) + return blend_patch_into_image(bbox, corner_copy, patch_inverted) + + +def fill_corners(card_image: Image) -> Image: + """Fill the corners of the card with the closest pixels around the corners to match the border color.""" + corner_size = card_image.width // 10 + + # top corners, vertical stripes + box_left = (0, 0, corner_size, corner_size) + card_image.paste( + blend_flipped_stripe( + square_image=card_image.crop(box=box_left), + stripe_width_fraction=1 / 6, + flip_how="vertical", + stripe_location="left", + ), + box=box_left, + ) + box_right = (card_image.width - corner_size, 0, card_image.width, corner_size) + card_image.paste( + blend_flipped_stripe( + square_image=card_image.crop(box=box_right), + stripe_width_fraction=1 / 6, + flip_how="vertical", + stripe_location="right", + ), + box=box_right, + ) + + # bottom corners, vertical stripes + box_left = (0, card_image.height - corner_size, corner_size, card_image.height) + card_image.paste( + blend_flipped_stripe( + square_image=card_image.crop(box=box_left), + stripe_width_fraction=1 / 6, + flip_how="vertical", + stripe_location="left", + ), + box=box_left, + ) + box_right = (card_image.width - corner_size, card_image.height - corner_size, card_image.width, card_image.height) + card_image.paste( + blend_flipped_stripe( + square_image=card_image.crop(box=box_right), + stripe_width_fraction=1 / 6, + flip_how="vertical", + stripe_location="right", + ), + box=box_right, + ) + + # top corners, horizontal stripes + box_top = (0, 0, corner_size, corner_size) + card_image.paste( + blend_flipped_stripe( + square_image=card_image.crop(box=box_top), + stripe_width_fraction=1 / 6, + flip_how="horizontal", + stripe_location="top", + ), + box=box_top, + ) + box_bottom = (card_image.width - corner_size, 0, card_image.width, corner_size) + card_image.paste( + blend_flipped_stripe( + square_image=card_image.crop(box=box_bottom), + stripe_width_fraction=1 / 6, + flip_how="horizontal", + stripe_location="top", + ), + box=box_bottom, + ) + + # bottom corners, horizontal stripes + box_top = (0, card_image.height - corner_size, corner_size, card_image.height) + card_image.paste( + blend_flipped_stripe( + square_image=card_image.crop(box=box_top), + stripe_width_fraction=1 / 6, + flip_how="horizontal", + stripe_location="bottom", + ), + box=box_top, + ) + box_bottom = (card_image.width - corner_size, card_image.height - corner_size, card_image.width, card_image.height) + card_image.paste( + blend_flipped_stripe( + square_image=card_image.crop(box=box_bottom), + stripe_width_fraction=1 / 6, + flip_how="horizontal", + stripe_location="bottom", + ), + box=box_bottom, + ) + + return card_image + + +class CardAssembler(abc.ABC): + """Base class for assembling cards into sheets.""" + + def __init__( + self, + paper_size: NDArray[2, Float], + card_size: NDArray[2, Float], + border_crop: float = 0, + crop_marks_thickness: float = 0, + cut_spacing_thickness: float = 0, + filled_corners: bool = False, + background_color: tuple[int, int, int] | None = None, + page_safe_margin: float = 0, + units: Units = "mm", + ): + """Initialize the CardAssembler. + + Args: + paper_size: Size of the paper in the specified units. + card_size: Size of a card in the specified units. + border_crop: How many units to crop from the border of each card. + crop_marks_thickness: Thickness of crop marks in the specified units. Use 0 to disable crop marks. + cut_spacing_thickness: Thickness of cut lines in the specified units. Use 0 to disable cut lines. + filled_corners: Whether to fill in the corners of the cards. + background_color: Background color of the paper. + page_safe_margin: How much to leave as a margin on the paper. + units: Units to use for the sizes. + """ + # self.mm_coeff = UNITS_TO_MM[units] + self.units = units + self.paper_size = paper_size + self.paper_safe_margin = page_safe_margin + self.card_size = card_size + self.border_crop = border_crop + self.crop_marks_thickness = crop_marks_thickness + self.cut_spacing_thickness = cut_spacing_thickness + self.background_color = background_color + self.filled_corners = filled_corners + + # precompute some values + self.card_bbox_size = self.card_size - (self.border_crop * 2) + self.safe_printable_area = self.paper_size - (self.paper_safe_margin * 2) + self.grid_dims = ( + (self.safe_printable_area + self.cut_spacing_thickness) + // (self.card_bbox_size + self.cut_spacing_thickness) + ).astype(np.int32) + self.rows, self.cols = self.grid_dims + self.grid_bbox_size = self.card_bbox_size * self.grid_dims + self.cut_spacing_thickness * (self.grid_dims - 1) + self.offset = (self.safe_printable_area - self.grid_bbox_size) / 2 + + @abc.abstractmethod + def assemble(self, card_image_filepaths: list[Path], output_filepath: Path): + ... + + def process_card_image(self, card_image_filepath: Path) -> Image: + """Process an image for assembly. + + Loads the image, fills in corners, crops the borders, etc. + + Args: + card_image_filepath: Image file to process. + """ + img = PIL.Image.open(card_image_filepath).copy() + # fill corners + if self.filled_corners: + img = fill_corners(img) + # crop the cards + ppsu = get_ppsu_from_size_and_pixels(pixel_values=img.size, size=self.card_size) + crop_px = int(get_pixels_from_size_and_ppsu(ppsu=ppsu, size=self.border_crop)) + + img = img.crop(box=(crop_px, crop_px, img.width - crop_px, img.height - crop_px)) + return img + + def get_page_generators( + self, + card_image_filepaths: list[str | Path], + ) -> Generator[Generator[tuple[Bbox, NDArray]]]: + """This method is a generator of generators of bounding boxes for each card on a page and the page indices. + + The method can be iterated over to get the bbox iterators for each page and its index. + + Yields: + tuple[Generator[Bbox], int]: Generator of bounding boxes and page index. + """ + per_sheet = self.rows * self.cols + remaining = card_image_filepaths + while remaining: + if len(remaining) >= per_sheet: + for_sheet = remaining[:per_sheet] + remaining = remaining[per_sheet:] + else: + for_sheet = remaining + remaining = [] + + yield self.get_bbox_generator(for_sheet) + + def get_bbox_generator(self, cards_on_page: list[Path]) -> Generator[tuple[Bbox, Image]]: + """This method is a generator of bounding boxes for each card on a page. + + The method takes a list of card image filepaths and yields the bounding boxes for each of those cards. + The bounding boxes are tiling a precalculated grid on the page. + + Args: + cards_on_page: Number of cards on the page. + + Yields: + Bbox: Bounding box for each card. The format is (x, y, width, height). + """ + for i, card_image_filepath in enumerate(cards_on_page): + card_pos = np.array([i % self.cols, i // self.cols]) + + cut_spacing_offset = self.cut_spacing_thickness * card_pos + preceding_cards_offset = self.card_bbox_size * card_pos + card_offset = cut_spacing_offset + preceding_cards_offset + self.paper_safe_margin + self.offset + + image = self.process_card_image(card_image_filepath) + + yield (*card_offset, *self.card_bbox_size), image + + def get_line_generator(self) -> Generator[Lcoords]: + """This method is a generator of line coordinates for crop marks. + + The crop marks are lining the edges of each card to help with aligning both printed sides together. + + Yields: + Bbox: Coordinate points for each cut line. The format is (x0, y0, x1, y1). + """ + # Horizontal lines + for i in range(self.rows): + y_top = self.paper_safe_margin + self.offset[1] + i * (self.card_bbox_size[1] + self.cut_spacing_thickness) + y_bottom = y_top + self.card_bbox_size[1] + yield 0, y_top, self.paper_size[0], y_top + yield 0, y_bottom, self.paper_size[0], y_bottom + + # Vertical lines + for i in range(self.cols): + x_left = self.paper_safe_margin + self.offset[0] + i * (self.card_bbox_size[0] + self.cut_spacing_thickness) + x_right = x_left + self.card_bbox_size[0] + yield x_left, self.paper_size[1], x_left, 0 + yield x_right, self.paper_size[1], x_right, 0 + + def prepare_routine(self, card_image_filepaths, output_filepath): + total_cards = len(card_image_filepaths) + pages = np.ceil(total_cards / (self.rows * self.cols)).astype(int) + tqdm_ = tqdm(total=total_cards, desc="Plotting cards") + logger.info(f"Will print {total_cards} cards in {pages} pages in a {self.rows}x{self.cols} grid.") + # Ensure parent directory exists for the output file + output_filepath.parent.mkdir(parents=True, exist_ok=True) + return pages, tqdm_ + + +class FPDF2CardAssembler(CardAssembler): + """Class for assembling cards into sheets using FPDF.""" + + def assemble(self, card_image_filepaths: list[Path], output_filepath: Path): + pages, tqdm_ = self.prepare_routine(card_image_filepaths, output_filepath) + + # Initialize PDF + pdf = FPDF(orientation="P", unit=self.units, format=self.paper_size) + + for page_idx, bbox_gen in enumerate(self.get_page_generators(card_image_filepaths)): + tqdm_.set_description(f"Plotting cards (page {page_idx + 1}/{pages})") + pdf.add_page() + if self.background_color is not None: + pdf.set_fill_color(*self.background_color) + pdf.rect(0, 0, float(self.paper_size[0]), float(self.paper_size[1]), "F") + + # print crop marks + if self.crop_marks_thickness is not None and self.crop_marks_thickness > 0.0: + tqdm_.set_description(f"Plotting crop marks (page {page_idx + 1}/{pages})") + pdf.set_line_width(self.crop_marks_thickness) + for line_coordinates in self.get_line_generator(): + pdf.line(*line_coordinates) + + # print cards + tqdm_.set_description(f"Plotting cards (page {page_idx + 1}/{pages})") + for bbox, image in bbox_gen: + pdf.image(name=image, x=bbox[0], y=bbox[1], w=bbox[2], h=bbox[3]) + tqdm_.update(1) + + tqdm.write(f"Writing to {output_filepath}") + pdf.output(str(output_filepath)) + + +class MatplotlibCardAssembler(CardAssembler): + """Class for assembling cards into sheets using Matplotlib.""" + + def __init__(self, dpi: int, *args, **kwargs): + super().__init__(*args, **kwargs) + self.dpi = dpi + + self.paper_size = self.paper_size * UNITS_TO_IN[self.units] + self.card_size = self.card_size * UNITS_TO_IN[self.units] + self.card_bbox_size = self.card_bbox_size * UNITS_TO_IN[self.units] + self.border_crop = self.border_crop * UNITS_TO_IN[self.units] + self.crop_marks_thickness = self.crop_marks_thickness * UNITS_TO_IN[self.units] + self.cut_spacing_thickness = self.cut_spacing_thickness * UNITS_TO_IN[self.units] + self.paper_safe_margin = self.paper_safe_margin * UNITS_TO_IN[self.units] + self.offset = self.offset * UNITS_TO_IN[self.units] + self.safe_printable_area = self.safe_printable_area * UNITS_TO_IN[self.units] + self.grid_bbox_size = self.grid_bbox_size * UNITS_TO_IN[self.units] + + def assemble(self, card_image_filepaths: list[Path], output_filepath: Path): + pages, tqdm_ = self.prepare_routine(card_image_filepaths, output_filepath) + digits = int(np.ceil(math.log10(pages))) + + with tqdm(total=len(card_image_filepaths), desc="Plotting cards") as pbar: + for page_idx, bbox_gen in enumerate(self.get_page_generators(card_image_filepaths)): + tqdm_.set_description(f"Plotting cards (page {page_idx + 1}/{pages})") + fig = plt.figure(figsize=self.paper_size) + ax = fig.add_axes(rect=(0, 0, 1, 1)) + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.invert_yaxis() + + if self.crop_marks_thickness > 0.0: + pbar.set_description("plotting crop marks...") + crop_marks_thickness_in_pt = self.crop_marks_thickness * 72 + for line_coordinates in self.get_line_generator(): + x0, y0, x1, y1 = line_coordinates + x_rel = np.asarray([x0, x1]) / self.paper_size[0] + y_rel = np.asarray([y0, y1]) / self.paper_size[1] + + # convert cropmarks thickness to point units + ax.plot(x_rel, y_rel, color="black", linewidth=crop_marks_thickness_in_pt) + + for bbox, image in bbox_gen: + left, top, width, height = bbox + + x0 = left / self.paper_size[0] + y0 = top / self.paper_size[1] + + width_scaled = width / self.paper_size[0] + height_scaled = height / self.paper_size[1] + + x1 = x0 + width_scaled + y1 = y0 + height_scaled + + # extent = (left, right, bottom, top) + extent = (x0, x1, y0, y1) + + _ = ax.imshow(image, extent=extent, interpolation="lanczos", aspect="auto", origin="lower") + pbar.update(1) + + # save the page and skip the rest + out_file_name = ( + output_filepath.parent / f"{output_filepath.stem}_{page_idx:0{digits}d}{output_filepath.suffix}" + ) + fig.savefig(fname=out_file_name, dpi=self.dpi) + plt.close() diff --git a/mtgproxies/scans.py b/mtgproxies/scans.py index 2597a18..435893f 100644 --- a/mtgproxies/scans.py +++ b/mtgproxies/scans.py @@ -1,19 +1,22 @@ from __future__ import annotations +from pathlib import Path from typing import Literal from tqdm import tqdm -import scryfall +from mtgproxies import scryfall from mtgproxies.decklists.decklist import Decklist -def fetch_scans_scryfall(decklist: Decklist, faces: Literal["all", "front", "back"] = "all") -> list[str]: +def fetch_scans_scryfall(decklist: Decklist, cache_dir: Path, + faces: Literal["all", "front", "back"] = "all") -> list[Path]: """Search Scryfall for scans of a decklist. Args: decklist: The decklist to fetch scans for faces: Which faces to fetch ("all", "front", "back") + cache_dir: Directory to cache the images Returns: List: List of image files @@ -22,6 +25,6 @@ def fetch_scans_scryfall(decklist: Decklist, faces: Literal["all", "front", "bac scan for card in tqdm(decklist.cards, desc="Fetching artwork") for i, image_uri in enumerate(card.image_uris) - for scan in [scryfall.get_image(image_uri["png"], silent=True)] * card.count + for scan in [scryfall.get_image(image_uri["png"], silent=True, cache_dir=cache_dir)] * card.count if faces == "all" or (faces == "front" and i == 0) or (faces == "back" and i > 0) ] diff --git a/mtgproxies/scryfall/__init__.py b/mtgproxies/scryfall/__init__.py new file mode 100644 index 0000000..71e9391 --- /dev/null +++ b/mtgproxies/scryfall/__init__.py @@ -0,0 +1,16 @@ +__all__ = [ + "canonic_card_name", + "get_card", + "get_cards", + "get_faces", + "get_image", + "search", + "recommend_print", + "card_by_id", + "get_cards_by_oracle_id", + "get_oracle_ids_by_name", + "get_price", +] + +from mtgproxies.scryfall.scryfall import get_oracle_ids_by_name, get_price, get_cards_by_oracle_id, card_by_id, recommend_print, \ + search, get_image, get_faces, get_cards, get_card, canonic_card_name diff --git a/scryfall/rate_limit.py b/mtgproxies/scryfall/rate_limit.py similarity index 96% rename from scryfall/rate_limit.py rename to mtgproxies/scryfall/rate_limit.py index 4d38aae..84e2aef 100644 --- a/scryfall/rate_limit.py +++ b/mtgproxies/scryfall/rate_limit.py @@ -1,35 +1,35 @@ -from __future__ import annotations - -import threading -import time -from types import TracebackType - - -class RateLimiter: - """Context manager for enforcing a rate limit to API calls.""" - - def __init__(self, delay: float) -> None: - """Initialie this RateLimit. - - Args: - delay: Delay between calls in seconds - """ - self.delay = delay - self.lock = threading.Lock() - self.last_call = 0 - - def __enter__(self) -> None: - with self.lock: # Prevent asynchronous access - # Check time since last call - if time.time() < self.last_call + self.delay: # Wait if neccessary - time.sleep(self.last_call + self.delay - time.time()) - self.last_call = time.time() - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - pass # Nothing to do here +from __future__ import annotations + +import threading +import time +from types import TracebackType + + +class RateLimiter: + """Context manager for enforcing a rate limit to API calls.""" + + def __init__(self, delay: float) -> None: + """Initialie this RateLimit. + + Args: + delay: Delay between calls in seconds + """ + self.delay = delay + self.lock = threading.Lock() + self.last_call = 0 + + def __enter__(self) -> None: + with self.lock: # Prevent asynchronous access + # Check time since last call + if time.time() < self.last_call + self.delay: # Wait if neccessary + time.sleep(self.last_call + self.delay - time.time()) + self.last_call = time.time() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + pass # Nothing to do here diff --git a/scryfall/scryfall.py b/mtgproxies/scryfall/scryfall.py similarity index 78% rename from scryfall/scryfall.py rename to mtgproxies/scryfall/scryfall.py index c4c5e18..3162592 100644 --- a/scryfall/scryfall.py +++ b/mtgproxies/scryfall/scryfall.py @@ -1,349 +1,360 @@ -"""Simple interface to the Scryfall API. - -See: - https://scryfall.com/docs/api -""" -from __future__ import annotations - -import json -import threading -from collections import defaultdict -from functools import lru_cache -from pathlib import Path -from tempfile import gettempdir - -import numpy as np -import requests -from tqdm import tqdm - -from scryfall.rate_limit import RateLimiter - -cache = Path(gettempdir()) / "scryfall_cache" -cache.mkdir(parents=True, exist_ok=True) # Create cache folder -scryfall_rate_limiter = RateLimiter(delay=0.1) -_download_lock = threading.Lock() - - -def get_image(image_uri: str, silent: bool = False) -> str: - """Download card artwork and return the path to a local copy. - - Uses cache and Scryfall API call rate limit. - - Returns: - string: Path to local file. - """ - split = image_uri.split("/") - file_name = split[-5] + "_" + split[-4] + "_" + split[-1].split("?")[0] - return get_file(file_name, image_uri, silent=silent) - - -def get_file(file_name: str, url: str, silent: bool = False) -> str: - """Download a file and return the path to a local copy. - - Uses cache and Scryfall API call rate limit. - - Returns: - string: Path to local file. - """ - file_path = cache / file_name - with _download_lock: - if not file_path.is_file(): - if "api.scryfall.com" in url: # Apply rate limit - with scryfall_rate_limiter: - download(url, file_path, silent=silent) - else: - download(url, file_path, silent=silent) - - return str(file_path) - - -def download(url: str, dst, chunk_size: int = 1024 * 4, silent: bool = False): - """Download a file with a tqdm progress bar.""" - with requests.get(url, stream=True) as req: - req.raise_for_status() - file_size = int(req.headers["Content-Length"]) if "Content-Length" in req.headers else None - with open(dst, "xb") as f, tqdm( - total=file_size, - unit="B", - unit_scale=True, - desc=url.split("/")[-1], - disable=silent, - ) as pbar: - for chunk in req.iter_content(chunk_size=chunk_size): - if chunk: - f.write(chunk) - pbar.update(chunk_size) - - -def depaginate(url: str) -> list[dict]: - """Depaginates Scryfall search results. - - Uses cache and Scryfall API call rate limit. - - Returns: - list: Concatenation of all `data` entries. - """ - with scryfall_rate_limiter: - response = requests.get(url).json() - assert response["object"] - - if "data" not in response: - return [] - data = response["data"] - if response["has_more"]: - data = data + depaginate(response["next_page"]) - - return data - - -def search(q: str) -> list[dict]: - """Perform Scryfall search. - - Returns: - list: All matching cards. - - See: - https://scryfall.com/docs/api/cards/search - """ - return depaginate(f"https://api.scryfall.com/cards/search?q={q}&format=json") - - -@lru_cache(maxsize=None) -def _get_database(database_name: str = "default_cards"): - databases = depaginate("https://api.scryfall.com/bulk-data") - bulk_data = [database for database in databases if database["type"] == database_name] - if len(bulk_data) != 1: - raise ValueError(f"Unknown database {database_name}") - - bulk_file = get_file(bulk_data[0]["download_uri"].split("/")[-1], bulk_data[0]["download_uri"]) - with open(bulk_file, encoding="utf-8") as json_file: - return json.load(json_file) - - -def canonic_card_name(card_name: str) -> str: - """Get canonic card name representation.""" - card_name = card_name.lower() - - # Replace special chars - card_name = card_name.replace("æ", "ae") # Sometimes used, e.g. in "Vedalken Aethermage" - - return card_name - - -def get_card(card_name: str, set_id: str = None, collector_number: str = None) -> dict | None: - """Find a card by it's name and possibly set and collector number. - - In case, the Scryfall database contains multiple cards, the first is returned. - - Args: - card_name: Exact English card name - set_id: Shorthand set name - collector_number: Collector number, may be a string for e.g. promo suffixes - - Returns: - card: Dictionary of card, or `None` if not found. - """ - cards = get_cards(name=card_name, set=set_id, collector_number=collector_number) - - return cards[0] if len(cards) > 0 else None - - -def get_cards(database: str = "default_cards", **kwargs): - """Get all cards matching certain attributes. - - Matching is case insensitive. - - Args: - kwargs: (key, value) pairs, e.g. `name="Tendershoot Dryad", set="RIX"`. - keys with a `None` value are ignored - - Returns: - List of all matching cards - """ - cards = _get_database(database) - - for key, value in kwargs.items(): - if value is not None: - value = value.lower() - if key == "name": # Normalize card name - value = canonic_card_name(value) - cards = [card for card in cards if key in card and card[key].lower() == value] - - return cards - - -def get_faces(card): - """All faces on this card. - - For single faced cards, this is just the card. - - Args: - card: Scryfall card object - """ - if "image_uris" in card: - return [card] - elif "card_faces" in card and "image_uris" in card["card_faces"][0]: - return card["card_faces"] - else: - raise ValueError(f"Unknown layout {card['layout']}") - - -def recommend_print(current=None, card_name: str | None = None, oracle_id: str | None = None, mode="best"): - if current is not None and oracle_id is None: # Use oracle id of current - if current.get("layout") == "reversible_card": - # Reversible cards have the same oracle id for both faces - oracle_id = current["card_faces"][0]["oracle_id"] - else: - oracle_id = current["oracle_id"] - - if oracle_id is not None: - alternatives = cards_by_oracle_id()[oracle_id] - else: - alternatives = get_cards(name=card_name) - - def score(card: dict): - points = 0 - if card["set"] != "mb1" and card["border_color"] != "gold": - points += 1 - if card["frame"] == "2015": - points += 2 - if not card["digital"]: - points += 4 - if card["border_color"] == "black" and ( - mode != "best" or "frame_effects" not in card or "extendedart" not in card["frame_effects"] - ): - points += 8 - if card["collector_number"][-1] not in ["p", "s"] and card["nonfoil"]: - points += 16 - if card["highres_image"]: - points += 32 - if card["lang"] == "en": - points += 64 - - return points - - scores = [score(card) for card in alternatives] - - if mode == "best": - if current is not None and scores[alternatives.index(current)] == np.max(scores): - return current # No better recommendation - - # Return print with highest score - recommendation = alternatives[np.argmax(scores)] - return recommendation - elif mode == "all": - recommendations = list(np.array(alternatives)[np.argsort(scores)][::-1]) - - # Bring current print to front - if current is not None: - if current in recommendations: - recommendations.remove(current) - recommendations = [current] + recommendations - - # Return all card in descending order - return recommendations - elif mode == "choices": - artworks = np.array( - [ - get_faces(card)[0]["illustration_id"] if "illustration_id" in get_faces(card)[0] else card["id"] - for card in alternatives - ] # Not all cards have illustrations, use id instead - ) - choices = [] - for artwork in set(artworks): - artwork_alternatives = np.array(alternatives)[artworks == artwork] - artwork_scores = np.array(scores)[artworks == artwork] - - recommendations = artwork_alternatives[artwork_scores == np.max(artwork_scores)] - # TODO Sort again - choices.extend(recommendations) - - # Bring current print to front - if current is not None: - choices = [current] + [c for c in choices if c["id"] != current["id"]] - - return choices - else: - raise ValueError(f"Unknown mode '{mode}'") - - -@lru_cache(maxsize=None) -def card_by_id(): - """Create dictionary to look up cards by their id. - - Faster than repeated lookup via get_cards(). - - Returns: - dict {id: card} - """ - return {c["id"]: c for c in get_cards()} - - -@lru_cache(maxsize=None) -def cards_by_oracle_id(): - """Create dictionary to look up cards by their oracle id. - - Faster than repeated lookup via get_cards(). - - Returns: - dict {id: [cards]} - """ - cards_by_oracle_id = defaultdict(list) - for c in get_cards(): - if "oracle_id" in c: # Not all cards have a oracle id, *sigh* - cards_by_oracle_id[c["oracle_id"]].append(c) - elif "card_faces" in c and "oracle_id" in c["card_faces"][0]: - cards_by_oracle_id[c["card_faces"][0]["oracle_id"]].append(c) - return cards_by_oracle_id - - -@lru_cache(maxsize=None) -def oracle_ids_by_name() -> dict[str, list[dict]]: - """Create dictionary to look up oracle ids by their name. - - Faster than repeated lookup via `get_cards(oracle_id=oracle_id)`. - Also matches the front side of double faced cards. - Names are lower case. - - Returns: - dict {name: [oracle_ids]} - """ - oracle_ids_by_name = defaultdict(set) - for oracle_id, cards in cards_by_oracle_id().items(): - card = cards[0] - if card["layout"] in ["art_series"]: # Skip art series, as they have double faced names - continue - name = card["name"].lower() - # Use name and also front face only for double faced cards - oracle_ids_by_name[name].add(oracle_id) - if "//" in name: - oracle_ids_by_name[name.split(" // ")[0]].add(oracle_id) - - # Converts sets to lists - oracle_ids_by_name = {k: list(v) for k, v in oracle_ids_by_name.items()} - return oracle_ids_by_name - - -def get_price(oracle_id: str, currency: str = "eur", foil: bool = None) -> float | None: - """Find lowest price for oracle id. - - Args: - oracle_id: oracle_id of card - currency: `usd`, `eur` or `tix` - foil: `False`, `True`, or `None` for any - """ - cards = cards_by_oracle_id()[oracle_id] - - slots = [] - if not foil: - slots += [currency] - if (foil or foil is None) and currency != "tix": # "TIX has no foil" - slots += [currency + "_foil"] - - prices = [float(c["prices"][slot]) for c in cards for slot in slots if c["prices"][slot] is not None] - - if len(prices) == 0 and currency == "eur": # Try dollar and apply conversion - usd = get_price(oracle_id, "usd") - return 0.83 * usd if usd is not None else None - - return min(prices) if len(prices) > 0 else None +"""Simple interface to the Scryfall API. + +See: + https://scryfall.com/docs/api +""" + +import json +import logging +import threading +from collections import defaultdict +from functools import cache +from pathlib import Path + +import numpy as np +import requests +from tqdm import tqdm + +from mtgproxies.scryfall.rate_limit import RateLimiter + + +logger = logging.getLogger(__name__) + +DEFAULT_CACHE_DIR = Path(__file__).parent / ".cache" / "mtgproxies" / "scryfall" +# cache.mkdir(parents=True, exist_ok=True) # Create cache folder +scryfall_rate_limiter = RateLimiter(delay=0.1) +_download_lock = threading.Lock() + + +def get_image(image_uri: str, cache_dir: Path, silent: bool = False) -> Path: + """Download card artwork and return the path to a local copy. + + Uses cache and Scryfall API call rate limit. + + Returns: + string: Path to local file. + """ + split = image_uri.split("/") + file_name = split[-5] + "_" + split[-4] + "_" + split[-1].split("?")[0] + logger.debug(f"Downloading {file_name} from {image_uri}") + return get_file(file_name, image_uri, cache_dir=cache_dir, silent=silent) + + +def get_file(file_name: str, url: str, cache_dir: Path, silent: bool = False) -> Path: + """Download a file and return the path to a local copy. + + Uses cache and Scryfall API call rate limit. + + Returns: + string: Path to local file. + """ + file_path = cache_dir / file_name + with _download_lock: + if not file_path.is_file(): + if "api.scryfall.com" in url: # Apply rate limit + with scryfall_rate_limiter: + download(url, file_path, silent=silent) + else: + download(url, file_path, silent=silent) + + return file_path + + +def download(url: str, dst, chunk_size: int = 1024 * 4, silent: bool = False): + """Download a file with a tqdm progress bar.""" + with requests.get(url, stream=True) as req: + req.raise_for_status() + file_size = int(req.headers["Content-Length"]) if "Content-Length" in req.headers else None + with open(dst, "xb") as f, tqdm( + total=file_size, + unit="B", + unit_scale=True, + desc=url.split("/")[-1], + disable=silent, + ) as pbar: + for chunk in req.iter_content(chunk_size=chunk_size): + if chunk: + f.write(chunk) + pbar.update(chunk_size) + + +def depaginate(url: str) -> list[dict]: + """Recursively unpack Scryfall search results from multiple pages into a single list. + + Uses Scryfall API call rate limit to avoid excessive requests. + + Returns: + list: Concatenation of all `data` entries. + """ + with scryfall_rate_limiter: + response = requests.get(url).json() + assert response["object"] + + if "data" not in response: + return [] + data = response["data"] + if response["has_more"]: + data = data + depaginate(response["next_page"]) + + return data + + +def search(q: str) -> list[dict]: + """Perform Scryfall search. + + Returns: + list: All matching cards. + + See: + https://scryfall.com/docs/api/cards/search + """ + return depaginate(f"https://api.scryfall.com/cards/search?q={q}&format=json") + + +@cache +def _get_database(cache_dir: Path, database_name: str = "default_cards"): + databases = depaginate("https://api.scryfall.com/bulk-data") + bulk_data = [database for database in databases if database["type"] == database_name] + if len(bulk_data) != 1: + raise ValueError(f"Unknown database {database_name}") + + bulk_file = get_file(bulk_data[0]["download_uri"].split("/")[-1], bulk_data[0]["download_uri"], cache_dir=cache_dir) + with open(bulk_file, encoding="utf-8") as json_file: + return json.load(json_file) + + +def canonic_card_name(card_name: str) -> str: + """Get canonic card name representation.""" + card_name = card_name.lower() + + # Replace special chars + card_name = card_name.replace("æ", "ae") # Sometimes used, e.g. in "Vedalken Aethermage" + + return card_name + + +def get_card( + card_name: str, cache_dir: Path, set_id: str | None = None, collector_number: str | None = None +) -> dict | None: + """Find a card by its name and possibly set and collector number. + + In case, the Scryfall database contains multiple cards, the first is returned. + + Args: + card_name: Exact English card name + cache_dir: Path to cache directory + set_id: Shorthand set name + collector_number: Collector number, may be a string for e.g. promo suffixes + + Returns: + card: Dictionary of card, or `None` if not found. + """ + cards = get_cards(name=card_name, set=set_id, collector_number=collector_number, cache_dir=cache_dir) + + return cards[0] if len(cards) > 0 else None + + +def get_cards(cache_dir: Path, database: str = "default_cards", **kwargs): + """Get all cards matching certain attributes. + + Matching is case-insensitive. + + Args: + cache_dir: Path to cache directory + database: Name of the database to use + kwargs: (key, value) pairs, e.g. `name="Tendershoot Dryad", set="RIX"`. + keys with a `None` value are ignored + + Returns: + List of all matching cards + """ + cards = _get_database(cache_dir=cache_dir, database_name=database) + + for key, value in kwargs.items(): + if value is not None: + value = value.lower() + if key == "name": # Normalize card name + value = canonic_card_name(value) + cards = [card for card in cards if key in card and card[key].lower() == value] + + return cards + + +def get_faces(card): + """All faces on this card. + + For single faced cards, this is just the card. + + Args: + card: Scryfall card object + """ + if "image_uris" in card: + return [card] + elif "card_faces" in card and "image_uris" in card["card_faces"][0]: + return card["card_faces"] + else: + raise ValueError(f"Unknown layout {card['layout']}") + + +def recommend_print( + cache_dir: Path, current=None, card_name: str | None = None, oracle_id: str | None = None, mode="best" +): + if current is not None and oracle_id is None: # Use oracle id of current + if current.get("layout") == "reversible_card": + # Reversible cards have the same oracle id for both faces + oracle_id = current["card_faces"][0]["oracle_id"] + else: + oracle_id = current["oracle_id"] + + if oracle_id is not None: + alternatives = get_cards_by_oracle_id(cache_dir=cache_dir)[oracle_id] + else: + alternatives = get_cards(name=card_name, cache_dir=cache_dir) + + def score(card: dict): + points = 0 + if card["set"] != "mb1" and card["border_color"] != "gold": + points += 1 + if card["frame"] == "2015": + points += 2 + if not card["digital"]: + points += 4 + if card["border_color"] == "black" and ( + mode != "best" or "frame_effects" not in card or "extendedart" not in card["frame_effects"] + ): + points += 8 + if card["collector_number"][-1] not in ["p", "s"] and card["nonfoil"]: + points += 16 + if card["highres_image"]: + points += 32 + if card["lang"] == "en": + points += 64 + + return points + + scores = [score(card) for card in alternatives] + + if mode == "best": + if current is not None and scores[alternatives.index(current)] == np.max(scores): + return current # No better recommendation + + # Return print with highest score + recommendation = alternatives[np.argmax(scores)] + return recommendation + elif mode == "all": + recommendations = list(np.array(alternatives)[np.argsort(scores)][::-1]) + + # Bring current print to front + if current is not None: + if current in recommendations: + recommendations.remove(current) + recommendations = [current, *recommendations] + + # Return all card in descending order + return recommendations + elif mode == "choices": + artworks = np.array( + [ + get_faces(card)[0]["illustration_id"] if "illustration_id" in get_faces(card)[0] else card["id"] + for card in alternatives + ] # Not all cards have illustrations, use id instead + ) + choices = [] + for artwork in set(artworks): + artwork_alternatives = np.array(alternatives)[artworks == artwork] + artwork_scores = np.array(scores)[artworks == artwork] + + recommendations = artwork_alternatives[artwork_scores == np.max(artwork_scores)] + # TODO Sort again + choices.extend(recommendations) + + # Bring current print to front + if current is not None: + choices = [current] + [c for c in choices if c["id"] != current["id"]] + + return choices + else: + raise ValueError(f"Unknown mode '{mode}'") + + +@cache +def card_by_id(cache_dir: Path): + """Create dictionary to look up cards by their id. + + Faster than repeated lookup via get_cards(). + + Returns: + dict {id: card} + """ + return {c["id"]: c for c in get_cards(cache_dir=cache_dir)} + + +@cache +def get_cards_by_oracle_id(cache_dir: Path): + """Create dictionary to look up cards by their oracle id. + + Faster than repeated lookup via get_cards(). + + Returns: + dict {id: [cards]} + """ + cards_by_oracle_id = defaultdict(list) + for c in get_cards(cache_dir=cache_dir): + if "oracle_id" in c: # Not all cards have a oracle id, *sigh* + cards_by_oracle_id[c["oracle_id"]].append(c) + elif "card_faces" in c and "oracle_id" in c["card_faces"][0]: + cards_by_oracle_id[c["card_faces"][0]["oracle_id"]].append(c) + return cards_by_oracle_id + + +@cache +def get_oracle_ids_by_name(cache_dir: Path) -> dict[str, list[dict]]: + """Create dictionary to look up oracle ids by their name. + + Faster than repeated lookup via `get_cards(oracle_id=oracle_id)`. + Also matches the front side of double faced cards. + Names are lower case. + + Returns: + dict {name: [oracle_ids]} + """ + oracle_ids_by_name = defaultdict(set) + for oracle_id, cards in get_cards_by_oracle_id(cache_dir=cache_dir).items(): + card = cards[0] + if card["layout"] in ["art_series"]: # Skip art series, as they have double faced names + continue + name = card["name"].lower() + # Use name and also front face only for double faced cards + oracle_ids_by_name[name].add(oracle_id) + if "//" in name: + oracle_ids_by_name[name.split(" // ")[0]].add(oracle_id) + + # Converts sets to lists + oracle_ids_by_name = {k: list(v) for k, v in oracle_ids_by_name.items()} + return oracle_ids_by_name + + +def get_price(cache_dir: Path, oracle_id: str, currency: str = "eur", foil: bool | None = None) -> float | None: + """Find the lowest price for oracle id. + + Args: + cache_dir: Path to cache directory + oracle_id: oracle_id of card + currency: `usd`, `eur` or `tix` + foil: `False`, `True`, or `None` for any + """ + cards = get_cards_by_oracle_id(cache_dir=cache_dir)[oracle_id] + + slots = [] + if not foil: + slots += [currency] + if (foil or foil is None) and currency != "tix": # "TIX has no foil" + slots += [currency + "_foil"] + + prices = [float(c["prices"][slot]) for c in cards for slot in slots if c["prices"][slot] is not None] + + if len(prices) == 0 and currency == "eur": # Try dollar and apply conversion + usd = get_price(cache_dir=cache_dir, oracle_id=oracle_id, currency="usd") + return 0.83 * usd if usd is not None else None + + return min(prices) if len(prices) > 0 else None diff --git a/print.py b/print.py index 340a9c7..93ce095 100644 --- a/print.py +++ b/print.py @@ -1,97 +1,272 @@ -import argparse - -import numpy as np - -from mtgproxies import fetch_scans_scryfall, print_cards_fpdf, print_cards_matplotlib -from mtgproxies.cli import parse_decklist_spec - - -def papersize(string: str) -> np.ndarray: - spec = string.lower() - if spec == "a4": - return np.array([21, 29.7]) / 2.54 - if "x" in spec: - split = spec.split("x") - return np.array([float(split[0]), float(split[1])]) - raise argparse.ArgumentTypeError() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Prepare a decklist for printing.") - parser.add_argument( - "decklist", - metavar="decklist_spec", - help="path to a decklist in text/arena format, or manastack:{manastack_id}, or archidekt:{archidekt_id}", - ) - parser.add_argument("outfile", help="output file. Supports pdf, png and jpg.") - parser.add_argument("--dpi", help="dpi of output file (default: %(default)d)", type=int, default=300) - parser.add_argument( - "--paper", - help="paper size in inches or preconfigured format (default: %(default)s)", - type=papersize, - default="a4", - metavar="WIDTHxHEIGHT", - ) - parser.add_argument( - "--scale", - help="scaling factor for printed cards (default: %(default)s)", - type=float, - default=1.0, - metavar="FLOAT", - ) - parser.add_argument( - "--border_crop", - help="how much to crop inner borders of printed cards (default: %(default)s)", - type=int, - default=14, - metavar="PIXELS", - ) - parser.add_argument( - "--background", - help='background color, either by name or by hex code (e.g. black or "#ff0000", default: %(default)s)', - type=str, - default=None, - metavar="COLOR", - ) - parser.add_argument("--cropmarks", action=argparse.BooleanOptionalAction, default=True, help="add crop marks") - parser.add_argument( - "--faces", - help="which faces to print (default: %(default)s)", - choices=["all", "front", "back"], - default="all", - ) - args = parser.parse_args() - - # Parse decklist - decklist = parse_decklist_spec(args.decklist) - - # Fetch scans - images = fetch_scans_scryfall(decklist, faces=args.faces) - - # Plot cards - if args.outfile.endswith(".pdf"): - import matplotlib.colors as colors - - background_color = args.background - if background_color is not None: - background_color = (np.array(colors.to_rgb(background_color)) * 255).astype(int) - - print_cards_fpdf( - images, - args.outfile, - papersize=args.paper * 25.4, - cardsize=np.array([2.5, 3.5]) * 25.4 * args.scale, - border_crop=args.border_crop, - background_color=background_color, - cropmarks=args.cropmarks, - ) - else: - print_cards_matplotlib( - images, - args.outfile, - papersize=args.paper, - cardsize=np.array([2.5, 3.5]) * args.scale, - dpi=args.dpi, - border_crop=args.border_crop, - background_color=args.background, - ) +from pathlib import Path +from typing import Literal + +import click +import numpy as np +from nptyping import Float32, NDArray, Shape +from webcolors import IntegerRGB, name_to_rgb + +from mtgproxies import fetch_scans_scryfall +from mtgproxies.cli import parse_decklist_spec +from mtgproxies.decklists import Decklist +from mtgproxies.dimensions import MTG_CARD_SIZE, PAPER_SIZE, UNITS_TO_MM, Units +from mtgproxies.print_cards import FPDF2CardAssembler, MatplotlibCardAssembler +from mtgproxies.scryfall.scryfall import DEFAULT_CACHE_DIR + + +# def papersize(string: str) -> np.ndarray: +# spec = string.lower() +# if spec == "a4": +# return np.array([21, 29.7]) / 2.54 +# if "x" in spec: +# split = spec.split("x") +# return np.array([float(split[0]), float(split[1])]) +# raise argparse.ArgumentTypeError() + + +def click_callback_cardsize( + ctx: click.Context, param: click.Parameter, value: str +) -> NDArray[Shape["2"], Float32] | None: + if value is None: + return None + spec = value.lower() + if "x" in spec: + split = spec.split("x") + return np.asarray([float(split[0]), float(split[1])]).astype(float) + raise click.BadParameter("Card size must be in the format WIDTHxHEIGHT", param=param, ctx=ctx) + + +def click_callback_papersize( + ctx: click.Context, param: click.Parameter, value: str +) -> str | NDArray[Shape["2"], Float32]: + spec = value.upper() + if spec in PAPER_SIZE: + return spec + elif "x" in spec: + split = spec.split("x") + if len(split) == 2: + return np.asarray([float(split[0]), float(split[1])], dtype=float) + else: + raise click.BadParameter("Paper size must be in the format WIDTHxHEIGHT", param=param, ctx=ctx) + + else: + raise click.BadParameter( + f"Paper size not supported: {spec}. Try one of: {', '.join(PAPER_SIZE.keys())}, " + f"or define the dimensions in a WIDTHxHEIGHT format", + param=param, + ctx=ctx, + ) + + +def click_callback_cache_dir(ctx: click.Context, param: click.Parameter, value: Path) -> Path: + assert isinstance(value, Path) + if not value.exists(): + value.mkdir(parents=True) + return value + + +@click.group(name="print") +@click.pass_context +def command_group_print(ctx): + ctx.ensure_object(dict) + + +def common_cli_arguments(func): + func = click.argument("output_file", type=click.Path(path_type=Path, exists=False, writable=True), required=True)( + func + ) + func = click.argument("deck_list", type=str, nargs=-1)(func) + func = click.option( + "--crop-mark-thickness", + "-cm", + type=float, + default=0.0, + help="Thickness of crop marks in the specified units. Use 0 to disable crop marks.", + )(func) + func = click.option( + "--cut-spacing-thickness", + "-cl", + type=float, + default=0.0, + help="Thickness of cut lines in the specified units. Use 0 to disable cut lines.", + )(func) + func = click.option( + "--crop-border", + "-cb", + type=float, + default=0.0, + help="How much to crop the borders of the cards in the specified units.", + )(func) + func = click.option( + "--background-color", + "-bg", + type=name_to_rgb, + default=None, + help="Background color of the cards, either by name or by hex code.", + )(func) + func = click.option( + "--paper-size", + "-ps", + type=str, + default="a4", + callback=click_callback_papersize, + help="Paper size keyword (A0 - A10) or dimensions in the format WIDTHxHEIGHT.", + )(func) + func = click.option( + "--page-safe-margin", + "-m", + type=float, + default=0.0, + help="Margin around the area where no cards will be printed. Useful for printers that can't print to the edge.", + )(func) + func = click.option( + "--faces", "-f", type=click.Choice(["all", "front", "back"]), default="all", help="Which faces to print." + )(func) + func = click.option( + "--units", + "-u", + type=click.Choice(Units.__args__), + default="mm", + help="Units of the specified dimensions. Default is mm.", + )(func) + func = click.option( + "--filled-corners", + "-fc", + is_flag=True, + help="Fill the corners of the cards with the colors from the edge of the card. " + "Works well for cards with uniformly colored borders (any color). May look fine even on borderless cards.", + )(func) + func = click.option( + "--cache-dir", + "-cd", + type=click.Path(path_type=Path, file_okay=False, dir_okay=True, writable=True), + default=DEFAULT_CACHE_DIR, + callback=click_callback_cache_dir, + help="Directory to store cached card images.", + )(func) + func = click.option( + "--card-size", + "-cs", + default=None, + help="Size of the cards in the format WIDTHxHEIGHT in the units specified by user. " + "Default is 2.5x3.5 inches (or 63.1x88 mm).", + callback=click_callback_cardsize, + )(func) + return func + + +@command_group_print.command(name="pdf") +@common_cli_arguments +def command_pdf( + deck_list: list[str], + output_file: Path, + faces: Literal["all", "front", "back"], + crop_mark_thickness: float, + cut_spacing_thickness: float, + crop_border: float, + background_color: IntegerRGB, + paper_size: str | NDArray[Shape["2"], Float32], + units: Units, + filled_corners: bool, + page_safe_margin: float, + cache_dir: Path, + card_size: NDArray[Shape["2"], Float32] | None, +): + """This command generates a PDF document at OUTPUT_FILE with the cards from the files in DECK_LIST. + + DECK_LIST is a list of files containing filepaths to decklist files in text/arena format + or entries in a manastack:{manastack_id} or archidekt:{archidekt_id} format. + + OUTPUT_FILE is the path to the output PDF file. + + """ + images, resolved_card_size, resolved_paper_size = process_dimensions_and_decklist( + cache_dir, card_size, deck_list, faces, paper_size, units + ) + + # Plot cards + printer = FPDF2CardAssembler( + units=units, + paper_size=resolved_paper_size, + card_size=resolved_card_size, + crop_marks_thickness=crop_mark_thickness, + cut_spacing_thickness=cut_spacing_thickness, + border_crop=crop_border, + background_color=background_color, + filled_corners=filled_corners, + page_safe_margin=page_safe_margin, + ) + + printer.assemble(card_image_filepaths=images, output_filepath=output_file) + + +@command_group_print.command(name="image") +@common_cli_arguments +@click.option("--dpi", "-d", type=int, default=300, help="DPI of the output image.") +def command_image( + deck_list: list[str], + output_file: Path, + faces: Literal["all", "front", "back"], + crop_mark_thickness: float, + cut_spacing_thickness: float, + crop_border: float, + background_color: IntegerRGB, + paper_size: str | NDArray[Shape["2"], Float32], + units: Units, + filled_corners: bool, + page_safe_margin: float, + cache_dir: Path, + card_size: NDArray[Shape["2"], Float32] | None, + dpi: int, +): + """This command generates an image file at OUTPUT_FILE with the cards from the files in DECK_LIST. + + DECK_LIST is a list of files containing filepaths to decklist files in text/arena format + or entries in a manastack:{manastack_id} or archidekt:{archidekt_id} format. + + OUTPUT_FILE is the path to the output image file. The extension of the file determines the format. Only formats + supported by matplotlib are allowed. + + """ + images, resolved_card_size, resolved_paper_size = process_dimensions_and_decklist( + cache_dir, card_size, deck_list, faces, paper_size, units + ) + + # Plot cards + printer = MatplotlibCardAssembler( + units=units, + paper_size=resolved_paper_size, + card_size=resolved_card_size, + crop_marks_thickness=crop_mark_thickness, + cut_spacing_thickness=cut_spacing_thickness, + border_crop=crop_border, + background_color=background_color, + filled_corners=filled_corners, + page_safe_margin=page_safe_margin, + dpi=dpi, + ) + + printer.assemble(card_image_filepaths=images, output_filepath=output_file) + + +def process_dimensions_and_decklist(cache_dir, card_size, deck_list, faces, paper_size, units): + parsed_deck_list = Decklist() + for deck in deck_list: + parsed_deck_list.extend(parse_decklist_spec(deck, cache_dir=cache_dir)) + # Fetch scans + images = fetch_scans_scryfall(decklist=parsed_deck_list, cache_dir=cache_dir, faces=faces) + + # resolve paper size + if isinstance(paper_size, str): + if units in PAPER_SIZE[paper_size]: + resolved_paper_size = PAPER_SIZE[paper_size][units] + else: + resolved_paper_size = PAPER_SIZE[paper_size]["mm"] / UNITS_TO_MM[units] + else: + resolved_paper_size = paper_size + resolved_card_size = MTG_CARD_SIZE[units] if card_size is None else card_size + return images, resolved_card_size, resolved_paper_size + + +if __name__ == "__main__": + command_group_print(obj={}) diff --git a/pyproject.toml b/pyproject.toml index f40db30..2f2b40f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,126 @@ -[tool.black] +[project] +dynamic = ["version"] +name = "MTG-Proxy-Print-Assembler" +dependencies = [ + "numpy", + "matplotlib", + "requests", + "tqdm", + "fpdf2>=2.3.0", + "nptyping>=2.5.0", + "Pillow>=10.3.0", + "click>=8.1.7", + "webcolors>=1.13", +] +requires-python = ">=3.9" +classifiers = [ # Optional + # How mature is this project? Common values are + # 3 - Alpha + # 4 - Beta + # 5 - Production/Stable + "Development Status :: 4 - Beta", + "Topic :: Games/Entertainment :: Card Games", + # Pick your license as you wish + "License :: OSI Approved :: MIT License", + # Specify the Python versions you support here. In particular, ensure + # that you indicate whether you support Python 2, Python 3 or both. + # These classifiers are *not* checked by 'pip install'. See instead + # 'python_requires' below. + "Programming Language :: Python :: 3", +] +authors = [ + {name = "Robin Kupper", email = "robin.kupper@rwth-aachen.de"}, + {name = "Adam Bajger", email = "adambaj@seznam.cz"}, +] +keywords = ["pdf", "proxies", "mtg", "magic-the-gathering", "mtg-cards", "scryfall", "decklist"] +description="Print high-resolution MtG proxies." + +[project.urls] +Homepage = "https://github.com/DiddiZ/mtg-proxies" + +[tool.setuptools.packages.find] +include = ["mtgproxies", "scryfall"] + + + +[tool.pdm] +[tool.pdm.dev-dependencies] +dev = [ + "mypy", + "pre-commit", + "ruff", +] +test = [ + "pytest>=8.2.0", + "pytest-cov", +] + +[tool.pdm.scripts] +l = { composite = ["lint", "format", "mypy"] } +test = "pytest tests" +lint = "ruff check ." +format = "ruff format ." +mypy = "mypy ." +post_install = { composite = [ + "pre-commit install", + "pre-commit install --hook-type commit-msg", +] } + +[build-system] +requires = ["setuptools >= 61.0"] +build-backend = "setuptools.build_meta" + +[tool.mypy] +strict = true +ignore_missing_imports = true + +[tool.ruff] +fix = true line-length = 120 -target-version = ['py39', 'py310', 'py311', 'py312'] +target-version = "py311" +extend-ignore = [ + "ERA001", # commented out code + "D100", # missing docstring in public module + "D101", # missing docstring in public class + "D102", # missing docstring in public method + "D103", # missing docstring in public function + "D104", # missing docstring in public package + "D105", # missing docstring in magic method + "D106", # missing docstring in public nested class + "D107", # missing docstring in __init__ +] + + +[tool.ruff.format] +# Enable reformatting of code snippets in docstrings. +docstring-code-format = true + +[tool.ruff.lint] +unfixable = [ + "ERA", # do not autoremove commented out code +] +extend-select = [ + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "ERA", # flake8-eradicate/eradicate + "I", # isort + "N", # pep8-naming + "PIE", # flake8-pie + "PGH", # pygrep + "RUF", # ruff checks + "SIM", # flake8-simplify + "TCH", # flake8-type-checking + "TID", # flake8-tidy-imports + "UP", # pyupgrade + "D", # pydocstyle +] + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.lint.isort] +lines-after-imports = 2 +known-first-party = ["tests"] + +[tool.ruff.lint.pydocstyle] +convention = "google" diff --git a/scryfall/__init__.py b/scryfall/__init__.py deleted file mode 100644 index cc3848b..0000000 --- a/scryfall/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -from scryfall.scryfall import ( - canonic_card_name, - card_by_id, - cards_by_oracle_id, - get_card, - get_cards, - get_faces, - get_image, - get_price, - oracle_ids_by_name, - recommend_print, - search, -) - -__all__ = [ - "canonic_card_name", - "get_card", - "get_cards", - "get_faces", - "get_image", - "search", - "recommend_print", - "card_by_id", - "cards_by_oracle_id", - "oracle_ids_by_name", - "get_price", -] diff --git a/setup.py b/setup.py deleted file mode 100644 index 8339a6e..0000000 --- a/setup.py +++ /dev/null @@ -1,185 +0,0 @@ -"""A setuptools based setup module. - -See: -https://packaging.python.org/guides/distributing-packages-using-setuptools/ -https://github.com/pypa/sampleproject -Modified by Madoshakalaka@Github (dependency links added) -""" - -from pathlib import Path - -from setuptools import find_packages, setup - -here = Path(__file__).parent - - -# Get the long description from the README file -with open(here / "README.md", encoding="utf-8") as f: - long_description = f.read() - -# Arguments marked as "Required" below must be included for upload to PyPI. -# Fields marked as "Optional" may be commented out. - -setup( - # This is the name of your project. The first time you publish this - # package, this name will be registered for you. It will determine how - # users can install this project, e.g.: - # - # $ pip install sampleproject - # - # And where it will live on PyPI: https://pypi.org/project/sampleproject/ - # - # There are some restrictions on what makes a valid project name - # specification here: - # https://packaging.python.org/specifications/core-metadata/#name - name="mtg-proxies", # Required - # Versions should comply with PEP 440: - # https://www.python.org/dev/peps/pep-0440/ - # - # For a discussion on single-sourcing the version across setup.py and the - # project code, see - # https://packaging.python.org/en/latest/single_source_version.html - version="0.1.0", # Required - # This is a one-line description or tagline of what your project does. This - # corresponds to the "Summary" metadata field: - # https://packaging.python.org/specifications/core-metadata/#summary - description="Print high-resolution MtG proxies.", # Optional - # This is an optional longer description of your project that represents - # the body of text which users will see when they visit PyPI. - # - # Often, this is the same as your README, so you can just read it in from - # that file directly (as we have already done above) - # - # This field corresponds to the "Description" metadata field: - # https://packaging.python.org/specifications/core-metadata/#description-optional - long_description=long_description, # Optional - # Denotes that our long_description is in Markdown; valid values are - # text/plain, text/x-rst, and text/markdown - # - # Optional if long_description is written in reStructuredText (rst) but - # required for plain-text or Markdown; if unspecified, "applications should - # attempt to render [the long_description] as text/x-rst; charset=UTF-8 and - # fall back to text/plain if it is not valid rst" (see link below) - # - # This field corresponds to the "Description-Content-Type" metadata field: - # https://packaging.python.org/specifications/core-metadata/#description-content-type-optional - long_description_content_type="text/markdown", # Optional (see note above) - # This should be a valid link to your project's main homepage. - # - # This field corresponds to the "Home-Page" metadata field: - # https://packaging.python.org/specifications/core-metadata/#home-page-optional - url="https://github.com/DiddiZ/mtg-proxies", # Optional - # This should be your name or the name of the organization which owns the - # project. - author="Robin Kupper", # Optional - # This should be a valid email address corresponding to the author listed - # above. - author_email="robin.kupper@rwth-aachen.de", # Optional - # Classifiers help users find your project by categorizing it. - # - # For a list of valid classifiers, see https://pypi.org/classifiers/ - classifiers=[ # Optional - # How mature is this project? Common values are - # 3 - Alpha - # 4 - Beta - # 5 - Production/Stable - "Development Status :: 4 - Beta", - "Topic :: Games/Entertainment :: Card Games", - # Pick your license as you wish - "License :: OSI Approved :: MIT License", - # Specify the Python versions you support here. In particular, ensure - # that you indicate whether you support Python 2, Python 3 or both. - # These classifiers are *not* checked by 'pip install'. See instead - # 'python_requires' below. - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - ], - # This field adds keywords for your project which will appear on the - # project page. What does your project relate to? - # - # Note that this is a string of words separated by whitespace, not a list. - keywords="pdf proxies mtg magic-the-gathering mtg-cards scryfall decklist", # Optional - # You can just specify package directories manually here if your project is - # simple. Or you can use find_packages(). - # - # Alternatively, if you just want to distribute a single Python file, use - # the `py_modules` argument instead as follows, which will expect a file - # called `my_module.py` to exist: - # - # py_modules=["my_module"], - # - packages=find_packages(exclude=["contrib", "docs", "tests"]), # Required - # Specify which Python versions you support. In contrast to the - # 'Programming Language' classifiers above, 'pip install' will check this - # and refuse to install the project if the version does not match. If you - # do not support Python 2, you can simplify this to '>=3.5' or similar, see - # https://packaging.python.org/guides/distributing-packages-using-setuptools/#python-requires - python_requires=">=3.9, <4", - # This field lists other packages that your project depends on to run. - # Any package you put here will be installed by pip when your project is - # installed, so they must be valid existing projects. - # - # For an analysis of "install_requires" vs pip's requirements files see: - # https://packaging.python.org/en/latest/requirements.html - install_requires=[ - "numpy", - "matplotlib", - "requests", - "tqdm", - "fpdf2>=2.3.0", - ], - # List additional groups of dependencies here (e.g. development - # dependencies). Users will be able to install these using the "extras" - # syntax, for example: - # - # $ pip install sampleproject[dev] - # - # Similar to `install_requires` above, these must be valid existing - # projects. - extras_require={ - "dev": [ - "pytest", - ] - }, - # If there are data files included in your packages that need to be - # installed, specify them here. - # - # Sometimes you’ll want to use packages that are properly arranged with - # setuptools, but are not published to PyPI. In those cases, you can specify - # a list of one or more dependency_links URLs where the package can - # be downloaded, along with some additional hints, and setuptools - # will find and install the package correctly. - # see https://python-packaging.readthedocs.io/en/latest/dependencies.html#packages-not-on-pypi - # - dependency_links=[], - # If using Python 2.6 or earlier, then these have to be included in - # MANIFEST.in as well. - # package_data={"sample": ["package_data.dat"]}, # Optional - # Although 'package_data' is the preferred approach, in some case you may - # need to place data files outside of your packages. See: - # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files - # - # In this case, 'data_file' will be installed into '/my_data' - # data_files=[("my_data", ["data/data_file"])], # Optional - # To provide executable scripts, use entry points in preference to the - # "scripts" keyword. Entry points provide cross-platform support and allow - # `pip` to create the appropriate form of executable for the target - # platform. - # - # For example, the following would provide a command called `sample` which - # executes the function `main` from this package when invoked: - # entry_points={"console_scripts": ["sample=sample:main"]}, # Optional - # List additional URLs that are relevant to your project as a dict. - # - # This field corresponds to the "Project-URL" metadata fields: - # https://packaging.python.org/specifications/core-metadata/#project-url-multiple-use - # - # Examples listed include a pattern for specifying where the package tracks - # issues, where the source is hosted, where to say thanks to the package - # maintainers, and where to support the project financially. The key is - # what's used to render the link text on PyPI. - project_urls={ # Optional - "Bug Reports": "https://github.com/DiddiZ/mtg-proxies/issues", - "Source": "https://github.com/DiddiZ/mtg-proxies", - }, -) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5cf13a9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,53 @@ +import itertools +from pathlib import Path + +import pytest + + +@pytest.fixture(scope="session") +def cache_dir(): + test_cache = TEST_ROOT_DIR / ".test_cache" + test_cache.mkdir(exist_ok=True) + return test_cache + + +@pytest.fixture(scope="session") +def test_outputs_dir(): + test_out_dir = TEST_ROOT_DIR / "outputs" + test_out_dir.mkdir(exist_ok=True) + return test_out_dir + + +@pytest.fixture(scope="session") +def example_images_dir() -> Path: + return TEST_ROOT_DIR / "resources" / "images" + + +@pytest.fixture(scope="session") +def example_decklists_dir() -> Path: + return TEST_ROOT_DIR / "resources" / "decklists" + + +def take_n_images(n: int, directory: Path) -> list[Path]: + """Return n image files from a directory cycling through the directory iterator.""" + available_image_files = [] + dir_iterator = directory.iterdir() + card_iter = itertools.cycle(dir_iterator) + while len(available_image_files) < n: + image = next(card_iter) + if image.is_file() and image.suffix in [".jpg", ".png"]: + available_image_files.append(image) + return available_image_files + + +@pytest.fixture(scope="session") +def example_images_7(example_images_dir) -> list[Path]: + return take_n_images(7, directory=example_images_dir) + + +@pytest.fixture(scope="session") +def example_images_24(example_images_dir) -> list[Path]: + return take_n_images(24, directory=example_images_dir) + + +TEST_ROOT_DIR = Path(__file__).parent diff --git a/tests/decklist_test.py b/tests/decklist_test.py index 7ff42ee..15c6034 100644 --- a/tests/decklist_test.py +++ b/tests/decklist_test.py @@ -1,17 +1,18 @@ import os +from pathlib import Path import pytest -def test_parsing(): +def test_parsing(cache_dir: Path, example_decklists_dir: Path): from mtgproxies.decklists import parse_decklist - decklist, ok, warnings = parse_decklist("examples/decklist.txt") + decklist, ok, warnings = parse_decklist(example_decklists_dir / "decklist.txt", cache_dir=cache_dir) assert ok assert len(warnings) == 0 - with open("examples/decklist.txt", encoding="utf-8") as f: + with open(example_decklists_dir / "decklist.txt", encoding="utf-8") as f: # Ignore differences in linebreaks assert (format(decklist, "arena") + os.linesep).replace("\r\n", "\n") == f.read().replace("\r\n", "\n") @@ -23,27 +24,27 @@ def test_parsing(): ("42", "Dromar's Cavern"), ], ) -def test_archidekt(archidekt_id: str, expected_first_card: str): +def test_archidekt(archidekt_id: str, expected_first_card: str, cache_dir: Path): from mtgproxies.decklists.archidekt import parse_decklist - decklist, ok, _ = parse_decklist(archidekt_id) + decklist, ok, _ = parse_decklist(archidekt_id, cache_dir=cache_dir) assert ok assert decklist.cards[0]["name"] == expected_first_card -def test_reversible_cards(): +def test_reversible_cards(cache_dir): """Check that reversible cards are parsed correctly.""" from io import StringIO from mtgproxies import fetch_scans_scryfall from mtgproxies.decklists import parse_decklist_stream - decklist, ok, _ = parse_decklist_stream(StringIO("1 Propaganda // Propaganda (SLD) 381\n")) + decklist, ok, _ = parse_decklist_stream(StringIO("1 Propaganda // Propaganda (SLD) 381\n"), cache_dir=cache_dir) assert ok assert decklist.cards[0]["name"] == "Propaganda // Propaganda" - images = fetch_scans_scryfall(decklist) + images = fetch_scans_scryfall(decklist, cache_dir=cache_dir) assert len(images) == 2 # Front and back diff --git a/tests/print_test.py b/tests/print_test.py index f03bb3c..d34231b 100644 --- a/tests/print_test.py +++ b/tests/print_test.py @@ -1,46 +1,74 @@ +import math from pathlib import Path import pytest - -@pytest.fixture(scope="module") -def example_images() -> list[str]: - from mtgproxies import fetch_scans_scryfall - from mtgproxies.decklists import parse_decklist - - decklist, _, _ = parse_decklist(Path(__file__).parent.parent / "examples/decklist.txt") - images = fetch_scans_scryfall(decklist) - - return images - - -def test_example_images(example_images: list[str]): - assert len(example_images) == 7 - - -def test_print_cards_fpdf(example_images: list[str], tmp_path: Path): - from mtgproxies import print_cards_fpdf - - out_file = tmp_path / "decklist.pdf" - print_cards_fpdf(example_images, out_file) - - assert out_file.is_file() - - -def test_print_cards_matplotlib_pdf(example_images: list[str], tmp_path: Path): - from mtgproxies import print_cards_matplotlib - - out_file = tmp_path / "decklist.pdf" - print_cards_matplotlib(example_images, out_file) - - assert out_file.is_file() - - -@pytest.mark.skip(reason="for some reason this fails on github actions, but works locally.") -def test_print_cards_matplotlib_png(example_images: list[str], tmp_path: Path): - from mtgproxies import print_cards_matplotlib - - out_file = tmp_path / "decklist.png" - print_cards_matplotlib(example_images, out_file) - - assert (tmp_path / "decklist_000.png").is_file() +from mtgproxies import dimensions +from mtgproxies.print_cards import FPDF2CardAssembler, MatplotlibCardAssembler + + +def test_example_images(example_images_7: list[str], example_images_24: list[Path]): + assert len(example_images_7) == 7 + assert len(example_images_24) == 24 + + +def test_print_cards_matplotlib(example_images_24: list[Path], test_outputs_dir: Path): + assembler = MatplotlibCardAssembler( + dpi=600, + paper_size=dimensions.PAPER_SIZE["A4"]["in"], + card_size=dimensions.MTG_CARD_SIZE["in"], + border_crop=0, + crop_marks_thickness=0.01, + cut_spacing_thickness=0.2, + filled_corners=True, + background_color=None, + page_safe_margin=0, + units="in", + ) + + out_file = test_outputs_dir / "test_proxies.png" + assembler.assemble(example_images_24, out_file) + glob_pattern = out_file.stem + "*" + out_file.suffix + result_files = list(out_file.parent.glob(glob_pattern)) + assert len(result_files) == 3, f"Expected 3 files, the glob pattern {glob_pattern} got {result_files}" + + +def test_print_cards_fpdf(example_images_24: list[Path], test_outputs_dir: Path): + assembler = FPDF2CardAssembler( + paper_size=dimensions.PAPER_SIZE["A4"]["mm"], + card_size=dimensions.MTG_CARD_SIZE["mm"], + border_crop=5, + crop_marks_thickness=0.5, + cut_spacing_thickness=0.1, + filled_corners=True, + background_color=None, + page_safe_margin=0, + units="mm", + ) + + out_file = test_outputs_dir / "test_proxies.pdf" + assembler.assemble(example_images_24, out_file) + assert out_file.exists() + + +def test_dimension_units_coverage(): + from mtgproxies.dimensions import PAPER_SIZE, Units + + for unit in Units.__args__: + for spec in PAPER_SIZE: + assert unit in PAPER_SIZE[spec] + + +@pytest.mark.parametrize( + "unit,amount,expected_mm", + [ + ("in", 6, 152.4), + ("cm", 6, 60), + ("mm", 6, 6), + ], +) +def test_units_to_mm(unit: str, amount: float, expected_mm: float): + from mtgproxies.dimensions import UNITS_TO_MM + + assert math.isclose(amount * UNITS_TO_MM[unit], expected_mm, rel_tol=1e-3) + assert math.isclose(expected_mm / UNITS_TO_MM[unit], amount, rel_tol=1e-3) diff --git a/examples/decklist.txt b/tests/resources/decklists/decklist.txt similarity index 96% rename from examples/decklist.txt rename to tests/resources/decklists/decklist.txt index 520beea..a2af1fb 100644 --- a/examples/decklist.txt +++ b/tests/resources/decklists/decklist.txt @@ -1,9 +1,9 @@ -Decklist -1 Alela, Artful Provocateur (ELD) 324 -1 Korvold, Fae-Cursed King (ELD) 329 -1 Liliana, Dreadhorde General (WAR) 97 -1 Murderous Rider // Swift End (ELD) 287 -1 Growing Rites of Itlimoc // Itlimoc, Cradle of the Sun (XLN) 191 - -Tokens -1 Saproling (TC19) 19 +Decklist +1 Alela, Artful Provocateur (ELD) 324 +1 Korvold, Fae-Cursed King (ELD) 329 +1 Liliana, Dreadhorde General (WAR) 97 +1 Murderous Rider // Swift End (ELD) 287 +1 Growing Rites of Itlimoc // Itlimoc, Cradle of the Sun (XLN) 191 + +Tokens +1 Saproling (TC19) 19 diff --git a/examples/decklist_text.txt b/tests/resources/decklists/decklist_text.txt similarity index 95% rename from examples/decklist_text.txt rename to tests/resources/decklists/decklist_text.txt index b5400e0..3140785 100644 --- a/examples/decklist_text.txt +++ b/tests/resources/decklists/decklist_text.txt @@ -1,9 +1,9 @@ -Decklist -1 Alela, Artful Provocateur -1 Korvold, Fae-Cursed King -1 Liliana, Dreadhorde General -1 Murderous Rider // Swift End -1 Growing Rites of Itlimoc - -Tokens -1 Saproling +Decklist +1 Alela, Artful Provocateur +1 Korvold, Fae-Cursed King +1 Liliana, Dreadhorde General +1 Murderous Rider // Swift End +1 Growing Rites of Itlimoc + +Tokens +1 Saproling diff --git a/examples/layouts.txt b/tests/resources/decklists/layouts.txt similarity index 93% rename from examples/layouts.txt rename to tests/resources/decklists/layouts.txt index e5fda0e..bc1405e 100644 --- a/examples/layouts.txt +++ b/tests/resources/decklists/layouts.txt @@ -1,50 +1,50 @@ -normal -1 Animate Wall (CEI) 1 - -token -1 1997 World Championships Ad (WC97) 0 - -emblem -1 Sorin, Lord of Innistrad Emblem (TDKA) 3 - -split -1 Stand // Deliver (INV) 292 - -flip -1 Bushi Tenderfoot // Kenzo the Hardhearted (CHK) 2 - -host -1 Adorable Kitten (UND) 1 - -augment -1 Half-Kitten, Half- (UST) 9 - -saga -1 Fall of the Thran (DOM) 18 - -adventure -1 Ardenvale Tactician // Dizzying Swoop (ELD) 5 - -vanguard -1 Ertai (PVAN) 101 - -leveler -1 Caravan Escort (DDP) 3 - -transform -1 Ludevic's Test Subject // Ludevic's Abomination - -art_series -1 Chillerpillar // Chillerpillar (AMH1) 1 - -planar -1 Tazeem (OPCA) 78 - -double_faced_token -1 Human // Wolf (F12) 1a - -meld -1 Bruna, the Fading Light (EMN) 15a - -scheme -1 Plots That Span Centuries (PARC) 54 +normal +1 Animate Wall (CEI) 1 + +token +1 1997 World Championships Ad (WC97) 0 + +emblem +1 Sorin, Lord of Innistrad Emblem (TDKA) 3 + +split +1 Stand // Deliver (INV) 292 + +flip +1 Bushi Tenderfoot // Kenzo the Hardhearted (CHK) 2 + +host +1 Adorable Kitten (UND) 1 + +augment +1 Half-Kitten, Half- (UST) 9 + +saga +1 Fall of the Thran (DOM) 18 + +adventure +1 Ardenvale Tactician // Dizzying Swoop (ELD) 5 + +vanguard +1 Ertai (PVAN) 101 + +leveler +1 Caravan Escort (DDP) 3 + +transform +1 Ludevic's Test Subject // Ludevic's Abomination + +art_series +1 Chillerpillar // Chillerpillar (AMH1) 1 + +planar +1 Tazeem (OPCA) 78 + +double_faced_token +1 Human // Wolf (F12) 1a + +meld +1 Bruna, the Fading Light (EMN) 15a + +scheme +1 Plots That Span Centuries (PARC) 54 diff --git a/examples/token_generators.txt b/tests/resources/decklists/token_generators.txt similarity index 100% rename from examples/token_generators.txt rename to tests/resources/decklists/token_generators.txt diff --git a/examples/wrong_names.txt b/tests/resources/decklists/wrong_names.txt similarity index 95% rename from examples/wrong_names.txt rename to tests/resources/decklists/wrong_names.txt index 3844386..4e99075 100644 --- a/examples/wrong_names.txt +++ b/tests/resources/decklists/wrong_names.txt @@ -1,27 +1,27 @@ -Wrong set -1 Liliana, Dreadhorde General (WAR2) 97 - -Only front of double faced card -1 Murderous Rider (ELD) 287 -1 Wear (DGM) 135 - -Wrong collector number -1 Forbidden Friendship (IKO) 120 - -Incomplete name (but unique), non-black border with alternative -1 Counterspel (5ED) 77 - -Incomplete name (ambiguous, few options) -1 Counters (5ED) 77 - -Incomplete name (ambiguous, many options) -1 Counter (5ED) 77 - -Non-black border without alternative -1 Adorable Kitten (UST) 1 - -Wrong card name -1 Countersark (5ED) 77 - -Same name as the front of a double faced card -1 Illusion (TXLN) 2 +Wrong set +1 Liliana, Dreadhorde General (WAR2) 97 + +Only front of double faced card +1 Murderous Rider (ELD) 287 +1 Wear (DGM) 135 + +Wrong collector number +1 Forbidden Friendship (IKO) 120 + +Incomplete name (but unique), non-black border with alternative +1 Counterspel (5ED) 77 + +Incomplete name (ambiguous, few options) +1 Counters (5ED) 77 + +Incomplete name (ambiguous, many options) +1 Counter (5ED) 77 + +Non-black border without alternative +1 Adorable Kitten (UST) 1 + +Wrong card name +1 Countersark (5ED) 77 + +Same name as the front of a double faced card +1 Illusion (TXLN) 2 diff --git a/tests/resources/images/mtg_card_large_borderless.png b/tests/resources/images/mtg_card_large_borderless.png new file mode 100644 index 0000000..c4c9b92 Binary files /dev/null and b/tests/resources/images/mtg_card_large_borderless.png differ diff --git a/tests/resources/images/mtg_card_large_no_image.png b/tests/resources/images/mtg_card_large_no_image.png new file mode 100644 index 0000000..eb9ca15 Binary files /dev/null and b/tests/resources/images/mtg_card_large_no_image.png differ diff --git a/tests/resources/images/mtg_card_small_compressed.png b/tests/resources/images/mtg_card_small_compressed.png new file mode 100644 index 0000000..d88ba10 Binary files /dev/null and b/tests/resources/images/mtg_card_small_compressed.png differ diff --git a/tests/scans_test.py b/tests/scans_test.py index e35e0d6..0f641e1 100644 --- a/tests/scans_test.py +++ b/tests/scans_test.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Literal import pytest @@ -6,10 +7,10 @@ @pytest.fixture(scope="module") -def example_decklist() -> Decklist: +def example_decklist(cache_dir: Path, example_decklists_dir: Path) -> Decklist: from mtgproxies.decklists import parse_decklist - decklist, _, _ = parse_decklist(Path(__file__).parent.parent / "examples/decklist.txt") + decklist, _, _ = parse_decklist(example_decklists_dir / "decklist.txt", cache_dir=cache_dir) return decklist @@ -22,9 +23,11 @@ def example_decklist() -> Decklist: ("back", 1), ], ) -def test_fetch_scans_scryfall(example_decklist: Decklist, faces: str, expected_images: int): +def test_fetch_scans_scryfall( + example_decklist: Decklist, faces: Literal["all", "front", "back"], expected_images: int, cache_dir: Path +): from mtgproxies import fetch_scans_scryfall - images = fetch_scans_scryfall(example_decklist, faces=faces) + images = fetch_scans_scryfall(example_decklist, faces=faces, cache_dir=cache_dir) assert len(images) == expected_images diff --git a/tests/scryfall_test.py b/tests/scryfall_test.py index f0f695b..89650dc 100644 --- a/tests/scryfall_test.py +++ b/tests/scryfall_test.py @@ -1,8 +1,11 @@ +import tempfile +from pathlib import Path + import pytest @pytest.mark.parametrize( - "id,n_faces", + "mtgjson_id,n_faces", [ ("76ac5b70-47db-4cdb-91e7-e5c18c42e516", 1), ("c470539a-9cc7-4175-8f7c-c982b6072b6d", 2), # Modal double-faced @@ -10,10 +13,10 @@ ("6ee6cd34-c117-4d7e-97d1-8f8464bfaac8", 1), # Flip ], ) -def test_get_faces(id: str, n_faces: int): - import scryfall +def test_get_faces(mtgjson_id: str, n_faces: int, cache_dir: Path): + from mtgproxies import scryfall - card = scryfall.card_by_id()[id] + card = scryfall.card_by_id(cache_dir=cache_dir)[mtgjson_id] faces = scryfall.get_faces(card) assert type(faces) is list @@ -31,9 +34,9 @@ def test_get_faces(id: str, n_faces: int): ("vedalken æthermage", "496eb37d-5c8f-4dd7-a0a7-3ed1bd2210d6"), ], ) -def test_canonic_card_name(name: str, expected_id: str): - import scryfall +def test_canonic_card_name(name: str, expected_id: str, cache_dir: Path): + from mtgproxies import scryfall - card = scryfall.get_card(name) + card = scryfall.get_card(name, cache_dir=cache_dir) assert card["id"] == expected_id diff --git a/tokens.py b/tokens.py index 47bace1..1824566 100644 --- a/tokens.py +++ b/tokens.py @@ -1,12 +1,12 @@ import argparse from pathlib import Path -import scryfall +from mtgproxies import scryfall from mtgproxies.cli import parse_decklist_spec from mtgproxies.decklists import Decklist -def get_tokens(decklist: Decklist): +def get_tokens(decklist: Decklist, cache_dir: Path): tokens = {} for card in decklist.cards: if card["layout"] in ["token", "double_faced_token"]: @@ -19,11 +19,11 @@ def get_tokens(decklist: Decklist): if related_card["component"] == "token": # Related cards are only provided by their id. # We need the oracle id to weed out duplicates - related = scryfall.get_cards(id=related_card["id"])[0] + related = scryfall.get_cards(cache_dir=cache_dir, id=related_card["id"])[0] tokens[related["oracle_id"]] = related # Resolve oracle ids to actual cards. - return [scryfall.recommend_print(token) for token in tokens.values()] + return [scryfall.recommend_print(cache_dir=cache_dir, current=token) for token in tokens.values()] if __name__ == "__main__": @@ -39,20 +39,20 @@ def get_tokens(decklist: Decklist): args = parser.parse_args() # Parse decklist - decklist = parse_decklist_spec(args.decklist, warn_levels=["ERROR", "WARNING"]) + decklist_ = parse_decklist_spec(args.decklist, warn_levels=["ERROR", "WARNING"]) # Find tokens - tokens = get_tokens(decklist) - print(f"Found {len(tokens)} created tokens.") + tokens_ = get_tokens(decklist_) + print(f"Found {len(tokens_)} created tokens.") # Append tokens - decklist.append_comment("") - decklist.append_comment("Tokens") - for token in tokens: - decklist.append_card(1, token) + decklist_.append_comment("") + decklist_.append_comment("Tokens") + for t in tokens_: + decklist_.append_card(1, t) # Write decklist out_file = args.decklist if Path(args.decklist).is_file() else f"{args.decklist.split(':')[-1]}.txt" - decklist.save(out_file, fmt=args.format) + decklist_.save(out_file, fmt=args.format) print(f"Successfully appended to {Path(out_file).resolve()}.")