"""
PSD Image module.
This module provides the main :py:class:`PSDImage` class, which is the primary
entry point for users of psd-tools. It represents a complete Photoshop document
and provides high-level methods for reading, manipulating, and saving PSD/PSB files.
The :py:class:`PSDImage` class wraps the low-level :py:class:`~psd_tools.psd.PSD`
structure and reconstructs the layer tree from the flat layer list, making it much
easier to work with layers and groups.
Key functionality:
- **Opening files**: :py:meth:`PSDImage.open` and :py:meth:`PSDImage.new`
- **Layer access**: Iterate, index, and search layers
- **Compositing**: Render layers to PIL Images via :py:meth:`~PSDImage.composite`
- **Saving**: Write modified documents back to PSD format
- **Metadata access**: Document properties, ICC profiles, resolution, etc.
Example usage::
from psd_tools import PSDImage
# Open a PSD file
psd = PSDImage.open('document.psd')
# Access document properties
print(f"Size: {psd.width}x{psd.height}")
print(f"Color mode: {psd.color_mode}")
# Iterate through layers
for layer in psd:
print(f"{layer.name}: {layer.kind}")
# Access layers by index
layer = psd[0] # First layer
# Modify layers
layer.visible = False
layer.opacity = 128
# Composite to image
image = psd.composite()
image.save('output.png')
# Save changes
psd.save('modified.psd')
The class inherits from :py:class:`~psd_tools.api.layers.GroupMixin`, providing
group-like behavior for accessing child layers.
"""
from __future__ import annotations
import logging
import os
from collections.abc import Sequence
from typing import Any, BinaryIO, Callable, Iterable, Literal
from typing_extensions import Self
import numpy as np
from PIL import Image
from psd_tools.api import adjustments, layers, numpy_io, pil_io
from psd_tools.api.protocols import PSDProtocol
from psd_tools.api.utils import (
EXPECTED_CHANNELS,
ColorInput,
denormalize_color,
normalize_color,
)
from psd_tools.constants import (
BlendMode,
ChannelID,
ColorMode,
CompatibilityMode,
Compression,
Resource,
SectionDivider,
Tag,
)
from psd_tools.psd.document import PSD
from psd_tools.psd.header import FileHeader
from psd_tools.psd.image_data import ImageData
from psd_tools.psd.image_resources import ImageResources
from psd_tools.psd.layer_and_mask import (
ChannelImageData,
GlobalLayerMaskInfo,
LayerInfo,
LayerRecords,
)
from psd_tools.psd.patterns import Patterns
from psd_tools.psd.tagged_blocks import TaggedBlocks
logger = logging.getLogger(__name__)
[docs]
class PSDImage(layers.GroupMixin, PSDProtocol):
"""
Photoshop PSD/PSB document.
The low-level data structure is accessible at :py:attr:`PSDImage._record`.
Example::
from psd_tools import PSDImage
psdimage = PSDImage.open('example.psd')
image = psdimage.composite()
for layer in psdimage:
layer_image = layer.composite()
"""
def __init__(self, data: PSD):
if not isinstance(data, PSD):
raise TypeError(f"Expected PSD instance, got {type(data).__name__}")
self._record = data
self._layers: list[layers.Layer] = []
self._compatibility_mode = CompatibilityMode.DEFAULT
self._background_color: float | tuple[float, ...] | None = None
self._updated: bool = False # Flag to check if the layer tree is edited.
self._psd = self # For GroupMixin protocol compatibility.
self._init()
[docs]
@classmethod
def new(
cls,
mode: str,
size: tuple[int, int],
color: ColorInput = 0,
depth: Literal[8, 16, 32] = 8,
**kwargs: Any,
) -> Self:
"""
Create a new PSD document.
:param mode: The color mode to use for the new image.
:param size: A tuple containing (width, height) in pixels.
:param color: What color to use for the image. Default is black.
Accepts a single integer (0-255 for 8-bit) or float in the
[0.0, 1.0] range, or a sequence of per-channel values using
the same ranges (e.g., 3 values for ``"RGB"``, 4 for
``"CMYK"``). Mixed int/float sequences are allowed. The color
is used both as the initial image data fill and as the
:py:attr:`~PSDImage.background_color` compositing backdrop
when saving.
:param depth: Bit depth (8, 16, or 32).
:return: A :py:class:`~psd_tools.api.psd_image.PSDImage` object.
"""
header = cls._make_header(mode, size, depth)
# Strip alpha channel(s) from color for background_color since
# composite() only expects color channels (alpha is separate).
bg_input: ColorInput = color
if isinstance(color, Sequence):
expected = EXPECTED_CHANNELS.get(header.color_mode)
if expected is not None and len(color) > expected:
bg_input = tuple(color[:expected])
bg_color = normalize_color(bg_input, depth, header.color_mode)
fill_color = denormalize_color(color, depth)
image_data = ImageData.new(header, color=fill_color, **kwargs)
# TODO: Add default metadata.
psdimage = cls(
PSD(
header=header,
image_data=image_data,
image_resources=ImageResources.new(),
)
)
psdimage._background_color = bg_color
return psdimage
[docs]
@classmethod
def frompil(
cls,
image: Image.Image,
compression: Compression = Compression.RLE,
color: ColorInput | None = None,
) -> Self:
"""
Create a new layer-less PSD document from PIL Image.
:param image: PIL Image object.
:param compression: ImageData compression option. See
:py:class:`~psd_tools.constants.Compression`.
:param color: Background color for the compositing backdrop when
saving. Accepts the same types as
:py:meth:`~PSDImage.new` ``color``. When set,
:py:meth:`~PSDImage.save` will composite layers against an
opaque backdrop of this color.
:return: A :py:class:`~psd_tools.api.psd_image.PSDImage` object.
"""
header = cls._make_header(image.mode, image.size)
# TODO: Add default metadata.
# TODO: Perhaps make this smart object.
image_data = ImageData(compression=compression)
image_data.set_data([channel.tobytes() for channel in image.split()], header)
psdimage = cls(
PSD(
header=header,
image_data=image_data,
image_resources=ImageResources.new(),
)
)
if color is not None:
psdimage.background_color = color
return psdimage
[docs]
@classmethod
def open(cls, fp: BinaryIO | str | bytes | os.PathLike, **kwargs: Any) -> Self:
"""
Open a PSD document.
:param fp: filename or file-like object.
:param encoding: charset encoding of the pascal string within the file,
default 'macroman'. Some psd files need explicit encoding option.
:return: A :py:class:`~psd_tools.api.psd_image.PSDImage` object.
"""
if isinstance(fp, (str, bytes, os.PathLike)):
with open(fp, "rb") as f:
self = cls(PSD.read(f, **kwargs))
else:
self = cls(PSD.read(fp, **kwargs))
return self
[docs]
def save(
self,
fp: BinaryIO | str | bytes | os.PathLike,
mode: str = "wb",
**kwargs: Any,
) -> None:
"""
Save the PSD file. Updates the ImageData section if the layer structure has been updated.
:param fp: filename or file-like object.
:param encoding: charset encoding of the pascal string within the file,
default 'macroman'.
:param mode: file open mode, default 'wb'.
"""
if self.is_updated():
# Update the preview image if the layer structure has been changed.
# TODO: Set a `has_composite` flag in VersionInfo resource.
try:
if self._background_color is not None:
composited_psd = self.composite(
color=self._background_color, alpha=1.0
).convert(self.pil_mode)
else:
composited_psd = self.composite().convert(self.pil_mode)
self._record.image_data.set_data(
[channel.tobytes() for channel in composited_psd.split()],
self._record.header,
)
except ImportError as e:
logger.warning(
"Failed to update preview image: %s. "
"Install composite dependencies with: pip install 'psd-tools[composite]'",
e,
)
if isinstance(fp, (str, bytes, os.PathLike)):
with open(fp, mode) as f:
self._record.write(f, **kwargs) # type: ignore[arg-type]
else:
self._record.write(fp, **kwargs) # type: ignore[arg-type]
[docs]
def topil(
self, channel: int | ChannelID | None = None, apply_icc: bool = True
) -> Image.Image | None:
"""
Get PIL Image.
: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 composed image is not
available.
"""
if self.has_preview():
return pil_io.convert_image_data_to_pil(self, channel, apply_icc)
return None
[docs]
def numpy(
self, channel: Literal["color", "shape", "alpha", "mask"] | None = None
) -> np.ndarray:
"""
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`
"""
array = numpy_io.get_array(self, channel)
assert array is not None
return array
[docs]
def composite(
self,
viewport: tuple[int, int, int, int] | None = None,
force: bool = False,
color: float | tuple[float, ...] | np.ndarray | None = 1.0,
alpha: float | np.ndarray = 0.0,
layer_filter: Callable | None = None,
ignore_preview: bool = False,
apply_icc: bool = True,
) -> Image.Image:
"""
Composite the PSD image.
:param viewport: Viewport bounding box specified by (x1, y1, x2, y2)
tuple. Default is the viewbox of the PSD.
:param ignore_preview: Boolean flag to whether skip compositing when a
pre-composited preview is available.
: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 # noqa: PLC0415
if (
not (ignore_preview or force or layer_filter)
and self.has_preview()
and not self.is_updated()
):
result = self.topil(apply_icc=apply_icc)
if result is None:
raise ValueError("Failed to composite PSD image from preview")
return result
result = composite_pil(
self,
color if color is not None else 1.0,
alpha,
viewport,
layer_filter,
force,
apply_icc=apply_icc,
)
if result is None:
raise ValueError("Failed to composite PSD image")
return result
def _mark_updated(self) -> None:
"""Mark the layer tree as updated."""
self._updated = True
[docs]
def is_updated(self) -> bool:
"""
Returns whether the layer tree has been updated.
:return: `bool`
"""
return self._updated
@property
def parent(self) -> None:
"""Parent of this layer."""
return None
[docs]
def has_preview(self) -> bool:
"""
Returns if the document has real merged data. When True, `topil()`
returns pre-composed data.
"""
version_info = self.image_resources.get_data(Resource.VERSION_INFO)
if version_info:
return version_info.has_composite
return True # Assuming the image data is valid by default.
@property
def name(self) -> str:
"""
Element name.
:return: `'Root'`
"""
return "Root"
@property
def kind(self) -> str:
"""
Kind.
:return: `'psdimage'`
"""
return self.__class__.__name__.lower()
@property
def visible(self) -> bool:
"""
Visibility.
:return: `True`
"""
return True
@property
def left(self) -> int:
"""
Left coordinate.
:return: `0`
"""
return 0
@property
def top(self) -> int:
"""
Top coordinate.
:return: `0`
"""
return 0
@property
def right(self) -> int:
"""
Right coordinate.
:return: `int`
"""
return self.width
@property
def bottom(self) -> int:
"""
Bottom coordinate.
:return: `int`
"""
return self.height
@property
def width(self) -> int:
"""
Document width.
:return: `int`
"""
return self._record.header.width
@property
def height(self) -> int:
"""
Document height.
:return: `int`
"""
return self._record.header.height
@property
def size(self) -> tuple[int, int]:
"""
(width, height) tuple.
:return: `tuple`
"""
return self.width, self.height
@property
def offset(self) -> tuple[int, int]:
"""
(left, top) tuple.
:return: `tuple`
"""
return self.left, self.top
@property
def bbox(self) -> tuple[int, int, int, int]:
"""
Minimal bounding box that contains all the visible layers.
Use :py:attr:`~psd_tools.api.psd_image.PSDImage.viewbox` to get
viewport bounding box. When the psd is empty, bbox is equal to the
canvas bounding box.
:return: (left, top, right, bottom) `tuple`.
"""
bbox = super(PSDImage, self).bbox
if bbox == (0, 0, 0, 0):
bbox = self.viewbox
return bbox
@property
def viewbox(self) -> tuple[int, int, int, int]:
"""
Return bounding box of the viewport.
:return: (left, top, right, bottom) `tuple`.
"""
return self.left, self.top, self.right, self.bottom
@property
def color_mode(self) -> ColorMode:
"""
Document color mode, such as 'RGB' or 'GRAYSCALE'. See
:py:class:`~psd_tools.constants.ColorMode`.
:return: :py:class:`~psd_tools.constants.ColorMode`
"""
return self._record.header.color_mode
@property
def channels(self) -> int:
"""
Number of color channels.
:return: `int`
"""
return self._record.header.channels
@property
def depth(self) -> int:
"""
Pixel depth bits.
:return: `int`
"""
return self._record.header.depth
@property
def version(self) -> int:
"""
Document version. PSD file is 1, and PSB file is 2.
:return: `int`
"""
return self._record.header.version
@property
def image_resources(self) -> ImageResources:
"""
Document image resources.
:py:class:`~psd_tools.psd.image_resources.ImageResources` is a
dict-like structure that keeps various document settings.
See :py:class:`psd_tools.constants.Resource` for available keys.
:return: :py:class:`~psd_tools.psd.image_resources.ImageResources`
Example::
from psd_tools.constants import Resource
version_info = psd.image_resources.get_data(Resource.VERSION_INFO)
slices = psd.image_resources.get_data(Resource.SLICES)
Image resources contain an ICC profile. The following shows how to
export a PNG file with embedded ICC profile::
from psd_tools.constants import Resource
icc_profile = psd.image_resources.get_data(Resource.ICC_PROFILE)
image = psd.compose(apply_icc=False)
image.save('output.png', icc_profile=icc_profile)
"""
return self._record.image_resources
@property
def tagged_blocks(self) -> TaggedBlocks | None:
"""
Document 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` or
`None`.
Example::
from psd_tools.constants import Tag
patterns = psd.tagged_blocks.get_data(Tag.PATTERNS1)
"""
return self._record.layer_and_mask_information.tagged_blocks
@property
def compatibility_mode(self) -> CompatibilityMode:
"""
Set the compositing and layer organization compatibility mode. Writable.
This property checks whether the PSD file is compatible with
specific authoring tools, such as Photoshop or CLIP Studio Paint.
Different authoring tools may have different ways of handling layers,
such as the use of clipping masks for groups.
Default is Photoshop compatibility mode.
:return: :py:class:`~psd_tools.constants.CompatibilityMode`
"""
return self._compatibility_mode
@compatibility_mode.setter
def compatibility_mode(self, value: CompatibilityMode) -> None:
if self._compatibility_mode != value:
self._mark_updated()
self._compatibility_mode = value
@property
def background_color(self) -> float | tuple[float, ...] | None:
"""
Background color for the merged composite image. Writable.
When set, :py:meth:`~PSDImage.save` composites layers against an
opaque backdrop of this color instead of a transparent one. This is
useful for creating RGB-mode PSD files where transparent areas should
appear as a specific color (e.g., white) in the merged composite.
Values are in [0.0, 1.0] range, matching the
:py:meth:`~PSDImage.composite` ``color`` parameter:
- **RGB / Grayscale**: ``1.0`` = white, ``0.0`` = black
- **CMYK**: ``(0.0, 0.0, 0.0, 0.0)`` = white (no ink)
Use a scalar for uniform color or a tuple for per-channel values.
Set to ``None`` for transparent backdrop (legacy behavior).
Documents created via :py:meth:`~PSDImage.new` have this set
automatically from the ``color`` parameter.
:return: `float`, `tuple[float, ...]`, or `None`
Example::
psd = PSDImage.new('RGB', (640, 480), color=1.0)
psd.save('output.psd') # White background, 3 channels (no alpha)
"""
return self._background_color
@background_color.setter
def background_color(
self,
value: ColorInput | None,
) -> None:
if value is not None:
value = normalize_color(value, self._record.header.depth, self.color_mode)
if self._background_color != value:
self._mark_updated()
self._background_color = value
@property
def pil_mode(self) -> str:
alpha = self.channels - pil_io.get_pil_channels(
pil_io.get_pil_mode(self.color_mode)
)
# TODO: Check when alpha > 1; Photoshop allows multiple alpha channels.
return pil_io.get_pil_mode(self.color_mode, alpha > 0)
[docs]
def has_thumbnail(self) -> bool:
"""True if the PSDImage has a thumbnail resource."""
return (
Resource.THUMBNAIL_RESOURCE in self.image_resources
or Resource.THUMBNAIL_RESOURCE_PS4 in self.image_resources
)
[docs]
def thumbnail(self) -> Image.Image | None:
"""
Returns a thumbnail image in PIL.Image. When the file does not
contain an embedded thumbnail image, returns None.
"""
if Resource.THUMBNAIL_RESOURCE in self.image_resources:
return pil_io.convert_thumbnail_to_pil(
self.image_resources.get_data(Resource.THUMBNAIL_RESOURCE)
)
elif Resource.THUMBNAIL_RESOURCE_PS4 in self.image_resources:
return pil_io.convert_thumbnail_to_pil(
self.image_resources.get_data(Resource.THUMBNAIL_RESOURCE_PS4)
)
return None
# Editing API
[docs]
def create_pixel_layer(
self,
image: Image.Image,
name: str = "Layer",
top: int = 0,
left: int = 0,
compression: Compression = Compression.RLE,
opacity: int = 255,
blend_mode: BlendMode = BlendMode.NORMAL,
) -> layers.PixelLayer:
"""
Create a new pixel layer and add it to the PSDImage.
Example::
psdimage = PSDImage.new("RGB", (640, 480))
layer = psdimage.create_pixel_layer(image, name='Layer 1')
:param name: Name of the new layer.
:param image: PIL Image object.
:param top: Top coordinate of the new layer.
:param left: Left coordinate of the new layer.
:param compression: Compression method for the layer image data.
:param opacity: Opacity of the new layer (0-255).
:param blend_mode: Blend mode of the new layer, default is ``BlendMode.NORMAL``.
:return: The created :py:class:`~psd_tools.api.layers.PixelLayer` object.
"""
layer = layers.PixelLayer.frompil(
image, parent=self, name=name, top=top, left=left, compression=compression
)
layer.opacity = opacity
layer.blend_mode = blend_mode
self._mark_updated()
return layer
[docs]
def create_group(
self,
layer_list: Iterable[layers.Layer] | None = None,
name: str = "Group",
opacity: int = 255,
blend_mode: BlendMode = BlendMode.PASS_THROUGH,
open_folder: bool = True,
) -> layers.Group:
"""
Create a new group layer and add it to the PSDImage.
Example::
group = psdimage.create_group(name='New Group')
group.append(psdimage.create_pixel_layer(image, name='Layer in Group'))
:param layer_list: Optional list of layers to add to the group.
:param name: Name of the new group.
:param opacity: Opacity of the new layer (0-255).
:param blend_mode: Blend mode of the new layer, default is ``BlendMode.PASS_THROUGH``.
:param open_folder: Whether the group is an open folder in the Photoshop UI.
:return: The created :py:class:`~psd_tools.api.layers.Group` object.
"""
group = layers.Group.new(parent=self, name=name, open_folder=open_folder)
if layer_list:
group.extend(layer_list)
group.opacity = opacity
group.blend_mode = blend_mode
self._mark_updated()
return group
# TODO: Add more editing APIs, such as duplicate_layers, resize_canvas, etc.
# Private methods
def __repr__(self) -> str:
return ("%s(mode=%s size=%dx%d depth=%d channels=%d)") % (
self.__class__.__name__,
self.color_mode,
self.width,
self.height,
self._record.header.depth,
self._record.header.channels,
)
def _repr_pretty_(self, p: Any, cycle: bool) -> None:
if cycle:
p.text(self.__repr__())
return
def _pretty(layer: layers.Layer | PSDImage, p: Any) -> None:
p.text(layer.__repr__())
if isinstance(layer, layers.GroupMixin):
with p.indent(2):
for idx, child in enumerate(layer):
p.break_()
p.text("[%d] " % idx)
if child.clipping:
p.text("+")
_pretty(child, p)
_pretty(self, p)
@classmethod
def _make_header(
cls, mode: str, size: tuple[int, int], depth: Literal[8, 16, 32] = 8
) -> FileHeader:
if depth not in (8, 16, 32):
raise ValueError(f"Invalid depth: {depth}. Must be 8, 16, or 32")
if size[0] > 300000:
raise ValueError(f"Width too large: {size[0]} > 300,000")
if size[1] > 300000:
raise ValueError(f"Height too large: {size[1]} > 300,000")
version = 1
if size[0] > 30000 or size[1] > 30000:
logger.debug("Width or height larger than 30,000 pixels")
version = 2
color_mode = pil_io.get_color_mode(mode)
alpha = mode.upper().endswith("A")
channels = ColorMode.channels(color_mode, alpha)
return FileHeader(
version=version,
width=size[0],
height=size[1],
depth=depth,
channels=channels,
color_mode=color_mode,
)
def _get_pattern(self, pattern_id: str) -> Any | None:
"""Get pattern item by id."""
if self.tagged_blocks is None:
return None
for key in (Tag.PATTERNS1, Tag.PATTERNS2, Tag.PATTERNS3):
if key in self.tagged_blocks:
data = self.tagged_blocks.get_data(key)
for pattern in data:
if pattern.pattern_id == pattern_id:
return pattern
return None
def _init(self) -> None:
"""Initialize layer structure."""
group_stack: list[layers.Group | PSDImage] = [self]
for record, channels in self._record._iter_layers():
current_group = group_stack[-1]
blocks = record.tagged_blocks
end_of_group = False
layer: layers.Layer | PSDImage | None = None
divider = blocks.get_data(Tag.SECTION_DIVIDER_SETTING, None)
divider = blocks.get_data(Tag.NESTED_SECTION_DIVIDER_SETTING, divider)
if (
divider is not None
and
# Some PSDs contain dividers with SectionDivider.OTHER.
# Ignoring them, allows the correct categorization of the layer.
# Issue : https://github.com/psd-tools/psd-tools/issues/338
divider.kind is not SectionDivider.OTHER
):
if divider.kind == SectionDivider.BOUNDING_SECTION_DIVIDER:
layer = layers.Group( # type: ignore
parent=current_group,
# We need to fill in the record and channels later.
record=None, # type: ignore
channels=None, # type: ignore
)
layer._set_bounding_records(record, channels)
group_stack.append(layer)
elif divider.kind in (
SectionDivider.OPEN_FOLDER,
SectionDivider.CLOSED_FOLDER,
):
layer = group_stack.pop()
if not isinstance(layer, layers.Group):
raise TypeError(
f"Expected Group layer, got {type(layer).__name__}"
)
# Set the record and channels now.
layer._record = record
layer._channels = channels
# If the group is an artboard, convert it.
for key in (
Tag.ARTBOARD_DATA1,
Tag.ARTBOARD_DATA2,
Tag.ARTBOARD_DATA3,
):
if key in blocks:
layer = layers.Artboard._move(layer)
end_of_group = True
else:
logger.warning("Divider %s found." % divider.kind)
elif Tag.TYPE_TOOL_OBJECT_SETTING in blocks or Tag.TYPE_TOOL_INFO in blocks:
layer = layers.TypeLayer(current_group, record, channels)
elif (
Tag.SMART_OBJECT_LAYER_DATA1 in blocks
or Tag.SMART_OBJECT_LAYER_DATA2 in blocks
or Tag.PLACED_LAYER1 in blocks
or Tag.PLACED_LAYER2 in blocks
):
layer = layers.SmartObjectLayer(current_group, record, channels)
else:
for key in adjustments.TYPES.keys():
if key in blocks:
layer = adjustments.TYPES[key](current_group, record, channels)
break
# If nothing applies, this is either a shape or pixel layer.
shape_condition = record.flags.pixel_data_irrelevant and (
Tag.VECTOR_ORIGINATION_DATA in blocks
or Tag.VECTOR_MASK_SETTING1 in blocks
or Tag.VECTOR_MASK_SETTING2 in blocks
or Tag.VECTOR_STROKE_DATA in blocks
or Tag.VECTOR_STROKE_CONTENT_DATA in blocks
)
if isinstance(layer, (type(None), layers.FillLayer)) and shape_condition:
layer = layers.ShapeLayer(current_group, record, channels)
if layer is None:
layer = layers.PixelLayer(current_group, record, channels)
if layer is None:
raise ValueError("Failed to create layer from record")
if not end_of_group:
if isinstance(layer, PSDImage):
raise TypeError("Cannot add PSDImage as a layer")
current_group._layers.append(layer)
def _update_record(self) -> None:
"""
Compiles the tree layer structure back into records and channels list
recursively from the API layer structure.
"""
# Initialize the layer structure information if not present.
if self._record.layer_and_mask_information.layer_info is None:
self._record.layer_and_mask_information.layer_info = LayerInfo()
if self._record.layer_and_mask_information.global_layer_mask_info is None:
self._record.layer_and_mask_information.global_layer_mask_info = (
GlobalLayerMaskInfo()
)
if self._record.layer_and_mask_information.tagged_blocks is None:
self._record.layer_and_mask_information.tagged_blocks = TaggedBlocks()
# Set layer records and channel image data.
layer_records, channel_image_data = _build_record_tree(self)
layer_info = self._record.layer_and_mask_information.layer_info
layer_info.layer_records = layer_records
layer_info.channel_image_data = channel_image_data
layer_info.layer_count = len(layer_records)
# Flag as updated.
self._mark_updated()
def _copy_patterns(self, psdimage: PSDProtocol) -> None:
"""Copy patterns from this psdimage to the target psdimage."""
if self.tagged_blocks is None:
# Nothing to copy.
return
if psdimage.tagged_blocks is None:
logger.debug("Creating tagged blocks for psdimage.")
psdimage._record.layer_and_mask_information.tagged_blocks = TaggedBlocks()
if psdimage.tagged_blocks is None:
raise ValueError("Failed to create tagged blocks for psdimage")
for tag in self.tagged_blocks.keys():
if not isinstance(tag, Tag):
raise TypeError(f"Expected Tag instance, got {type(tag).__name__}")
if tag in (Tag.PATTERNS1, Tag.PATTERNS2, Tag.PATTERNS3):
logger.debug("Copying patterns for tag %s", tag)
source_patterns: Patterns = self.tagged_blocks.get_data(tag)
target_patterns: Patterns = psdimage.tagged_blocks.get_data(tag)
if target_patterns is None:
target_patterns = Patterns()
psdimage.tagged_blocks.set_data(tag, target_patterns)
target_pattern_ids = {p.pattern_id for p in target_patterns}
for pattern in source_patterns:
if pattern.pattern_id not in target_pattern_ids:
target_patterns.append(pattern)
def _build_record_tree(
layer_group: layers.GroupMixin,
) -> tuple[LayerRecords, ChannelImageData]:
"""
Builds the layer tree structure from records and channels list recursively
"""
layer_records = LayerRecords()
channel_image_data = ChannelImageData()
for layer in layer_group:
if isinstance(layer, (layers.Group, layers.Artboard)):
layer_records.append(layer._bounding_record)
channel_image_data.append(layer._bounding_channels)
tmp_layer_records, tmp_channel_image_data = _build_record_tree(layer)
layer_records.extend(tmp_layer_records)
channel_image_data.extend(tmp_channel_image_data)
layer_records.append(layer._record)
channel_image_data.append(layer._channels)
return (layer_records, channel_image_data)