From ccbdf5f88c698f170272007af87979652e214bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20V=C3=A1clav=20Bajger?= <33228382+AdamBajger@users.noreply.github.com> Date: Thu, 9 May 2024 00:53:49 +0200 Subject: [PATCH 1/4] ref: rewrite for flexibility and readability (#1) * feat: restructure, update - update from setup.py to pyproject.toml - use pdm - add support for "x" after the numbers in the decklists * feat: add pre-set sizes for papers - add a dedicated module for sizes and dimensions - add nptyping for better typehints * refactor: rewrite for flexibility and readability - remove pdf case from matplotlib function, since PDF output is handled by a separate method - add cache CLI argument for the following cache refactor - fix methods being shadowed by their local variables - replace Argparse with CLick - rewrite dimensions for better accessibility - decompose printing methods into classes for better extensibility * refactor: refactor caching and add few tests - modify cache location - add cache argument to each function that uses it to remove cache definition through side effects (globals are 'ew'!) - add some tests for units and dimensions * fix: patch errors from cache refactor - add conftest for fixtures - fix some broken tests - * fix: patch errors from cache refactor and plotting - add conftest for fixtures - fix broken tests - remove obsolete tests - change str -> Path where applicable - fix matplotlib plotting and FPDF2 printing issues, including issues rooted in the abstract base class. * feat: update pre-commit hooks and add example images - use the faster better RUFF instead of older things. - add conventional commits hook to encourage readable reflogs - add example card images for testing and offline development * ref: update python-package.yml * feat: use PDM in github action * feat: use PDM in github action - fix pre-commit dependency and stuff - remove invalid import - update README.md - update deps in pyproject.toml - remove prettier * fix: install correct deps in github actions * fix: use PDM to run tests in github actions * fix: tests - use correct fixtures --------- Co-authored-by: Adam Bajger --- .github/workflows/python-package.yml | 10 +- .pre-commit-config.yaml | 48 +- README.md | 106 +-- deck_value.py | 2 +- mtgproxies/__init__.py | 3 - mtgproxies/cli.py | 5 +- mtgproxies/decklists/archidekt/archidekt.py | 14 +- mtgproxies/decklists/decklist.py | 14 +- mtgproxies/decklists/sanitizing.py | 21 +- mtgproxies/dimensions.py | 148 ++++ mtgproxies/plotting/splitpages.py | 6 + mtgproxies/print_cards.py | 442 ++++++----- mtgproxies/scans.py | 9 +- mtgproxies/scryfall/__init__.py | 16 + .../scryfall}/rate_limit.py | 70 +- {scryfall => mtgproxies/scryfall}/scryfall.py | 706 +++++++++--------- print.py | 464 +++++++++--- pyproject.toml | 127 +++- scryfall/__init__.py | 27 - setup.py | 185 ----- tests/__init__.py | 0 tests/conftest.py | 47 ++ tests/decklist_test.py | 17 +- tests/print_test.py | 138 ++-- .../resources/decklists}/decklist.txt | 18 +- .../resources/decklists}/decklist_text.txt | 18 +- .../resources/decklists}/layouts.txt | 100 +-- .../resources/decklists}/token_generators.txt | 0 .../resources/decklists}/wrong_names.txt | 54 +- .../images/mtg_card_large_borderless.png | Bin 0 -> 155265 bytes .../images/mtg_card_large_no_image.png | Bin 0 -> 6819 bytes .../images/mtg_card_small_compressed.png | Bin 0 -> 11987 bytes tests/scans_test.py | 11 +- tests/scryfall_test.py | 17 +- tokens.py | 24 +- 35 files changed, 1719 insertions(+), 1148 deletions(-) create mode 100644 mtgproxies/dimensions.py create mode 100644 mtgproxies/scryfall/__init__.py rename {scryfall => mtgproxies/scryfall}/rate_limit.py (96%) rename {scryfall => mtgproxies/scryfall}/scryfall.py (80%) delete mode 100644 scryfall/__init__.py delete mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py rename {examples => tests/resources/decklists}/decklist.txt (96%) rename {examples => tests/resources/decklists}/decklist_text.txt (95%) rename {examples => tests/resources/decklists}/layouts.txt (93%) rename {examples => tests/resources/decklists}/token_generators.txt (100%) rename {examples => tests/resources/decklists}/wrong_names.txt (95%) create mode 100644 tests/resources/images/mtg_card_large_borderless.png create mode 100644 tests/resources/images/mtg_card_large_no_image.png create mode 100644 tests/resources/images/mtg_card_small_compressed.png 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/.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..7cbe6d4 --- /dev/null +++ b/mtgproxies/dimensions.py @@ -0,0 +1,148 @@ +from logging import getLogger + +from typing import Literal +from collections.abc import Iterable + +import numpy as np +from nptyping import NDArray, Float +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 +) -> Iterable[int]: + """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..54f82db 100644 --- a/mtgproxies/print_cards.py +++ b/mtgproxies/print_cards.py @@ -1,177 +1,281 @@ from __future__ import annotations +import abc +import math from pathlib import Path +from logging import getLogger +from typing import Generator +import PIL import matplotlib.pyplot as plt + +from nptyping import NDArray, Float, Shape import numpy as np -from matplotlib.backends.backend_pdf import PdfPages -from matplotlib.patches import Rectangle -from tqdm import tqdm -from mtgproxies.plotting import SplitPages - -image_size = np.array([745, 1040]) - - -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 - - -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. - - 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 - - # 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) - - # Initialize PDF - pdf = FPDF(orientation="P", unit="mm", format="A4") - - for i, image in enumerate(tqdm(images, desc="Plotting cards")): - if i % cards_per_sheet == 0: # Startign a new sheet +from fpdf import FPDF +from PIL.Image import Image + +from tqdm import tqdm +from mtgproxies.dimensions import ( + get_pixels_from_size_and_ppsu, + get_ppsu_from_size_and_pixels, + Units, UNITS_TO_IN +) + +logger = getLogger(__name__) + +Bbox = tuple[float, float, float, float] # (x, y, width, height) +Lcoords = tuple[float, float, float, float] # (x0, y0, x1, y1) + + +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, + fill_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. + fill_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.fill_corners_ = fill_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 = np.asarray(PIL.Image.open(card_image_filepath)) + # fill corners + if self.fill_corners_: + img = self.fill_corners(img) + # crop the cards + ppsu = get_ppsu_from_size_and_pixels(pixel_values=img.shape[:2], size=self.card_size) + crop_px = get_pixels_from_size_and_ppsu(ppsu=ppsu, size=self.border_crop) + img = img[crop_px:, crop_px:] + return PIL.Image.fromarray(img) + + def fill_corners(self, img: NDArray[Shape["2"], Float]) -> NDArray[Shape["2"], Float]: + """Fill the corners of the card with the closest pixels around the corners to match the border color.""" + logger.warning("Filling corners not implemented, returning original image.") + 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) # type: ignore + + 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 background_color is not None: - pdf.set_fill_color(*background_color) - pdf.rect(0, 0, papersize[0], papersize[1], "F") - - x = (i % cards_per_sheet) % N[0] - y = (i % cards_per_sheet) // N[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 - - if left == 0 and top == 0: - cropped_image = image - 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) + 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: + 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: + # extent = (left, right, bottom, top) + 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 = (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 80% rename from scryfall/scryfall.py rename to mtgproxies/scryfall/scryfall.py index c4c5e18..3209df8 100644 --- a/scryfall/scryfall.py +++ b/mtgproxies/scryfall/scryfall.py @@ -1,349 +1,357 @@ -"""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 threading +from collections import defaultdict +from functools import lru_cache +from pathlib import Path +import logging + +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.home() / ".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") + + +@lru_cache(maxsize=None) +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, 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 + 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}'") + + +@lru_cache(maxsize=None) +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)} + + +@lru_cache(maxsize=None) +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 + + +@lru_cache(maxsize=None) +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) -> 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..0ed46a7 100644 --- a/print.py +++ b/print.py @@ -1,97 +1,367 @@ -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 ur_mom(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-lines-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( + "--fill-corners", + "-fc", + is_flag=True, + help="Fill the corners of the cards with the colors of the closest pixels.", + )(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 + + +@ur_mom.command(name="pdf") +@common_cli_arguments +def my_mom( + 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, + fill_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. + + """ + if not cache_dir.exists(): + cache_dir.mkdir(parents=True) + + 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 + + # 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, + fill_corners=fill_corners, + page_safe_margin=page_safe_margin, + ) + + printer.assemble(card_image_filepaths=images, output_filepath=output_file) + + +@ur_mom.command(name="image") +@common_cli_arguments +@click.option("--dpi", "-d", type=int, default=300, help="DPI of the output image.") +def his_mom( + 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, + fill_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. + + """ + if not cache_dir.exists(): + cache_dir.mkdir(parents=True) + print("CACHE:", cache_dir.absolute().as_posix()) + + 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 + + # 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, + fill_corners=fill_corners, + page_safe_margin=page_safe_margin, + dpi=dpi, + ) + + printer.assemble(card_image_filepaths=images, output_filepath=output_file) + + +if __name__ == "__main__": + ur_mom(obj={}) + + +# 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, +# ) 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..9d75b40 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,47 @@ +import itertools + +import pytest +from pathlib import Path + + +TEST_ROOT_DIR = Path(__file__).parent + + +@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 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) + 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..ad75cc5 100644 --- a/tests/print_test.py +++ b/tests/print_test.py @@ -1,46 +1,100 @@ +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 MatplotlibCardAssembler, FPDF2CardAssembler + + +# @pytest.fixture(scope="module") +# def example_images(cache_dir) -> list[Path]: +# from mtgproxies import fetch_scans_scryfall +# from mtgproxies.decklists import parse_decklist +# +# decklist, _, _ = parse_decklist(Path(__file__).parent.parent / "examples/decklist.txt", cache_dir=cache_dir) +# images = fetch_scans_scryfall(decklist) +# +# return images + + +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_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() + + +def test_print_cards_matplotlib(example_images_24: list[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, + fill_corners=False, + background_color=None, + page_safe_margin=0, + units="in", + ) + + out_file = Path("test_proxies.png") + assembler.assemble(example_images_24, out_file) + print(out_file.absolute().as_posix()) + + +def test_print_cards_fpdf(example_images_24: list[Path]): + assembler = FPDF2CardAssembler( + paper_size=dimensions.PAPER_SIZE['A4']['mm'], + card_size=dimensions.MTG_CARD_SIZE['mm'], + border_crop=0, + crop_marks_thickness=0.5, + cut_spacing_thickness=0.1, + fill_corners=False, + background_color=None, + page_safe_margin=0, + units="mm", + ) + + out_file = Path("test_proxies.pdf") + assembler.assemble(example_images_24, out_file) + print(out_file.absolute().as_posix()) + + +def test_dimension_units_coverage(): + from mtgproxies.dimensions import Units, PAPER_SIZE + + 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 0000000000000000000000000000000000000000..c4c9b92e48dd7b53f58b3cad2aab77e1656b57ec GIT binary patch literal 155265 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 z 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

`WmhqCjF6oB}2MsR)Iz zSO^J)6ZUz2UJw-aJcT|RxYB`FW1HM3QH3hYNylk#$W*h+$>C=tHghvA_I3|(&eEvY z!40UED@d(8f$3mox{2@Gvy|ep*FIQ_afU$Zf|YiZ;xa)L`eZ~07?Tr(Ay|vW6ltk1 zd-)P`P%yJT7Z6He^SnrH4M^WfA*6m9tlZhzD!$RkT6?a?@#IJ$r*lwkR58|GsMuTx zb;I$5TD^)8f@-x)sa*2i(`TupkSxs3`E;>JdxaAimm!4q;mH-J93ZJx%SfgDVI$TQ z$_dZIQ_x?|f;GM|IS8ZTVV&=s5DIH^q|)bps_3Be=W3kQSFM+vIhz+dLKTF*Llz>X zY8WvX4iL}x_KwrR1eu$23dmU z2sl^z74G9*t32BN6obeT0-G$%I<9iq;xy3@VJUc}|w+=pZ0a5@QWI2)$o8TLA0n zFX?=7RHBOhLO&|yb8x?K4jd9CA#>{ap$?#4YamHbQjumkm0E>lJYj8PgKD+RU^qc5 z&F@cONrzK6DOnCHSHPlTNd4WFL2-f(2djOEY% z?4M9AmkT{!K@Fd8FI#+9ooim5JFAkOA4%p1P&hW>in=)h-})}jb86oS9e5!@Q77;X zO@-!1T7)%NFS0>tUxpqH$9&@}Ut?~1hTK@PWI~Rj94nH{qJw}^TtP~~r+4r3#V@_d zPv8IGvuP}Aa;92!KK#`uWWdQupBGlHu-`c$);ZI&bL?*K632C1AX%P&6{a?ldju+@xpbM=mLk^oQ}z!J$ z%N;TjL{S}MVYX2wibH0nW>~&97~gGU=IEiW~yr4s%A#X{vXofd-GnQ2ViWPZBF=|Pvv%P;WGw|mQJ2 z2{=b`dd#VvXRaM`bFs?y!4a1h=lR^{-=Z94%ru(3xH`*WZ^S!adW}JEOsTBc-Fd_( zk594rn0LPR6?C9*PSEWS+1}el3q`qFCCxlQ3M34A1G=XtJlWjl_~`KY@HWXEH|i0e z`|8&Tz9re*+2i6mi4N}x3x8*6;>={>A5 zG^g9tl;-jJF3VSzIiBn=wJ=3B2R9iru>qxQgLF_Mow~|!e4qOd*ZAP}r@Z*;TfA|- zMP^`ivCP_jhv}exie%=QK>dD)*#LsPF2D`a`BRz- z*e#UAriPF1Z_-Z_9^U=%T$yoU@Xh876&=t`QmnQ7;UB!i!w2^n91mE$x=NUz((NUL z<&f#wOC(v&WN^wTwahKfGdk%o9H%U=EHXIhl3PbQNr_4!$H%AC>UAoy#vn;EM<^js zmM2d(nVP9F91G$&q7-T#uWixok3d+m%rZ4M#p2u?LIkX>Z?bY_g~yK`GZ^-$L@`O0 zP_M?E48}~io3yJ{PL7VTS`k&6q+|?x6ArfSop1Z!I0jPu#)*>8es=NL$KO1L@#%a1 zFFq5`xtDnEA&*awgnZs%j?cTt#oxJjq-1feI8zsqo*w|p|IhyR4!b_s?j3dokUrag l^6%;u_wRb%MOuuP{{x0xQYx@W=TZOw002ovPDHLkV1g1P*E|3K literal 0 HcmV?d00001 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()}.") From 0f778308ab1afd4fe51069212dccd283bd19a96d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20V=C3=A1clav=20Bajger?= <33228382+AdamBajger@users.noreply.github.com> Date: Tue, 14 May 2024 00:45:27 +0200 Subject: [PATCH 2/4] Feature to intelligently fill corners (#2) * feat: Implement corner filling - fill the corners of the cards using their own edges, allowing filled border for cards of any border color. - make filled corners for borderless cards look good as well. - make the border-filling finctions static - rename CLI functions to generic names - designate output destination for tests * refactor: rewrite the corner-filling logic - since for each corner, the code gets executed similarly, make the methods share code as much as possible - add docstrings - fix method name shadowing * fix: wrong keyword --- mtgproxies/print_cards.py | 204 ++++++++++++++++++++++++++++++-------- print.py | 14 +-- tests/conftest.py | 14 ++- tests/print_test.py | 60 ++++------- 4 files changed, 196 insertions(+), 96 deletions(-) diff --git a/mtgproxies/print_cards.py b/mtgproxies/print_cards.py index 54f82db..b18487f 100644 --- a/mtgproxies/print_cards.py +++ b/mtgproxies/print_cards.py @@ -2,25 +2,27 @@ import abc import math -from pathlib import Path from logging import getLogger -from typing import Generator +from typing import TYPE_CHECKING -import PIL import matplotlib.pyplot as plt - -from nptyping import NDArray, Float, Shape import numpy as np - +import PIL from fpdf import FPDF -from PIL.Image import Image - +from PIL import ImageChops, ImageFilter +from PIL.Image import Image, Transpose from tqdm import tqdm -from mtgproxies.dimensions import ( - get_pixels_from_size_and_ppsu, - get_ppsu_from_size_and_pixels, - Units, UNITS_TO_IN -) + +from mtgproxies.dimensions import UNITS_TO_IN, Units, get_pixels_from_size_and_ppsu, get_ppsu_from_size_and_pixels + + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + from typing import Any, Literal + + from nptyping import Float, NDArray, Shape + logger = getLogger(__name__) @@ -28,20 +30,140 @@ Lcoords = tuple[float, float, float, float] # (x0, y0, x1, y1) +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. + + 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) + + 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. + + 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. + + 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: NDArray[Shape[Any, Any, 4], Float], + stripe_width_fraction: float, + flip_how: Literal["horizontal", "vertical"], + stripe_location: Literal["top", "bottom", "left", "right"], +) -> NDArray[Shape[2], Float]: + """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. + + 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.shape[:2] + 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: + 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}") + + image = PIL.Image.fromarray(corner_copy) + patch_inverted = image.crop(bbox).transpose(method=transpose_method) + return blend_patch_into_image(bbox, image, patch_inverted) + + +def fill_corners(img: NDArray[Shape[2], Float]) -> NDArray[Shape[2], Float]: + """Fill the corners of the card with the closest pixels around the corners to match the border color.""" + card_width = img.shape[0] + corner_size = card_width // 10 + + # left side + img[:corner_size, :corner_size] = blend_flipped_stripe(img[:corner_size, :corner_size], 1 / 6, "vertical", "left") + img[:corner_size, -corner_size:] = blend_flipped_stripe( + img[:corner_size, -corner_size:], 1 / 6, "vertical", "right" + ) + + # right side + img[-corner_size:, :corner_size] = blend_flipped_stripe(img[-corner_size:, :corner_size], 1 / 6, "vertical", "left") + img[-corner_size:, -corner_size:] = blend_flipped_stripe( + img[-corner_size:, -corner_size:], 1 / 6, "vertical", "right" + ) + + # top side + img[:corner_size, :corner_size] = blend_flipped_stripe(img[:corner_size, :corner_size], 1 / 6, "horizontal", "top") + img[-corner_size:, :corner_size] = blend_flipped_stripe( + img[-corner_size:, :corner_size], 1 / 6, "horizontal", "bottom" + ) + + # bottom side + img[:corner_size, -corner_size:] = blend_flipped_stripe( + img[:corner_size, -corner_size:], 1 / 6, "horizontal", "top" + ) + img[-corner_size:, -corner_size:] = blend_flipped_stripe( + img[-corner_size:, -corner_size:], 1 / 6, "horizontal", "bottom" + ) + + return img + + 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, - fill_corners: bool = False, - background_color: tuple[int, int, int] | None = None, - page_safe_margin: float = 0, - units: Units = "mm", + 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. @@ -51,7 +173,7 @@ def __init__( 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. - fill_corners: Whether to fill in the corners of the cards. + 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. @@ -65,13 +187,15 @@ def __init__( self.crop_marks_thickness = crop_marks_thickness self.cut_spacing_thickness = cut_spacing_thickness self.background_color = background_color - self.fill_corners_ = fill_corners + 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.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 @@ -88,25 +212,19 @@ def process_card_image(self, card_image_filepath: Path) -> Image: Args: card_image_filepath: Image file to process. """ - img = np.asarray(PIL.Image.open(card_image_filepath)) + img = np.asarray(PIL.Image.open(card_image_filepath)).copy() # fill corners - if self.fill_corners_: - img = self.fill_corners(img) + if self.filled_corners: + img = fill_corners(img) # crop the cards ppsu = get_ppsu_from_size_and_pixels(pixel_values=img.shape[:2], size=self.card_size) crop_px = get_pixels_from_size_and_ppsu(ppsu=ppsu, size=self.border_crop) img = img[crop_px:, crop_px:] return PIL.Image.fromarray(img) - def fill_corners(self, img: NDArray[Shape["2"], Float]) -> NDArray[Shape["2"], Float]: - """Fill the corners of the card with the closest pixels around the corners to match the border color.""" - logger.warning("Filling corners not implemented, returning original image.") - return img - def get_page_generators( - self, - card_image_filepaths: list[str | Path], - + 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. @@ -189,7 +307,7 @@ 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) # type: ignore + 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})") @@ -247,7 +365,8 @@ def assemble(self, card_image_filepaths: list[Path], output_filepath: Path): ax.invert_yaxis() if self.crop_marks_thickness > 0.0: - crop_marks_thickness_in_pt = self.crop_marks_thickness*72 + 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] @@ -275,7 +394,8 @@ def assemble(self, card_image_filepaths: list[Path], output_filepath: Path): 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}" + 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/print.py b/print.py index 0ed46a7..64ba53d 100644 --- a/print.py +++ b/print.py @@ -67,7 +67,7 @@ def click_callback_cache_dir(ctx: click.Context, param: click.Parameter, value: @click.group(name="print") @click.pass_context -def ur_mom(ctx): +def command_group_print(ctx): ctx.ensure_object(dict) @@ -154,9 +154,9 @@ def common_cli_arguments(func): return func -@ur_mom.command(name="pdf") +@command_group_print.command(name="pdf") @common_cli_arguments -def my_mom( +def command_pdf( deck_list: list[str], output_file: Path, faces: Literal["all", "front", "back"], @@ -209,17 +209,17 @@ def my_mom( cut_spacing_thickness=cut_spacing_thickness, border_crop=crop_border, background_color=background_color, - fill_corners=fill_corners, + filled_corners=fill_corners, page_safe_margin=page_safe_margin, ) printer.assemble(card_image_filepaths=images, output_filepath=output_file) -@ur_mom.command(name="image") +@command_group_print.command(name="image") @common_cli_arguments @click.option("--dpi", "-d", type=int, default=300, help="DPI of the output image.") -def his_mom( +def command_image( deck_list: list[str], output_file: Path, faces: Literal["all", "front", "back"], @@ -284,7 +284,7 @@ def his_mom( if __name__ == "__main__": - ur_mom(obj={}) + command_group_print(obj={}) # parser = argparse.ArgumentParser(description="Prepare a decklist for printing.") diff --git a/tests/conftest.py b/tests/conftest.py index 9d75b40..5cf13a9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,7 @@ import itertools - -import pytest from pathlib import Path - -TEST_ROOT_DIR = Path(__file__).parent +import pytest @pytest.fixture(scope="session") @@ -14,6 +11,13 @@ def cache_dir(): 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" @@ -45,3 +49,5 @@ def example_images_7(example_images_dir) -> list[Path]: 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/print_test.py b/tests/print_test.py index ad75cc5..515b650 100644 --- a/tests/print_test.py +++ b/tests/print_test.py @@ -4,18 +4,7 @@ import pytest from mtgproxies import dimensions -from mtgproxies.print_cards import MatplotlibCardAssembler, FPDF2CardAssembler - - -# @pytest.fixture(scope="module") -# def example_images(cache_dir) -> list[Path]: -# from mtgproxies import fetch_scans_scryfall -# from mtgproxies.decklists import parse_decklist -# -# decklist, _, _ = parse_decklist(Path(__file__).parent.parent / "examples/decklist.txt", cache_dir=cache_dir) -# images = fetch_scans_scryfall(decklist) -# -# return images +from mtgproxies.print_cards import FPDF2CardAssembler, MatplotlibCardAssembler def test_example_images(example_images_7: list[str], example_images_24: list[Path]): @@ -23,63 +12,47 @@ def test_example_images(example_images_7: list[str], example_images_24: list[Pat assert len(example_images_24) == 24 -# 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() - - -def test_print_cards_matplotlib(example_images_24: list[Path]): +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'], + 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, - fill_corners=False, + filled_corners=True, background_color=None, page_safe_margin=0, units="in", ) - out_file = Path("test_proxies.png") + out_file = test_outputs_dir / "test_proxies.png" assembler.assemble(example_images_24, out_file) - print(out_file.absolute().as_posix()) + 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]): +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'], + paper_size=dimensions.PAPER_SIZE["A4"]["mm"], + card_size=dimensions.MTG_CARD_SIZE["mm"], border_crop=0, crop_marks_thickness=0.5, cut_spacing_thickness=0.1, - fill_corners=False, + filled_corners=True, background_color=None, page_safe_margin=0, units="mm", ) - out_file = Path("test_proxies.pdf") + out_file = test_outputs_dir / "test_proxies.pdf" assembler.assemble(example_images_24, out_file) - print(out_file.absolute().as_posix()) + assert out_file.exists() def test_dimension_units_coverage(): - from mtgproxies.dimensions import Units, PAPER_SIZE + from mtgproxies.dimensions import PAPER_SIZE, Units for unit in Units.__args__: for spec in PAPER_SIZE: @@ -96,5 +69,6 @@ def test_dimension_units_coverage(): ) 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) From 95208b7873831f30a6264657f97d3b6851deb671 Mon Sep 17 00:00:00 2001 From: Adam Bajger Date: Thu, 16 May 2024 23:33:02 +0200 Subject: [PATCH 3/4] fix: cropping and image type - crop all sides the same - change the Image object from numpy.ndarray to PIL.Image - rework the image operations to use PIL - add cache and outputs into .gitignore - fix other minor typing issues --- .gitignore | 4 + mtgproxies/dimensions.py | 33 ++----- mtgproxies/print_cards.py | 133 ++++++++++++++++++-------- mtgproxies/scryfall/scryfall.py | 29 +++--- print.py | 159 +++++++------------------------- tests/print_test.py | 2 +- 6 files changed, 160 insertions(+), 200 deletions(-) diff --git a/.gitignore b/.gitignore index b6e4761..47fa52d 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,7 @@ dmypy.json # Pyre type checker .pyre/ + +# MTG Cache +.cache_mtg/ +tests/outputs/ diff --git a/mtgproxies/dimensions.py b/mtgproxies/dimensions.py index 7cbe6d4..3a1e518 100644 --- a/mtgproxies/dimensions.py +++ b/mtgproxies/dimensions.py @@ -1,10 +1,9 @@ -from logging import getLogger - -from typing import Literal from collections.abc import Iterable +from logging import getLogger +from typing import Any, Literal import numpy as np -from nptyping import NDArray, Float +from nptyping import Float, NDArray, UInt from nptyping.shape import Shape @@ -79,29 +78,19 @@ }, } -UNITS_TO_MM = { - "in": 25.4, - "mm": 1.0, - "cm": 10 -} +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_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 -) -> Iterable[int]: +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. @@ -113,14 +102,15 @@ def get_pixels_from_size_and_ppsu( def get_ppsu_from_size_and_pixels( - pixel_values: Iterable[int] | int, - size: Iterable[float] | float, + 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. @@ -143,6 +133,3 @@ def parse_papersize_from_spec(spec: str, units: Units) -> NDArray[Shape["2"], Fl raise ValueError(f"Units {units} not supported for papersize {spec}") else: raise ValueError(f"Paper size not supported: {spec}") - - - diff --git a/mtgproxies/print_cards.py b/mtgproxies/print_cards.py index b18487f..19065fd 100644 --- a/mtgproxies/print_cards.py +++ b/mtgproxies/print_cards.py @@ -19,9 +19,9 @@ if TYPE_CHECKING: from collections.abc import Generator from pathlib import Path - from typing import Any, Literal + from typing import Literal - from nptyping import Float, NDArray, Shape + from nptyping import Float, NDArray logger = getLogger(__name__) @@ -61,11 +61,11 @@ def blend_patch_into_image(bbox: tuple[int, int, int, int], image: PIL.Image, pa def blend_flipped_stripe( - square_image: NDArray[Shape[Any, Any, 4], Float], + square_image: Image, stripe_width_fraction: float, flip_how: Literal["horizontal", "vertical"], stripe_location: Literal["top", "bottom", "left", "right"], -) -> NDArray[Shape[2], Float]: +) -> 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 @@ -82,7 +82,7 @@ def blend_flipped_stripe( The image with the flipped stripe blended in. """ corner_copy = square_image.copy() - width, height = corner_copy.shape[:2] + width, height = corner_copy.size if stripe_location in ["top", "bottom"]: transpose_method = Transpose.FLIP_LEFT_RIGHT elif stripe_location in ["left", "right"]: @@ -111,43 +111,103 @@ def blend_flipped_stripe( else: raise ValueError(f"Invalid flip_how: {flip_how}") - image = PIL.Image.fromarray(corner_copy) - patch_inverted = image.crop(bbox).transpose(method=transpose_method) - return blend_patch_into_image(bbox, image, patch_inverted) + patch_inverted = corner_copy.crop(bbox).transpose(method=transpose_method) + return blend_patch_into_image(bbox, corner_copy, patch_inverted) -def fill_corners(img: NDArray[Shape[2], Float]) -> NDArray[Shape[2], Float]: +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.""" - card_width = img.shape[0] - corner_size = card_width // 10 - - # left side - img[:corner_size, :corner_size] = blend_flipped_stripe(img[:corner_size, :corner_size], 1 / 6, "vertical", "left") - img[:corner_size, -corner_size:] = blend_flipped_stripe( - img[:corner_size, -corner_size:], 1 / 6, "vertical", "right" + 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, ) - # right side - img[-corner_size:, :corner_size] = blend_flipped_stripe(img[-corner_size:, :corner_size], 1 / 6, "vertical", "left") - img[-corner_size:, -corner_size:] = blend_flipped_stripe( - img[-corner_size:, -corner_size:], 1 / 6, "vertical", "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 side - img[:corner_size, :corner_size] = blend_flipped_stripe(img[:corner_size, :corner_size], 1 / 6, "horizontal", "top") - img[-corner_size:, :corner_size] = blend_flipped_stripe( - img[-corner_size:, :corner_size], 1 / 6, "horizontal", "bottom" + # 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 side - img[:corner_size, -corner_size:] = blend_flipped_stripe( - img[:corner_size, -corner_size:], 1 / 6, "horizontal", "top" + # 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, ) - img[-corner_size:, -corner_size:] = blend_flipped_stripe( - img[-corner_size:, -corner_size:], 1 / 6, "horizontal", "bottom" + 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 img + return card_image class CardAssembler(abc.ABC): @@ -212,15 +272,16 @@ def process_card_image(self, card_image_filepath: Path) -> Image: Args: card_image_filepath: Image file to process. """ - img = np.asarray(PIL.Image.open(card_image_filepath)).copy() + 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.shape[:2], size=self.card_size) - crop_px = get_pixels_from_size_and_ppsu(ppsu=ppsu, size=self.border_crop) - img = img[crop_px:, crop_px:] - return PIL.Image.fromarray(img) + 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, @@ -376,7 +437,6 @@ def assemble(self, card_image_filepaths: list[Path], output_filepath: Path): ax.plot(x_rel, y_rel, color="black", linewidth=crop_marks_thickness_in_pt) for bbox, image in bbox_gen: - # extent = (left, right, bottom, top) left, top, width, height = bbox x0 = left / self.paper_size[0] @@ -388,6 +448,7 @@ def assemble(self, card_image_filepaths: list[Path], output_filepath: Path): 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") diff --git a/mtgproxies/scryfall/scryfall.py b/mtgproxies/scryfall/scryfall.py index 3209df8..3162592 100644 --- a/mtgproxies/scryfall/scryfall.py +++ b/mtgproxies/scryfall/scryfall.py @@ -5,11 +5,11 @@ """ import json +import logging import threading from collections import defaultdict -from functools import lru_cache +from functools import cache from pathlib import Path -import logging import numpy as np import requests @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) -DEFAULT_CACHE_DIR = Path.home() / ".cache" / "mtgproxies" / "scryfall" +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() @@ -111,7 +111,7 @@ def search(q: str) -> list[dict]: return depaginate(f"https://api.scryfall.com/cards/search?q={q}&format=json") -@lru_cache(maxsize=None) +@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] @@ -133,8 +133,10 @@ def canonic_card_name(card_name: str) -> str: return card_name -def get_card(card_name: str, cache_dir: Path, set_id: str = None, collector_number: str = None) -> dict | None: - """Find a card by it's name and possibly set and collector number. +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. @@ -194,8 +196,9 @@ def get_faces(card): 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"): +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 @@ -245,7 +248,7 @@ def score(card: dict): if current is not None: if current in recommendations: recommendations.remove(current) - recommendations = [current] + recommendations + recommendations = [current, *recommendations] # Return all card in descending order return recommendations @@ -274,7 +277,7 @@ def score(card: dict): raise ValueError(f"Unknown mode '{mode}'") -@lru_cache(maxsize=None) +@cache def card_by_id(cache_dir: Path): """Create dictionary to look up cards by their id. @@ -286,7 +289,7 @@ def card_by_id(cache_dir: Path): return {c["id"]: c for c in get_cards(cache_dir=cache_dir)} -@lru_cache(maxsize=None) +@cache def get_cards_by_oracle_id(cache_dir: Path): """Create dictionary to look up cards by their oracle id. @@ -304,7 +307,7 @@ def get_cards_by_oracle_id(cache_dir: Path): return cards_by_oracle_id -@lru_cache(maxsize=None) +@cache def get_oracle_ids_by_name(cache_dir: Path) -> dict[str, list[dict]]: """Create dictionary to look up oracle ids by their name. @@ -331,7 +334,7 @@ def get_oracle_ids_by_name(cache_dir: Path) -> dict[str, list[dict]]: return oracle_ids_by_name -def get_price(cache_dir: Path, oracle_id: str, currency: str = "eur", foil: bool = None) -> float | None: +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: diff --git a/print.py b/print.py index 64ba53d..93ce095 100644 --- a/print.py +++ b/print.py @@ -84,7 +84,7 @@ def common_cli_arguments(func): help="Thickness of crop marks in the specified units. Use 0 to disable crop marks.", )(func) func = click.option( - "--cut-lines-thickness", + "--cut-spacing-thickness", "-cl", type=float, default=0.0, @@ -130,10 +130,11 @@ def common_cli_arguments(func): help="Units of the specified dimensions. Default is mm.", )(func) func = click.option( - "--fill-corners", + "--filled-corners", "-fc", is_flag=True, - help="Fill the corners of the cards with the colors of the closest pixels.", + 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", @@ -166,7 +167,7 @@ def command_pdf( background_color: IntegerRGB, paper_size: str | NDArray[Shape["2"], Float32], units: Units, - fill_corners: bool, + filled_corners: bool, page_safe_margin: float, cache_dir: Path, card_size: NDArray[Shape["2"], Float32] | None, @@ -179,26 +180,9 @@ def command_pdf( OUTPUT_FILE is the path to the output PDF file. """ - if not cache_dir.exists(): - cache_dir.mkdir(parents=True) - - 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 + 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( @@ -209,7 +193,7 @@ def command_pdf( cut_spacing_thickness=cut_spacing_thickness, border_crop=crop_border, background_color=background_color, - filled_corners=fill_corners, + filled_corners=filled_corners, page_safe_margin=page_safe_margin, ) @@ -229,7 +213,7 @@ def command_image( background_color: IntegerRGB, paper_size: str | NDArray[Shape["2"], Float32], units: Units, - fill_corners: bool, + filled_corners: bool, page_safe_margin: float, cache_dir: Path, card_size: NDArray[Shape["2"], Float32] | None, @@ -244,14 +228,31 @@ def command_image( supported by matplotlib are allowed. """ - if not cache_dir.exists(): - cache_dir.mkdir(parents=True) - print("CACHE:", cache_dir.absolute().as_posix()) + 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) @@ -263,105 +264,9 @@ def command_image( 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 - - # 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, - fill_corners=fill_corners, - page_safe_margin=page_safe_margin, - dpi=dpi, - ) - - printer.assemble(card_image_filepaths=images, output_filepath=output_file) + return images, resolved_card_size, resolved_paper_size if __name__ == "__main__": command_group_print(obj={}) - - -# 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, -# ) diff --git a/tests/print_test.py b/tests/print_test.py index 515b650..d34231b 100644 --- a/tests/print_test.py +++ b/tests/print_test.py @@ -37,7 +37,7 @@ 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=0, + border_crop=5, crop_marks_thickness=0.5, cut_spacing_thickness=0.1, filled_corners=True, From b804d28d7e2f023d7a13749b78f69c09b664db1c Mon Sep 17 00:00:00 2001 From: Adam Bajger Date: Thu, 16 May 2024 23:40:42 +0200 Subject: [PATCH 4/4] fix: add test cache to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 47fa52d..1eb0568 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,4 @@ dmypy.json # MTG Cache .cache_mtg/ tests/outputs/ +tests/.test_cache/