From f0a753e0ecb69f84372a313dcaffa39985e408de Mon Sep 17 00:00:00 2001 From: pappnu Date: Sun, 13 Apr 2025 17:37:05 +0300 Subject: [PATCH 1/3] feat(CaseLayout): Add support for rendering Case cards --- src/enums/layers.py | 3 + src/enums/mtg.py | 4 + src/helpers/position.py | 6 +- src/layouts.py | 11 +++ src/templates/case.py | 162 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 src/templates/case.py diff --git a/src/enums/layers.py b/src/enums/layers.py index 816e9922..96bf7e86 100644 --- a/src/enums/layers.py +++ b/src/enums/layers.py @@ -172,6 +172,9 @@ class LAYERS (StrConstant): BANNER = 'Banner' STRIPE = 'Stripe' + # Case + CASE = 'Case' + # Class CLASS = 'Class' STAGE = 'Stage' diff --git a/src/enums/mtg.py b/src/enums/mtg.py index a65c8955..b6fdf506 100644 --- a/src/enums/mtg.py +++ b/src/enums/mtg.py @@ -17,6 +17,7 @@ class LayoutCategory(StrConstant): """Card layout category, broad naming used for displaying on GUI elements.""" Adventure = 'Adventure' Battle = 'Battle' + Case = 'Case' Class = 'Class' Leveler = 'Leveler' MDFC = 'MDFC' @@ -37,6 +38,7 @@ class LayoutType(StrConstant): """Card layout type, fine-grained naming separated by front/back where applicable.""" Adventure = 'adventure' Battle = 'battle' + Case = 'case' Class = 'class' Leveler = 'leveler' MDFCBack = 'mdfc_back' @@ -65,6 +67,7 @@ class LayoutScryfall(StrConstant): MDFC = 'modal_dfc' Meld = 'meld' Leveler = 'leveler' + Case = 'case' Class = 'class' Saga = 'saga' Adventure = 'adventure' @@ -97,6 +100,7 @@ class LayoutScryfall(StrConstant): LayoutCategory.PlaneswalkerMDFC: [LayoutType.PlaneswalkerMDFCFront, LayoutType.PlaneswalkerMDFCBack], LayoutCategory.PlaneswalkerTransform: [LayoutType.PlaneswalkerTransformFront, LayoutType.PlaneswalkerTransformBack], LayoutCategory.Saga: [LayoutType.Saga], + LayoutCategory.Case: [LayoutType.Case], LayoutCategory.Class: [LayoutType.Class], LayoutCategory.Mutate: [LayoutType.Mutate], LayoutCategory.Prototype: [LayoutType.Prototype], diff --git a/src/helpers/position.py b/src/helpers/position.py index 782843d1..e4802df2 100644 --- a/src/helpers/position.py +++ b/src/helpers/position.py @@ -3,7 +3,7 @@ """ # Standard Library Imports import math -from typing import Optional, Union +from typing import Optional, Sequence, Union # Third Party Imports from photoshop.api import DialogModes, AnchorPosition @@ -157,8 +157,8 @@ def position_between_layers( def position_dividers( - dividers: list[Union[ArtLayer, LayerSet]], - layers: list[Union[ArtLayer, LayerSet]], + dividers: Sequence[ArtLayer | LayerSet], + layers: Sequence[ArtLayer | LayerSet], docref: Optional[Document] = None ) -> None: """Positions a list of dividers between a list of layers. diff --git a/src/layouts.py b/src/layouts.py index 19705915..ee0e9728 100644 --- a/src/layouts.py +++ b/src/layouts.py @@ -1216,6 +1216,16 @@ def class_lines(self) -> list[dict]: # Otherwise add line to the previous ability abilities[-1]['text'] += f'\n{line}' return abilities + + +class CaseLayout(NormalLayout): + """Case card layout, introduced in Murders at Karlov Manor.""" + card_class: str = LayoutType.Case + + @cached_property + def case_lines(self) -> list[str]: + """Split Case text into sections.""" + return self.oracle_text.split("\n") class BattleLayout(TransformLayout): @@ -1561,6 +1571,7 @@ def card_count(self) -> Optional[int]: LayoutScryfall.MDFC: ModalDoubleFacedLayout, LayoutScryfall.Meld: TransformLayout, LayoutScryfall.Leveler: LevelerLayout, + LayoutScryfall.Case: CaseLayout, LayoutScryfall.Class: ClassLayout, LayoutScryfall.Saga: SagaLayout, LayoutScryfall.Adventure: AdventureLayout, diff --git a/src/templates/case.py b/src/templates/case.py new file mode 100644 index 00000000..2d7787f2 --- /dev/null +++ b/src/templates/case.py @@ -0,0 +1,162 @@ +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_layer_height +from src.helpers.layers import getLayer, getLayerSet +from src.helpers.position import position_dividers, spread_layers_over_reference +from src.helpers.text import scale_text_layers_to_height +from src.layouts import CaseLayout +from src.templates._core import NormalTemplate +from src.text_layers import FormattedTextField + + +class CaseMod(NormalTemplate): + """ + * A template modifier for Case cards introduced in Murders at Karlov Manor. + + Adds: + * Evenly spaced ability sections and dividers. + """ + + def __init__(self, layout: CaseLayout, **kwargs: None): + self.line_layers: list[ArtLayer] = [] + self.divider_layers: list[ArtLayer] = [] + super().__init__(layout, **kwargs) + + """ + * Checks + """ + + @cached_property + def is_case_layout(self) -> bool: + """bool: Checks if this card uses Case layout.""" + return isinstance(self.layout, CaseLayout) + + """ + * Mixin Methods + """ + + @cached_property + def text_layer_methods(self) -> list[Callable[[], None]]: + """Add Case text layers.""" + funcs = [self.text_layers_case] if self.is_case_layout else [] + return [*super().text_layer_methods, *funcs] + + @cached_property + def frame_layer_methods(self) -> list[Callable[[], None]]: + """Add Case frame layers.""" + funcs = [self.frame_layers_case] if self.is_case_layout else [] + return [*super().frame_layer_methods, *funcs] + + @cached_property + def post_text_methods(self) -> list[Callable[[], None]]: + """Position Case abilities and dividers.""" + funcs = [self.layer_positioning_case] if self.is_case_layout else [] + return [*super().post_text_methods, *funcs] + + """ + * Groups + """ + + @cached_property + def case_group(self) -> LayerSet | None: + return getLayerSet(LAYERS.CASE) + + """ + * Text Layers + """ + + @cached_property + def text_layer_ability(self) -> ArtLayer | None: + return getLayer(LAYERS.TEXT, self.case_group) + + @cached_property + def case_ability_divider(self) -> ArtLayer | None: + return getLayer(LAYERS.DIVIDER, self.case_group) + + """ + * Layer Methods + """ + + def rules_text_and_pt_layers(self) -> None: + if self.is_case_layout and not self.is_creature: + return + return super().rules_text_and_pt_layers() + + """ + * Text Layer Methods + """ + + def text_layers_case(self) -> None: + """Add and modify text layers relating to Case type cards.""" + + skip_divider_for = len(self.layout.case_lines) - 1 + + # Add text fields for each line + for i, line in enumerate(self.layout.case_lines): + # Create a new ability line + if layer := self.text_layer_ability: + line_layer: ArtLayer = layer if i == 0 else layer.duplicate() + self.line_layers.append(line_layer) + self.text.append(FormattedTextField(layer=line_layer, contents=line)) + + # Use existing ability divider or create a new one + if i != skip_divider_for and (layer := self.case_ability_divider): + divider: ArtLayer = ( + self.case_ability_divider + if i == 0 + else self.case_ability_divider.duplicate() + ) + self.divider_layers.append(divider) + + """ + * Frame Layer Methods + """ + + def frame_layers_case(self) -> None: + """Enable frame layers required by Case cards. None by default.""" + pass + + """ + * Positioning Methods + """ + + def layer_positioning_case(self) -> None: + """Positions and sizes Case ability layers and dividers.""" + + # Core vars + spacing = self.app.scale_by_dpi(80) + spaces = len(self.line_layers) + divider_height = get_layer_height(self.divider_layers[0]) + ref_height: float | int = self.textbox_reference.dims["height"] + spacing_total = (spaces * (spacing + divider_height)) + (spacing * 2) + total_height = ref_height - spacing_total + + # Resize text items till they fit in the available space + scale_text_layers_to_height( + text_layers=self.line_layers, ref_height=total_height + ) + + # Get the exact gap between each layer left over + layer_heights = sum([get_layer_height(lyr) for lyr in self.line_layers]) + gap = (ref_height - layer_heights) * (spacing / spacing_total) + inside_gap = (ref_height - layer_heights) * ( + (spacing + divider_height) / spacing_total + ) + + # Space lines evenly apart + spread_layers_over_reference( + layers=self.line_layers, + ref=self.textbox_reference, + gap=gap, + inside_gap=inside_gap, + ) + + # Position a divider between each ability line + position_dividers( + dividers=self.divider_layers, layers=self.line_layers, docref=self.docref + ) From 953778e24a254200d15509e7112dffb1a3d3acab Mon Sep 17 00:00:00 2001 From: pappnu Date: Wed, 16 Apr 2025 19:15:45 +0300 Subject: [PATCH 2/3] fix(CaseMod): Allow case templates not to use a text divider --- src/templates/case.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/templates/case.py b/src/templates/case.py index 2d7787f2..f607a513 100644 --- a/src/templates/case.py +++ b/src/templates/case.py @@ -131,7 +131,11 @@ def layer_positioning_case(self) -> None: # Core vars spacing = self.app.scale_by_dpi(80) spaces = len(self.line_layers) - divider_height = get_layer_height(self.divider_layers[0]) + divider_height = ( + get_layer_height(self.divider_layers[0]) + if len(self.divider_layers) > 0 + else 0 + ) ref_height: float | int = self.textbox_reference.dims["height"] spacing_total = (spaces * (spacing + divider_height)) + (spacing * 2) total_height = ref_height - spacing_total @@ -157,6 +161,9 @@ def layer_positioning_case(self) -> None: ) # Position a divider between each ability line - position_dividers( - dividers=self.divider_layers, layers=self.line_layers, docref=self.docref - ) + if len(self.divider_layers) == len(self.line_layers) - 1: + position_dividers( + dividers=self.divider_layers, + layers=self.line_layers, + docref=self.docref, + ) From 5c8f61b301abdbd50d5c2c0c3d07f4cf1457087f Mon Sep 17 00:00:00 2001 From: pappnu Date: Sat, 17 May 2025 14:38:08 +0300 Subject: [PATCH 3/3] fix(Case): Import Case templates in the template module's __init__.py --- src/templates/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/templates/__init__.py b/src/templates/__init__.py index 42fc6b49..6db0dbb3 100644 --- a/src/templates/__init__.py +++ b/src/templates/__init__.py @@ -13,6 +13,7 @@ from src.templates.saga import * from src.templates.token import * from src.templates.mutate import * +from src.templates.case import * from src.templates.classes import * from src.templates.battle import * from src.templates.prototype import *