"""
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.
"""
import logging
import os
from typing import Any, BinaryIO, Callable, Iterable, Literal, Optional, Union
try:
from typing import Self # type: ignore[attr-defined]
except ImportError:
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.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._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: int = 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.
:return: A :py:class:`~psd_tools.api.psd_image.PSDImage` object.
"""
header = cls._make_header(mode, size, depth)
image_data = ImageData.new(header, color=color, **kwargs)
# TODO: Add default metadata.
return cls(
PSD(
header=header,
image_data=image_data,
image_resources=ImageResources.new(),
)
)
[docs]
@classmethod
def frompil(
cls, image: Image.Image, compression: Compression = Compression.RLE
) -> 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`.
: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)
return cls(
PSD(
header=header,
image_data=image_data,
image_resources=ImageResources.new(),
)
)
[docs]
@classmethod
def open(cls, fp: Union[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: Union[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: Fill in a white background for the given color mode on failure.
# TODO: Set a `has_composite` flag in VersionInfo resource.
try:
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: Union[int, ChannelID, None] = None, apply_icc: bool = True
) -> Union[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: Optional[Literal["color", "shape", "alpha", "mask"]] = 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: Optional[tuple[int, int, int, int]] = None,
force: bool = False,
color: Union[float, tuple[float, ...], np.ndarray, None] = 1.0,
alpha: Union[float, np.ndarray] = 0.0,
layer_filter: Optional[Callable] = 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
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) -> Optional[TaggedBlocks]:
"""
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 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) -> Optional[Image.Image]:
"""
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: Optional[Iterable[layers.Layer]] = 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: Union[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) -> Optional[Any]:
"""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."""
from psd_tools.api import layers
group_stack: list[Union[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: Union[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)