Source code for psd_tools.api.layers

"""
Layer module.
"""
from __future__ import absolute_import, unicode_literals

import logging

from psd_tools.api import deprecated
from psd_tools.api.effects import Effects
from psd_tools.api.mask import Mask
from psd_tools.api.shape import Origination, Stroke, VectorMask
from psd_tools.api.smart_object import SmartObject
from psd_tools.constants import BlendMode, Clipping, Tag

logger = logging.getLogger(__name__)


class Layer(object):
    def __init__(self, psd, record, channels, parent):
        self._psd = psd
        self._record = record
        self._channels = channels
        self._parent = parent
        self._clip_layers = []
        self._has_clip_target = True

    @property
    def name(self):
        """
        Layer name. Writable.

        :return: `str`
        """
        return self._record.tagged_blocks.get_data(
            Tag.UNICODE_LAYER_NAME, self._record.name
        )

    @name.setter
    def name(self, value):
        assert len(value) < 256, "Layer name too long (%d) %s" % (len(value), value)
        try:
            value.encode("macroman")
            self._record.name = value
        except UnicodeEncodeError:
            self._record.name = str("?")
        self._record.tagged_blocks.set_data(Tag.UNICODE_LAYER_NAME, value)

    @property
    def kind(self):
        """
        Kind of this layer, such as group, pixel, shape, type, smartobject,
        or psdimage. Class name without `layer` suffix.

        :return: `str`
        """
        return self.__class__.__name__.lower().replace("layer", "")

    @property
    def layer_id(self):
        """
        Layer ID.

        :return: int layer id. if the layer is not assigned an id, -1.
        """
        return self.tagged_blocks.get_data(Tag.LAYER_ID, -1)

    def _invalidate_bbox(self):
        """
        Invalidate this layer's _bbox and any parents recursively to the root.
        """
        current = self
        while current is not None:
            if hasattr(current, "_bbox"):
                delattr(current, "_bbox")
            current = current.parent

    @property
    def visible(self):
        """
        Layer visibility. Doesn't take group visibility in account. Writable.

        :return: `bool`
        """
        return self._record.flags.visible

    @visible.setter
    def visible(self, value):
        self._invalidate_bbox()
        self._record.flags.visible = bool(value)

    def is_visible(self):
        """
        Layer visibility. Takes group visibility in account.

        :return: `bool`
        """
        return self.visible and self.parent.is_visible()

    @property
    def opacity(self):
        """
        Opacity of this layer in [0, 255] range. Writable.

        :return: int
        """
        return self._record.opacity

    @opacity.setter
    def opacity(self, value):
        assert 0 <= value and value <= 255
        self._record.opacity = int(value)

    @property
    def parent(self):
        """Parent of this layer."""
        return self._parent

    def is_group(self):
        """
        Return True if the layer is a group.

        :return: `bool`
        """
        return isinstance(self, GroupMixin)

    @property
    def blend_mode(self):
        """
        Blend mode of this layer. Writable.

        Example::

            from psd_tools.constants import BlendMode
            if layer.blend_mode == BlendMode.NORMAL:
                layer.blend_mode = BlendMode.SCREEN

        :return: :py:class:`~psd_tools.constants.BlendMode`.
        """
        return self._record.blend_mode

    @blend_mode.setter
    def blend_mode(self, value):
        self._record.blend_mode = BlendMode(value)

    @property
    def left(self):
        """
        Left coordinate. Writable.

        :return: int
        """
        return self._record.left

    @left.setter
    def left(self, value):
        self._invalidate_bbox()
        w = self.width
        self._record.left = int(value)
        self._record.right = int(value) + w

    @property
    def top(self):
        """
        Top coordinate. Writable.

        :return: int
        """
        return self._record.top

    @top.setter
    def top(self, value):
        self._invalidate_bbox()
        h = self.height
        self._record.top = int(value)
        self._record.bottom = int(value) + h

    @property
    def right(self):
        """
        Right coordinate.

        :return: int
        """
        return self._record.right

    @property
    def bottom(self):
        """
        Bottom coordinate.

        :return: int
        """
        return self._record.bottom

    @property
    def width(self):
        """
        Width of the layer.

        :return: int
        """
        return self.right - self.left

    @property
    def height(self):
        """
        Height of the layer.

        :return: int
        """
        return self.bottom - self.top

    @property
    def offset(self):
        """
        (left, top) tuple. Writable.

        :return: `tuple`
        """
        return self.left, self.top

    @offset.setter
    def offset(self, value):
        self.left, self.top = tuple(int(x) for x in value)

    @property
    def size(self):
        """
        (width, height) tuple.

        :return: `tuple`
        """
        return self.width, self.height

    @property
    def bbox(self):
        """(left, top, right, bottom) tuple."""
        return self.left, self.top, self.right, self.bottom

    def has_pixels(self):
        """
        Returns True if the layer has associated pixels. When this is True,
        `topil` method returns :py:class:`PIL.Image`.

        :return: `bool`
        """
        return any(
            ci.id >= 0 and cd.data and len(cd.data) > 0
            for ci, cd in zip(self._record.channel_info, self._channels)
        )

    def has_mask(self):
        """
        Returns True if the layer has a mask.

        :return: `bool`
        """
        return self._record.mask_data is not None

    @property
    def mask(self):
        """
        Returns mask associated with this layer.

        :return: :py:class:`~psd_tools.api.mask.Mask` or `None`
        """
        if not hasattr(self, "_mask"):
            self._mask = Mask(self) if self.has_mask() else None
        return self._mask

    def has_vector_mask(self):
        """
        Returns True if the layer has a vector mask.

        :return: `bool`
        """
        return any(
            key in self.tagged_blocks
            for key in (Tag.VECTOR_MASK_SETTING1, Tag.VECTOR_MASK_SETTING2)
        )

    @property
    def vector_mask(self):
        """
        Returns vector mask associated with this layer.

        :return: :py:class:`~psd_tools.api.shape.VectorMask` or `None`
        """
        if not hasattr(self, "_vector_mask"):
            self._vector_mask = None
            blocks = self.tagged_blocks
            for key in (Tag.VECTOR_MASK_SETTING1, Tag.VECTOR_MASK_SETTING2):
                if key in blocks:
                    self._vector_mask = VectorMask(blocks.get_data(key))
                    break
        return self._vector_mask

    def has_origination(self):
        """
        Returns True if the layer has live shape properties.

        :return: `bool`
        """
        if self.origination:
            return True
        return False

    @property
    def origination(self):
        """
        Property for a list of live shapes or a line.

        Some of the vector masks have associated live shape properties, that
        are Photoshop feature to handle primitive shapes such as a rectangle,
        an ellipse, or a line. Vector masks without live shape properties are
        plain path objects.

        See :py:mod:`psd_tools.api.shape`.

        :return: List of :py:class:`~psd_tools.api.shape.Invalidated`,
            :py:class:`~psd_tools.api.shape.Rectangle`,
            :py:class:`~psd_tools.api.shape.RoundedRectangle`,
            :py:class:`~psd_tools.api.shape.Ellipse`, or
            :py:class:`~psd_tools.api.shape.Line`.
        """
        if not hasattr(self, "_origination"):
            data = self.tagged_blocks.get_data(Tag.VECTOR_ORIGINATION_DATA, {})
            self._origination = [
                Origination.create(x)
                for x in data.get(b"keyDescriptorList", [])
                if not data.get(b"keyShapeInvalidated")
            ]
        return self._origination

    def has_stroke(self):
        """Returns True if the shape has a stroke."""
        return Tag.VECTOR_STROKE_DATA in self.tagged_blocks

    @property
    def stroke(self):
        """Property for strokes."""
        if not hasattr(self, "_stroke"):
            self._stroke = None
            stroke = self.tagged_blocks.get_data(Tag.VECTOR_STROKE_DATA)
            if stroke:
                self._stroke = Stroke(stroke)
        return self._stroke

    def topil(self, channel=None, apply_icc=False):
        """
        Get PIL Image of the layer.

        :param channel: Which channel to return; e.g., 0 for 'R' channel in RGB
            image. See :py:class:`~psd_tools.constants.ChannelID`. When `None`,
            the method returns all the channels supported by PIL modes.
        :param apply_icc: Whether to apply ICC profile conversion to sRGB.
        :return: :py:class:`PIL.Image`, or `None` if the layer has no pixels.

        Example::

            from psd_tools.constants import ChannelID

            image = layer.topil()
            red = layer.topil(ChannelID.CHANNEL_0)
            alpha = layer.topil(ChannelID.TRANSPARENCY_MASK)

        .. note:: Not all of the PSD image modes are supported in
            :py:class:`PIL.Image`. For example, 'CMYK' mode cannot include
            alpha channel in PIL. In this case, topil drops alpha channel.
        """
        from .pil_io import convert_layer_to_pil

        return convert_layer_to_pil(self, channel, apply_icc)

    @deprecated
    def compose(self, force=False, bbox=None, layer_filter=None):
        """
        Deprecated, use :py:func:`~psd_tools.api.layers.PixelLayer.composite`.

        Compose layer and masks (mask, vector mask, and clipping layers).

        Note that the resulting image size is not necessarily equal to the
        layer size due to different mask dimensions. The offset of the
        composed image is stored at `.info['offset']` attribute of `PIL.Image`.

        :param bbox: Viewport bounding box specified by (x1, y1, x2, y2) tuple.
        :return: :py:class:`PIL.Image`, or `None` if the layer has no pixel.
        """
        from psd_tools.composer import compose, compose_layer

        if self.bbox == (0, 0, 0, 0):
            return None
        if bbox is None:
            return compose_layer(self, force=force)
        return compose(self, force=force, bbox=bbox, layer_filter=layer_filter)

    def numpy(self, channel=None, real_mask=True):
        """
        Get NumPy array of the layer.

        :param channel: Which channel to return, can be 'color',
            'shape', 'alpha', or 'mask'. Default is 'color+alpha'.
        :return: :py:class:`numpy.ndarray` or None if there is no pixel.
        """
        from .numpy_io import get_array

        return get_array(self, channel, real_mask=real_mask)

    def composite(
        self,
        viewport=None,
        force=False,
        color=1.0,
        alpha=0.0,
        layer_filter=None,
        apply_icc=False,
    ):
        """
        Composite layer and masks (mask, vector mask, and clipping layers).

        :param viewport: Viewport bounding box specified by (x1, y1, x2, y2)
            tuple. Default is the layer's bbox.
        :param force: Boolean flag to force vector drawing.
        :param color: Backdrop color specified by scalar or tuple of scalar.
            The color value should be in [0.0, 1.0]. For example, (1., 0., 0.)
            specifies red in RGB color mode.
        :param alpha: Backdrop alpha in [0.0, 1.0].
        :param layer_filter: Callable that takes a layer as argument and
            returns whether if the layer is composited. Default is
            :py:func:`~psd_tools.api.layers.PixelLayer.is_visible`.
        :return: :py:class:`PIL.Image`.
        """
        from psd_tools.composite import composite_pil

        return composite_pil(
            self, color, alpha, viewport, layer_filter, force, apply_icc=apply_icc
        )

    def has_clip_layers(self):
        """
        Returns True if the layer has associated clipping.

        :return: `bool`
        """
        return len(self.clip_layers) > 0

    @property
    def clip_layers(self):
        """
        Clip layers associated with this layer.

        To compose clipping layers::

            from psd_tools import compose
            clip_mask = compose(layer.clip_layers)

        :return: list of layers
        """
        return self._clip_layers

    @property
    def clipping_layer(self):
        """
        Clipping flag for this layer. Writable.

        :return: `bool`
        """
        return self._record.clipping == Clipping.NON_BASE

    @clipping_layer.setter
    def clipping_layer(self, value):
        self._record.clipping = Clipping.NON_BASE if value else Clipping.Base
        self._psd._compute_clipping_layers()

    def has_effects(self):
        """
        Returns True if the layer has effects.

        :return: `bool`
        """
        has_effect_tag = any(
            tag in self.tagged_blocks
            for tag in (
                Tag.OBJECT_BASED_EFFECTS_LAYER_INFO,
                Tag.OBJECT_BASED_EFFECTS_LAYER_INFO_V0,
                Tag.OBJECT_BASED_EFFECTS_LAYER_INFO_V1,
            )
        )
        if not has_effect_tag:
            return False
        if not self.effects.enabled:
            return False
        for effect in self.effects:
            if effect.enabled:
                return True
        return False

    @property
    def effects(self):
        """
        Layer effects.

        :return: :py:class:`~psd_tools.api.effects.Effects`
        """
        if not hasattr(self, "_effects"):
            self._effects = Effects(self)
        return self._effects

    @property
    def tagged_blocks(self):
        """
        Layer tagged blocks that is a dict-like container of settings.

        See :py:class:`psd_tools.constants.Tag` for available
        keys.

        :return: :py:class:`~psd_tools.psd.tagged_blocks.TaggedBlocks` or
            `None`.

        Example::

            from psd_tools.constants import Tag
            metadata = layer.tagged_blocks.get_data(Tag.METADATA_SETTING)
        """
        return self._record.tagged_blocks

    def __repr__(self):
        has_size = self.width > 0 and self.height > 0
        return "%s(%r%s%s%s%s)" % (
            self.__class__.__name__,
            self.name,
            " size=%dx%d" % (self.width, self.height) if has_size else "",
            " invisible" if not self.visible else "",
            " mask" if self.has_mask() else "",
            " effects" if self.has_effects() else "",
        )


class GroupMixin(object):
    @property
    def left(self):
        return self.bbox[0]

    @property
    def top(self):
        return self.bbox[1]

    @property
    def right(self):
        return self.bbox[2]

    @property
    def bottom(self):
        return self.bbox[3]

    @property
    def bbox(self):
        """(left, top, right, bottom) tuple."""
        if not hasattr(self, "_bbox"):
            self._bbox = Group.extract_bbox(self)
        return self._bbox

    def __len__(self):
        return self._layers.__len__()

    def __iter__(self):
        return self._layers.__iter__()

    def __getitem__(self, key):
        return self._layers.__getitem__(key)

    def __setitem__(self, key, value):
        return self._layers.__setitem__(key, value)

    def __delitem__(self, key):
        return self._layers.__delitem__(key)

    @deprecated
    def compose(
        self, force=False, bbox=None, layer_filter=None, context=None, color=None
    ):
        """
        Compose layer and masks (mask, vector mask, and clipping layers).

        :return: PIL Image object, or None if the layer has no pixels.
        """
        from psd_tools.composer import compose

        return compose(
            self,
            force=force,
            context=context,
            bbox=bbox,
            layer_filter=layer_filter,
            color=color,
        )

    def descendants(self, include_clip=True):
        """
        Return a generator to iterate over all descendant layers.

        Example::

            # Iterate over all layers
            for layer in psd.descendants():
                print(layer)

            # Iterate over all layers in reverse order
            for layer in reversed(list(psd.descendants())):
                print(layer)

        :param include_clip: include clipping layers.
        """
        for layer in self:
            yield layer
            if layer.is_group():
                for child in layer.descendants(include_clip):
                    yield child
            if include_clip and hasattr(layer, "clip_layers"):
                for clip_layer in layer.clip_layers:
                    yield clip_layer


[docs] class Group(GroupMixin, Layer): """ Group of layers. Example:: group = psd[1] for layer in group: if layer.kind == 'pixel': print(layer.name) """
[docs] @staticmethod def extract_bbox(layers, include_invisible=False): """ Returns a bounding box for ``layers`` or (0, 0, 0, 0) if the layers have no bounding box. :param include_invisible: include invisible layers in calculation. :return: tuple of four int """ def _get_bbox(layer, **kwargs): if layer.is_group(): return Group.extract_bbox(layer, **kwargs) else: return layer.bbox if not hasattr(layers, "__iter__"): layers = [layers] bboxes = [ _get_bbox(layer, include_invisible=include_invisible) for layer in layers if include_invisible or layer.is_visible() ] bboxes = [bbox for bbox in bboxes if bbox != (0, 0, 0, 0)] if len(bboxes) == 0: # Empty bounding box. return (0, 0, 0, 0) lefts, tops, rights, bottoms = zip(*bboxes) return (min(lefts), min(tops), max(rights), max(bottoms))
def __init__(self, *args): super(Group, self).__init__(*args) self._layers = [] @property def _setting(self): # Can be None. return self.tagged_blocks.get_data(Tag.SECTION_DIVIDER_SETTING) @property def blend_mode(self): setting = self._setting if setting: return self._setting.blend_mode return super(Group, self).blend_mode @blend_mode.setter def blend_mode(self, value): _value = BlendMode(value) if _value == BlendMode.PASS_THROUGH: self._record.blend_mode = BlendMode.NORMAL else: self._record.blend_mode = _value setting = self._setting if setting: setting.blend_mode = _value
[docs] def composite( self, viewport=None, force=False, color=1.0, alpha=0.0, layer_filter=None, apply_icc=False, ): """ Composite layer and masks (mask, vector mask, and clipping layers). :param viewport: Viewport bounding box specified by (x1, y1, x2, y2) tuple. Default is the layer's bbox. :param force: Boolean flag to force vector drawing. :param color: Backdrop color specified by scalar or tuple of scalar. The color value should be in [0.0, 1.0]. For example, (1., 0., 0.) specifies red in RGB color mode. :param alpha: Backdrop alpha in [0.0, 1.0]. :param layer_filter: Callable that takes a layer as argument and returns whether if the layer is composited. Default is :py:func:`~psd_tools.api.layers.PixelLayer.is_visible`. :return: :py:class:`PIL.Image`. """ from psd_tools.composite import composite_pil return composite_pil( self, color, alpha, viewport, layer_filter, force, as_layer=True, apply_icc=apply_icc, )
[docs] class Artboard(Group): """ Artboard is a special kind of group that has a pre-defined viewbox. Example:: artboard = psd[1] image = artboard.compose() """ @classmethod def _move(kls, group): self = kls(group._psd, group._record, group._channels, group._parent) self._layers = group._layers for layer in self._layers: layer._parent = self for index in range(len(self.parent)): if group == self.parent[index]: self.parent._layers[index] = self return self @property def left(self): return self.bbox[0] @property def top(self): return self.bbox[1] @property def right(self): return self.bbox[2] @property def bottom(self): return self.bbox[3] @property def bbox(self): """(left, top, right, bottom) tuple.""" if not hasattr(self, "_bbox"): data = None for key in (Tag.ARTBOARD_DATA1, Tag.ARTBOARD_DATA2, Tag.ARTBOARD_DATA3): if key in self.tagged_blocks: data = self.tagged_blocks.get_data(key) assert data is not None rect = data.get(b"artboardRect") self._bbox = ( int(rect.get(b"Left")), int(rect.get(b"Top ")), int(rect.get(b"Rght")), int(rect.get(b"Btom")), ) return self._bbox
[docs] def compose(self, bbox=None, **kwargs): """ Compose the artboard. See :py:func:`~psd_tools.compose` for available extra arguments. :param bbox: Viewport tuple (left, top, right, bottom). :return: :py:class:`PIL.Image`, or `None` if there is no pixel. """ from psd_tools.composer import compose return compose(self, bbox=bbox or self.bbox, **kwargs)
[docs] class PixelLayer(Layer): """ Layer that has rasterized image in pixels. Example:: assert layer.kind == 'pixel': image = layer.topil() image.save('layer.png') composed_image = layer.compose() composed_image.save('composed-layer.png') """ pass
[docs] class SmartObjectLayer(Layer): """ Layer that inserts external data. Use :py:attr:`~psd_tools.api.layers.SmartObjectLayer.smart_object` attribute to get the external data. See :py:class:`~psd_tools.api.smart_object.SmartObject`. Example:: import io if layer.smart_object.filetype == 'jpg': image = Image.open(io.BytesIO(layer.smart_object.data)) """ @property def smart_object(self): """ Associated smart object. :return: :py:class:`~psd_tools.api.smart_object.SmartObject`. """ if not hasattr(self, "_smart_object"): self._smart_object = SmartObject(self) return self._smart_object
[docs] class TypeLayer(Layer): """ Layer that has text and styling information for fonts or paragraphs. Text is accessible at :py:attr:`~psd_tools.api.layers.TypeLayer.text` property. Styling information for paragraphs is in :py:attr:`~psd_tools.api.layers.TypeLayer.engine_dict`. Document styling information such as font list is is :py:attr:`~psd_tools.api.layers.TypeLayer.resource_dict`. Currently, textual information is read-only. Example:: if layer.kind == 'type': print(layer.text) print(layer.engine_dict['StyleRun']) # Extract font for each substring in the text. text = layer.engine_dict['Editor']['Text'].value fontset = layer.resource_dict['FontSet'] runlength = layer.engine_dict['StyleRun']['RunLengthArray'] rundata = layer.engine_dict['StyleRun']['RunArray'] index = 0 for length, style in zip(runlength, rundata): substring = text[index:index + length] stylesheet = style['StyleSheet']['StyleSheetData'] font = fontset[stylesheet['Font']] print('%r gets %s' % (substring, font)) index += length """ def __init__(self, *args): super(TypeLayer, self).__init__(*args) self._data = self.tagged_blocks.get_data(Tag.TYPE_TOOL_OBJECT_SETTING) @property def text(self): """ Text in the layer. Read-only. .. note:: New-line character in Photoshop is `'\\\\r'`. """ return self._data.text_data.get(b"Txt ").value.rstrip("\x00") @property def transform(self): """Matrix (xx, xy, yx, yy, tx, ty) applies affine transformation.""" return self._data.transform @property def _engine_data(self): """Styling and resource information.""" return self._data.text_data.get(b"EngineData").value @property def engine_dict(self): """Styling information dict.""" return self._engine_data.get("EngineDict") @property def resource_dict(self): """Resource set.""" return self._engine_data.get("ResourceDict") @property def document_resources(self): """Resource set relevant to the document.""" return self._engine_data.get("DocumentResources") @property def warp(self): """Warp configuration.""" return self._data.warp
[docs] class ShapeLayer(Layer): """ Layer that has drawing in vector mask. """ @property def left(self): return self.bbox[0] @property def top(self): return self.bbox[1] @property def right(self): return self.bbox[2] @property def bottom(self): return self.bbox[3] @property def bbox(self): """(left, top, right, bottom) tuple.""" if not hasattr(self, "_bbox"): if self.has_pixels(): self._bbox = ( self._record.left, self._record.top, self._record.right, self._record.bottom, ) elif self.has_origination() and not any( x.invalidated for x in self.origination ): lefts, tops, rights, bottoms = zip(*[x.bbox for x in self.origination]) self._bbox = ( int(min(lefts)), int(min(tops)), int(max(rights)), int(max(bottoms)), ) elif self.has_vector_mask(): bbox = self.vector_mask.bbox self._bbox = ( int(round(bbox[0] * self._psd.width)), int(round(bbox[1] * self._psd.height)), int(round(bbox[2] * self._psd.width)), int(round(bbox[3] * self._psd.height)), ) else: self._bbox = (0, 0, 0, 0) return self._bbox
class AdjustmentLayer(Layer): """Layer that applies specified image adjustment effect.""" def __init__(self, *args): super(AdjustmentLayer, self).__init__(*args) self._data = None if hasattr(self.__class__, "_KEY"): self._data = self.tagged_blocks.get_data(self.__class__._KEY) def compose(self, **kwargs): """ Adjustment layer is never composed. :return: None """ return None class FillLayer(Layer): """Layer that fills the canvas region.""" def __init__(self, *args): super(FillLayer, self).__init__(*args) self._data = None if hasattr(self.__class__, "_KEY"): self._data = self.tagged_blocks.get_data(self.__class__._KEY) @property def left(self): return self._record.left @property def top(self): return self._record.top @property def right(self): return self._record.right or self._psd.width @property def bottom(self): return self._record.bottom or self._psd.height