From 0086a178b4d9f1efac93f62d9be378cd43335f81 Mon Sep 17 00:00:00 2001 From: pappnu Date: Sat, 12 Jul 2025 19:05:15 +0300 Subject: [PATCH] feat(Station): Add support for Station layout --- src/cards.py | 4 + src/enums/layers.py | 5 + src/enums/mtg.py | 6 +- src/helpers/bounds.py | 31 +++-- src/layouts.py | 62 +++++++++- src/templates/__init__.py | 1 + src/templates/station.py | 245 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 345 insertions(+), 9 deletions(-) create mode 100644 src/templates/station.py diff --git a/src/cards.py b/src/cards.py index 8cd9db0a..351fd4ab 100644 --- a/src/cards.py +++ b/src/cards.py @@ -214,6 +214,10 @@ def process_card_data(data: dict, card: CardDetails) -> dict: data['layout'] = 'planeswalker' return data + # Check for Station layout + if 'STATION ' in data.get('oracle_text', ''): + data['layout'] = 'station' + # Return updated data return data diff --git a/src/enums/layers.py b/src/enums/layers.py index 816e9922..425a3dd0 100644 --- a/src/enums/layers.py +++ b/src/enums/layers.py @@ -76,6 +76,7 @@ class LAYERS (StrConstant): FULL = 'Full' NORMAL = 'Normal' SNOW = 'Snow' + LEVEL = 'Level' # Borders BORDER = 'Border' @@ -249,3 +250,7 @@ class LAYERS (StrConstant): # Battles DEFENSE = 'Defense' DEFENSE_REFERENCE = 'Defense Reference' + + # Station + STATION = 'Station' + REQUIREMENT = 'Requirement' diff --git a/src/enums/mtg.py b/src/enums/mtg.py index a65c8955..1e34d861 100644 --- a/src/enums/mtg.py +++ b/src/enums/mtg.py @@ -29,6 +29,7 @@ class LayoutCategory(StrConstant): Prototype = 'Prototype' Saga = 'Saga' Split = 'Split' + Station = 'Station' Token = 'Token' Transform = 'Transform' @@ -52,6 +53,7 @@ class LayoutType(StrConstant): Prototype = 'prototype' Saga = 'saga' Split = 'split' + Station = 'station' TransformBack = 'transform_back' TransformFront = 'transform_front' @@ -86,6 +88,7 @@ class LayoutScryfall(StrConstant): Planeswalker = 'planeswalker' PlaneswalkerMDFC = 'planeswalker_mdfc' PlaneswalkerTransform = 'planeswalker_tf' + Station = 'station' """Maps Layout categories to a list of equivalent Layout types.""" @@ -104,7 +107,8 @@ class LayoutScryfall(StrConstant): LayoutCategory.Leveler: [LayoutType.Leveler], LayoutCategory.Split: [LayoutType.Split], LayoutCategory.Battle: [LayoutType.Battle], - LayoutCategory.Planar: [LayoutType.Planar] + LayoutCategory.Planar: [LayoutType.Planar], + LayoutCategory.Station: [LayoutType.Station], } """Maps Layout types to their equivalent Layout category.""" diff --git a/src/helpers/bounds.py b/src/helpers/bounds.py index a95d2d07..b053932a 100644 --- a/src/helpers/bounds.py +++ b/src/helpers/bounds.py @@ -14,6 +14,7 @@ from src import APP from src.helpers.descriptors import get_layer_action_ref from src.helpers.document import undo_action +from src.helpers.layers import duplicate_group, select_layer from src.utils.adobe import PS_EXCEPTIONS # QOL Definitions @@ -24,8 +25,8 @@ * Types """ -# Layer bounds: left, top, right, bottom LayerBounds = tuple[int, int, int, int] +"""left, top, right, bottom""" class LayerDimensions(TypedDict): @@ -51,7 +52,7 @@ class TextboxDimensions(TypedDict): """ -def get_dimensions_from_bounds(bounds: LayerBounds) -> type[LayerDimensions]: +def get_dimensions_from_bounds(bounds: LayerBounds) -> LayerDimensions: """Compute width and height based on a set of bounds given. Args: @@ -71,7 +72,7 @@ def get_dimensions_from_bounds(bounds: LayerBounds) -> type[LayerDimensions]: top=int(bounds[1]), bottom=int(bounds[3])) -def get_layer_dimensions(layer: Union[ArtLayer, LayerSet]) -> type[LayerDimensions]: +def get_layer_dimensions(layer: Union[ArtLayer, LayerSet]) -> LayerDimensions: """Compute the width and height dimensions of a layer. Args: @@ -83,6 +84,22 @@ def get_layer_dimensions(layer: Union[ArtLayer, LayerSet]) -> type[LayerDimensio return get_dimensions_from_bounds(layer.bounds) +def get_group_dimensions(group: LayerSet) -> LayerDimensions: + """ + Compute the dimensions of a group. + + Uses a workaround to avoid erroneous dimensions, which might occur + when the group contains shapes. + """ + select_layer(group) + group_copy = duplicate_group(group.name) + group_copy.visible = True + merged = group_copy.merge() + dims = get_layer_dimensions(merged) + merged.remove() + return dims + + def get_layer_width(layer: Union[ArtLayer, LayerSet]) -> Union[float, int]: """Returns the width of a given layer. @@ -140,7 +157,7 @@ def get_bounds_no_effects(layer: Union[ArtLayer, LayerSet]) -> LayerBounds: return layer.bounds -def get_dimensions_no_effects(layer: Union[ArtLayer, LayerSet]) -> type[LayerDimensions]: +def get_dimensions_no_effects(layer: Union[ArtLayer, LayerSet]) -> LayerDimensions: """Compute the dimensions of a layer without its effects applied. Args: @@ -153,7 +170,7 @@ def get_dimensions_no_effects(layer: Union[ArtLayer, LayerSet]) -> type[LayerDim return get_dimensions_from_bounds(bounds) -def get_width_no_effects(layer: Union[ArtLayer, LayerSet]) -> int: +def get_width_no_effects(layer: Union[ArtLayer, LayerSet]) -> float | int: """Returns the width of a given layer without its effects applied. Args: @@ -170,7 +187,7 @@ def get_width_no_effects(layer: Union[ArtLayer, LayerSet]) -> int: return get_layer_width(layer) -def get_height_no_effects(layer: Union[ArtLayer, LayerSet]) -> int: +def get_height_no_effects(layer: Union[ArtLayer, LayerSet]) -> float | int: """Returns the height of a given layer without its effects applied. Args: @@ -230,7 +247,7 @@ def get_textbox_bounds(layer: ArtLayer) -> LayerBounds: ) -def get_textbox_dimensions(layer: ArtLayer) -> type[TextboxDimensions]: +def get_textbox_dimensions(layer: ArtLayer) -> TextboxDimensions: """Get the dimensions of a TextLayer's bounding box. Args: diff --git a/src/layouts.py b/src/layouts.py index 19705915..2312fe19 100644 --- a/src/layouts.py +++ b/src/layouts.py @@ -3,7 +3,8 @@ """ # Standard Library Imports from datetime import date, datetime -from typing import Optional, Match, Union, Type, ForwardRef +import re +from typing import NotRequired, Optional, Match, TypedDict, Union, Type, ForwardRef from os import path as osp from pathlib import Path from functools import cached_property @@ -35,6 +36,12 @@ check_hybrid_mana_cost, get_mana_cost_colors) + +class PowerToughness(TypedDict): + power: str + toughness: str + + """ * Layout Processing """ @@ -1524,6 +1531,58 @@ def card_count(self) -> Optional[int]: return self.set_data.get('count_tokens', None) +class StationDetails(TypedDict): + requirement: str + ability: str + pt: NotRequired[PowerToughness] + + +class StationLayout(NormalLayout): + _pt_pattern = re.compile(r"([0-9]+)/([0-9]+)") + + card_class: str = LayoutType.Station + + @cached_property + def oracle_text_unprocessed(self) -> str: + """Unaltered oracle text.""" + return ( + self.card.get("printed_text", self.oracle_text_raw) + if self.is_alt_lang + else self.oracle_text_raw + ) + + @cached_property + def oracle_text(self) -> str: + """Oracle text with station levels stripped.""" + stations_start = self.oracle_text_unprocessed.index("\nSTATION ") + return self.oracle_text_unprocessed[0:stations_start] + + @cached_property + def stations(self) -> list[StationDetails]: + stations_start = self.oracle_text_unprocessed.index("\nSTATION ") + station_splits = self.oracle_text_unprocessed[stations_start:].split("STATION ") + out: list[StationDetails] = [] + for split in station_splits: + if stripped := split.strip(): + lines = stripped.split("\n") + + details: StationDetails = {"requirement": lines.pop(0), "ability": ""} + + for line in lines: + if match := self._pt_pattern.match(line): + details["pt"] = { + "power": match[1], + "toughness": match[2] + } + else: + details["ability"] += line + "\n" + + details["ability"] = details["ability"].strip() + + out.append(details) + return out + + """ * Types & Enums """ @@ -1575,6 +1634,7 @@ def card_count(self) -> Optional[int]: LayoutScryfall.Planeswalker: PlaneswalkerLayout, LayoutScryfall.PlaneswalkerMDFC: PlaneswalkerMDFCLayout, LayoutScryfall.PlaneswalkerTransform: PlaneswalkerTransformLayout, + LayoutScryfall.Station: StationLayout, # TODO: Supported by Scryfall, not implemented LayoutScryfall.Flip: TransformLayout, diff --git a/src/templates/__init__.py b/src/templates/__init__.py index 42fc6b49..55cb150a 100644 --- a/src/templates/__init__.py +++ b/src/templates/__init__.py @@ -18,3 +18,4 @@ from src.templates.prototype import * from src.templates.planar import * from src.templates.split import * +from src.templates.station import * diff --git a/src/templates/station.py b/src/templates/station.py new file mode 100644 index 00000000..e65fac11 --- /dev/null +++ b/src/templates/station.py @@ -0,0 +1,245 @@ +from functools import cached_property +from typing import Callable + +from photoshop.api._artlayer import ArtLayer +from photoshop.api._layerSet import LayerSet + +from src.enums.layers import LAYERS +from src.helpers.bounds import ( + get_group_dimensions, + get_layer_dimensions, + get_layer_height, +) +from src.helpers.layers import duplicate_group, getLayer, getLayerSet, select_layer +from src.helpers.position import spread_layers_over_reference +from src.helpers.text import scale_text_layers_to_height +from src.layouts import StationLayout +from src.templates._core import NormalTemplate +from src.text_layers import FormattedTextField + + +class StationMod(NormalTemplate): + """ + A template modifier for Station cards introduced in Edge of Eternities. + + Adds: + * Station requirement, ability and P/T texts and shapes. + """ + + # region Checks + + @cached_property + def is_station(self) -> bool: + """Checks if this card uses Station layout""" + return isinstance(self.layout, StationLayout) + + @cached_property + def is_centered(self) -> bool: + if self.is_station: + return False + return super().is_centered + + # endregion Checks + + # region Options + + @cached_property + def rules_text_gap(self) -> int | float: + return 64 + + # endregion Options + + # region Groups + + @cached_property + def station_group(self) -> LayerSet | None: + return getLayerSet(LAYERS.STATION) + + @cached_property + def station_level_base_group(self) -> LayerSet | None: + return getLayerSet(LAYERS.LEVEL, self.station_group) + + @cached_property + def station_level_groups(self) -> list[LayerSet]: + groups: list[LayerSet] = [] + if self.station_level_base_group: + if isinstance(self.layout, StationLayout): + for i in range(len(self.layout.stations) - 1): + select_layer(self.station_level_base_group) + groups.append( + duplicate_group(f"{self.station_level_base_group.name} {i}") + ) + groups.append(self.station_level_base_group) + return groups + + @cached_property + def station_requirement_groups(self) -> list[LayerSet]: + groups: list[LayerSet] = [] + for level_group in self.station_level_groups: + if group := getLayerSet(LAYERS.REQUIREMENT, level_group): + groups.append(group) + return groups + + @cached_property + def station_pt_groups(self) -> list[LayerSet]: + groups: list[LayerSet] = [] + for level_group in self.station_level_groups: + if group := getLayerSet(LAYERS.PT_BOX, level_group): + groups.append(group) + return groups + + # endregion Groups + + # region Text layers + + @cached_property + def station_requirement_text_layers(self) -> list[ArtLayer]: + layers: list[ArtLayer] = [] + for requirement_group in self.station_requirement_groups: + if layer := getLayer(LAYERS.TEXT, requirement_group): + layers.append(layer) + return layers + + @cached_property + def station_level_text_layers(self) -> list[ArtLayer]: + layers: list[ArtLayer] = [] + if isinstance(self.layout, StationLayout): + for details, level_group in zip( + self.layout.stations, self.station_level_groups + ): + if layer := getLayer( + LAYERS.RULES_TEXT_CREATURE + if "pt" in details + else LAYERS.RULES_TEXT, + level_group, + ): + layers.append(layer) + return layers + + @cached_property + def station_pt_text_layers(self) -> list[ArtLayer]: + layers: list[ArtLayer] = [] + for pt_group in self.station_pt_groups: + if layer := getLayer(LAYERS.TEXT, pt_group): + layers.append(layer) + return layers + + # endregion Text layers + + # region Mixin methods + + @cached_property + def text_layer_methods(self) -> list[Callable[[], None]]: + """Add Station text layers.""" + funcs = super().text_layer_methods + if self.is_station: + funcs.append(self.text_layers_station) + return funcs + + @cached_property + def frame_layer_methods(self) -> list[Callable[[], None]]: + """Add Station frame layers.""" + funcs = super().frame_layer_methods + if self.is_station: + funcs.append(self.frame_layers_station) + return funcs + + @cached_property + def post_text_methods(self) -> list[Callable[[], None]]: + """Position Station abilities.""" + funcs = super().post_text_methods + if self.is_station: + funcs.append(self.layer_positioning_station) + return funcs + + # endregion Mixin methods + + # region Text layer methods + + def text_layers_station(self) -> None: + """Add and modify text layers relating to Station cards.""" + if isinstance(self.layout, StationLayout): + for details, ability, requirement, pt in zip( + self.layout.stations, + self.station_level_text_layers, + self.station_requirement_text_layers, + self.station_pt_text_layers, + ): + self.text.append( + FormattedTextField(layer=ability, contents=details["ability"]) + ) + requirement.textItem.contents = details["requirement"] + if "pt" in details: + pt.textItem.contents = ( + f"{details['pt']['power']}/{details['pt']['toughness']}" + ) + + # endregion Text layer methods + + # region Frame layer methods + + def frame_layers_station(self) -> None: + """Enable frame layers required by Station cards.""" + if self.station_group: + self.station_group.visible = True + + if isinstance(self.layout, StationLayout): + for details, group in zip(self.layout.stations, self.station_pt_groups): + if "pt" in details: + group.visible = True + + # endregion Frame layer methods + + # region Positioning methods + + def align_center_ys(self, group: LayerSet, ref: ArtLayer) -> None: + dims = get_group_dimensions(group) + dims_ref = get_layer_dimensions(ref) + group.translate(0, dims_ref["center_y"] - dims["center_y"]) + + def layer_positioning_station(self) -> None: + """Positions and sizes Station ability layers.""" + if ( + isinstance(self.layout, StationLayout) + and self.textbox_reference + and self.text_layer_rules + ): + spacing = self.app.scale_by_dpi(self.rules_text_gap) + spaces = len(self.layout.stations) + 1 + ref_height = self.textbox_reference.dims["height"] + spacing_total = spaces * spacing + spacing + total_height = ref_height - spacing_total + + ability_layers = [self.text_layer_rules, *self.station_level_text_layers] + + # Resize text items till they fit in the available space + scale_text_layers_to_height( + text_layers=ability_layers, + ref_height=total_height, + ) + + # Get the exact gap between each layer left over + layer_heights = sum([get_layer_height(lyr) for lyr in ability_layers]) + gap = (ref_height - layer_heights) * (spacing / spacing_total) + inside_gap = (ref_height - layer_heights) * (spacing / spacing_total) + + # Space lines evenly apart + spread_layers_over_reference( + layers=ability_layers, + ref=self.textbox_reference, + gap=gap, + inside_gap=inside_gap, + ) + + # Shift requirement and pt elements + for details, level_text, requirement, pt in zip( + self.layout.stations, + self.station_level_text_layers, + self.station_requirement_groups, + self.station_pt_groups, + ): + self.align_center_ys(requirement, level_text) + if "pt" in details: + self.align_center_ys(pt, level_text) + + # endregion Positioning methods