"""
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 numpy_io, pil_io
from psd_tools.color_convert import rgb_to_grayscale
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,
ColorMode,
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,
)
from psd_tools.terminology import Key
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.
"""
return pil_io.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.
"""
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 # noqa: PLC0415
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 # noqa: PLC0415
return composite_pil(
self,
color,
alpha,
viewport,
layer_filter,
force,
as_layer=True,
apply_icc=apply_icc,
)
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
[docs]
def composite(
self,
viewport: tuple[int, int, int, int] | None = None,
force: bool = False,
color: float | tuple[float, ...] | np.ndarray | None = None,
alpha: float | np.ndarray | None = None,
layer_filter: Callable | None = None,
apply_icc: bool = True,
) -> Image.Image | None:
"""
Composite layer, automatically applying the artboard's background color.
When either ``color`` or ``alpha`` is not explicitly provided, reads
the artboard background from the ARTBOARD_DATA tagged block
(``artboardBackgroundType`` and ``Clr`` descriptor) and uses the
missing value or values as the compositing backdrop.
"""
if color is None or alpha is None:
artboard_color, artboard_alpha = self._artboard_background_defaults()
if color is None:
color = artboard_color
if alpha is None:
alpha = artboard_alpha
# NOTE: The lower-level psd_tools.composite.composite(artboard) function
# does not inject artboard background.
return super().composite(
viewport=viewport,
force=force,
color=color,
alpha=alpha,
layer_filter=layer_filter,
apply_icc=apply_icc,
)
def _artboard_background_defaults(
self,
) -> tuple[float | tuple[float, ...], float]:
"""Return (color, alpha) based on artboard background settings.
Reads artboardBackgroundType from ARTBOARD_DATA1/2/3 tagged blocks
(last one found wins, matching the bbox() iteration pattern).
Background types (Photoshop scripting convention):
1 = Transparent, 2 = White, 3 = Black, 4 = Custom color
Falls back to (1.0, 0.0) — the Group default — on any missing or
unsupported data.
"""
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:
return 1.0, 0.0
bg_type = data.get(b"artboardBackgroundType")
if bg_type is None:
return 1.0, 0.0
psd = self._psd
color_mode = psd.color_mode if psd is not None else ColorMode.RGB
bg_type = int(bg_type)
if bg_type == 1: # Transparent
return 1.0, 0.0
elif bg_type in (2, 3): # White or Black — color-mode aware
white = bg_type == 2
if color_mode in (
ColorMode.RGB,
ColorMode.GRAYSCALE,
ColorMode.BITMAP,
ColorMode.DUOTONE,
):
return (1.0 if white else 0.0), 1.0
elif color_mode == ColorMode.LAB:
# LAB: L in [0,1]; a and b channels are neutral at 0.5
return (1.0 if white else 0.0, 0.5, 0.5), 1.0
elif color_mode == ColorMode.CMYK:
# CMYK: white = no ink (0,0,0,0); black = full K (0,0,0,1)
return ((0.0, 0.0, 0.0, 0.0) if white else (0.0, 0.0, 0.0, 1.0)), 1.0
else:
logger.debug(
"Artboard background color not applied: unsupported color mode %s",
color_mode,
)
return 1.0, 0.0
elif bg_type == 4: # Custom color (Clr descriptor, RGB 0-255)
clr = data.get(Key.Color)
if clr is None:
return 1.0, 0.0
rd = clr.get(Key.Red)
gn = clr.get(Key.Green)
bl = clr.get(Key.Blue)
if rd is None or gn is None or bl is None:
return 1.0, 0.0
r, g, b_val = float(rd) / 255.0, float(gn) / 255.0, float(bl) / 255.0
if color_mode == ColorMode.RGB:
return (r, g, b_val), 1.0
elif color_mode in (
ColorMode.GRAYSCALE,
ColorMode.BITMAP,
ColorMode.DUOTONE,
):
lum = rgb_to_grayscale(r, g, b_val)
return lum, 1.0
else:
# NOTE: The Clr descriptor is always RGB regardless of document
# color mode. Conversion to CMYK, LAB, etc. is non-trivial and
# not implemented; those modes fall back to the transparent default.
logger.debug(
"Artboard background color not applied: unsupported color mode %s",
color_mode,
)
return 1.0, 0.0
return 1.0, 0.0 # Unknown background type
@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 parent: The parent group or PSDImage this layer belongs to.
: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
:raises ValueError: If parent is None
.. note::
If the image has an alpha channel and the parent PSD mode supports
layer transparency (e.g. RGBA, LA), the alpha is stored as the
layer transparency channel and no extra mask is created. For PSD
modes that do not carry a transparency band (e.g. RGB, CMYK), the
alpha channel is instead stored as a pixel mask
(``USER_LAYER_MASK``).
"""
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)
# Only create a mask if alpha was present in the original image but is
# NOT preserved in the converted image (e.g. RGB-mode PSD with RGBA
# input). For alpha-capable PSD modes (RGBA, LA) the alpha is already
# stored as the transparency channel, so no extra mask is needed.
if "A" in original_image.getbands() and "A" not in 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 # noqa: PLC0415
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