"""
Layer module.
"""
from __future__ import annotations
import logging
from typing import (
Any,
Callable,
Iterable,
Iterator,
Protocol,
TypeVar,
runtime_checkable,
)
try:
from typing import Self
except ImportError:
from typing_extensions import Self
import numpy as np
from PIL.Image import Image as PILImage
import psd_tools.psd.engine_data as engine_data
from psd_tools.api.effects import Effects
from psd_tools.api.mask import Mask
from psd_tools.api.pil_io import get_pil_channels, get_pil_depth
from psd_tools.api.shape import Origination, Stroke, VectorMask
from psd_tools.api.smart_object import SmartObject
from psd_tools.constants import (
BlendMode,
ChannelID,
Clipping,
Compression,
ProtectedFlags,
SectionDivider,
Tag,
TextType,
)
from psd_tools.psd.descriptor import DescriptorBlock
from psd_tools.psd.layer_and_mask import (
ChannelData,
ChannelDataList,
ChannelInfo,
LayerRecord,
)
from psd_tools.psd.patterns import Patterns
from psd_tools.psd.tagged_blocks import (
ProtectedSetting,
SectionDividerSetting,
TaggedBlocks,
)
from psd_tools.terminology import Key
logger = logging.getLogger(__name__)
TGroupMixin = TypeVar("TGroupMixin", bound="GroupMixin")
class Layer(object):
def __init__(
self,
psd: Any,
record: LayerRecord,
channels: ChannelDataList,
parent: TGroupMixin | None,
):
from psd_tools.api.psd_image import PSDImage # Circular import
assert isinstance(psd, PSDImage) or psd is None
self._psd: PSDImage | None = psd
self._record = record
self._channels = channels
self._parent: GroupMixin | None = parent
self._clip_layers: list[Self] = []
self._has_clip_target = True
@property
def name(self) -> str:
"""
Layer name. Writable.
:return: `str`
"""
return self._record.tagged_blocks.get_data(
Tag.UNICODE_LAYER_NAME, self._record.name
)
@name.setter
def name(self, value: str) -> None:
assert len(value) < 256, "Layer name too long (%d) %s" % (len(value), value)
try:
value.encode("macroman")
self._record.name = value
except UnicodeEncodeError:
self._record.name = str("?")
self._record.tagged_blocks.set_data(Tag.UNICODE_LAYER_NAME, value)
@property
def kind(self) -> str:
"""
Kind of this layer, such as group, pixel, shape, type, smartobject,
or psdimage. Class name without `layer` suffix.
:return: `str`
"""
return self.__class__.__name__.lower().replace("layer", "")
@property
def layer_id(self) -> int:
"""
Layer ID.
:return: int layer id. if the layer is not assigned an id, -1.
"""
return self.tagged_blocks.get_data(Tag.LAYER_ID, -1)
def _invalidate_bbox(self) -> None:
"""
Invalidate this layer's _bbox and any parents recursively to the root.
"""
if isinstance(self, (GroupMixin, ShapeLayer)):
self._bbox: tuple[int, int, int, int] | None = None
if isinstance(self.parent, (Group, Artboard)):
self.parent._invalidate_bbox()
@property
def visible(self) -> bool:
"""
Layer visibility. Doesn't take group visibility in account. Writable.
:return: `bool`
"""
return self._record.flags.visible
@visible.setter
def visible(self, value: bool) -> None:
self._invalidate_bbox()
self._record.flags.visible = bool(value)
def is_visible(self) -> bool:
"""
Layer visibility. Takes group visibility in account.
:return: `bool`
"""
return self.visible and self.parent is not None and self.parent.is_visible() # type: ignore
@property
def opacity(self) -> int:
"""
Opacity of this layer in [0, 255] range. Writable.
:return: int
"""
return self._record.opacity
@opacity.setter
def opacity(self, value: int) -> None:
assert 0 <= value and value <= 255
self._record.opacity = int(value)
@property
def parent(self) -> TGroupMixin | None:
"""Parent of this layer."""
return self._parent # type: ignore
def is_group(self) -> bool:
"""
Return True if the layer is a group.
:return: `bool`
"""
return isinstance(self, GroupMixin)
@property
def blend_mode(self) -> BlendMode:
"""
Blend mode of this layer. Writable.
Example::
from psd_tools.constants import BlendMode
if layer.blend_mode == BlendMode.NORMAL:
layer.blend_mode = BlendMode.SCREEN
:return: :py:class:`~psd_tools.constants.BlendMode`.
"""
return self._record.blend_mode
@blend_mode.setter
def blend_mode(self, value: bytes | str | BlendMode) -> None:
if isinstance(value, str):
value = value.encode("ascii")
self._record.blend_mode = BlendMode(value)
@property
def left(self) -> int:
"""
Left coordinate. Writable.
:return: int
"""
return self._record.left
@left.setter
def left(self, value: int) -> None:
self._invalidate_bbox()
w = self.width
self._record.left = int(value)
self._record.right = int(value) + w
@property
def top(self) -> int:
"""
Top coordinate. Writable.
:return: int
"""
return self._record.top
@top.setter
def top(self, value: int) -> None:
self._invalidate_bbox()
h = self.height
self._record.top = int(value)
self._record.bottom = int(value) + h
@property
def right(self) -> int:
"""
Right coordinate.
:return: int
"""
return self._record.right
@property
def bottom(self) -> int:
"""
Bottom coordinate.
:return: int
"""
return self._record.bottom
@property
def width(self) -> int:
"""
Width of the layer.
:return: int
"""
return self.right - self.left
@property
def height(self) -> int:
"""
Height of the layer.
:return: int
"""
return self.bottom - self.top
@property
def offset(self) -> tuple[int, int]:
"""
(left, top) tuple. Writable.
:return: `tuple`
"""
return self.left, self.top
@offset.setter
def offset(self, value: tuple[int, int]) -> None:
self.left, self.top = tuple(int(x) for x in value)
@property
def size(self) -> tuple[int, int]:
"""
(width, height) tuple.
:return: `tuple`
"""
return self.width, self.height
@property
def bbox(self) -> tuple[int, int, int, int]:
"""(left, top, right, bottom) tuple."""
return self.left, self.top, self.right, self.bottom
def has_pixels(self) -> bool:
"""
Returns True if the layer has associated pixels. When this is True,
`topil` method returns :py:class:`PIL.Image`.
:return: `bool`
"""
return any(
ci.id >= 0 and cd.data and len(cd.data) > 0
for ci, cd in zip(self._record.channel_info, self._channels)
)
def has_mask(self) -> bool:
"""
Returns True if the layer has a mask.
:return: `bool`
"""
return self._record.mask_data is not None
@property
def mask(self) -> Mask | None:
"""
Returns mask associated with this layer.
:return: :py:class:`~psd_tools.api.mask.Mask` or `None`
"""
if not hasattr(self, "_mask"):
self._mask = Mask(self) if self.has_mask() else None
return self._mask
def has_vector_mask(self) -> bool:
"""
Returns True if the layer has a vector mask.
:return: `bool`
"""
return any(
key in self.tagged_blocks
for key in (Tag.VECTOR_MASK_SETTING1, Tag.VECTOR_MASK_SETTING2)
)
@property
def vector_mask(self) -> VectorMask | None:
"""
Returns vector mask associated with this layer.
:return: :py:class:`~psd_tools.api.shape.VectorMask` or `None`
"""
if not hasattr(self, "_vector_mask"):
self._vector_mask = None
blocks = self.tagged_blocks
for key in (Tag.VECTOR_MASK_SETTING1, Tag.VECTOR_MASK_SETTING2):
if key in blocks:
self._vector_mask = VectorMask(blocks.get_data(key))
break
return self._vector_mask
def has_origination(self) -> bool:
"""
Returns True if the layer has live shape properties.
:return: `bool`
"""
if self.origination:
return True
return False
@property
def origination(self) -> list[Origination]:
"""
Property for a list of live shapes or a line.
Some of the vector masks have associated live shape properties, that
are Photoshop feature to handle primitive shapes such as a rectangle,
an ellipse, or a line. Vector masks without live shape properties are
plain path objects.
See :py:mod:`psd_tools.api.shape`.
:return: List of :py:class:`~psd_tools.api.shape.Invalidated`,
:py:class:`~psd_tools.api.shape.Rectangle`,
:py:class:`~psd_tools.api.shape.RoundedRectangle`,
:py:class:`~psd_tools.api.shape.Ellipse`, or
:py:class:`~psd_tools.api.shape.Line`.
"""
if not hasattr(self, "_origination"):
data = self.tagged_blocks.get_data(Tag.VECTOR_ORIGINATION_DATA, {})
self._origination: list[Origination] = [
Origination.create(x)
for x in data.get(b"keyDescriptorList", [])
if not data.get(b"keyShapeInvalidated")
]
return self._origination
def has_stroke(self) -> bool:
"""Returns True if the shape has a stroke."""
return Tag.VECTOR_STROKE_DATA in self.tagged_blocks
@property
def stroke(self) -> Stroke | None:
"""Property for strokes."""
if not hasattr(self, "_stroke"):
self._stroke = None
stroke = self.tagged_blocks.get_data(Tag.VECTOR_STROKE_DATA)
if stroke:
self._stroke = Stroke(stroke)
return self._stroke
def lock(self, lock_flags: int = ProtectedFlags.COMPLETE) -> None:
"""
Locks a layer accordind to the combination of flags.
:param lockflags: An integer representing the locking state
Example using the constants of ProtectedFlags and bitwise or operation to lock both pixels and positions::
layer.lock(ProtectedFlags.COMPOSITE | ProtectedFlags.POSITION)
"""
locks = self.locks
if locks is None:
locks = ProtectedSetting(0)
self.tagged_blocks.set_data(Tag.PROTECTED_SETTING, locks)
locks.lock(lock_flags)
def unlock(self) -> None:
self.lock(0)
@property
def locks(self) -> ProtectedSetting | None:
protected_settings_block = self.tagged_blocks.get(Tag.PROTECTED_SETTING)
if protected_settings_block is not None:
return protected_settings_block.data
return None
def topil(
self, channel: int | None = None, apply_icc: bool = True
) -> PILImage | None:
"""
Get PIL Image of the layer.
:param channel: Which channel to return; e.g., 0 for 'R' channel in RGB
image. See :py:class:`~psd_tools.constants.ChannelID`. When `None`,
the method returns all the channels supported by PIL modes.
:param apply_icc: Whether to apply ICC profile conversion to sRGB.
:return: :py:class:`PIL.Image`, or `None` if the layer has no pixels.
Example::
from psd_tools.constants import ChannelID
image = layer.topil()
red = layer.topil(ChannelID.CHANNEL_0)
alpha = layer.topil(ChannelID.TRANSPARENCY_MASK)
.. note:: Not all of the PSD image modes are supported in
:py:class:`PIL.Image`. For example, 'CMYK' mode cannot include
alpha channel in PIL. In this case, topil drops alpha channel.
"""
from .pil_io import convert_layer_to_pil
return convert_layer_to_pil(self, channel, apply_icc)
def numpy(
self, channel: str | None = None, real_mask: bool = True
) -> np.ndarray | None:
"""
Get NumPy array of the layer.
:param channel: Which channel to return, can be 'color',
'shape', 'alpha', or 'mask'. Default is 'color+alpha'.
:return: :py:class:`numpy.ndarray` or None if there is no pixel.
"""
from .numpy_io import get_array
return get_array(self, channel, real_mask=real_mask)
def composite(
self,
viewport: tuple[int, int, int, int] | None = None,
force: bool = False,
color: float | tuple[float, ...] | np.ndarray = 1.0,
alpha: float | np.ndarray = 0.0,
layer_filter: Callable | None = None,
apply_icc: bool = True,
) -> PILImage | None:
"""
Composite layer and masks (mask, vector mask, and clipping layers).
:param viewport: Viewport bounding box specified by (x1, y1, x2, y2)
tuple. Default is the layer's bbox.
:param force: Boolean flag to force vector drawing.
:param color: Backdrop color specified by scalar or tuple of scalar.
The color value should be in [0.0, 1.0]. For example, (1., 0., 0.)
specifies red in RGB color mode.
:param alpha: Backdrop alpha in [0.0, 1.0].
:param layer_filter: Callable that takes a layer as argument and
returns whether if the layer is composited. Default is
:py:func:`~psd_tools.api.layers.PixelLayer.is_visible`.
:return: :py:class:`PIL.Image` or `None`.
"""
from psd_tools.composite import composite_pil
return composite_pil(
self, color, alpha, viewport, layer_filter, force, apply_icc=apply_icc
)
def has_clip_layers(self) -> bool:
"""
Returns True if the layer has associated clipping.
:return: `bool`
"""
return len(self.clip_layers) > 0
@property
def clip_layers(self) -> list[Self]:
"""
Clip layers associated with this layer.
:return: list of layers
"""
return self._clip_layers
@property
def clipping_layer(self) -> bool:
"""
Clipping flag for this layer. Writable.
:return: `bool`
"""
return self._record.clipping == Clipping.NON_BASE
@clipping_layer.setter
def clipping_layer(self, value: bool) -> None:
if self._psd:
self._record.clipping = Clipping.NON_BASE if value else Clipping.BASE
self._psd._compute_clipping_layers()
def has_effects(self) -> bool:
"""
Returns True if the layer has effects.
:return: `bool`
"""
has_effect_tag = any(
tag in self.tagged_blocks
for tag in (
Tag.OBJECT_BASED_EFFECTS_LAYER_INFO,
Tag.OBJECT_BASED_EFFECTS_LAYER_INFO_V0,
Tag.OBJECT_BASED_EFFECTS_LAYER_INFO_V1,
)
)
if not has_effect_tag:
return False
if not self.effects.enabled:
return False
for effect in self.effects:
if effect.enabled:
return True
return False
@property
def effects(self) -> Effects:
"""
Layer effects.
:return: :py:class:`~psd_tools.api.effects.Effects`
"""
if not hasattr(self, "_effects"):
self._effects = Effects(self)
return self._effects
@property
def tagged_blocks(self) -> TaggedBlocks:
"""
Layer tagged blocks that is a dict-like container of settings.
See :py:class:`psd_tools.constants.Tag` for available
keys.
:return: :py:class:`~psd_tools.psd.tagged_blocks.TaggedBlocks`.
Example::
from psd_tools.constants import Tag
metadata = layer.tagged_blocks.get_data(Tag.METADATA_SETTING)
"""
return self._record.tagged_blocks
def __repr__(self) -> str:
has_size = self.width > 0 and self.height > 0
return "%s(%r%s%s%s%s)" % (
self.__class__.__name__,
self.name,
" size=%dx%d" % (self.width, self.height) if has_size else "",
" invisible" if not self.visible else "",
" mask" if self.has_mask() else "",
" effects" if self.has_effects() else "",
)
# Structure operations, supposes unique references to layers, deep copy might be needed in the future
def delete_layer(self) -> Self:
"""
Deletes the layer and all its child layers if the layer is a group from its parent (group or psdimage).
"""
if self.parent is not None and isinstance(self.parent, GroupMixin):
if self in self.parent:
self.parent.remove(self)
self.parent._update_psd_record()
else:
logger.warning(
"Cannot delete layer {} because there is no parent.".format(self)
)
return self
def move_to_group(self, group: "GroupMixin") -> Self:
"""
Moves the layer to the given group, updates the tree metadata as needed.
:param group: The group the current layer will be moved into.
"""
assert isinstance(group, GroupMixin)
assert group is not self
if isinstance(self, GroupMixin):
assert (
group not in self.descendants()
), "Cannot move group {} into its descendant {}".format(self, group)
if self.parent is not None and isinstance(self.parent, GroupMixin):
if self in self.parent:
self.parent.remove(self)
group.append(self)
return self
def move_up(self, offset: int = 1) -> Self:
"""
Moves the layer up a certain offset within the group the layer is in.
:param offset:
"""
assert self.parent is not None and isinstance(self.parent, GroupMixin)
newindex = self.parent.index(self) + offset
if newindex < 0:
newindex = 0
elif newindex >= len(self.parent):
newindex = len(self.parent) - 1
self.parent.remove(self)
self.parent.insert(newindex, self)
return self
def move_down(self, offset: int = 1) -> Self:
"""
Moves the layer down a certain offset within the group the layer is in.
:param offset:
"""
return self.move_up(-1 * offset)
def _fetch_tagged_blocks(self, target_psd: Any) -> None: # Circular import
# Retrieve the patterns contained in the layer current ._psd and add them to the target psd
_psd = target_psd
effects = [effect for effect in self.effects if effect.has_patterns()]
pattern_ids = [
effect.pattern[Key.ID].value.rstrip("\x00") # type: ignore
for effect in effects
]
if pattern_ids:
psd_global_blocks = _psd.tagged_blocks
if psd_global_blocks is None:
psd_global_blocks = TaggedBlocks()
_psd._record.layer_and_mask_information.tagged_blocks = (
psd_global_blocks
)
if Tag.PATTERNS1 not in psd_global_blocks.keys():
psd_global_blocks.set_data(Tag.PATTERNS1, Patterns())
sourcePatterns = []
for tag in (Tag.PATTERNS1, Tag.PATTERNS2, Tag.PATTERNS3):
if (
self._psd is not None
and self._psd.tagged_blocks is not None
and tag in self._psd.tagged_blocks
):
sourcePatterns.extend(self._psd.tagged_blocks.get_data(tag))
# TODO: Use the exact tag.
psd_global_blocks.get(Tag.PATTERNS1).data.extend(
[
pattern
for pattern in sourcePatterns
if pattern.pattern_id in pattern_ids
and pattern.pattern_id
not in [
targetPattern.pattern_id
for targetPattern in psd_global_blocks.get(Tag.PATTERNS1).data
]
]
)
@runtime_checkable
class GroupMixin(Protocol):
_bbox: tuple[int, int, int, int] | None = None
_layers: list[Layer]
_psd: Any # TODO: Circular import
@property
def left(self) -> int:
return self.bbox[0]
@property
def top(self) -> int:
return self.bbox[1]
@property
def right(self) -> int:
return self.bbox[2]
@property
def bottom(self) -> int:
return self.bbox[3]
@property
def bbox(self) -> tuple[int, int, int, int]:
"""(left, top, right, bottom) tuple."""
if self._bbox is None:
self._bbox = Group.extract_bbox(self)
return self._bbox
def __len__(self) -> int:
return self._layers.__len__()
def __iter__(self) -> Iterator[Layer]:
return self._layers.__iter__()
def __getitem__(self, key) -> Layer:
return self._layers.__getitem__(key)
def __setitem__(self, key, value) -> None:
self._check_valid_layers(value)
self._layers.__setitem__(key, value)
self._update_layer_metadata()
self._update_psd_record()
def __delitem__(self, key) -> None:
self._update_psd_record()
self._layers.__delitem__(key)
def append(self, layer: Layer) -> None:
"""
Add a layer to the end (top) of the group
:param layer: The layer to add
"""
assert layer is not self
self.extend([layer])
def extend(self, layers: Iterable[Layer]) -> None:
"""
Add a list of layers to the end (top) of the group
:param layers: The layers to add
"""
self._check_valid_layers(layers)
self._layers.extend(layers)
self._update_layer_metadata()
self._update_psd_record()
def insert(self, index: int, layer: Layer) -> None:
"""
Insert the given layer at the specified index.
:param index:
:param layer:
"""
self._check_valid_layers(layer)
self._layers.insert(index, layer)
self._update_layer_metadata()
self._update_psd_record()
def remove(self, layer: Layer) -> Self:
"""
Removes the specified layer from the group
:param layer:
"""
self._layers.remove(layer)
self._update_psd_record()
return self
def pop(self, index: int = -1) -> Layer:
"""
Removes the specified layer from the list and returns it.
:param index:
"""
popLayer = self._layers.pop(index)
self._update_psd_record()
return popLayer
def clear(self) -> None:
"""
Clears the group.
"""
self._layers.clear()
self._update_psd_record()
def index(self, layer: Layer) -> int:
"""
Returns the index of the specified layer in the group.
:param layer:
"""
return self._layers.index(layer)
def count(self, layer: Layer) -> int:
"""
Counts the number of occurences of a layer in the group.
:param layer:
"""
return self._layers.count(layer)
def _check_valid_layers(self, layers: Layer | Iterable[Layer]) -> None:
assert layers is not self, "Cannot add the group {} to itself.".format(self)
if isinstance(layers, Layer):
layers = [layers]
for layer in layers:
assert isinstance(layer, Layer)
if isinstance(layer, GroupMixin):
assert (
self not in list(layer.descendants())
), "This operation would create a reference loop within the group between {} and {}.".format(
self, layer
)
def _update_layer_metadata(self) -> None:
from psd_tools.api.psd_image import PSDImage # Circular import
_psd: PSDImage | None = self if isinstance(self, PSDImage) else self._psd
for layer in self.descendants():
if layer._psd != _psd and _psd is not None:
if isinstance(layer, PixelLayer):
layer._convert(_psd)
layer._fetch_tagged_blocks(_psd) # type: ignore
layer._psd = _psd
for layer in self._layers[:]:
layer._parent = self
def _update_psd_record(self) -> None:
from psd_tools.api.psd_image import PSDImage # Circular import
if isinstance(self, PSDImage):
self._updated_layers = True # type: ignore
elif self._psd is not None:
self._psd._updated_layers = True
def descendants(self, include_clip: bool = True) -> Iterator[Layer]:
"""
Return a generator to iterate over all descendant layers.
Example::
# Iterate over all layers
for layer in psd.descendants():
print(layer)
# Iterate over all layers in reverse order
for layer in reversed(list(psd.descendants())):
print(layer)
:param include_clip: include clipping layers.
"""
for layer in self:
yield layer
if isinstance(layer, GroupMixin):
for child in layer.descendants(include_clip):
yield child
if include_clip and hasattr(layer, "clip_layers"):
for clip_layer in layer.clip_layers:
yield clip_layer
def find(self, name: str) -> Layer | None:
"""
Returns the first layer found for the given layer name
:param name:
"""
for layer in self.findall(name):
return layer
return None
def findall(self, name: str) -> Iterator[Layer]:
"""
Return a generator to iterate over all layers with the given name.
:param name:
"""
for layer in self.descendants():
if layer.name == name:
yield layer
[docs]
class Group(GroupMixin, Layer):
"""
Group of layers.
Example::
group = psd[1]
for layer in group:
if layer.kind == 'pixel':
print(layer.name)
"""
def __init__(
self,
psd: Any,
record: LayerRecord,
channels: ChannelDataList,
parent: TGroupMixin | None,
):
self._layers = []
self._bounding_record = None
self._bounding_channels = None
Layer.__init__(self, psd, record, channels, parent)
@property
def _setting(self) -> SectionDividerSetting | None:
# Can be None.
return self.tagged_blocks.get_data(Tag.SECTION_DIVIDER_SETTING)
@property
def blend_mode(self) -> BlendMode:
setting = self._setting
if setting is not None:
return setting.blend_mode
return super(Group, self).blend_mode
@blend_mode.setter
def blend_mode(self, value: str | bytes | BlendMode) -> None:
_value = BlendMode(value.encode("ascii") if isinstance(value, str) else value)
if _value == BlendMode.PASS_THROUGH:
self._record.blend_mode = BlendMode.NORMAL
else:
self._record.blend_mode = _value
setting = self._setting
if setting is not None:
setting.blend_mode = _value
[docs]
def composite(
self,
viewport: tuple[int, int, int, int] | None = None,
force: bool = False,
color: float | tuple[float, ...] | np.ndarray = 1.0,
alpha: float | np.ndarray = 0.0,
layer_filter: Callable | None = None,
apply_icc: bool = True,
):
"""
Composite layer and masks (mask, vector mask, and clipping layers).
:param viewport: Viewport bounding box specified by (x1, y1, x2, y2)
tuple. Default is the layer's bbox.
:param force: Boolean flag to force vector drawing.
:param color: Backdrop color specified by scalar or tuple of scalar.
The color value should be in [0.0, 1.0]. For example, (1., 0., 0.)
specifies red in RGB color mode.
:param alpha: Backdrop alpha in [0.0, 1.0].
:param layer_filter: Callable that takes a layer as argument and
returns whether if the layer is composited. Default is
:py:func:`~psd_tools.api.layers.PixelLayer.is_visible`.
:return: :py:class:`PIL.Image`.
"""
from psd_tools.composite import composite_pil
return composite_pil(
self,
color,
alpha,
viewport,
layer_filter,
force,
as_layer=True,
apply_icc=apply_icc,
)
def _set_bounding_records(self, _bounding_record, _bounding_channels) -> None:
# Attributes that store the record for the folder divider.
# Used when updating the record so that we don't need to recompute
# Them from the ending layer
self._bounding_record = _bounding_record
self._bounding_channels = _bounding_channels
return
[docs]
@classmethod
def new(
cls,
name: str = "Group",
open_folder: bool = True,
parent: GroupMixin | None = None,
) -> Self:
"""
Create a new Group object with minimal records and data channels and metadata to properly include the group in the PSD file.
:param name: The display name of the group. Default to "Group".
:param open_folder: Boolean defining whether the folder will be open or closed in photoshop. Default to True.
:param parent: Optional parent folder to move the newly created group into.
:return: A :py:class:`~psd_tools.api.layers.Group` object
"""
record = LayerRecord(top=0, left=0, bottom=0, right=0, name=name)
record.tagged_blocks = TaggedBlocks()
record.tagged_blocks.set_data(
Tag.SECTION_DIVIDER_SETTING,
SectionDivider.OPEN_FOLDER if open_folder else SectionDivider.CLOSED_FOLDER,
)
record.tagged_blocks.set_data(Tag.UNICODE_LAYER_NAME, name)
_bounding_record = LayerRecord(
top=0, left=0, bottom=0, right=0, name="</Layer group>"
)
_bounding_record.tagged_blocks = TaggedBlocks()
_bounding_record.tagged_blocks.set_data(
Tag.SECTION_DIVIDER_SETTING, SectionDivider.BOUNDING_SECTION_DIVIDER
)
_bounding_record.tagged_blocks.set_data(
Tag.UNICODE_LAYER_NAME, "</Layer group>"
)
record.channel_info = [ChannelInfo(id=i - 1, length=2) for i in range(4)]
_bounding_record.channel_info = [
ChannelInfo(id=i - 1, length=2) for i in range(4)
]
channels = ChannelDataList()
for i in range(4):
channels.append(ChannelData(compression=Compression.RAW, data=b""))
_bounding_channels = channels
group = cls(None, record, channels, None)
group._set_bounding_records(_bounding_record, _bounding_channels)
if parent is not None and isinstance(parent, GroupMixin):
group.move_to_group(parent)
return group
[docs]
@classmethod
def group_layers(
cls,
layers: list[Layer],
name: str = "Group",
parent: GroupMixin | None = None,
open_folder: bool = True,
):
"""
Create a new Group object containing the given layers and moved into the parent folder.
If no parent is provided, the group will be put in place of the first layer in the given list. Example below:
:param layers: The layers to group. Can by any subclass of :py:class:`~psd_tools.api.layers.Layer`
:param name: The display name of the group. Default to "Group".
:param parent: The parent group to add the newly created Group object into.
:param open_folder: Boolean defining whether the folder will be open or closed in photoshop. Default to True.
:return: A :py:class:`~psd_tools.api.layers.Group`
"""
assert len(layers) > 0
if parent is None and isinstance(layers[0]._parent, GroupMixin):
parent = layers[0]._parent
else:
# Newly created groups do not have a parent yet.
logger.debug("Failed to find a parent for the new group.")
group = cls.new(name, open_folder)
for layer in layers:
layer.move_to_group(group)
if isinstance(parent, GroupMixin):
parent.append(group)
return group
[docs]
class Artboard(Group):
"""
Artboard is a special kind of group that has a pre-defined viewbox.
"""
@classmethod
def _move(kls, group: Group) -> "Artboard":
assert group.parent is not None
self = kls(group._psd, group._record, group._channels, group.parent) # type: ignore
self._layers = group._layers
self._set_bounding_records(group._bounding_record, group._bounding_channels)
for layer in self._layers:
layer._parent = self
assert self.parent is not None
for index in range(len(self.parent)):
if group == self.parent[index]:
self.parent._layers[index] = self
return self
@property
def left(self) -> int:
return self.bbox[0]
@left.setter
def left(self, value: int) -> None:
raise NotImplementedError("Artboard left position is not writable yet.")
@property
def top(self) -> int:
return self.bbox[1]
@top.setter
def top(self, value: int) -> None:
raise NotImplementedError("Artboard top position is not writable yet.")
@property
def right(self) -> int:
return self.bbox[2]
@property
def bottom(self) -> int:
return self.bbox[3]
@property
def bbox(self) -> tuple[int, int, int, int]:
"""(left, top, right, bottom) tuple."""
if self._bbox is None:
data = None
for key in (Tag.ARTBOARD_DATA1, Tag.ARTBOARD_DATA2, Tag.ARTBOARD_DATA3):
if key in self.tagged_blocks:
data = self.tagged_blocks.get_data(key)
assert data is not None
rect = data.get(b"artboardRect")
self._bbox = (
int(rect.get(b"Left")),
int(rect.get(b"Top ")),
int(rect.get(b"Rght")),
int(rect.get(b"Btom")),
)
return self._bbox
[docs]
class PixelLayer(Layer):
"""
Layer that has rasterized image in pixels.
Example::
assert layer.kind == 'pixel':
image = layer.composite()
image.save('layer.png')
"""
[docs]
@classmethod
def frompil(
cls,
pil_im: PILImage,
psd_file: Any | None = None, # TODO: Fix circular import
layer_name: str = "Layer",
top: int = 0,
left: int = 0,
compression: Compression = Compression.RLE,
**kwargs: Any,
) -> "PixelLayer":
"""
Creates a PixelLayer from a PIL image for a given psd file.
:param pil_im: The :py:class:`~PIL.Image` object to convert to photoshop
:param psdfile: The psd file the image will be converted for.
:param layer_name: The name of the layer. Defaults to "Layer"
:param top: Pixelwise offset from the top of the canvas for the new layer.
:param left: Pixelwise offset from the left of the canvas for the new layer.
:param compression: Compression algorithm to use for the data.
:return: A :py:class:`~psd_tools.api.layers.PixelLayer` object
"""
assert pil_im
if pil_im.mode == "1":
pil_im = pil_im.convert("L")
if psd_file is not None:
pil_im = pil_im.convert(psd_file.pil_mode)
else:
logger.warning(
"No psd file was provided, it will not be possible to convert it when moving to another psd. Might create corrupted psds."
)
if pil_im.mode == "CMYK":
from PIL import ImageChops
pil_im = ImageChops.invert(pil_im)
layer_record = LayerRecord(
top=top,
left=left,
bottom=top + pil_im.height,
right=left + pil_im.width,
**kwargs,
)
channel_data_list = ChannelDataList()
layer_record.name = layer_name
layer_record.channel_info = [ChannelInfo(ChannelID.TRANSPARENCY_MASK, 2)]
# Initialize the alpha channel to full opacity, photoshop sometimes didn't handle the file when not done
channel_data_list.append(ChannelData(compression))
channel_data_list[0].set_data(
b"\xff" * (pil_im.width * pil_im.height),
pil_im.width,
pil_im.height,
get_pil_depth(pil_im.mode.rstrip("A")),
)
layer_record.channel_info[0].length = len(channel_data_list[0].data) + 2
for channel_index in range(get_pil_channels(pil_im.mode.rstrip("A"))):
channel_data = ChannelData(compression)
channel_data.set_data(
pil_im.getchannel(channel_index).tobytes(),
pil_im.width,
pil_im.height,
get_pil_depth(pil_im.mode.rstrip("A")),
)
channel_info = ChannelInfo(
id=ChannelID(channel_index), length=len(channel_data.data) + 2
)
channel_data_list.append(channel_data)
layer_record.channel_info.append(channel_info)
if pil_im.has_transparency_data:
# Need check for other types of transparency, palette for "indexed" mode
transparency_channel_index = pil_im.getbands().index("A")
channel_data_list[0].set_data(
pil_im.getchannel(transparency_channel_index).tobytes(),
pil_im.width,
pil_im.height,
get_pil_depth(pil_im.mode.rstrip("A")),
)
layer_record.channel_info[0].length = len(channel_data_list[0].data) + 2
self = cls(psd_file, layer_record, channel_data_list, None)
return self
def _convert(self, target_psd: Any) -> "PixelLayer":
# assert self._psd is not None, "This layer cannot be converted because it has no psd file linked."
if self._psd is None:
logger.warning(
"This layer {} cannot be converted to the target psd".format(self)
)
return self
if target_psd.pil_mode == self._psd.pil_mode:
return self
rendered_image = self.composite()
if not isinstance(rendered_image, PILImage):
raise ValueError("Failed to render the image for conversion.")
new_layer = PixelLayer.frompil(
rendered_image,
target_psd,
self.name,
self.top,
self.left,
self._channels[0].compression,
)
self._record.channel_info = new_layer._record.channel_info
self._channels = new_layer._channels
return self
[docs]
class SmartObjectLayer(Layer):
"""
Layer that inserts external data.
Use :py:attr:`~psd_tools.api.layers.SmartObjectLayer.smart_object`
attribute to get the external data. See
:py:class:`~psd_tools.api.smart_object.SmartObject`.
Example::
import io
if layer.smart_object.filetype == 'jpg':
image = Image.open(io.BytesIO(layer.smart_object.data))
"""
@property
def smart_object(self) -> SmartObject:
"""
Associated smart object.
:return: :py:class:`~psd_tools.api.smart_object.SmartObject`.
"""
if not hasattr(self, "_smart_object"):
self._smart_object = SmartObject(self)
return self._smart_object
[docs]
class TypeLayer(Layer):
"""
Layer that has text and styling information for fonts or paragraphs.
Text is accessible at :py:attr:`~psd_tools.api.layers.TypeLayer.text`
property. Styling information for paragraphs is in
:py:attr:`~psd_tools.api.layers.TypeLayer.engine_dict`.
Document styling information such as font list is is
:py:attr:`~psd_tools.api.layers.TypeLayer.resource_dict`.
Currently, textual information is read-only.
Example::
if layer.kind == 'type':
print(layer.text)
print(layer.engine_dict['StyleRun'])
# Extract font for each substring in the text.
text = layer.engine_dict['Editor']['Text'].value
fontset = layer.resource_dict['FontSet']
runlength = layer.engine_dict['StyleRun']['RunLengthArray']
rundata = layer.engine_dict['StyleRun']['RunArray']
index = 0
for length, style in zip(runlength, rundata):
substring = text[index:index + length]
stylesheet = style['StyleSheet']['StyleSheetData']
font = fontset[stylesheet['Font']]
print('%r gets %s' % (substring, font))
index += length
"""
def __init__(self, *args: Any):
super(TypeLayer, self).__init__(*args)
self._data = self.tagged_blocks.get_data(Tag.TYPE_TOOL_OBJECT_SETTING)
@property
def text(self) -> str:
"""
Text in the layer. Read-only.
.. note:: New-line character in Photoshop is `'\\\\r'`.
"""
return self._data.text_data.get(b"Txt ").value.rstrip("\x00")
@property
def text_type(self) -> TextType | None:
"""
Text type. Read-only.
:return:
- :py:attr:`psd_tools.constants.TextType.POINT` for point type text (also known as character type)
- :py:attr:`psd_tools.constants.TextType.PARAGRAPH` for paragraph type text (also known as area type)
- `None` if text type cannot be determined or information is unavailable
See :py:class:`psd_tools.constants.TextType`.
"""
shapes = (
self._engine_data.get("EngineDict", {})
.get("Rendered", {})
.get("Shapes", {})
.get("Children", {})
)
if len(shapes) == 1:
text_type = (
shapes[0].get("Cookie", {}).get("Photoshop", {}).get("ShapeType", {})
)
if text_type in (0, 1):
return TextType.POINT if text_type == 0 else TextType.PARAGRAPH
else:
logger.warning(
f"Cannot determine text_type of layer '{self.name}' because information inside ShapeType was not found."
)
elif not shapes:
logger.warning(
f"Cannot determine text_type of layer '{self.name}' because information inside EngineDict was not found."
)
elif len(shapes) > 1:
logger.warning(
f"Cannot determine text_type of layer '{self.name}' because EngineDict has {len(shapes)} shapes."
)
return None
@property
def transform(self) -> tuple[float, float, float, float, float, float]:
"""Matrix (xx, xy, yx, yy, tx, ty) applies affine transformation."""
return self._data.transform
@property
def _engine_data(self) -> engine_data.EngineData | engine_data.EngineData2:
"""Styling and resource information."""
return self._data.text_data.get(b"EngineData").value
@property
def engine_dict(self) -> engine_data.Dict:
"""Styling information dict."""
return self._engine_data.get("EngineDict")
@property
def resource_dict(self) -> engine_data.Dict:
"""Resource set."""
return self._engine_data.get("ResourceDict")
@property
def document_resources(self) -> engine_data.Dict:
"""Resource set relevant to the document."""
return self._engine_data.get("DocumentResources")
@property
def warp(self) -> DescriptorBlock | None:
"""Warp configuration."""
return self._data.warp
[docs]
class ShapeLayer(Layer):
"""
Layer that has drawing in vector mask.
"""
def __init__(self, *args: Any):
super(ShapeLayer, self).__init__(*args)
self._bbox: tuple[int, int, int, int] | None = None
@property
def left(self) -> int:
return self.bbox[0]
@left.setter
def left(self, value: int) -> None:
raise NotImplementedError("ShapeLayer left position is not writable yet.")
@property
def top(self) -> int:
return self.bbox[1]
@top.setter
def top(self, value: int) -> None:
raise NotImplementedError("ShapeLayer top position is not writable yet.")
@property
def right(self) -> int:
return self.bbox[2]
@property
def bottom(self) -> int:
return self.bbox[3]
@property
def bbox(self) -> tuple[int, int, int, int]:
"""(left, top, right, bottom) tuple."""
if self._bbox is None:
if self.has_pixels():
self._bbox = (
self._record.left,
self._record.top,
self._record.right,
self._record.bottom,
)
elif self.has_origination() and not any(
x.invalidated for x in self.origination
):
lefts, tops, rights, bottoms = zip(*[x.bbox for x in self.origination])
self._bbox = (
int(min(lefts)),
int(min(tops)),
int(max(rights)),
int(max(bottoms)),
)
elif self.has_vector_mask():
assert self.vector_mask is not None
bbox = self.vector_mask.bbox
assert self._psd is not None
self._bbox = (
int(round(bbox[0] * self._psd.width)),
int(round(bbox[1] * self._psd.height)),
int(round(bbox[2] * self._psd.width)),
int(round(bbox[3] * self._psd.height)),
)
else:
self._bbox = (0, 0, 0, 0)
assert self._bbox is not None
return self._bbox
class AdjustmentLayer(Layer):
"""Layer that applies specified image adjustment effect."""
def __init__(self, *args: Any):
super(AdjustmentLayer, self).__init__(*args)
self._data = None
if hasattr(self.__class__, "_KEY"):
self._data = self.tagged_blocks.get_data(self.__class__._KEY)
class FillLayer(Layer):
"""Layer that fills the canvas region."""
def __init__(self, *args: Any):
super(FillLayer, self).__init__(*args)
self._data = None
if hasattr(self.__class__, "_KEY"):
self._data = self.tagged_blocks.get_data(self.__class__._KEY)
@property
def right(self) -> int:
if self._record.right:
return self._record.right
if self._psd is None:
raise ValueError("Cannot determine the right position of the layer.")
return self._psd.width
@property
def bottom(self) -> int:
if self._record.bottom:
return self._record.bottom
if self._psd is None:
raise ValueError("Cannot determine the right position of the layer.")
return self._psd.height