Source code for psd_tools.psd.layer_and_mask

"""
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 LayerAndMaskInformation(BaseElement): """ Layer and mask information section. .. py:attribute:: layer_info See :py:class:`.LayerInfo`. .. py:attribute:: global_layer_mask_info See :py:class:`.GlobalLayerMaskInfo`. .. py:attribute:: tagged_blocks See :py:class:`.TaggedBlocks`. """ layer_info: "LayerInfo | None" = None global_layer_mask_info: "GlobalLayerMaskInfo | None" = None tagged_blocks: "TaggedBlocks | None" = None @classmethod def read( cls: type[T_LayerAndMaskInformation], fp: BinaryIO, encoding: str = "macroman", version: int = 1, **kwargs: Any, ) -> T_LayerAndMaskInformation: start_pos = fp.tell() length = read_fmt(("I", "Q")[version - 1], fp)[0] end_pos = fp.tell() + length logger.debug( "reading layer and mask info, len=%d, offset=%d" % (length, start_pos) ) if length == 0: self = cls() else: self = cls._read_body(fp, end_pos, encoding, version) if fp.tell() > end_pos: logger.warning( "LayerAndMaskInformation is broken: current fp=%d, expected=%d" % (fp.tell(), end_pos) ) fp.seek(end_pos, 0) return self @classmethod def _read_body( cls: type[T_LayerAndMaskInformation], fp: BinaryIO, end_pos: int, encoding: str, version: int, ) -> T_LayerAndMaskInformation: layer_info = LayerInfo.read(fp, encoding, version) global_layer_mask_info = None if is_readable(fp, 17) and fp.tell() < end_pos: global_layer_mask_info = GlobalLayerMaskInfo.read(fp) tagged_blocks = None if is_readable(fp): # For some reason, global tagged blocks aligns 4 byte tagged_blocks = TaggedBlocks.read( fp, version=version, padding=4, end_pos=end_pos ) return cls(layer_info, global_layer_mask_info, tagged_blocks) 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 and mask info, len=%d" % (written)) return written fmt = ("I", "Q")[version - 1] return write_length_block(fp, writer, fmt=fmt) def _write_body( self, fp: BinaryIO, encoding: str, version: int, padding: int ) -> int: written = 0 if self.layer_info: written += self.layer_info.write(fp, encoding, version, padding) if self.global_layer_mask_info: written += self.global_layer_mask_info.write(fp) if self.tagged_blocks: written += self.tagged_blocks.write(fp, version=version, padding=4) return written
[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: 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]) 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