Source code for psd_tools.api.effects

"""
Effects module.
"""

import logging
from typing import Any, Iterator, Protocol

from psd_tools.api.protocols import LayerProtocol
from psd_tools.constants import Resource, Tag
from psd_tools.psd.descriptor import Descriptor, List
from psd_tools.psd.image_resources import ImageResources
from psd_tools.terminology import Enum, Key, Klass
from psd_tools.registry import new_registry

logger = logging.getLogger(__name__)

_TYPES, register = new_registry()


def _get_value(descriptor: Descriptor, key: bytes, default: Any = None) -> Any:
    """
    Get a value from a descriptor, extracting the .value attribute if present.

    This helper ensures we correctly handle descriptor objects that have a .value
    attribute (like NumericElement, BooleanElement, etc.) while also supporting
    cases where a default value is returned when the key is missing.

    Args:
        descriptor: The descriptor to get the value from
        key: The key to look up
        default: The default value if the key is not found

    Returns:
        The extracted value, either from obj.value or the object itself
    """
    result = descriptor.get(key, default)
    return getattr(result, "value", result)


[docs] class Effects: """ List-like effects. Only present effects are kept. """ def __init__(self, layer: LayerProtocol): self._data: Descriptor | None = None for tag in ( Tag.OBJECT_BASED_EFFECTS_LAYER_INFO, Tag.OBJECT_BASED_EFFECTS_LAYER_INFO_V0, Tag.OBJECT_BASED_EFFECTS_LAYER_INFO_V1, ): if tag in layer.tagged_blocks: self._data = layer.tagged_blocks.get_data(tag) break self._items: list["_Effect"] = [] if self._data is None: return for key in self._data: value = self._data[key] if not isinstance(value, List): value = [value] for item in value: # Keep only present effects. if not (isinstance(item, Descriptor) and item.get(b"present")): continue kls = _TYPES.get(item.classID) if kls is None: raise ValueError(f"Effect class not found for {item.classID!r}") self._items.append(kls(item, layer._psd.image_resources)) @property def scale(self) -> float: """Scale value.""" if self._data is None: raise ValueError("Effects data is None") return float(_get_value(self._data, Key.Scale, 100.0)) @property def enabled(self) -> bool: """Whether if all the effects are enabled. :rtype: bool """ if self._data is None: return False return bool(self._data.get(b"masterFXSwitch")) @property def items(self) -> list["_Effect"]: return self._items
[docs] def find(self, name: str, enabled: bool = True) -> Iterator["_Effect"]: """Iterate effect items by name. :param name: Effect name, e.g. `DropShadow`, `InnerShadow`, `OuterGlow`, `InnerGlow`, `ColorOverlay`, `GradientOverlay`, `PatternOverlay`, `Stroke`, `BevelEmboss`, or `Satin`. :param enabled: If true, only return enabled effects. :rtype: Iterator[Effect] """ if enabled and not self.enabled: return KLASS = {kls.__name__.lower(): kls for kls in _TYPES.values()} target_kls = KLASS.get(name.lower()) if target_kls is None: logger.debug("Effect class not found for name=%r", name) return for item in self: if isinstance(item, target_kls): if enabled and item.enabled: yield item elif not enabled: yield item
def __len__(self) -> int: return self._items.__len__() def __iter__(self) -> Iterator["_Effect"]: return self._items.__iter__() def __getitem__(self, key: int) -> "_Effect": return self._items.__getitem__(key) def __repr__(self) -> str: return "%s(%s)" % ( self.__class__.__name__, " ".join(x.__class__.__name__.lower() for x in self) if self._data else "", )
class _EffectProtocol(Protocol): """Effect protocol.""" descriptor: Descriptor _image_resources: ImageResources class _Effect(_EffectProtocol): """Base Effect class.""" def __init__(self, descriptor: Descriptor, image_resources: ImageResources): self.descriptor = descriptor self._image_resources = image_resources @property def value(self) -> Descriptor: """Deprecated Effect descriptor value. Use `descriptor` property instead. """ logger.debug("Deprecated, use 'descriptor' property instead.") return self.descriptor @property def enabled(self) -> bool: """Whether if the effect is enabled.""" return bool(self.descriptor.get(Key.Enabled)) @property def present(self) -> bool: """Whether if the effect is present in Photoshop UI.""" return bool(self.descriptor.get(b"present")) @property def shown(self) -> bool: """Whether if the effect is shown in dialog.""" return bool(self.descriptor.get(b"showInDialog")) @property def opacity(self) -> float: """Layer effect opacity in percentage.""" return float(_get_value(self.descriptor, Key.Opacity, 100.0)) def has_patterns(self) -> bool: return isinstance(self, _PatternMixin) and self.pattern is not None @property def name(self) -> str: """Effect name.""" return self.__class__.__name__ def __repr__(self) -> str: return self.name def _repr_pretty_(self, p: Any, cycle: bool) -> None: if cycle: return p.text(self.__repr__()) class _ColorMixin(_EffectProtocol): @property def color(self) -> Descriptor: """Color.""" return self.descriptor.get(Key.Color) @property def blend_mode(self) -> bytes: """Effect blending mode.""" mode = self.descriptor.get(Key.Mode) return getattr(mode, "enum", Enum.Normal) if mode is not None else Enum.Normal class _ChokeNoiseMixin(_ColorMixin): @property def choke(self) -> float: """Choke level in pixels.""" return float(_get_value(self.descriptor, Key.ChokeMatte, 0.0)) @property def size(self) -> float: """Size in pixels.""" return float(_get_value(self.descriptor, Key.Blur, 0.0)) @property def noise(self) -> float: """Noise level in percent.""" return float(_get_value(self.descriptor, Key.Noise, 0.0)) @property def anti_aliased(self) -> bool: """Angi-aliased.""" return bool(self.descriptor.get(Key.AntiAlias)) @property def contour(self) -> Descriptor: """Contour configuration.""" return self.descriptor.get(Key.TransferSpec) class _AngleMixin(_EffectProtocol): @property def use_global_light(self) -> bool: """Using global light.""" return bool(self.descriptor.get(Key.UseGlobalAngle)) @property def angle(self) -> float: """Angle value.""" if self.use_global_light: return self._image_resources.get_data(Resource.GLOBAL_ANGLE, 30.0) return float(_get_value(self.descriptor, Key.LocalLightingAngle, 0.0)) class _GradientMixin(_EffectProtocol): @property def gradient(self) -> Descriptor: """Gradient configuration.""" return self.descriptor.get(Key.Gradient) @property def angle(self) -> float: """Angle value.""" return float(_get_value(self.descriptor, Key.Angle, 0.0)) @property def type(self) -> bytes: """ Gradient type, one of `linear`, `radial`, `angle`, `reflected`, or `diamond`. """ type_value = self.descriptor.get(Key.Type) return ( getattr(type_value, "enum", b"Lnr ") if type_value is not None else b"Lnr " ) @property def reversed(self) -> bool: """Reverse flag.""" return bool(self.descriptor.get(Key.Reverse)) @property def dithered(self) -> bool: """Dither flag.""" return bool(self.descriptor.get(Key.Dither)) @property def offset(self) -> Descriptor: """Offset value in Pnt descriptor.""" return self.descriptor.get(Key.Offset) class _PatternMixin(_EffectProtocol): @property def pattern(self) -> Descriptor: """Pattern config.""" # TODO: Expose nested property. return self.descriptor.get(b"Ptrn") # Enum.Pattern. Seems a bug. @property def linked(self) -> bool: """Linked.""" return bool(self.descriptor.get(b"Lnkd")) # Enum.Linked. Seems a bug. @property def angle(self) -> float: """Angle value.""" return float(_get_value(self.descriptor, Key.Angle, 0.0)) @property def phase(self) -> Descriptor: """Phase value in Point.""" return self.descriptor.get(b"phase") class _ShadowEffect(_Effect, _ChokeNoiseMixin, _AngleMixin): """Base class for shadow effect.""" @property def distance(self) -> float: """Distance in pixels.""" return float(_get_value(self.descriptor, Key.Distance, 0.0)) class _GlowEffect(_Effect, _ChokeNoiseMixin, _GradientMixin): """Base class for glow effect.""" @property def glow_type(self) -> bytes: """Glow type.""" glow_technique = self.descriptor.get(Key.GlowTechnique) return ( getattr(glow_technique, "enum", b"SfBL") if glow_technique is not None else b"SfBL" ) @property def quality_range(self) -> float: """Quality range in percent.""" return float(_get_value(self.descriptor, Key.InputRange, 0.0)) @property def quality_jitter(self) -> float: """Quality jitter in percent.""" return float(_get_value(self.descriptor, Key.ShadingNoise, 0.0)) class _OverlayEffect(_Effect): pass class _AlignScaleMixin(_EffectProtocol): @property def blend_mode(self) -> bytes: """Effect blending mode.""" mode = self.descriptor.get(Key.Mode) return getattr(mode, "enum", Enum.Normal) if mode is not None else Enum.Normal @property def scale(self) -> float: """Scale value.""" return float(_get_value(self.descriptor, Key.Scale, 1.0)) @property def aligned(self) -> bool: """Aligned.""" return bool(self.descriptor.get(Key.Alignment))
[docs] @register(Klass.DropShadow.value) class DropShadow(_ShadowEffect): @property def layer_knocks_out(self) -> bool: """Layers are knocking out.""" return bool(self.descriptor.get(b"layerConceals"))
[docs] @register(Klass.InnerShadow.value) class InnerShadow(_ShadowEffect): pass
[docs] @register(Klass.OuterGlow.value) class OuterGlow(_GlowEffect): @property def spread(self) -> float: """Spread level in percent.""" return float(_get_value(self.descriptor, Key.ShadingNoise, 0.0))
[docs] @register(Klass.InnerGlow.value) class InnerGlow(_GlowEffect): @property def glow_source(self) -> bytes: """Elements source.""" source = self.descriptor.get(Key.InnerGlowSource) return getattr(source, "enum", b"SrcE") if source is not None else b"SrcE"
[docs] @register(Klass.SolidFill.value) class ColorOverlay(_OverlayEffect, _ColorMixin): pass
[docs] @register(b"GrFl") # Equal to Enum.GradientFill. This seems a bug. class GradientOverlay(_OverlayEffect, _AlignScaleMixin, _GradientMixin): pass
[docs] @register(b"patternFill") class PatternOverlay(_OverlayEffect, _AlignScaleMixin, _PatternMixin): pass
[docs] @register(Klass.FrameFX.value) class Stroke(_Effect, _ColorMixin, _PatternMixin, _GradientMixin): @property def position(self) -> bytes: """ Position of the stroke, InsetFrame, OutsetFrame, or CenteredFrame. """ style = self.descriptor.get(Key.Style) return getattr(style, "enum", b"OutF") if style is not None else b"OutF" @property def fill_type(self) -> bytes: """Fill type, SolidColor, Gradient, or Pattern.""" paint_type = self.descriptor.get(Key.PaintType) return ( getattr(paint_type, "enum", b"SClr") if paint_type is not None else b"SClr" ) @property def size(self) -> float: """Size value.""" return float(_get_value(self.descriptor, Key.SizeKey, 0.0)) @property def overprint(self) -> bool: """Overprint flag.""" return bool(self.descriptor.get(b"overprint"))
[docs] @register(Klass.BevelEmboss.value) class BevelEmboss(_Effect, _AngleMixin): @property def highlight_mode(self) -> bytes: """Highlight blending mode.""" mode = self.descriptor.get(Key.HighlightMode) return getattr(mode, "enum", Enum.Normal) if mode is not None else Enum.Normal @property def highlight_color(self) -> Descriptor: """Highlight color value.""" return self.descriptor.get(Key.HighlightColor) @property def highlight_opacity(self) -> float: """Highlight opacity value in percentage.""" return float(_get_value(self.descriptor, Key.HighlightOpacity, 50.0)) @property def shadow_mode(self) -> bytes: """Shadow blending mode.""" mode = self.descriptor.get(Key.ShadowMode) return getattr(mode, "enum", Enum.Normal) if mode is not None else Enum.Normal @property def shadow_color(self) -> Descriptor: """Shadow color value.""" return self.descriptor.get(Key.ShadowColor) @property def shadow_opacity(self) -> float: """Shadow opacity value in percentage.""" return float(_get_value(self.descriptor, Key.ShadowOpacity, 50.0)) @property def bevel_type(self) -> bytes: """Bevel type, one of `SoftMatte`, `HardLight`, `SoftLight`.""" technique = self.descriptor.get(Key.BevelTechnique) return getattr(technique, "enum", b"SfBL") if technique is not None else b"SfBL" @property def bevel_style(self) -> bytes: """ Bevel style, one of `OuterBevel`, `InnerBevel`, `Emboss`, `PillowEmboss`, or `StrokeEmboss`. """ style = self.descriptor.get(Key.BevelStyle) return getattr(style, "enum", b"OtrB") if style is not None else b"OtrB" @property def altitude(self) -> float: """Altitude value in angle.""" return float(_get_value(self.descriptor, Key.LocalLightingAltitude, 30.0)) @property def depth(self) -> float: """Depth value in percentage.""" return float(_get_value(self.descriptor, Key.StrengthRatio, 0.0)) @property def size(self) -> float: """Size value in pixel.""" return float(_get_value(self.descriptor, Key.Blur, 0.0)) @property def direction(self) -> bytes: """Direction, either `StampIn` or `StampOut`.""" direction = self.descriptor.get(Key.BevelDirection) return getattr(direction, "enum", b"In ") if direction is not None else b"In " @property def contour(self) -> Descriptor: """Contour configuration.""" return self.descriptor.get(Key.TransferSpec) @property def anti_aliased(self) -> bool: """Anti-aliased.""" return bool(self.descriptor.get(b"antialiasGloss")) @property def soften(self) -> float: """Soften value in pixels.""" return float(_get_value(self.descriptor, Key.Softness, 0.0)) @property def use_shape(self) -> bool: """Using shape.""" return bool(self.descriptor.get(b"useShape")) @property def use_texture(self) -> bool: """Using texture.""" return bool(self.descriptor.get(b"useTexture"))
[docs] @register(Klass.ChromeFX.value) class Satin(_Effect, _ColorMixin): """Satin effect""" @property def anti_aliased(self) -> bool: """Anti-aliased.""" return bool(self.descriptor.get(Key.AntiAlias)) @property def inverted(self) -> bool: """Inverted.""" return bool(self.descriptor.get(Key.Invert)) @property def angle(self) -> float: """Angle value in degrees.""" return float(_get_value(self.descriptor, Key.LocalLightingAngle, 0.0)) @property def distance(self) -> float: """Distance value in pixels.""" return float(_get_value(self.descriptor, Key.Distance, 120.0)) @property def size(self) -> float: """Size value in pixel.""" return float(_get_value(self.descriptor, Key.Blur, 120.0)) @property def contour(self) -> Descriptor: """Contour configuration.""" return self.descriptor.get(Key.MappingShape)