Source code for psd_tools.api.layers

"""
Layer module.

This module implements the high-level layer API for psd-tools, providing
Pythonic interfaces for working with Photoshop layers. It defines the layer
type hierarchy and common operations.

Key classes:

- :py:class:`Layer`: Base class for all layer types
- :py:class:`GroupMixin`: Mixin for layers that contain children (groups, documents)
- :py:class:`Group`: Folder/group layer containing other layers
- :py:class:`PixelLayer`: Regular raster layer with pixel data
- :py:class:`TypeLayer`: Text layer with typography information
- :py:class:`ShapeLayer`: Vector shape layer
- :py:class:`SmartObjectLayer`: Embedded or linked smart object
- :py:class:`AdjustmentLayer`: Non-destructive adjustment (curves, levels, etc.)

Layer hierarchy:

Layers are organized in a tree structure where groups can contain child layers.
The :py:class:`GroupMixin` provides iteration, indexing, and search capabilities::

    # Iterate through all layers
    for layer in psd:
        print(layer.name)

    # Access by index
    first_layer = psd[0]

    # Check if layer is a specific type
    if layer.kind == 'pixel':
        pixels = layer.numpy()

Common layer properties:

- ``name``: Layer name
- ``visible``: Visibility flag
- ``opacity``: Opacity (0-255)
- ``blend_mode``: Blend mode enum
- ``bbox``: Bounding box (left, top, right, bottom)
- ``width``, ``height``: Dimensions
- ``kind``: Layer type string ('pixel', 'group', 'type', etc.)
- ``parent``: Parent layer or document

Layer operations:

- :py:meth:`~Layer.composite`: Render layer to PIL Image
- :py:meth:`~Layer.numpy`: Get pixel data as NumPy array
- :py:meth:`~Layer.topil`: Convert to PIL Image
- :py:meth:`~Layer.has_mask`: Check if layer has a mask
- :py:meth:`~Layer.has_clip_layers`: Check if layer has clipping mask

Example usage::

    from psd_tools import PSDImage

    psd = PSDImage.open('document.psd')

    # Access first layer
    layer = psd[0]

    # Modify layer properties
    layer.visible = False
    layer.opacity = 128
    layer.name = "New Name"

    # Get pixel data
    pixels = layer.numpy()  # NumPy array
    image = layer.topil()   # PIL Image

    # Work with groups
    for group in psd.descendants():
        if group.kind == 'group':
            print(f"Group: {group.name} with {len(group)} layers")

    # Composite specific layer
    rendered = layer.composite()
    rendered.save('layer.png')

Layer types are automatically determined from the underlying PSD structures
and exposed through the ``kind`` property for easy type checking.
"""

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

from typing_extensions import Self

if TYPE_CHECKING:
    from psd_tools.api.typesetting import TypeSetting

import numpy as np
from PIL import Image, ImageChops

import psd_tools.psd.engine_data as engine_data
from psd_tools.api import pil_io
from psd_tools.api.effects import Effects
from psd_tools.api.mask import Mask
from psd_tools.api.protocols import GroupMixinProtocol, LayerProtocol, PSDProtocol
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,
    CompatibilityMode,
    Compression,
    ProtectedFlags,
    SectionDivider,
    SheetColorType,
    Tag,
    TextType,
)
from psd_tools.psd.descriptor import DescriptorBlock
from psd_tools.psd.layer_and_mask import (
    ChannelData,
    ChannelDataList,
    ChannelInfo,
    LayerRecord,
    MaskData,
    MaskFlags,
)
from psd_tools.psd.tagged_blocks import (
    ProtectedSetting,
    SectionDividerSetting,
    TaggedBlocks,
)

logger = logging.getLogger(__name__)


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


[docs] class Layer(LayerProtocol): def __init__( self, parent: "GroupMixin", record: LayerRecord, channels: ChannelDataList, ): self._psd = parent._psd self._parent: "GroupMixinProtocol | None" = parent self._record = record self._channels = channels @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: if len(value) >= 256: raise ValueError( "Layer name too long (%d characters, max 255): %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: if self.visible != value and self._psd is not None: self._psd._mark_updated() self._invalidate_bbox() self._record.flags.visible = bool(value)
[docs] def is_visible(self) -> bool: """ Layer visibility. Takes group visibility in account. :return: `bool` """ if not self.visible: return False elif self.parent is not None: return self.parent.is_visible() return True
@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: if not (0 <= value <= 255): raise ValueError(f"Opacity must be in range [0, 255], got {value}") if self.opacity != value and self._psd is not None: self._psd._mark_updated() self._record.opacity = int(value) @property def parent(self) -> GroupMixinProtocol | None: """Parent of this layer.""" return self._parent # type: ignore
[docs] def next_sibling(self, visible: bool = False) -> Self | None: """Next sibling of this layer.""" if self.parent is None: return None index = self.parent.index(self) # type: ignore for i in range(index + 1, len(self.parent)): if not visible or self.parent[i].visible: return self.parent[i] # type: ignore[return-value] return None
[docs] def previous_sibling(self, visible: bool = False) -> Self | None: """Previous sibling of this layer.""" if self.parent is None: return None index = self.parent.index(self) # type: ignore for i in range(index - 1, -1, -1): if not visible or self.parent[i].visible: return self.parent[i] # type: ignore[return-value] return None
[docs] def is_group(self) -> bool: """ Return True if the layer is a group. :return: `bool` """ return False
@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") blend_mode = BlendMode(value) if self.blend_mode != blend_mode: self._psd._mark_updated() self._record.blend_mode = blend_mode @property def left(self) -> int: """ Left coordinate. Writable. :return: int """ return self._record.left @left.setter def left(self, value: int) -> None: if self.left != value: self._psd._mark_updated() 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: if self.top != value and self._psd is not None: self._psd._mark_updated() 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: if len(value) != 2: raise ValueError( f"Offset must be a tuple of 2 integers, got {len(value)} elements" ) 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
[docs] def has_pixels(self) -> bool: """ Returns True if the layer has associated pixels. When this is True, `topil` method returns :py:class:`PIL.Image.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) )
[docs] 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 _make_mask_channel_data( self, image: Image.Image, compression: Compression, ) -> tuple[ChannelData, int, int]: """Return ``(channel_data, width, height)`` for a mask image. If the image has an alpha channel the alpha channel is used as the mask data; otherwise the image is converted to grayscale (``L`` mode). Layer masks in PSD are always 8-bit regardless of document depth. """ if "A" in image.getbands(): mask_pixels = image.getchannel("A") else: mask_pixels = image.convert("L") width, height = mask_pixels.size version = self._psd._record.header.version channel_data = ChannelData(compression) channel_data.set_data(mask_pixels.tobytes(), width, height, 8, version) return channel_data, width, height
[docs] def create_mask( self, image: Image.Image, top: int | None = None, left: int | None = None, compression: Compression = Compression.RLE, ) -> Mask: """ Create a pixel mask on this layer from a PIL Image. If the image has an alpha channel (e.g. RGBA, LA), the alpha channel is used as the mask data. Otherwise the image is converted to grayscale (``L`` mode). White (255) means fully unmasked, black (0) means fully masked. :param image: Source :py:class:`~PIL.Image.Image` for the mask. :param top: Top offset of the mask. Defaults to the layer's top. :param left: Left offset of the mask. Defaults to the layer's left. :param compression: Compression algorithm for the mask data. :return: The new :py:class:`~psd_tools.api.mask.Mask`. :raises ValueError: If the layer already has a mask. """ if self.has_mask(): raise ValueError("Layer already has a mask. Remove it first.") if top is None: top = self._record.top if left is None: left = self._record.left channel_data, width, height = self._make_mask_channel_data(image, compression) mask_data = MaskData( top=top, left=left, bottom=top + height, right=left + width, background_color=0, flags=MaskFlags(), ) channel_info = ChannelInfo( id=ChannelID.USER_LAYER_MASK, length=channel_data._length, ) self._record.mask_data = mask_data self._record.channel_info.append(channel_info) self._channels.append(channel_data) if hasattr(self, "_mask"): del self._mask self._psd._mark_updated() return self.mask # type: ignore[return-value]
[docs] def remove_mask(self) -> None: """ Remove the pixel mask from this layer. :raises ValueError: If the layer does not have a mask. """ if not self.has_mask(): raise ValueError("Layer does not have a mask.") for i, ci in enumerate(self._record.channel_info): if ci.id == ChannelID.USER_LAYER_MASK: self._record.channel_info.pop(i) self._channels.pop(i) break self._record.mask_data = None if hasattr(self, "_mask"): del self._mask self._psd._mark_updated()
[docs] def update_mask( self, image: Image.Image, top: int | None = None, left: int | None = None, compression: Compression = Compression.RLE, ) -> Mask: """ Update the pixel mask of this layer with a new image. If the image has an alpha channel (e.g. RGBA, LA), the alpha channel is used as the mask data. Otherwise the image is converted to grayscale (``L`` mode). White (255) means fully unmasked, black (0) means fully masked. :param image: New source :py:class:`~PIL.Image.Image` for the mask. :param top: New top offset of the mask. Defaults to current mask top. :param left: New left offset of the mask. Defaults to current mask left. :param compression: Compression algorithm for the mask data. :return: The updated :py:class:`~psd_tools.api.mask.Mask`. :raises ValueError: If the layer does not have a mask. """ if not self.has_mask(): raise ValueError("Layer does not have a mask. Use create_mask() first.") channel_data, width, height = self._make_mask_channel_data(image, compression) mask_data = cast(MaskData, self._record.mask_data) new_top = top if top is not None else mask_data.top new_left = left if left is not None else mask_data.left mask_data.top = new_top mask_data.left = new_left mask_data.bottom = new_top + height mask_data.right = new_left + width for i, ci in enumerate(self._record.channel_info): if ci.id == ChannelID.USER_LAYER_MASK: self._channels[i] = channel_data self._record.channel_info[i].length = channel_data._length break if hasattr(self, "_mask"): del self._mask self._psd._mark_updated() return self.mask # type: ignore[return-value]
[docs] 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
[docs] 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
[docs] 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
[docs] 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
[docs] def topil( self, channel: int | None = None, apply_icc: bool = True ) -> Image.Image | 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.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.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)
[docs] 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 . import numpy_io return numpy_io.get_array(self, channel, real_mask=real_mask)
[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, ) -> Image.Image | 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.Image` or `None`. """ from psd_tools.composite import composite_pil if self._psd is not None and self._psd.is_updated(): force = True return composite_pil( self, color, alpha, viewport, layer_filter, force, apply_icc=apply_icc )
[docs] def has_clip_layers(self, visible: bool = False) -> bool: """ Returns True if the layer has associated clipping. :param visible: If True, check for visible clipping layers. :return: `bool` """ if visible: return any(layer.is_visible() for layer in self.clip_layers) return len(self.clip_layers) > 0
@property def clip_layers(self) -> list[Self]: """ Clip layers associated with this layer. :return: list of layers """ if self.clipping: return [] # Look for clipping layers in the parent scope. parent: GroupMixin = self.parent or self._psd # type: ignore index = parent.index(self) # TODO: Cache the result and invalidate when needed. _clip_layers = [] for layer in parent[index + 1 :]: # type: ignore if layer.clipping: if ( isinstance(layer, GroupMixin) and layer._psd.compatibility_mode == CompatibilityMode.PHOTOSHOP ): # In Photoshop, clipping groups are not supported. break _clip_layers.append(layer) else: break return _clip_layers @property def clipping(self) -> bool: """ Clipping flag for this layer. Writable. :return: `bool` """ return self._record.clipping == Clipping.NON_BASE @clipping.setter def clipping(self, value: bool) -> None: clipping = Clipping.NON_BASE if value else Clipping.BASE if self._record.clipping != clipping and self._psd is not None: self._psd._mark_updated() self._record.clipping = clipping self._invalidate_bbox() @property def clipping_layer(self) -> bool: """Deprecated. Use clipping property instead.""" logger.warning( "clipping_layer property is deprecated. Use clipping property instead." ) return self.clipping @clipping_layer.setter def clipping_layer(self, value: bool) -> None: """Deprecated. Use clipping property instead.""" logger.warning( "clipping_layer property is deprecated. Use clipping property instead." ) self.clipping = value
[docs] def has_effects(self, enabled: bool = True, name: str | None = None) -> bool: """ Returns True if the layer has effects. :param enabled: If True, check for enabled effects. :param name: If given, check for specific effect type. :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, ) ) # No effects tag. if not has_effect_tag: return False # Global enable flag check. if enabled and not self.effects.enabled: return False # No specific effect type, check for any effect. if name is None: if enabled: return any(effect.enabled for effect in self.effects) return True # Check for specific effect type and enabled state. return any(self.effects.find(name, enabled))
@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 @property def fill_opacity(self) -> int: """ Fill opacity of this layer in [0, 255] range. Writable. :return: int """ return self.tagged_blocks.get_data(Tag.BLEND_FILL_OPACITY, 255) @fill_opacity.setter def fill_opacity(self, value: int) -> None: if value < 0 or value > 255: raise ValueError("Fill opacity must be between 0 and 255.") if self.fill_opacity != value and self._psd is not None: self._psd._mark_updated() self.tagged_blocks.set_data(Tag.BLEND_FILL_OPACITY, int(value)) @property def reference_point(self) -> tuple[float, float]: """ Reference point of this layer as (x, y) tuple in the canvas coordinates. Writable. Reference point is used for transformations such as rotation and scaling. :return: (x, y) tuple """ return tuple(self.tagged_blocks.get_data(Tag.REFERENCE_POINT, (0.0, 0.0))) @reference_point.setter def reference_point(self, value: Sequence[float]) -> None: if len(value) != 2: raise ValueError("Reference point must be a sequence of two floats.") if self.reference_point != value and self._psd is not None: self._psd._mark_updated() self.tagged_blocks.set_data( Tag.REFERENCE_POINT, [float(value[0]), float(value[1])] ) @property def sheet_color(self) -> SheetColorType: """ Color label of this layer in the Photoshop layers panel. Writable. :return: :py:class:`~psd_tools.constants.SheetColorType` """ return self.tagged_blocks.get_data( Tag.SHEET_COLOR_SETTING, SheetColorType.NO_COLOR ) @sheet_color.setter def sheet_color(self, value: SheetColorType) -> None: value = SheetColorType(value) if self.sheet_color != value and self._psd is not None: self._psd._mark_updated() self.tagged_blocks.set_data(Tag.SHEET_COLOR_SETTING, value) def __repr__(self) -> str: has_size = self.width > 0 and self.height > 0 return "%s(%r%s%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 "", " clip" if self.clipping else "", " mask" if self.has_mask() else "", " effects" if self.has_effects() else "", ) # Structure operations
[docs] def delete_layer(self) -> Self: """ Deprecated: Use layer.parent.remove(layer) instead. """ if self.parent is not None and isinstance(self.parent, GroupMixin): self.parent.remove(self) return self
[docs] def move_to_group(self, group: "GroupMixin") -> Self: """ Deprecated: Use group.append(layer) instead. :param group: The group the current layer will be moved into. """ group.append(self) return self
[docs] def move_up(self, offset: int = 1) -> Self: """ Moves the layer up a certain offset within the group the layer is in. :param offset: The number of positions to move the layer up (can be negative). :raises ValueError: If layer has no parent or parent is not a group :raises IndexError: If the new index is out of bounds :return: self """ if self.parent is None: raise ValueError(f"Cannot move layer {self} without a parent") if not isinstance(self.parent, GroupMixin): raise TypeError( f"Parent must be a GroupMixin, got {type(self.parent).__name__}" ) newindex = self.parent.index(self) + offset if newindex < 0: raise IndexError("Cannot move layer beyond the bottom of the group") elif newindex >= len(self.parent): raise IndexError("Cannot move layer beyond the top of the group") parent = self.parent parent.remove(self) parent.insert(newindex, self) return self
[docs] def move_down(self, offset: int = 1) -> Self: """ Moves the layer down a certain offset within the group the layer is in. :param offset: The number of positions to move the layer down (can be negative). :raises ValueError: If layer has no parent or parent is not a group :raises IndexError: If the new index is out of bounds :return: self """ return self.move_up(-1 * offset)
@runtime_checkable class GroupMixin(GroupMixinProtocol, Protocol): _psd: PSDProtocol _bbox: tuple[int, int, int, int] | None = None _layers: list[Layer] # Note: left, top, right, bottom properties are inherited from Layer # and computed via bbox. Groups compute bbox from children, not from _record. @property def bbox(self) -> tuple[int, int, int, int]: """(left, top, right, bottom) tuple computed from visible, non-clipping children.""" 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 __reversed__(self) -> Iterator[Layer]: return self._layers.__reversed__() def __contains__(self, item: object) -> bool: return item in self._layers def __getitem__(self, key: int) -> Layer: return self._layers.__getitem__(key) def __setitem__(self, key: int, value: Layer) -> None: self.insert(key, value) def __delitem__(self, key: int) -> None: self.remove(self._layers[key]) def append(self, layer: Layer) -> None: """ Add a layer to the end (top) of the group. This operation rewrites the internal references of the layer. Adding the same layer will not create a duplicate. :param layer: The layer to add. :raises TypeError: If the provided object is not a Layer instance. :raises ValueError: If attempting to add a group to itself. """ self.extend([layer]) def extend(self, layers: Iterable[Layer]) -> None: """ Add a list of layers to the end (top) of the group. This operation rewrites the internal references of the layers. Adding the same layer will not create a duplicate. :param layers: The layers to add. :raises TypeError: If the provided object is not a Layer instance. :raises ValueError: If attempting to add a group to itself. """ self._check_insertion(layers) # Remove parent's reference to the layers. for layer in layers: # NOTE: New or removed layers may not be in the parent container. if isinstance(layer.parent, GroupMixin) and layer in layer.parent: layer.parent._layers.remove(layer) # Skip checks for performance self._layers.extend(layers) self._update_children() self._psd._update_record() def insert(self, index: int, layer: Layer) -> None: """ Insert the given layer at the specified index. This operation rewrites the internal references of the layer. :param index: The index to insert the layer at. :param layer: The layer to insert. :raises TypeError: If the provided object is not a Layer instance. :raises ValueError: If attempting to add a group to itself. """ self._check_insertion([layer]) # Remove parent's reference to the layer. if isinstance(layer.parent, GroupMixin) and layer in layer.parent: layer.parent._layers.remove(layer) # Skip checks for performance self._layers.insert(index, layer) self._update_children() self._psd._update_record() def remove(self, layer: Layer) -> Self: """ Removes the specified layer from the group. This operation rewrites the internal references of the layer. :param layer: The layer to remove. :raises ValueError: If the layer is not found in the group. :return: self """ if layer not in self: raise ValueError(f"Layer {layer} not found in group {self}") self._layers.remove(layer) layer._parent = None self._psd._update_record() return self def pop(self, index: int = -1) -> Layer: """ Removes the specified layer from the list and returns it. This operation rewrites the internal references of the layer. :param index: The index of the layer to remove. Default is -1 (the last layer). :raises IndexError: If the index is out of range. :return: The removed layer. """ layer = self[index] self.remove(layer) return layer def clear(self) -> None: """ Clears the group. This operation rewrites the internal references of the layers. :return: None """ for layer in self._layers: layer._parent = None self._layers.clear() self._psd._update_record() def index(self, layer: Layer) -> int: """ Returns the index of the specified layer in the group. :param layer: The layer to find. """ return self._layers.index(layer) def count(self, layer: Layer) -> int: """ Counts the number of occurrences of a layer in the group. :param layer: The layer to count. """ return self._layers.count(layer) def _check_insertion(self, layers: Iterable[Layer]) -> None: """Check that the given layers can be added to this group. :raises ValueError: If attempting to add a group to itself or create a reference loop :raises TypeError: If the provided object is not a Layer instance """ for layer in layers: if not isinstance(layer, Layer): raise TypeError(f"Expected Layer instance, got {type(layer).__name__}") if layer is self: raise ValueError(f"Cannot add the group {self} to itself") if isinstance(layer, GroupMixin): if self in list(layer.descendants()): raise ValueError( "This operation would create a reference loop " f"within the group between {self} and {layer}" ) def _update_children(self) -> None: """Update children's _psd and _parent references.""" for layer in self: # Update PSD reference if needed if layer._psd != self._psd: if isinstance(layer, PixelLayer): layer._convert_mode(self) layer._psd._copy_patterns(self._psd) # TODO: optimize layer._psd = self._psd # Update parent reference layer._parent = self if isinstance(layer, GroupMixin): layer._update_children() def is_visible(self) -> bool: """Returns visibility of the element.""" return Layer.is_visible(self) # type: ignore def is_group(self) -> bool: """Return True if this is a group.""" return True def descendants(self, include_clip: bool = True) -> Iterator[Layer]: """ Return a generator to iterate over all descendant layers. :param include_clip: Whether to include clipping layers. Default is True. 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) """ for layer in self: if not include_clip and hasattr(layer, "clipping") and layer.clipping: continue yield layer if isinstance(layer, GroupMixin): yield from layer.descendants(include_clip=include_clip) 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) """ def __init__( self, parent: GroupMixin, record: LayerRecord, channels: ChannelDataList, ): self._layers = [] self._bounding_record: LayerRecord | None = None self._bounding_channels: ChannelDataList | None = None Layer.__init__(self, parent, record, channels) @property def _setting(self) -> SectionDividerSetting | None: """Low-level section divider setting.""" # Can be None. return self.tagged_blocks.get_data(Tag.SECTION_DIVIDER_SETTING) @property def blend_mode(self) -> BlendMode: """Blend mode of this layer. Writable.""" setting = self._setting # Use the blend mode from the section divider setting if present. if setting is not None and setting.blend_mode 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 self.blend_mode != _value and self._psd is not None: self._psd._mark_updated() 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 # Override Layer's writable position properties with read-only computed ones @property def left(self) -> int: """Left coordinate (computed from children, read-only).""" return self.bbox[0] @left.setter def left(self, value: int) -> None: raise NotImplementedError( "Cannot set position on Group directly. Position is computed from children." ) @property def top(self) -> int: """Top coordinate (computed from children, read-only).""" return self.bbox[1] @top.setter def top(self, value: int) -> None: raise NotImplementedError( "Cannot set position on Group directly. Position is computed from children." ) @property def right(self) -> int: """Right coordinate (computed from children, read-only).""" return self.bbox[2] @property def bottom(self) -> int: """Bottom coordinate (computed from children, read-only).""" return self.bbox[3] @property def clipping(self) -> bool: """ Clipping flag for this layer. Writable. :return: `bool` """ if self._psd.compatibility_mode == CompatibilityMode.PHOTOSHOP: # In Photoshop, clipping groups are not supported. return False return self._record.clipping == Clipping.NON_BASE @clipping.setter def clipping(self, value: bool) -> None: if self._psd.compatibility_mode == CompatibilityMode.PHOTOSHOP: logger.warning( "Cannot set clipping flag on groups in Photoshop compatibility mode." ) return clipping = Clipping.NON_BASE if value else Clipping.BASE if self._record.clipping != clipping: self._psd._mark_updated() self._record.clipping = clipping self._invalidate_bbox() @property def open_folder(self) -> bool: """ Returns True if the group is an open folder. :return: `bool` """ if self._setting is None: raise ValueError("Section divider setting is missing.") return self._setting.kind == SectionDivider.OPEN_FOLDER @open_folder.setter def open_folder(self, value: bool) -> None: """ Sets whether the group is an open folder. :param value: `bool` """ if self._setting is None: raise ValueError("Section divider setting is missing.") kind = SectionDivider.OPEN_FOLDER if value else SectionDivider.CLOSED_FOLDER current_kind = self._setting.kind if current_kind != kind: self._setting.kind = kind # This change does not affect pixel data, so no need to mark PSD as updated.
[docs] def is_group(self) -> bool: """ Return True if the layer is a group. :return: `bool` """ return True
[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, ) -> Image.Image | 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.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, )
[docs] @staticmethod def extract_bbox( layers: Sequence[Layer] | GroupMixin, include_invisible: bool = False, include_clipping: 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 layers: sequence of layers or a group. :param include_invisible: include invisible layers in calculation. :param include_clipping: include clipping layers in calculation. Defaults to False to match visible pixel bounds. :return: tuple of four int """ def _get_bbox(layer: Layer, **kwargs: Any) -> tuple[int, int, int, int]: if layer.is_group() and isinstance(layer, GroupMixin): return Group.extract_bbox(layer, **kwargs) else: return layer.bbox bboxes = [ _get_bbox( layer, include_invisible=include_invisible, include_clipping=include_clipping, ) for layer in layers if (include_invisible or layer.is_visible()) and (include_clipping or not layer.clipping) ] bboxes = [bbox for bbox in bboxes if bbox != (0, 0, 0, 0)] if len(bboxes) == 0: # Empty bounding box. logger.info("No bounding box could be extracted from the given layers.") return (0, 0, 0, 0) lefts, tops, rights, bottoms = zip(*bboxes) return (min(lefts), min(tops), max(rights), max(bottoms))
def _set_bounding_records( self, _bounding_record: LayerRecord, _bounding_channels: ChannelDataList ) -> 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, parent: GroupMixin, name: str = "Group", open_folder: bool = True, ) -> 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 :raises ValueError: If parent is None """ if parent is None: raise ValueError("Parent cannot be None") # Create the layer record for the group. record = LayerRecord(top=0, left=0, bottom=0, right=0, name=name) record.tagged_blocks = TaggedBlocks() kind = ( SectionDivider.OPEN_FOLDER if open_folder else SectionDivider.CLOSED_FOLDER ) record.tagged_blocks.set_data(Tag.SECTION_DIVIDER_SETTING, kind=kind) record.tagged_blocks.set_data(Tag.UNICODE_LAYER_NAME, name) # TODO: Check the number of channels needed record.channel_info = [ChannelInfo(id=i - 1, length=2) for i in range(4)] # Create the bounding layer record. 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>") bounding_record.channel_info = [ ChannelInfo(id=i - 1, length=2) for i in range(4) ] channels = ChannelDataList() for _ in range(4): # TODO: Check the number of channels needed channels.append(ChannelData(compression=Compression.RAW, data=b"")) bounding_channels = channels group = cls(parent, record, channels) group._set_bounding_records(bounding_record, bounding_channels) parent.append(group) return group
[docs] @classmethod def group_layers( cls, parent: GroupMixin, layers: Sequence[Layer], name: str = "Group", open_folder: bool = True, ) -> Self: """ Deprecated: Use ``psdimage.create_group(layer_list, name)`` instead. :param parent: The parent group to add the newly created Group object into. :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 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` :raises ValueError: If layers is empty """ if len(layers) == 0: raise ValueError("Cannot create a group from an empty list of layers") group = cls.new(parent, name, open_folder) group.extend(layers) 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": """Converts a Group into an Artboard, updating all references as needed. :raises ValueError: If group has no parent """ if group.parent is None: raise ValueError("Cannot convert a group without a parent to an Artboard") self = kls(group.parent, group._record, group._channels) # type: ignore self._layers = group._layers if group._bounding_record is not None and group._bounding_channels is not None: self._set_bounding_records(group._bounding_record, group._bounding_channels) for layer in self._layers: layer._parent = self if self.parent is None: raise ValueError("Artboard parent is None after conversion") for index in range(len(self.parent)): if group == self.parent[index]: if not isinstance(self.parent, GroupMixin): raise TypeError( f"Parent must be GroupMixin, got {type(self.parent).__name__}" ) 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) if data is None: raise ValueError("Artboard data not found in tagged blocks") 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, image: Image.Image, parent: GroupMixin, name: str = "Layer", top: int = 0, left: int = 0, compression: Compression = Compression.RLE, **kwargs: Any, ) -> "PixelLayer": """ Create a PixelLayer from a PIL image for a given psd file. :param image: The :py:class:`~PIL.Image.Image` object to convert to photoshop :param psdimage: The target psdimage the image will be converted for. :param 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 :raises TypeError: If image is not a PIL Image or parent is None """ if not isinstance(image, Image.Image): raise TypeError(f"Expected PIL Image, got {type(image).__name__}") if parent is None: raise ValueError("Parent cannot be None") # Preserve original image to extract alpha for mask creation later. original_image = image # Convert 1-bit images to 8-bit grayscale if image.mode == "1": image = image.convert("L") image = image.convert(parent._psd.pil_mode) if image.mode == "CMYK": image = ImageChops.invert(image) # Build layer record and channel data list. layer_record, channel_data_list = cls._build_layer_record_and_channels( image, name, left, top, compression, version=parent._psd._record.header.version, ) self = cls(parent, layer_record, channel_data_list) parent.append(self) # Automatically create a mask from the alpha channel if present. if "A" in original_image.getbands(): self.create_mask( original_image, top=top, left=left, compression=compression ) return self
def _convert_mode(self, parent: GroupMixin) -> "PixelLayer": """Convert the image format to match the given group.""" if parent._psd.pil_mode == self._psd.pil_mode: return self # Get the current layer image. image = self.topil() if not isinstance(image, Image.Image): raise ValueError("Failed to render the image for mode conversion.") # Rebuild layer record and channels. layer_record, channel_data_list = self._build_layer_record_and_channels( image.convert(parent._psd.pil_mode), self.name, self.left, self.top, Compression.RLE, version=self._psd._record.header.version, ) self._record = layer_record self._channels = channel_data_list return self @staticmethod def _build_layer_record_and_channels( image: Image.Image, name: str, left: int, top: int, compression: Compression, version: int = 1, **kwargs: Any, ) -> tuple[LayerRecord, ChannelDataList]: """Build layer record and channel data list from a PIL image.""" # Initialize the layer record and channel data list. layer_record = LayerRecord( top=top, left=left, bottom=top + image.height, right=left + image.width, channel_info=[], **kwargs, ) channel_data_list = ChannelDataList() # Set layer name. layer_record.name = name depth = pil_io.get_pil_depth(image.mode.rstrip("A")) # Transparency channel. transparency_data = ChannelData(compression) if image.has_transparency_data: # TODO: Need check for other types of transparency, palette for "indexed" mode image_bytes = image.getchannel(image.getbands().index("A")).tobytes() else: image_bytes = b"\xff" * (image.width * image.height) transparency_data.set_data( image_bytes, image.width, image.height, depth, version, ) transparency_info = ChannelInfo( ChannelID.TRANSPARENCY_MASK, len(transparency_data.data) + 2 ) layer_record.channel_info.append(transparency_info) channel_data_list.append(transparency_data) # Color channels. for channel_index in range(pil_io.get_pil_channels(image.mode.rstrip("A"))): channel_data = ChannelData(compression) channel_data.set_data( image.getchannel(channel_index).tobytes(), image.width, image.height, depth, version, ) 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) return layer_record, channel_data_list
[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 @property def typesetting(self) -> "TypeSetting": """ Structured typographic data. Returns a :py:class:`~psd_tools.api.typesetting.TypeSetting` object that provides Pythonic access to fonts, paragraphs, styled runs, and default styles without navigating raw engine data dicts. Example:: ts = layer.typesetting for paragraph in ts: print(paragraph.style.justification) for run in paragraph.runs: print(run.text, run.style.font_name, run.style.font_size) See also: :py:attr:`engine_dict`, :py:attr:`resource_dict` for raw data. """ if not hasattr(self, "_typesetting"): from psd_tools.api.typesetting import TypeSetting self._typesetting = TypeSetting( self.text, self.engine_dict, self.resource_dict, ) return self._typesetting @property def font_names(self) -> list[str]: """ List of PostScript font names used in this text layer. Convenience shortcut for:: [font.postscript_name for font in layer.typesetting.fonts] """ return [font.postscript_name for font in self.typesetting.fonts]
[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(): if self.vector_mask is None: raise ValueError( "Vector mask is None despite has_vector_mask() returning True" ) bbox = self.vector_mask.bbox if self._psd is None: raise ValueError("PSD is None for shape layer") 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) if self._bbox is None: raise ValueError("Failed to compute bbox for shape layer") 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