Source code for psd_tools.api.layers

"""
Layer module.
"""

from __future__ import annotations

import logging
from typing import (
    Any,
    Callable,
    Iterable,
    Iterator,
    Protocol,
    TypeVar,
    runtime_checkable,
)

try:
    from typing import Self
except ImportError:
    from typing_extensions import Self

import numpy as np
from PIL.Image import Image as PILImage

import psd_tools.psd.engine_data as engine_data
from psd_tools.api.effects import Effects
from psd_tools.api.mask import Mask
from psd_tools.api.pil_io import get_pil_channels, get_pil_depth
from psd_tools.api.shape import Origination, Stroke, VectorMask
from psd_tools.api.smart_object import SmartObject
from psd_tools.constants import (
    BlendMode,
    ChannelID,
    Clipping,
    Compression,
    ProtectedFlags,
    SectionDivider,
    Tag,
    TextType,
)
from psd_tools.psd.descriptor import DescriptorBlock
from psd_tools.psd.layer_and_mask import (
    ChannelData,
    ChannelDataList,
    ChannelInfo,
    LayerRecord,
)
from psd_tools.psd.patterns import Patterns
from psd_tools.psd.tagged_blocks import (
    ProtectedSetting,
    SectionDividerSetting,
    TaggedBlocks,
)
from psd_tools.terminology import Key

logger = logging.getLogger(__name__)


TGroupMixin = TypeVar("TGroupMixin", bound="GroupMixin")


class Layer(object):
    def __init__(
        self,
        psd: Any,
        record: LayerRecord,
        channels: ChannelDataList,
        parent: TGroupMixin | None,
    ):
        from psd_tools.api.psd_image import PSDImage  # Circular import

        assert isinstance(psd, PSDImage) or psd is None

        self._psd: PSDImage | None = psd
        self._record = record
        self._channels = channels
        self._parent: GroupMixin | None = parent
        self._clip_layers: list[Self] = []
        self._has_clip_target = True

    @property
    def name(self) -> str:
        """
        Layer name. Writable.

        :return: `str`
        """
        return self._record.tagged_blocks.get_data(
            Tag.UNICODE_LAYER_NAME, self._record.name
        )

    @name.setter
    def name(self, value: str) -> None:
        assert len(value) < 256, "Layer name too long (%d) %s" % (len(value), value)
        try:
            value.encode("macroman")
            self._record.name = value
        except UnicodeEncodeError:
            self._record.name = str("?")
        self._record.tagged_blocks.set_data(Tag.UNICODE_LAYER_NAME, value)

    @property
    def kind(self) -> str:
        """
        Kind of this layer, such as group, pixel, shape, type, smartobject,
        or psdimage. Class name without `layer` suffix.

        :return: `str`
        """
        return self.__class__.__name__.lower().replace("layer", "")

    @property
    def layer_id(self) -> int:
        """
        Layer ID.

        :return: int layer id. if the layer is not assigned an id, -1.
        """
        return self.tagged_blocks.get_data(Tag.LAYER_ID, -1)

    def _invalidate_bbox(self) -> None:
        """
        Invalidate this layer's _bbox and any parents recursively to the root.
        """
        if isinstance(self, (GroupMixin, ShapeLayer)):
            self._bbox: tuple[int, int, int, int] | None = None
        if isinstance(self.parent, (Group, Artboard)):
            self.parent._invalidate_bbox()

    @property
    def visible(self) -> bool:
        """
        Layer visibility. Doesn't take group visibility in account. Writable.

        :return: `bool`
        """
        return self._record.flags.visible

    @visible.setter
    def visible(self, value: bool) -> None:
        self._invalidate_bbox()
        self._record.flags.visible = bool(value)

    def is_visible(self) -> bool:
        """
        Layer visibility. Takes group visibility in account.

        :return: `bool`
        """
        return self.visible and self.parent is not None and self.parent.is_visible()  # type: ignore

    @property
    def opacity(self) -> int:
        """
        Opacity of this layer in [0, 255] range. Writable.

        :return: int
        """
        return self._record.opacity

    @opacity.setter
    def opacity(self, value: int) -> None:
        assert 0 <= value and value <= 255
        self._record.opacity = int(value)

    @property
    def parent(self) -> TGroupMixin | None:
        """Parent of this layer."""
        return self._parent  # type: ignore

    def is_group(self) -> bool:
        """
        Return True if the layer is a group.

        :return: `bool`
        """
        return isinstance(self, GroupMixin)

    @property
    def blend_mode(self) -> BlendMode:
        """
        Blend mode of this layer. Writable.

        Example::

            from psd_tools.constants import BlendMode
            if layer.blend_mode == BlendMode.NORMAL:
                layer.blend_mode = BlendMode.SCREEN

        :return: :py:class:`~psd_tools.constants.BlendMode`.
        """
        return self._record.blend_mode

    @blend_mode.setter
    def blend_mode(self, value: bytes | str | BlendMode) -> None:
        if isinstance(value, str):
            value = value.encode("ascii")
        self._record.blend_mode = BlendMode(value)

    @property
    def left(self) -> int:
        """
        Left coordinate. Writable.

        :return: int
        """
        return self._record.left

    @left.setter
    def left(self, value: int) -> None:
        self._invalidate_bbox()
        w = self.width
        self._record.left = int(value)
        self._record.right = int(value) + w

    @property
    def top(self) -> int:
        """
        Top coordinate. Writable.

        :return: int
        """
        return self._record.top

    @top.setter
    def top(self, value: int) -> None:
        self._invalidate_bbox()
        h = self.height
        self._record.top = int(value)
        self._record.bottom = int(value) + h

    @property
    def right(self) -> int:
        """
        Right coordinate.

        :return: int
        """
        return self._record.right

    @property
    def bottom(self) -> int:
        """
        Bottom coordinate.

        :return: int
        """
        return self._record.bottom

    @property
    def width(self) -> int:
        """
        Width of the layer.

        :return: int
        """
        return self.right - self.left

    @property
    def height(self) -> int:
        """
        Height of the layer.

        :return: int
        """
        return self.bottom - self.top

    @property
    def offset(self) -> tuple[int, int]:
        """
        (left, top) tuple. Writable.

        :return: `tuple`
        """
        return self.left, self.top

    @offset.setter
    def offset(self, value: tuple[int, int]) -> None:
        self.left, self.top = tuple(int(x) for x in value)

    @property
    def size(self) -> tuple[int, int]:
        """
        (width, height) tuple.

        :return: `tuple`
        """
        return self.width, self.height

    @property
    def bbox(self) -> tuple[int, int, int, int]:
        """(left, top, right, bottom) tuple."""
        return self.left, self.top, self.right, self.bottom

    def has_pixels(self) -> bool:
        """
        Returns True if the layer has associated pixels. When this is True,
        `topil` method returns :py:class:`PIL.Image`.

        :return: `bool`
        """
        return any(
            ci.id >= 0 and cd.data and len(cd.data) > 0
            for ci, cd in zip(self._record.channel_info, self._channels)
        )

    def has_mask(self) -> bool:
        """
        Returns True if the layer has a mask.

        :return: `bool`
        """
        return self._record.mask_data is not None

    @property
    def mask(self) -> Mask | None:
        """
        Returns mask associated with this layer.

        :return: :py:class:`~psd_tools.api.mask.Mask` or `None`
        """
        if not hasattr(self, "_mask"):
            self._mask = Mask(self) if self.has_mask() else None
        return self._mask

    def has_vector_mask(self) -> bool:
        """
        Returns True if the layer has a vector mask.

        :return: `bool`
        """
        return any(
            key in self.tagged_blocks
            for key in (Tag.VECTOR_MASK_SETTING1, Tag.VECTOR_MASK_SETTING2)
        )

    @property
    def vector_mask(self) -> VectorMask | None:
        """
        Returns vector mask associated with this layer.

        :return: :py:class:`~psd_tools.api.shape.VectorMask` or `None`
        """
        if not hasattr(self, "_vector_mask"):
            self._vector_mask = None
            blocks = self.tagged_blocks
            for key in (Tag.VECTOR_MASK_SETTING1, Tag.VECTOR_MASK_SETTING2):
                if key in blocks:
                    self._vector_mask = VectorMask(blocks.get_data(key))
                    break
        return self._vector_mask

    def has_origination(self) -> bool:
        """
        Returns True if the layer has live shape properties.

        :return: `bool`
        """
        if self.origination:
            return True
        return False

    @property
    def origination(self) -> list[Origination]:
        """
        Property for a list of live shapes or a line.

        Some of the vector masks have associated live shape properties, that
        are Photoshop feature to handle primitive shapes such as a rectangle,
        an ellipse, or a line. Vector masks without live shape properties are
        plain path objects.

        See :py:mod:`psd_tools.api.shape`.

        :return: List of :py:class:`~psd_tools.api.shape.Invalidated`,
            :py:class:`~psd_tools.api.shape.Rectangle`,
            :py:class:`~psd_tools.api.shape.RoundedRectangle`,
            :py:class:`~psd_tools.api.shape.Ellipse`, or
            :py:class:`~psd_tools.api.shape.Line`.
        """
        if not hasattr(self, "_origination"):
            data = self.tagged_blocks.get_data(Tag.VECTOR_ORIGINATION_DATA, {})
            self._origination: list[Origination] = [
                Origination.create(x)
                for x in data.get(b"keyDescriptorList", [])
                if not data.get(b"keyShapeInvalidated")
            ]
        return self._origination

    def has_stroke(self) -> bool:
        """Returns True if the shape has a stroke."""
        return Tag.VECTOR_STROKE_DATA in self.tagged_blocks

    @property
    def stroke(self) -> Stroke | None:
        """Property for strokes."""
        if not hasattr(self, "_stroke"):
            self._stroke = None
            stroke = self.tagged_blocks.get_data(Tag.VECTOR_STROKE_DATA)
            if stroke:
                self._stroke = Stroke(stroke)
        return self._stroke

    def lock(self, lock_flags: int = ProtectedFlags.COMPLETE) -> None:
        """
        Locks a layer accordind to the combination of flags.

        :param lockflags: An integer representing the locking state

        Example using the constants of ProtectedFlags and bitwise or operation to lock both pixels and positions::

            layer.lock(ProtectedFlags.COMPOSITE | ProtectedFlags.POSITION)
        """

        locks = self.locks

        if locks is None:
            locks = ProtectedSetting(0)
            self.tagged_blocks.set_data(Tag.PROTECTED_SETTING, locks)

        locks.lock(lock_flags)

    def unlock(self) -> None:
        self.lock(0)

    @property
    def locks(self) -> ProtectedSetting | None:
        protected_settings_block = self.tagged_blocks.get(Tag.PROTECTED_SETTING)

        if protected_settings_block is not None:
            return protected_settings_block.data

        return None

    def topil(
        self, channel: int | None = None, apply_icc: bool = True
    ) -> PILImage | None:
        """
        Get PIL Image of the layer.

        :param channel: Which channel to return; e.g., 0 for 'R' channel in RGB
            image. See :py:class:`~psd_tools.constants.ChannelID`. When `None`,
            the method returns all the channels supported by PIL modes.
        :param apply_icc: Whether to apply ICC profile conversion to sRGB.
        :return: :py:class:`PIL.Image`, or `None` if the layer has no pixels.

        Example::

            from psd_tools.constants import ChannelID

            image = layer.topil()
            red = layer.topil(ChannelID.CHANNEL_0)
            alpha = layer.topil(ChannelID.TRANSPARENCY_MASK)

        .. note:: Not all of the PSD image modes are supported in
            :py:class:`PIL.Image`. For example, 'CMYK' mode cannot include
            alpha channel in PIL. In this case, topil drops alpha channel.
        """
        from .pil_io import convert_layer_to_pil

        return convert_layer_to_pil(self, channel, apply_icc)

    def numpy(
        self, channel: str | None = None, real_mask: bool = True
    ) -> np.ndarray | None:
        """
        Get NumPy array of the layer.

        :param channel: Which channel to return, can be 'color',
            'shape', 'alpha', or 'mask'. Default is 'color+alpha'.
        :return: :py:class:`numpy.ndarray` or None if there is no pixel.
        """
        from .numpy_io import get_array

        return get_array(self, channel, real_mask=real_mask)

    def composite(
        self,
        viewport: tuple[int, int, int, int] | None = None,
        force: bool = False,
        color: float | tuple[float, ...] | np.ndarray = 1.0,
        alpha: float | np.ndarray = 0.0,
        layer_filter: Callable | None = None,
        apply_icc: bool = True,
    ) -> PILImage | None:
        """
        Composite layer and masks (mask, vector mask, and clipping layers).

        :param viewport: Viewport bounding box specified by (x1, y1, x2, y2)
            tuple. Default is the layer's bbox.
        :param force: Boolean flag to force vector drawing.
        :param color: Backdrop color specified by scalar or tuple of scalar.
            The color value should be in [0.0, 1.0]. For example, (1., 0., 0.)
            specifies red in RGB color mode.
        :param alpha: Backdrop alpha in [0.0, 1.0].
        :param layer_filter: Callable that takes a layer as argument and
            returns whether if the layer is composited. Default is
            :py:func:`~psd_tools.api.layers.PixelLayer.is_visible`.
        :return: :py:class:`PIL.Image` or `None`.
        """
        from psd_tools.composite import composite_pil

        return composite_pil(
            self, color, alpha, viewport, layer_filter, force, apply_icc=apply_icc
        )

    def has_clip_layers(self) -> bool:
        """
        Returns True if the layer has associated clipping.

        :return: `bool`
        """
        return len(self.clip_layers) > 0

    @property
    def clip_layers(self) -> list[Self]:
        """
        Clip layers associated with this layer.

        :return: list of layers
        """
        return self._clip_layers

    @property
    def clipping_layer(self) -> bool:
        """
        Clipping flag for this layer. Writable.

        :return: `bool`
        """
        return self._record.clipping == Clipping.NON_BASE

    @clipping_layer.setter
    def clipping_layer(self, value: bool) -> None:
        if self._psd:
            self._record.clipping = Clipping.NON_BASE if value else Clipping.BASE
            self._psd._compute_clipping_layers()

    def has_effects(self) -> bool:
        """
        Returns True if the layer has effects.

        :return: `bool`
        """
        has_effect_tag = any(
            tag in self.tagged_blocks
            for tag in (
                Tag.OBJECT_BASED_EFFECTS_LAYER_INFO,
                Tag.OBJECT_BASED_EFFECTS_LAYER_INFO_V0,
                Tag.OBJECT_BASED_EFFECTS_LAYER_INFO_V1,
            )
        )
        if not has_effect_tag:
            return False
        if not self.effects.enabled:
            return False
        for effect in self.effects:
            if effect.enabled:
                return True
        return False

    @property
    def effects(self) -> Effects:
        """
        Layer effects.

        :return: :py:class:`~psd_tools.api.effects.Effects`
        """
        if not hasattr(self, "_effects"):
            self._effects = Effects(self)
        return self._effects

    @property
    def tagged_blocks(self) -> TaggedBlocks:
        """
        Layer tagged blocks that is a dict-like container of settings.

        See :py:class:`psd_tools.constants.Tag` for available
        keys.

        :return: :py:class:`~psd_tools.psd.tagged_blocks.TaggedBlocks`.

        Example::

            from psd_tools.constants import Tag
            metadata = layer.tagged_blocks.get_data(Tag.METADATA_SETTING)
        """
        return self._record.tagged_blocks

    def __repr__(self) -> str:
        has_size = self.width > 0 and self.height > 0
        return "%s(%r%s%s%s%s)" % (
            self.__class__.__name__,
            self.name,
            " size=%dx%d" % (self.width, self.height) if has_size else "",
            " invisible" if not self.visible else "",
            " mask" if self.has_mask() else "",
            " effects" if self.has_effects() else "",
        )

    # Structure operations, supposes unique references to layers, deep copy might be needed in the future
    def delete_layer(self) -> Self:
        """
        Deletes the layer and all its child layers if the layer is a group from its parent (group or psdimage).
        """

        if self.parent is not None and isinstance(self.parent, GroupMixin):
            if self in self.parent:
                self.parent.remove(self)
            self.parent._update_psd_record()
        else:
            logger.warning(
                "Cannot delete layer {} because there is no parent.".format(self)
            )

        return self

    def move_to_group(self, group: "GroupMixin") -> Self:
        """
        Moves the layer to the given group, updates the tree metadata as needed.

        :param group: The group the current layer will be moved into.
        """

        assert isinstance(group, GroupMixin)
        assert group is not self

        if isinstance(self, GroupMixin):
            assert (
                group not in self.descendants()
            ), "Cannot move group {} into its descendant {}".format(self, group)

        if self.parent is not None and isinstance(self.parent, GroupMixin):
            if self in self.parent:
                self.parent.remove(self)

        group.append(self)

        return self

    def move_up(self, offset: int = 1) -> Self:
        """
        Moves the layer up a certain offset within the group the layer is in.

        :param offset:
        """

        assert self.parent is not None and isinstance(self.parent, GroupMixin)

        newindex = self.parent.index(self) + offset

        if newindex < 0:
            newindex = 0
        elif newindex >= len(self.parent):
            newindex = len(self.parent) - 1

        self.parent.remove(self)
        self.parent.insert(newindex, self)

        return self

    def move_down(self, offset: int = 1) -> Self:
        """
        Moves the layer down a certain offset within the group the layer is in.

        :param offset:
        """

        return self.move_up(-1 * offset)

    def _fetch_tagged_blocks(self, target_psd: Any) -> None:  # Circular import
        # Retrieve the patterns contained in the layer current ._psd and add them to the target psd
        _psd = target_psd

        effects = [effect for effect in self.effects if effect.has_patterns()]
        pattern_ids = [
            effect.pattern[Key.ID].value.rstrip("\x00")  # type: ignore
            for effect in effects
        ]

        if pattern_ids:
            psd_global_blocks = _psd.tagged_blocks

            if psd_global_blocks is None:
                psd_global_blocks = TaggedBlocks()
                _psd._record.layer_and_mask_information.tagged_blocks = (
                    psd_global_blocks
                )

            if Tag.PATTERNS1 not in psd_global_blocks.keys():
                psd_global_blocks.set_data(Tag.PATTERNS1, Patterns())

            sourcePatterns = []
            for tag in (Tag.PATTERNS1, Tag.PATTERNS2, Tag.PATTERNS3):
                if (
                    self._psd is not None
                    and self._psd.tagged_blocks is not None
                    and tag in self._psd.tagged_blocks
                ):
                    sourcePatterns.extend(self._psd.tagged_blocks.get_data(tag))

            # TODO: Use the exact tag.
            psd_global_blocks.get(Tag.PATTERNS1).data.extend(
                [
                    pattern
                    for pattern in sourcePatterns
                    if pattern.pattern_id in pattern_ids
                    and pattern.pattern_id
                    not in [
                        targetPattern.pattern_id
                        for targetPattern in psd_global_blocks.get(Tag.PATTERNS1).data
                    ]
                ]
            )


@runtime_checkable
class GroupMixin(Protocol):
    _bbox: tuple[int, int, int, int] | None = None
    _layers: list[Layer]
    _psd: Any  # TODO: Circular import

    @property
    def left(self) -> int:
        return self.bbox[0]

    @property
    def top(self) -> int:
        return self.bbox[1]

    @property
    def right(self) -> int:
        return self.bbox[2]

    @property
    def bottom(self) -> int:
        return self.bbox[3]

    @property
    def bbox(self) -> tuple[int, int, int, int]:
        """(left, top, right, bottom) tuple."""
        if self._bbox is None:
            self._bbox = Group.extract_bbox(self)
        return self._bbox

    def __len__(self) -> int:
        return self._layers.__len__()

    def __iter__(self) -> Iterator[Layer]:
        return self._layers.__iter__()

    def __getitem__(self, key) -> Layer:
        return self._layers.__getitem__(key)

    def __setitem__(self, key, value) -> None:
        self._check_valid_layers(value)

        self._layers.__setitem__(key, value)

        self._update_layer_metadata()
        self._update_psd_record()

    def __delitem__(self, key) -> None:
        self._update_psd_record()
        self._layers.__delitem__(key)

    def append(self, layer: Layer) -> None:
        """
        Add a layer to the end (top) of the group

        :param layer: The layer to add
        """

        assert layer is not self
        self.extend([layer])

    def extend(self, layers: Iterable[Layer]) -> None:
        """
        Add a list of layers to the end (top) of the group

        :param layers: The layers to add
        """

        self._check_valid_layers(layers)
        self._layers.extend(layers)
        self._update_layer_metadata()
        self._update_psd_record()

    def insert(self, index: int, layer: Layer) -> None:
        """
        Insert the given layer at the specified index.

        :param index:
        :param layer:
        """

        self._check_valid_layers(layer)
        self._layers.insert(index, layer)
        self._update_layer_metadata()
        self._update_psd_record()

    def remove(self, layer: Layer) -> Self:
        """
        Removes the specified layer from the group

        :param layer:
        """

        self._layers.remove(layer)
        self._update_psd_record()
        return self

    def pop(self, index: int = -1) -> Layer:
        """
        Removes the specified layer from the list and returns it.

        :param index:
        """

        popLayer = self._layers.pop(index)
        self._update_psd_record()
        return popLayer

    def clear(self) -> None:
        """
        Clears the group.
        """

        self._layers.clear()
        self._update_psd_record()

    def index(self, layer: Layer) -> int:
        """
        Returns the index of the specified layer in the group.

        :param layer:
        """

        return self._layers.index(layer)

    def count(self, layer: Layer) -> int:
        """
        Counts the number of occurences of a layer in the group.

        :param layer:
        """

        return self._layers.count(layer)

    def _check_valid_layers(self, layers: Layer | Iterable[Layer]) -> None:
        assert layers is not self, "Cannot add the group {} to itself.".format(self)

        if isinstance(layers, Layer):
            layers = [layers]

        for layer in layers:
            assert isinstance(layer, Layer)
            if isinstance(layer, GroupMixin):
                assert (
                    self not in list(layer.descendants())
                ), "This operation would create a reference loop within the group between {} and {}.".format(
                    self, layer
                )

    def _update_layer_metadata(self) -> None:
        from psd_tools.api.psd_image import PSDImage  # Circular import

        _psd: PSDImage | None = self if isinstance(self, PSDImage) else self._psd

        for layer in self.descendants():
            if layer._psd != _psd and _psd is not None:
                if isinstance(layer, PixelLayer):
                    layer._convert(_psd)

                layer._fetch_tagged_blocks(_psd)  # type: ignore
                layer._psd = _psd

        for layer in self._layers[:]:
            layer._parent = self

    def _update_psd_record(self) -> None:
        from psd_tools.api.psd_image import PSDImage  # Circular import

        if isinstance(self, PSDImage):
            self._updated_layers = True  # type: ignore
        elif self._psd is not None:
            self._psd._updated_layers = True

    def descendants(self, include_clip: bool = True) -> Iterator[Layer]:
        """
        Return a generator to iterate over all descendant layers.

        Example::

            # Iterate over all layers
            for layer in psd.descendants():
                print(layer)

            # Iterate over all layers in reverse order
            for layer in reversed(list(psd.descendants())):
                print(layer)

        :param include_clip: include clipping layers.
        """
        for layer in self:
            yield layer
            if isinstance(layer, GroupMixin):
                for child in layer.descendants(include_clip):
                    yield child
            if include_clip and hasattr(layer, "clip_layers"):
                for clip_layer in layer.clip_layers:
                    yield clip_layer

    def find(self, name: str) -> Layer | None:
        """
        Returns the first layer found for the given layer name

        :param name:
        """

        for layer in self.findall(name):
            return layer
        return None

    def findall(self, name: str) -> Iterator[Layer]:
        """
        Return a generator to iterate over all layers with the given name.

        :param name:
        """

        for layer in self.descendants():
            if layer.name == name:
                yield layer


[docs] class Group(GroupMixin, Layer): """ Group of layers. Example:: group = psd[1] for layer in group: if layer.kind == 'pixel': print(layer.name) """
[docs] @staticmethod def extract_bbox( layers, include_invisible: bool = False ) -> tuple[int, int, int, int]: """ Returns a bounding box for ``layers`` or (0, 0, 0, 0) if the layers have no bounding box. :param include_invisible: include invisible layers in calculation. :return: tuple of four int """ def _get_bbox(layer, **kwargs): if layer.is_group(): return Group.extract_bbox(layer, **kwargs) else: return layer.bbox if not hasattr(layers, "__iter__"): layers = [layers] bboxes = [ _get_bbox(layer, include_invisible=include_invisible) for layer in layers if include_invisible or layer.is_visible() ] bboxes = [bbox for bbox in bboxes if bbox != (0, 0, 0, 0)] if len(bboxes) == 0: # Empty bounding box. return (0, 0, 0, 0) lefts, tops, rights, bottoms = zip(*bboxes) return (min(lefts), min(tops), max(rights), max(bottoms))
def __init__( self, psd: Any, record: LayerRecord, channels: ChannelDataList, parent: TGroupMixin | None, ): self._layers = [] self._bounding_record = None self._bounding_channels = None Layer.__init__(self, psd, record, channels, parent) @property def _setting(self) -> SectionDividerSetting | None: # Can be None. return self.tagged_blocks.get_data(Tag.SECTION_DIVIDER_SETTING) @property def blend_mode(self) -> BlendMode: setting = self._setting if setting is not None: return setting.blend_mode return super(Group, self).blend_mode @blend_mode.setter def blend_mode(self, value: str | bytes | BlendMode) -> None: _value = BlendMode(value.encode("ascii") if isinstance(value, str) else value) if _value == BlendMode.PASS_THROUGH: self._record.blend_mode = BlendMode.NORMAL else: self._record.blend_mode = _value setting = self._setting if setting is not None: setting.blend_mode = _value
[docs] def composite( self, viewport: tuple[int, int, int, int] | None = None, force: bool = False, color: float | tuple[float, ...] | np.ndarray = 1.0, alpha: float | np.ndarray = 0.0, layer_filter: Callable | None = None, apply_icc: bool = True, ): """ Composite layer and masks (mask, vector mask, and clipping layers). :param viewport: Viewport bounding box specified by (x1, y1, x2, y2) tuple. Default is the layer's bbox. :param force: Boolean flag to force vector drawing. :param color: Backdrop color specified by scalar or tuple of scalar. The color value should be in [0.0, 1.0]. For example, (1., 0., 0.) specifies red in RGB color mode. :param alpha: Backdrop alpha in [0.0, 1.0]. :param layer_filter: Callable that takes a layer as argument and returns whether if the layer is composited. Default is :py:func:`~psd_tools.api.layers.PixelLayer.is_visible`. :return: :py:class:`PIL.Image`. """ from psd_tools.composite import composite_pil return composite_pil( self, color, alpha, viewport, layer_filter, force, as_layer=True, apply_icc=apply_icc, )
def _set_bounding_records(self, _bounding_record, _bounding_channels) -> None: # Attributes that store the record for the folder divider. # Used when updating the record so that we don't need to recompute # Them from the ending layer self._bounding_record = _bounding_record self._bounding_channels = _bounding_channels return
[docs] @classmethod def new( cls, name: str = "Group", open_folder: bool = True, parent: GroupMixin | None = None, ) -> Self: """ Create a new Group object with minimal records and data channels and metadata to properly include the group in the PSD file. :param name: The display name of the group. Default to "Group". :param open_folder: Boolean defining whether the folder will be open or closed in photoshop. Default to True. :param parent: Optional parent folder to move the newly created group into. :return: A :py:class:`~psd_tools.api.layers.Group` object """ record = LayerRecord(top=0, left=0, bottom=0, right=0, name=name) record.tagged_blocks = TaggedBlocks() record.tagged_blocks.set_data( Tag.SECTION_DIVIDER_SETTING, SectionDivider.OPEN_FOLDER if open_folder else SectionDivider.CLOSED_FOLDER, ) record.tagged_blocks.set_data(Tag.UNICODE_LAYER_NAME, name) _bounding_record = LayerRecord( top=0, left=0, bottom=0, right=0, name="</Layer group>" ) _bounding_record.tagged_blocks = TaggedBlocks() _bounding_record.tagged_blocks.set_data( Tag.SECTION_DIVIDER_SETTING, SectionDivider.BOUNDING_SECTION_DIVIDER ) _bounding_record.tagged_blocks.set_data( Tag.UNICODE_LAYER_NAME, "</Layer group>" ) record.channel_info = [ChannelInfo(id=i - 1, length=2) for i in range(4)] _bounding_record.channel_info = [ ChannelInfo(id=i - 1, length=2) for i in range(4) ] channels = ChannelDataList() for i in range(4): channels.append(ChannelData(compression=Compression.RAW, data=b"")) _bounding_channels = channels group = cls(None, record, channels, None) group._set_bounding_records(_bounding_record, _bounding_channels) if parent is not None and isinstance(parent, GroupMixin): group.move_to_group(parent) return group
[docs] @classmethod def group_layers( cls, layers: list[Layer], name: str = "Group", parent: GroupMixin | None = None, open_folder: bool = True, ): """ Create a new Group object containing the given layers and moved into the parent folder. If no parent is provided, the group will be put in place of the first layer in the given list. Example below: :param layers: The layers to group. Can by any subclass of :py:class:`~psd_tools.api.layers.Layer` :param name: The display name of the group. Default to "Group". :param parent: The parent group to add the newly created Group object into. :param open_folder: Boolean defining whether the folder will be open or closed in photoshop. Default to True. :return: A :py:class:`~psd_tools.api.layers.Group` """ assert len(layers) > 0 if parent is None and isinstance(layers[0]._parent, GroupMixin): parent = layers[0]._parent else: # Newly created groups do not have a parent yet. logger.debug("Failed to find a parent for the new group.") group = cls.new(name, open_folder) for layer in layers: layer.move_to_group(group) if isinstance(parent, GroupMixin): parent.append(group) return group
[docs] class Artboard(Group): """ Artboard is a special kind of group that has a pre-defined viewbox. """ @classmethod def _move(kls, group: Group) -> "Artboard": assert group.parent is not None self = kls(group._psd, group._record, group._channels, group.parent) # type: ignore self._layers = group._layers self._set_bounding_records(group._bounding_record, group._bounding_channels) for layer in self._layers: layer._parent = self assert self.parent is not None for index in range(len(self.parent)): if group == self.parent[index]: self.parent._layers[index] = self return self @property def left(self) -> int: return self.bbox[0] @left.setter def left(self, value: int) -> None: raise NotImplementedError("Artboard left position is not writable yet.") @property def top(self) -> int: return self.bbox[1] @top.setter def top(self, value: int) -> None: raise NotImplementedError("Artboard top position is not writable yet.") @property def right(self) -> int: return self.bbox[2] @property def bottom(self) -> int: return self.bbox[3] @property def bbox(self) -> tuple[int, int, int, int]: """(left, top, right, bottom) tuple.""" if self._bbox is None: data = None for key in (Tag.ARTBOARD_DATA1, Tag.ARTBOARD_DATA2, Tag.ARTBOARD_DATA3): if key in self.tagged_blocks: data = self.tagged_blocks.get_data(key) assert data is not None rect = data.get(b"artboardRect") self._bbox = ( int(rect.get(b"Left")), int(rect.get(b"Top ")), int(rect.get(b"Rght")), int(rect.get(b"Btom")), ) return self._bbox
[docs] class PixelLayer(Layer): """ Layer that has rasterized image in pixels. Example:: assert layer.kind == 'pixel': image = layer.composite() image.save('layer.png') """
[docs] @classmethod def frompil( cls, pil_im: PILImage, psd_file: Any | None = None, # TODO: Fix circular import layer_name: str = "Layer", top: int = 0, left: int = 0, compression: Compression = Compression.RLE, **kwargs: Any, ) -> "PixelLayer": """ Creates a PixelLayer from a PIL image for a given psd file. :param pil_im: The :py:class:`~PIL.Image` object to convert to photoshop :param psdfile: The psd file the image will be converted for. :param layer_name: The name of the layer. Defaults to "Layer" :param top: Pixelwise offset from the top of the canvas for the new layer. :param left: Pixelwise offset from the left of the canvas for the new layer. :param compression: Compression algorithm to use for the data. :return: A :py:class:`~psd_tools.api.layers.PixelLayer` object """ assert pil_im if pil_im.mode == "1": pil_im = pil_im.convert("L") if psd_file is not None: pil_im = pil_im.convert(psd_file.pil_mode) else: logger.warning( "No psd file was provided, it will not be possible to convert it when moving to another psd. Might create corrupted psds." ) if pil_im.mode == "CMYK": from PIL import ImageChops pil_im = ImageChops.invert(pil_im) layer_record = LayerRecord( top=top, left=left, bottom=top + pil_im.height, right=left + pil_im.width, **kwargs, ) channel_data_list = ChannelDataList() layer_record.name = layer_name layer_record.channel_info = [ChannelInfo(ChannelID.TRANSPARENCY_MASK, 2)] # Initialize the alpha channel to full opacity, photoshop sometimes didn't handle the file when not done channel_data_list.append(ChannelData(compression)) channel_data_list[0].set_data( b"\xff" * (pil_im.width * pil_im.height), pil_im.width, pil_im.height, get_pil_depth(pil_im.mode.rstrip("A")), ) layer_record.channel_info[0].length = len(channel_data_list[0].data) + 2 for channel_index in range(get_pil_channels(pil_im.mode.rstrip("A"))): channel_data = ChannelData(compression) channel_data.set_data( pil_im.getchannel(channel_index).tobytes(), pil_im.width, pil_im.height, get_pil_depth(pil_im.mode.rstrip("A")), ) channel_info = ChannelInfo( id=ChannelID(channel_index), length=len(channel_data.data) + 2 ) channel_data_list.append(channel_data) layer_record.channel_info.append(channel_info) if pil_im.has_transparency_data: # Need check for other types of transparency, palette for "indexed" mode transparency_channel_index = pil_im.getbands().index("A") channel_data_list[0].set_data( pil_im.getchannel(transparency_channel_index).tobytes(), pil_im.width, pil_im.height, get_pil_depth(pil_im.mode.rstrip("A")), ) layer_record.channel_info[0].length = len(channel_data_list[0].data) + 2 self = cls(psd_file, layer_record, channel_data_list, None) return self
def _convert(self, target_psd: Any) -> "PixelLayer": # assert self._psd is not None, "This layer cannot be converted because it has no psd file linked." if self._psd is None: logger.warning( "This layer {} cannot be converted to the target psd".format(self) ) return self if target_psd.pil_mode == self._psd.pil_mode: return self rendered_image = self.composite() if not isinstance(rendered_image, PILImage): raise ValueError("Failed to render the image for conversion.") new_layer = PixelLayer.frompil( rendered_image, target_psd, self.name, self.top, self.left, self._channels[0].compression, ) self._record.channel_info = new_layer._record.channel_info self._channels = new_layer._channels return self
[docs] class SmartObjectLayer(Layer): """ Layer that inserts external data. Use :py:attr:`~psd_tools.api.layers.SmartObjectLayer.smart_object` attribute to get the external data. See :py:class:`~psd_tools.api.smart_object.SmartObject`. Example:: import io if layer.smart_object.filetype == 'jpg': image = Image.open(io.BytesIO(layer.smart_object.data)) """ @property def smart_object(self) -> SmartObject: """ Associated smart object. :return: :py:class:`~psd_tools.api.smart_object.SmartObject`. """ if not hasattr(self, "_smart_object"): self._smart_object = SmartObject(self) return self._smart_object
[docs] class TypeLayer(Layer): """ Layer that has text and styling information for fonts or paragraphs. Text is accessible at :py:attr:`~psd_tools.api.layers.TypeLayer.text` property. Styling information for paragraphs is in :py:attr:`~psd_tools.api.layers.TypeLayer.engine_dict`. Document styling information such as font list is is :py:attr:`~psd_tools.api.layers.TypeLayer.resource_dict`. Currently, textual information is read-only. Example:: if layer.kind == 'type': print(layer.text) print(layer.engine_dict['StyleRun']) # Extract font for each substring in the text. text = layer.engine_dict['Editor']['Text'].value fontset = layer.resource_dict['FontSet'] runlength = layer.engine_dict['StyleRun']['RunLengthArray'] rundata = layer.engine_dict['StyleRun']['RunArray'] index = 0 for length, style in zip(runlength, rundata): substring = text[index:index + length] stylesheet = style['StyleSheet']['StyleSheetData'] font = fontset[stylesheet['Font']] print('%r gets %s' % (substring, font)) index += length """ def __init__(self, *args: Any): super(TypeLayer, self).__init__(*args) self._data = self.tagged_blocks.get_data(Tag.TYPE_TOOL_OBJECT_SETTING) @property def text(self) -> str: """ Text in the layer. Read-only. .. note:: New-line character in Photoshop is `'\\\\r'`. """ return self._data.text_data.get(b"Txt ").value.rstrip("\x00") @property def text_type(self) -> TextType | None: """ Text type. Read-only. :return: - :py:attr:`psd_tools.constants.TextType.POINT` for point type text (also known as character type) - :py:attr:`psd_tools.constants.TextType.PARAGRAPH` for paragraph type text (also known as area type) - `None` if text type cannot be determined or information is unavailable See :py:class:`psd_tools.constants.TextType`. """ shapes = ( self._engine_data.get("EngineDict", {}) .get("Rendered", {}) .get("Shapes", {}) .get("Children", {}) ) if len(shapes) == 1: text_type = ( shapes[0].get("Cookie", {}).get("Photoshop", {}).get("ShapeType", {}) ) if text_type in (0, 1): return TextType.POINT if text_type == 0 else TextType.PARAGRAPH else: logger.warning( f"Cannot determine text_type of layer '{self.name}' because information inside ShapeType was not found." ) elif not shapes: logger.warning( f"Cannot determine text_type of layer '{self.name}' because information inside EngineDict was not found." ) elif len(shapes) > 1: logger.warning( f"Cannot determine text_type of layer '{self.name}' because EngineDict has {len(shapes)} shapes." ) return None @property def transform(self) -> tuple[float, float, float, float, float, float]: """Matrix (xx, xy, yx, yy, tx, ty) applies affine transformation.""" return self._data.transform @property def _engine_data(self) -> engine_data.EngineData | engine_data.EngineData2: """Styling and resource information.""" return self._data.text_data.get(b"EngineData").value @property def engine_dict(self) -> engine_data.Dict: """Styling information dict.""" return self._engine_data.get("EngineDict") @property def resource_dict(self) -> engine_data.Dict: """Resource set.""" return self._engine_data.get("ResourceDict") @property def document_resources(self) -> engine_data.Dict: """Resource set relevant to the document.""" return self._engine_data.get("DocumentResources") @property def warp(self) -> DescriptorBlock | None: """Warp configuration.""" return self._data.warp
[docs] class ShapeLayer(Layer): """ Layer that has drawing in vector mask. """ def __init__(self, *args: Any): super(ShapeLayer, self).__init__(*args) self._bbox: tuple[int, int, int, int] | None = None @property def left(self) -> int: return self.bbox[0] @left.setter def left(self, value: int) -> None: raise NotImplementedError("ShapeLayer left position is not writable yet.") @property def top(self) -> int: return self.bbox[1] @top.setter def top(self, value: int) -> None: raise NotImplementedError("ShapeLayer top position is not writable yet.") @property def right(self) -> int: return self.bbox[2] @property def bottom(self) -> int: return self.bbox[3] @property def bbox(self) -> tuple[int, int, int, int]: """(left, top, right, bottom) tuple.""" if self._bbox is None: if self.has_pixels(): self._bbox = ( self._record.left, self._record.top, self._record.right, self._record.bottom, ) elif self.has_origination() and not any( x.invalidated for x in self.origination ): lefts, tops, rights, bottoms = zip(*[x.bbox for x in self.origination]) self._bbox = ( int(min(lefts)), int(min(tops)), int(max(rights)), int(max(bottoms)), ) elif self.has_vector_mask(): assert self.vector_mask is not None bbox = self.vector_mask.bbox assert self._psd is not None self._bbox = ( int(round(bbox[0] * self._psd.width)), int(round(bbox[1] * self._psd.height)), int(round(bbox[2] * self._psd.width)), int(round(bbox[3] * self._psd.height)), ) else: self._bbox = (0, 0, 0, 0) assert self._bbox is not None return self._bbox
class AdjustmentLayer(Layer): """Layer that applies specified image adjustment effect.""" def __init__(self, *args: Any): super(AdjustmentLayer, self).__init__(*args) self._data = None if hasattr(self.__class__, "_KEY"): self._data = self.tagged_blocks.get_data(self.__class__._KEY) class FillLayer(Layer): """Layer that fills the canvas region.""" def __init__(self, *args: Any): super(FillLayer, self).__init__(*args) self._data = None if hasattr(self.__class__, "_KEY"): self._data = self.tagged_blocks.get_data(self.__class__._KEY) @property def right(self) -> int: if self._record.right: return self._record.right if self._psd is None: raise ValueError("Cannot determine the right position of the layer.") return self._psd.width @property def bottom(self) -> int: if self._record.bottom: return self._record.bottom if self._psd is None: raise ValueError("Cannot determine the right position of the layer.") return self._psd.height