"""
Layer and mask data structures.
This module implements the low-level binary structures for PSD layers and masks,
corresponding to the "Layer and Mask Information" section of PSD files. This is
one of the most complex parts of the PSD format.
Key classes:
- :py:class:`LayerAndMaskInformation`: Top-level container for all layer data
- :py:class:`LayerInfo`: Contains layer records and channel image data
- :py:class:`LayerRecords`: List of individual layer records
- :py:class:`LayerRecord`: Single layer metadata (name, bounds, blend mode, etc.)
- :py:class:`ChannelInfo`: Channel metadata within a layer record
- :py:class:`ChannelImageData`: Compressed pixel data for all channels
- :py:class:`ChannelData`: Single channel's compressed pixel data
- :py:class:`MaskData`: Layer mask parameters
- :py:class:`GlobalLayerMaskInfo`: Document-wide mask settings
- :py:class:`TaggedBlocks`: Extended layer metadata (see :py:mod:`psd_tools.psd.tagged_blocks`)
The layer structure in PSD files is stored as a flat list with implicit hierarchy.
Group boundaries are marked by special layers with ``SectionDivider`` tagged blocks:
- ``BOUNDING_SECTION_DIVIDER``: Marks the start of a group (the layer that opens the group)
- ``OPEN_FOLDER`` or ``CLOSED_FOLDER``: Marks the end of a group (the closing divider layer)
- ``OPEN_FOLDER``: Group was open in Photoshop UI
- ``CLOSED_FOLDER``: Group was closed in Photoshop UI
The high-level API (:py:mod:`psd_tools.api`) reconstructs this into a proper
tree structure with parent-child relationships.
Each layer record contains:
1. **Metadata**: Rectangle bounds, blend mode, opacity, flags
2. **Channel info**: List of channels (R, G, B, A, masks, etc.) with byte offsets
3. **Blend ranges**: Advanced blending parameters
4. **Layer name**: Pascal string (legacy, inaccurate for Unicode names)
5. **Tagged blocks**: Extended metadata in key-value format
The channel image data section follows all layer records and contains the actual
compressed pixel data for each channel, referenced by the channel info structures.
Example of reading layer metadata::
from psd_tools.psd import PSD
with open('file.psd', 'rb') as f:
psd = PSD.read(f)
layer_info = psd.layer_and_mask_information.layer_info
for record in layer_info.layer_records:
print(f"Layer: {record.name}")
print(f" Bounds: {record.top}, {record.left}, {record.bottom}, {record.right}")
print(f" Blend mode: {record.blend_mode}")
print(f" Channels: {len(record.channel_info)}")
For most use cases, prefer the high-level :py:class:`~psd_tools.api.layers.Layer`
API which provides easier access to this data.
"""
import io
import logging
from typing import Any, BinaryIO, TypeVar
from attrs import define, field, astuple
from psd_tools.compression import compress, decompress
from psd_tools.constants import (
BlendMode,
ChannelID,
Clipping,
Compression,
GlobalLayerMaskKind,
Tag,
)
from psd_tools.psd.base import BaseElement, ListElement
from psd_tools.psd.tagged_blocks import TaggedBlocks, register
from psd_tools.psd.bin_utils import (
is_readable,
read_fmt,
read_length_block,
read_pascal_string,
write_bytes,
write_fmt,
write_length_block,
write_padding,
write_pascal_string,
)
from psd_tools.validators import in_, range_
logger = logging.getLogger(__name__)
T_LayerAndMaskInformation = TypeVar(
"T_LayerAndMaskInformation", bound="LayerAndMaskInformation"
)
T_LayerInfo = TypeVar("T_LayerInfo", bound="LayerInfo")
T_ChannelInfo = TypeVar("T_ChannelInfo", bound="ChannelInfo")
T_LayerFlags = TypeVar("T_LayerFlags", bound="LayerFlags")
T_LayerBlendingRanges = TypeVar("T_LayerBlendingRanges", bound="LayerBlendingRanges")
T_LayerRecords = TypeVar("T_LayerRecords", bound="LayerRecords")
T_LayerRecord = TypeVar("T_LayerRecord", bound="LayerRecord")
T_MaskFlags = TypeVar("T_MaskFlags", bound="MaskFlags")
T_MaskData = TypeVar("T_MaskData", bound="MaskData")
T_MaskParameters = TypeVar("T_MaskParameters", bound="MaskParameters")
T_ChannelImageData = TypeVar("T_ChannelImageData", bound="ChannelImageData")
T_ChannelDataList = TypeVar("T_ChannelDataList", bound="ChannelDataList")
T_ChannelData = TypeVar("T_ChannelData", bound="ChannelData")
T_GlobalLayerMaskInfo = TypeVar("T_GlobalLayerMaskInfo", bound="GlobalLayerMaskInfo")
[docs]
@define(repr=False)
class LayerInfo(BaseElement):
"""
High-level organization of the layer information.
.. py:attribute:: layer_count
Layer count. If it is a negative number, its absolute value is the
number of layers and the first alpha channel contains the transparency
data for the merged result.
.. py:attribute:: layer_records
Information about each layer. See :py:class:`.LayerRecords`.
.. py:attribute:: channel_image_data
Channel image data. See :py:class:`.ChannelImageData`.
"""
layer_count: int = 0
layer_records: "LayerRecords" = field(factory=lambda: LayerRecords())
channel_image_data: "ChannelImageData" = field(factory=lambda: ChannelImageData())
@classmethod
def read(
cls: type[T_LayerInfo],
fp: BinaryIO,
encoding: str = "macroman",
version: int = 1,
**kwargs: Any,
) -> T_LayerInfo:
length = read_fmt(("I", "Q")[version - 1], fp)[0]
logger.debug("reading layer info, len=%d" % length)
end_pos = fp.tell() + length
if length == 0:
self = LayerInfo()
else:
self = cls._read_body(fp, encoding, version)
assert fp.tell() <= end_pos
fp.seek(end_pos, 0)
return self # type: ignore[return-value]
@classmethod
def _read_body(
cls: type[T_LayerInfo], fp: BinaryIO, encoding: str, version: int
) -> T_LayerInfo:
start_pos = fp.tell()
layer_count = read_fmt("h", fp)[0]
layer_records = LayerRecords.read(fp, layer_count, encoding, version)
logger.debug(" read layer records, len=%d" % (fp.tell() - start_pos))
channel_image_data = ChannelImageData.read(fp, layer_records)
return cls(
layer_count=layer_count,
layer_records=layer_records,
channel_image_data=channel_image_data,
)
def write(
self,
fp: BinaryIO,
encoding: str = "macroman",
version: int = 1,
padding: int = 4,
**kwargs: Any,
) -> int:
def writer(f: BinaryIO) -> int:
written = self._write_body(f, encoding, version, padding)
logger.debug("writing layer info, len=%d" % (written))
return written
fmt = ("I", "Q")[version - 1]
if self.layer_count == 0:
return write_fmt(fp, fmt, 0)
return write_length_block(fp, writer, fmt=fmt)
def _write_body(
self, fp: BinaryIO, encoding: str, version: int, padding: int
) -> int:
start_pos = fp.tell()
written = write_fmt(fp, "h", self.layer_count)
if self.layer_records:
self._update_channel_length()
written += self.layer_records.write(fp, encoding, version)
logger.debug(" wrote layer records, len=%d" % (fp.tell() - start_pos))
if self.channel_image_data:
written += self.channel_image_data.write(fp)
# Seems the padding size here is different between Photoshop and GIMP.
written += write_padding(fp, written, padding)
return written
def _update_channel_length(self) -> None:
if not self.layer_records or not self.channel_image_data:
return
for layer, lengths in zip(self.layer_records, self.channel_image_data._lengths):
for channel_info, length in zip(layer.channel_info, lengths):
channel_info.length = length
@register(Tag.LAYER_16)
@register(Tag.LAYER_32)
@define(repr=False)
class LayerInfoBlock(LayerInfo):
""" """
@classmethod
def read(
cls: type[T_LayerInfo],
fp: BinaryIO,
encoding: str = "macroman",
version: int = 1,
**kwargs: Any,
) -> T_LayerInfo:
return cls._read_body(fp, encoding, version)
def write(
self,
fp: BinaryIO,
encoding: str = "macroman",
version: int = 1,
padding: int = 4,
**kwargs: Any,
) -> int:
return self._write_body(fp, encoding, version, padding)
[docs]
@define(repr=False)
class ChannelInfo(BaseElement):
"""
Channel information.
.. py:attribute:: id
Channel ID: 0 = red, 1 = green, etc.; -1 = transparency mask; -2 =
user supplied layer mask, -3 real user supplied layer mask (when both
a user mask and a vector mask are present). See
:py:class:`~psd_tools.constants.ChannelID`.
.. py:attribute:: length
Length of the corresponding channel data.
"""
id: ChannelID = field(
default=ChannelID.CHANNEL_0, converter=ChannelID, validator=in_(ChannelID)
)
length: int = 0
@classmethod
def read(
cls: type[T_ChannelInfo], fp: BinaryIO, version: int = 1, **kwargs: Any
) -> T_ChannelInfo:
values = read_fmt(("hI", "hQ")[version - 1], fp)
return cls(id=values[0], length=values[1])
def write(self, fp: BinaryIO, version: int = 1, **kwargs: Any) -> int:
return write_fmt(fp, ("hI", "hQ")[version - 1], *astuple(self))
[docs]
@define(repr=False)
class LayerFlags(BaseElement):
"""
Layer flags.
Note there are undocumented flags. Maybe photoshop version.
.. py:attribute:: transparency_protected
.. py:attribute:: visible
.. py:attribute:: pixel_data_irrelevant
"""
transparency_protected: bool = False
visible: bool = True
obsolete: bool = field(default=False, repr=False)
photoshop_v5_later: bool = field(default=True, repr=False)
pixel_data_irrelevant: bool = False
undocumented_1: bool = field(default=False, repr=False)
undocumented_2: bool = field(default=False, repr=False)
undocumented_3: bool = field(default=False, repr=False)
@classmethod
def read(cls: type[T_LayerFlags], fp: BinaryIO, **kwargs: Any) -> T_LayerFlags:
flags = read_fmt("B", fp)[0]
return cls(
bool(flags & 1),
not bool(flags & 2), # why "not"?
bool(flags & 4),
bool(flags & 8),
bool(flags & 16),
bool(flags & 32),
bool(flags & 64),
bool(flags & 128),
)
def write(self, fp: BinaryIO, **kwargs: Any) -> int:
flags = (
(self.transparency_protected * 1)
| ((not self.visible) * 2)
| (self.obsolete * 4)
| (self.photoshop_v5_later * 8)
| (self.pixel_data_irrelevant * 16)
| (self.undocumented_1 * 32)
| (self.undocumented_2 * 64)
| (self.undocumented_3 * 128)
)
return write_fmt(fp, "B", flags)
[docs]
@define(repr=False)
class LayerBlendingRanges(BaseElement):
"""
Layer blending ranges.
All ranges contain 2 black values followed by 2 white values.
.. py:attribute:: composite_ranges
List of composite gray blend source and destination ranges.
.. py:attribute:: channel_ranges
List of channel source and destination ranges.
"""
composite_ranges: list[tuple[int, int]] = field(
factory=lambda: [(0, 65535), (0, 65535)]
)
channel_ranges: list[list[tuple[int, int]]] = field(
factory=lambda: [
[(0, 65535), (0, 65535)],
[(0, 65535), (0, 65535)],
[(0, 65535), (0, 65535)],
[(0, 65535), (0, 65535)],
]
)
@classmethod
def read(
cls: type[T_LayerBlendingRanges], fp: BinaryIO, **kwargs: Any
) -> T_LayerBlendingRanges:
data = read_length_block(fp)
if len(data) == 0:
return cls(None, None) # type: ignore[arg-type]
with io.BytesIO(data) as f:
return cls._read_body(f)
@classmethod
def _read_body(
cls: type[T_LayerBlendingRanges], fp: BinaryIO
) -> T_LayerBlendingRanges:
def read_channel_range(f: BinaryIO) -> list[tuple[int, int]]:
values = read_fmt("4H", f)
return [values[0:2], values[2:4]] # type: ignore[return-value]
composite_ranges = read_channel_range(fp)
channel_ranges = []
while is_readable(fp, 8):
channel_ranges.append(read_channel_range(fp))
return cls(composite_ranges, channel_ranges) # type: ignore[arg-type]
def write(self, fp: BinaryIO, **kwargs: Any) -> int:
return write_length_block(fp, lambda f: self._write_body(f))
def _write_body(self, fp: BinaryIO) -> int:
written = 0
if self.composite_ranges is not None:
for x in self.composite_ranges:
written += write_fmt(fp, "2H", *x)
if self.channel_ranges is not None:
for channel in self.channel_ranges:
for x in channel:
written += write_fmt(fp, "2H", *x)
return written
[docs]
class LayerRecords(ListElement):
"""
List of layer records. See :py:class:`.LayerRecord`.
"""
@classmethod
def read( # type: ignore[override]
cls: type[T_LayerRecords],
fp: BinaryIO,
layer_count: int,
encoding: str = "macroman",
version: int = 1,
**kwargs: Any,
) -> T_LayerRecords: # type: ignore[override]
items = []
for _ in range(abs(layer_count)):
items.append(LayerRecord.read(fp, encoding, version))
return cls(items) # type: ignore[arg-type]
[docs]
@define(repr=False)
class LayerRecord(BaseElement):
"""
Layer record.
.. py:attribute:: top
Top position.
.. py:attribute:: left
Left position.
.. py:attribute:: bottom
Bottom position.
.. py:attribute:: right
Right position.
.. py:attribute:: channel_info
List of :py:class:`.ChannelInfo`.
.. py:attribute:: signature
Blend mode signature ``b'8BIM'``.
.. py:attribute:: blend_mode
Blend mode key. See :py:class:`~psd_tools.constants.BlendMode`.
.. py:attribute:: opacity
Opacity, 0 = transparent, 255 = opaque.
.. py:attribute:: clipping
Clipping, 0 = base, 1 = non-base. See
:py:class:`~psd_tools.constants.Clipping`.
.. py:attribute:: flags
See :py:class:`.LayerFlags`.
.. py:attribute:: mask_data
:py:class:`.MaskData` or None.
.. py:attribute:: blending_ranges
See :py:class:`~psd_tools.constants.LayerBlendingRanges`.
.. py:attribute:: name
Layer name.
.. py:attribute:: tagged_blocks
See :py:class:`.TaggedBlocks`.
"""
top: int = 0
left: int = 0
bottom: int = 0
right: int = 0
channel_info: list[ChannelInfo] = field(factory=list)
signature: bytes = field(default=b"8BIM", repr=False, validator=in_((b"8BIM",)))
blend_mode: BlendMode = field(
default=BlendMode.NORMAL, converter=BlendMode, validator=in_(BlendMode)
)
opacity: int = field(default=255, validator=range_(0, 255))
clipping: Clipping = field(
default=Clipping.BASE, converter=Clipping, validator=in_(Clipping)
)
flags: LayerFlags = field(factory=LayerFlags)
mask_data: object = None
blending_ranges: LayerBlendingRanges = field(factory=LayerBlendingRanges)
name: str = ""
tagged_blocks: TaggedBlocks = field(factory=TaggedBlocks)
@classmethod
def read(
cls: type[T_LayerRecord],
fp: BinaryIO,
encoding: str = "macroman",
version: int = 1,
**kwargs: Any,
) -> T_LayerRecord:
start_pos = fp.tell()
top, left, bottom, right, num_channels = read_fmt("4iH", fp)
channel_info = [ChannelInfo.read(fp, version) for i in range(num_channels)]
signature, blend_mode, opacity, clipping = read_fmt("4s4sBB", fp)
flags = LayerFlags.read(fp)
data = read_length_block(fp, fmt="xI")
logger.debug(" read layer record, len=%d" % (fp.tell() - start_pos))
with io.BytesIO(data) as f:
mask_data, blending_ranges, name, tagged_blocks = cls._read_extra(
f, encoding, version
)
self = cls(
top=top,
left=left,
bottom=bottom,
right=right,
channel_info=channel_info,
signature=signature,
blend_mode=blend_mode,
opacity=opacity,
clipping=clipping,
flags=flags,
mask_data=mask_data,
blending_ranges=blending_ranges,
name=name,
tagged_blocks=tagged_blocks,
)
# with io.BytesIO() as f:
# self._write_extra(f, encoding, version)
# assert data == f.getvalue()
return self
@classmethod
def _read_extra(
cls, fp: BinaryIO, encoding: str, version: int
) -> tuple["MaskData | None", LayerBlendingRanges, str, TaggedBlocks]:
mask_data = MaskData.read(fp)
blending_ranges = LayerBlendingRanges.read(fp)
name = read_pascal_string(fp, encoding, padding=4)
tagged_blocks = TaggedBlocks.read(fp, version=version, padding=1)
return mask_data, blending_ranges, name, tagged_blocks
def write(
self, fp: BinaryIO, encoding: str = "macroman", version: int = 1, **kwargs: Any
) -> int:
start_pos = fp.tell()
written = write_fmt(
fp,
"4iH",
self.top,
self.left,
self.bottom,
self.right,
len(self.channel_info),
)
written += sum(c.write(fp, version) for c in self.channel_info)
written += write_fmt(
fp,
"4s4sBB",
self.signature,
self.blend_mode.value,
self.opacity,
self.clipping.value,
)
written += self.flags.write(fp)
def writer(f: BinaryIO) -> int:
written = self._write_extra(f, encoding, version)
logger.debug(" wrote layer record, len=%d" % (fp.tell() - start_pos))
return written
written += write_length_block(fp, writer, fmt="xI")
return written
def _write_extra(self, fp: BinaryIO, encoding: str, version: int) -> int:
written = 0
if self.mask_data and hasattr(self.mask_data, "write"):
written += self.mask_data.write(fp) # type: ignore[attr-defined]
else:
written += write_fmt(fp, "I", 0)
written += self.blending_ranges.write(fp)
written += write_pascal_string(fp, self.name, encoding, padding=4)
written += self.tagged_blocks.write(fp, version, padding=1)
written += write_padding(fp, written, 2)
return written
@property
def width(self) -> int:
"""Width of the layer."""
return max(self.right - self.left, 0)
@property
def height(self) -> int:
"""Height of the layer."""
return max(self.bottom - self.top, 0)
@property
def channel_sizes(self) -> list[tuple[int, int]]:
"""List of channel sizes: [(width, height)]."""
sizes = []
for channel in self.channel_info:
if channel.id == ChannelID.USER_LAYER_MASK:
sizes.append((self.mask_data.width, self.mask_data.height)) # type: ignore[attr-defined]
elif channel.id == ChannelID.REAL_USER_LAYER_MASK:
sizes.append((self.mask_data.real_width, self.mask_data.real_height)) # type: ignore[attr-defined]
else:
sizes.append((self.width, self.height))
return sizes
[docs]
@define(repr=False)
class MaskFlags(BaseElement):
"""
Mask flags.
.. py:attribute:: pos_relative_to_layer
Position relative to layer.
.. py:attribute:: mask_disabled
Layer mask disabled.
.. py:attribute:: invert_mask
Invert layer mask when blending (Obsolete).
.. py:attribute:: user_mask_from_render
The user mask actually came from rendering other data.
.. py:attribute:: parameters_applied
The user and/or vector masks have parameters applied to them.
"""
pos_relative_to_layer: bool = False
mask_disabled: bool = False
invert_mask: bool = False
user_mask_from_render: bool = False
parameters_applied: bool = False
undocumented_1: bool = field(default=False, repr=False)
undocumented_2: bool = field(default=False, repr=False)
undocumented_3: bool = field(default=False, repr=False)
@classmethod
def read(cls: type[T_MaskFlags], fp: BinaryIO, **kwargs: Any) -> T_MaskFlags:
flags = read_fmt("B", fp)[0]
return cls(
bool(flags & 1),
bool(flags & 2),
bool(flags & 4),
bool(flags & 8),
bool(flags & 16),
bool(flags & 32),
bool(flags & 64),
bool(flags & 128),
)
def write(self, fp: BinaryIO, **kwargs: Any) -> int:
flags = (
(self.pos_relative_to_layer * 1)
| (self.mask_disabled * 2)
| (self.invert_mask * 4)
| (self.user_mask_from_render * 8)
| (self.parameters_applied * 16)
| (self.undocumented_1 * 32)
| (self.undocumented_2 * 64)
| (self.undocumented_3 * 128)
)
return write_fmt(fp, "B", flags)
[docs]
@define(repr=False)
class MaskData(BaseElement):
"""
Mask data.
Real user mask is a final composite mask of vector and pixel masks.
.. py:attribute:: top
Top position.
.. py:attribute:: left
Left position.
.. py:attribute:: bottom
Bottom position.
.. py:attribute:: right
Right position.
.. py:attribute:: background_color
Default color. 0 or 255.
.. py:attribute:: flags
See :py:class:`.MaskFlags`.
.. py:attribute:: parameters
:py:class:`.MaskParameters` or None.
.. py:attribute:: real_flags
Real user mask flags. See :py:class:`.MaskFlags`.
.. py:attribute:: real_background_color
Real user mask background. 0 or 255.
.. py:attribute:: real_top
Top position of real user mask.
.. py:attribute:: real_left
Left position of real user mask.
.. py:attribute:: real_bottom
Bottom position of real user mask.
.. py:attribute:: real_right
Right position of real user mask.
"""
top: int = 0
left: int = 0
bottom: int = 0
right: int = 0
background_color: int = 0
flags: MaskFlags = field(factory=MaskFlags)
parameters: "MaskParameters | None" = None
real_flags: MaskFlags | None = None
real_background_color: int | None = None
real_top: int | None = None
real_left: int | None = None
real_bottom: int | None = None
real_right: int | None = None
@classmethod
def read(cls: type[T_MaskData], fp: BinaryIO, **kwargs: Any) -> T_MaskData: # type: ignore[return]
data = read_length_block(fp)
if len(data) == 0:
return None # type: ignore[return-value]
with io.BytesIO(data) as f:
return cls._read_body(f, len(data))
@classmethod
def _read_body(cls: type[T_MaskData], fp: BinaryIO, length: int) -> T_MaskData:
top, left, bottom, right, background_color = read_fmt("4iB", fp)
flags = MaskFlags.read(fp)
# Order is based on tests. The specification is messed up here...
# if length == 20:
# read_fmt('2x', fp)
# return cls(top, left, bottom, right, background_color, flags)
real_flags, real_background_color = None, None
real_top, real_left, real_bottom, real_right = None, None, None, None
if length >= 36:
real_flags = MaskFlags.read(fp)
real_background_color = read_fmt("B", fp)[0]
real_top, real_left, real_bottom, real_right = read_fmt("4i", fp)
parameters = None
if flags.parameters_applied:
parameters = MaskParameters.read(fp)
# logger.debug(' skipping %d' % (len(fp.read())))
return cls(
top=top,
left=left,
bottom=bottom,
right=right,
background_color=background_color,
flags=flags,
parameters=parameters,
real_flags=real_flags,
real_background_color=real_background_color,
real_top=real_top,
real_left=real_left,
real_bottom=real_bottom,
real_right=real_right,
)
def write(self, fp: BinaryIO, **kwargs: Any) -> int:
return write_length_block(fp, lambda f: self._write_body(f))
def _write_body(self, fp: BinaryIO) -> int:
written = write_fmt(
fp,
"4iB",
self.top,
self.left,
self.bottom,
self.right,
self.background_color,
)
written += self.flags.write(fp)
# if self.real_flags is None and self.parameters is None:
# written += write_fmt(fp, '2x')
# assert written == 20
if self.real_flags is not None:
written += self.real_flags.write(fp)
written += write_fmt(
fp,
"B4i",
self.real_background_color,
self.real_top,
self.real_left,
self.real_bottom,
self.real_right,
)
if self.flags.parameters_applied and self.parameters is not None:
written += self.parameters.write(fp)
written += write_padding(fp, written, 4)
return written
@property
def width(self) -> int:
"""Width of the mask."""
return max(self.right - self.left, 0)
@property
def height(self) -> int:
"""Height of the mask."""
return max(self.bottom - self.top, 0)
@property
def real_width(self) -> int:
"""Width of real user mask."""
return max((self.real_right or 0) - (self.real_left or 0), 0)
@property
def real_height(self) -> int:
"""Height of real user mask."""
return max((self.real_bottom or 0) - (self.real_top or 0), 0)
[docs]
@define(repr=False)
class MaskParameters(BaseElement):
"""
Mask parameters.
.. py:attribute:: user_mask_density
.. py:attribute:: user_mask_feather
.. py:attribute:: vector_mask_density
.. py:attribute:: vector_mask_feather
"""
user_mask_density: int | None = None
user_mask_feather: float | None = None
vector_mask_density: int | None = None
vector_mask_feather: float | None = None
@classmethod
def read(
cls: type[T_MaskParameters], fp: BinaryIO, **kwargs: Any
) -> T_MaskParameters:
parameters = read_fmt("B", fp)[0]
user_mask_density = None
user_mask_feather = None
vector_mask_density = None
vector_mask_feather = None
try:
if bool(parameters & 1):
user_mask_density = read_fmt("B", fp)[0]
if bool(parameters & 2):
user_mask_feather = read_fmt("d", fp)[0]
if bool(parameters & 4):
vector_mask_density = read_fmt("B", fp)[0]
if bool(parameters & 8):
vector_mask_feather = read_fmt("d", fp)[0]
except OSError as exc:
logger.warning(
"Truncated MaskParameters data (parameters=0x%02x); some fields will be missing: %s",
parameters,
exc,
)
return cls(
user_mask_density,
user_mask_feather,
vector_mask_density,
vector_mask_feather,
)
def write(self, fp: BinaryIO, **kwargs: Any) -> int:
written = 0
written += write_fmt(
fp,
"B",
(
(1 if self.user_mask_density is not None else 0)
| (2 if self.user_mask_feather is not None else 0)
| (4 if self.vector_mask_density is not None else 0)
| (8 if self.vector_mask_feather is not None else 0)
),
)
if self.user_mask_density is not None:
written += write_fmt(fp, "B", self.user_mask_density)
if self.user_mask_feather is not None:
written += write_fmt(fp, "d", self.user_mask_feather)
if self.vector_mask_density is not None:
written += write_fmt(fp, "B", self.vector_mask_density)
if self.vector_mask_feather is not None:
written += write_fmt(fp, "d", self.vector_mask_feather)
return written
[docs]
class ChannelImageData(ListElement):
"""
List of channel data list.
This size of this list corresponds to the size of
:py:class:`LayerRecords`. Each item corresponds to the channels of each
layer.
See :py:class:`.ChannelDataList`.
"""
@classmethod
def read(
cls: type[T_ChannelImageData],
fp: BinaryIO,
layer_records: "LayerRecords | None" = None,
**kwargs: Any,
) -> T_ChannelImageData:
start_pos = fp.tell()
items = []
if layer_records:
for layer in layer_records:
items.append(ChannelDataList.read(fp, layer.channel_info))
logger.debug(" read channel image data, len=%d" % (fp.tell() - start_pos))
return cls(items) # type: ignore[arg-type]
def write(self, fp: BinaryIO, **kwargs: Any) -> int:
start_pos = fp.tell()
written = sum(item.write(fp) for item in self)
logger.debug(" wrote channel image data, len=%d" % (fp.tell() - start_pos))
return written
@property
def _lengths(self) -> list[list[int]]:
"""List of layer channel lengths."""
return [item._lengths for item in self]
[docs]
class ChannelDataList(ListElement):
"""
List of channel image data, corresponding to each color or alpha.
See :py:class:`.ChannelData`.
"""
@classmethod
def read( # type: ignore[override]
cls: type[T_ChannelDataList],
fp: BinaryIO,
channel_info: list["ChannelInfo"],
**kwargs: Any,
) -> T_ChannelDataList:
items = []
for c in channel_info:
if c.length == 0:
# length=0 means no channel data in the standard location
# (e.g. pixel data is stored in a tagged block). Don't read.
# Note: on save, this channel will be written as a minimal
# ChannelData (compression header only, length=2), which
# differs from the original length=0. This is still a valid
# PSD representation per spec; byte-identical round-tripping
# is not guaranteed for these channels.
logger.debug(" channel %s: length=0, skipping read", c.id)
items.append(ChannelData())
elif c.length == 1:
# length=1 is malformed (spec requires 0 or >=2). Consume the
# stray byte to keep the file pointer aligned, then fall back
# to an empty ChannelData so parsing can continue.
logger.warning(
" channel %s: length=1 is invalid, skipping 1 byte", c.id
)
fp.read(1)
items.append(ChannelData())
else:
items.append(ChannelData.read(fp, c.length - 2, **kwargs))
return cls(items) # type: ignore[arg-type]
@property
def _lengths(self) -> list[int]:
"""List of channel lengths."""
return [item._length for item in self]
[docs]
@define(repr=False)
class ChannelData(BaseElement):
"""
Channel data.
.. py:attribute:: compression
Compression type. See :py:class:`~psd_tools.constants.Compression`.
.. py:attribute:: data
Data.
"""
compression: Compression = field(
default=Compression.RAW, converter=Compression, validator=in_(Compression)
)
data: bytes = b""
@classmethod
def read(
cls: type[T_ChannelData], fp: BinaryIO, length: int = 0, **kwargs: Any
) -> T_ChannelData:
compression = Compression(read_fmt("H", fp)[0])
# length is c.length - 2 (the 2-byte compression header is excluded).
# A negative value here indicates an upstream logic error; warn and
# clamp to 0 rather than letting fp.read(negative) consume until EOF.
if length < 0:
logger.warning(
"ChannelData.read: negative length %d, clamping to 0", length
)
length = 0
data = fp.read(length)
return cls(compression=compression, data=data)
def write(self, fp: BinaryIO, **kwargs: Any) -> int:
written = write_fmt(fp, "H", self.compression.value)
written += write_bytes(fp, self.data)
# written += write_padding(fp, written, 2) # Seems no padding here.
return written
[docs]
def get_data(self, width: int, height: int, depth: int, version: int = 1) -> bytes:
"""Get decompressed channel data.
:param width: width.
:param height: height.
:param depth: bit depth of the pixel.
:param version: psd file version.
:rtype: bytes
"""
return decompress(self.data, self.compression, width, height, depth, version)
[docs]
def set_data(
self, data: bytes, width: int, height: int, depth: int, version: int = 1
) -> int:
"""Set raw channel data and compress to store.
:param data: raw data bytes to write.
:param compression: compression type,
see :py:class:`~psd_tools.constants.Compression`.
:param width: width.
:param height: height.
:param depth: bit depth of the pixel.
:param version: psd file version.
"""
self.data = compress(data, self.compression, width, height, depth, version)
return len(self.data)
@property
def _length(self) -> int:
"""Length of channel data block."""
return 2 + len(self.data)
[docs]
@define(repr=False)
class GlobalLayerMaskInfo(BaseElement):
"""
Global mask information.
.. py:attribute:: overlay_color
Overlay color space (undocumented) and color components.
.. py:attribute:: opacity
Opacity. 0 = transparent, 100 = opaque.
.. py:attribute:: kind
Kind.
0 = Color selected--i.e. inverted;
1 = Color protected;
128 = use value stored per layer. This value is preferred. The others
are for backward compatibility with beta versions.
"""
overlay_color: list[int] | None = None
opacity: int = 0
kind: GlobalLayerMaskKind = field(
default=GlobalLayerMaskKind.PER_LAYER,
converter=GlobalLayerMaskKind,
validator=in_(GlobalLayerMaskKind),
)
@classmethod
def read(
cls: type[T_GlobalLayerMaskInfo], fp: BinaryIO, **kwargs: Any
) -> T_GlobalLayerMaskInfo:
pos = fp.tell()
data = read_length_block(fp) # fmt?
logger.debug("reading global layer mask info, len=%d" % (len(data)))
if len(data) == 0:
return cls(overlay_color=None)
elif len(data) < 13:
logger.warning(
"global layer mask info is broken, expected 13 bytes but found"
" only %d" % (len(data))
)
fp.seek(pos)
return cls(overlay_color=None)
with io.BytesIO(data) as f:
return cls._read_body(f)
@classmethod
def _read_body(
cls: type[T_GlobalLayerMaskInfo], fp: BinaryIO
) -> T_GlobalLayerMaskInfo:
overlay_color = list(read_fmt("5H", fp))
opacity, kind = read_fmt("HB", fp)
return cls(overlay_color, opacity, kind)
def write(self, fp: BinaryIO, **kwargs: Any) -> int:
return write_length_block(fp, lambda f: self._write_body(f))
def _write_body(self, fp: BinaryIO) -> int:
written = 0
if self.overlay_color is not None:
written = write_fmt(fp, "5H", *self.overlay_color)
written += write_fmt(fp, "HB", self.opacity, self.kind.value)
written += write_padding(fp, written, 4)
logger.debug("writing global layer mask info, len=%d" % (written))
return written