Source code for psd_tools.api.psd_image

"""
PSD Image module.
"""
from __future__ import absolute_import, unicode_literals

import logging

from psd_tools.api import adjustments, deprecated
from psd_tools.api.layers import (
    Artboard,
    FillLayer,
    Group,
    GroupMixin,
    PixelLayer,
    ShapeLayer,
    SmartObjectLayer,
    TypeLayer,
)
from psd_tools.constants import (
    BlendMode,
    ColorMode,
    CompatibilityMode,
    Compression,
    Resource,
    SectionDivider,
    Tag,
)
from psd_tools.psd import PSD, FileHeader, ImageData, ImageResources

logger = logging.getLogger(__name__)


[docs] class PSDImage(GroupMixin): """ Photoshop PSD/PSB file object. The low-level data structure is accessible at :py:attr:`PSDImage._record`. Example:: from psd_tools import PSDImage psd = PSDImage.open('example.psd') image = psd.compose() for layer in psd: layer_image = layer.compose() """ def __init__(self, data): assert isinstance(data, PSD) self._record = data self._layers = [] self._tagged_blocks = None self._compatibility_mode = CompatibilityMode.DEFAULT self._init()
[docs] @classmethod def new(cls, mode, size, color=0, depth=8, **kwargs): """ 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, compression=Compression.RLE): """ Create a new 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, **kwargs): """ 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 hasattr(fp, "read"): self = cls(PSD.read(fp, **kwargs)) else: with open(fp, "rb") as f: self = cls(PSD.read(f, **kwargs)) return self
[docs] def save(self, fp, mode="wb", **kwargs): """ Save the PSD file. :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 hasattr(fp, "write"): self._record.write(fp, **kwargs) else: with open(fp, mode) as f: self._record.write(f, **kwargs)
[docs] def topil(self, channel=None, apply_icc=False): """ 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. """ from .pil_io import convert_image_data_to_pil if self.has_preview(): return convert_image_data_to_pil(self, channel, apply_icc) return None
[docs] @deprecated def compose(self, force=False, bbox=None, layer_filter=None): """ Deprecated, use :py:func:`~psd_tools.PSDImage.composite`. Compose the PSD image. :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 image = None if (not force or len(self) == 0) and not bbox and not layer_filter: image = self.topil() if image is None: image = compose( self, bbox=bbox or self.viewbox, force=force, layer_filter=layer_filter, ) elif bbox is not None: image = image.crop(bbox) return image
[docs] def numpy(self, channel=None): """ 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` """ from .numpy_io import get_array return get_array(self, channel)
[docs] def composite( self, viewport=None, force=False, color=1.0, alpha=0.0, layer_filter=None, ignore_preview=False, apply_icc=False, ): """ 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(): return self.topil(apply_icc=apply_icc) return composite_pil( self, color, alpha, viewport, layer_filter, force, apply_icc=apply_icc )
[docs] def is_visible(self): """ Returns visibility of the element. :return: `bool` """ return self.visible
@property def parent(self): """Parent of this layer.""" return None
[docs] def is_group(self): """ Return True if the layer is a group. :return: `bool` """ return isinstance(self, GroupMixin)
[docs] def has_preview(self): """ 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): """ Element name. :return: `'Root'` """ return "Root" @property def kind(self): """ Kind. :return: `'psdimage'` """ return self.__class__.__name__.lower() @property def visible(self): """ Visibility. :return: `True` """ return True @property def left(self): """ Left coordinate. :return: `0` """ return 0 @property def top(self): """ Top coordinate. :return: `0` """ return 0 @property def right(self): """ Right coordinate. :return: `int` """ return self.width @property def bottom(self): """ Bottom coordinate. :return: `int` """ return self.height @property def width(self): """ Document width. :return: `int` """ return self._record.header.width @property def height(self): """ Document height. :return: `int` """ return self._record.header.height @property def size(self): """ (width, height) tuple. :return: `tuple` """ return self.width, self.height @property def offset(self): """ (left, top) tuple. :return: `tuple` """ return self.left, self.top @property def bbox(self): """ 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): """ 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): """ 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): """ Number of color channels. :return: `int` """ return self._record.header.channels @property def depth(self): """ Pixel depth bits. :return: `int` """ return self._record.header.depth @property def version(self): """ Document version. PSD file is 1, and PSB file is 2. :return: `int` """ return self._record.header.version @property def image_resources(self): """ 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): """ 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): """ Set the compositing and layer organization compatibility mode. Writable. :return: :py:class:`~psd_tools.constants.CompatibilityMode` """ return self._compatibility_mode @compatibility_mode.setter def compatibility_mode(self, value): self._compatibility_mode = value self._compute_clipping_layers()
[docs] def has_thumbnail(self): """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): """ Returns a thumbnail image in PIL.Image. When the file does not contain an embedded thumbnail image, returns None. """ from .pil_io import convert_thumbnail_to_pil if Resource.THUMBNAIL_RESOURCE in self.image_resources: return convert_thumbnail_to_pil( self.image_resources.get_data(Resource.THUMBNAIL_RESOURCE) ) elif Resource.THUMBNAIL_RESOURCE_PS4 in self.image_resources: return convert_thumbnail_to_pil( self.image_resources.get_data(Resource.THUMBNAIL_RESOURCE_PS4), "BGR" ) return None
def __repr__(self): 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, cycle): if cycle: return self.__repr__() def _pretty(layer, p): p.text(layer.__repr__()) if hasattr(layer, "clip_layers"): for clip_layer in layer.clip_layers or []: p.break_() p.text(" + ") p.pretty(clip_layer) if hasattr(layer, "__iter__"): with p.indent(2): for idx, child in enumerate(layer): p.break_() p.text("[%d] " % idx) _pretty(child, p) _pretty(self, p) @classmethod def _make_header(cls, mode, size, depth=8): from .pil_io import get_color_mode assert depth in (8, 16, 32), "Invalid depth: %d" % (depth) assert size[0] <= 300000, "Width too large > 300,000" assert size[1] <= 300000, "Height too large > 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 = get_color_mode(mode) alpha = int(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): """Get pattern item by id.""" 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 _clear_clipping_layers(self): for layer in self.descendants(): layer._clip_layers = [] layer._has_clip_target = True def _compute_clipping_layers(self): self._clear_clipping_layers() def rec_helper(layer): if not layer.is_group(): return stack = [] for sublayer in reversed(layer._layers): if sublayer.clipping_layer: stack.append(sublayer) else: if sublayer.blend_mode == BlendMode.PASS_THROUGH and ( self.compatibility_mode == CompatibilityMode.PAINT_TOOL_SAI or self.compatibility_mode == CompatibilityMode.CLIP_STUDIO_PAINT ): for clip_layer in stack: clip_layer._has_clip_target = False else: stack.reverse() sublayer._clip_layers = stack stack = [] rec_helper(sublayer) for clip_layer in stack: clip_layer._has_clip_target = False rec_helper(self) def _init(self): """Initialize layer structure.""" group_stack = [self] for record, channels in self._record._iter_layers(): current_group = group_stack[-1] blocks = record.tagged_blocks end_of_group = False layer = 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: if divider.kind == SectionDivider.BOUNDING_SECTION_DIVIDER: layer = Group(self, None, None, current_group) group_stack.append(layer) elif divider.kind in ( SectionDivider.OPEN_FOLDER, SectionDivider.CLOSED_FOLDER, ): layer = group_stack.pop() assert layer is not self layer._record = record layer._channels = channels for key in ( Tag.ARTBOARD_DATA1, Tag.ARTBOARD_DATA2, Tag.ARTBOARD_DATA3, ): if key in blocks: layer = 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 = TypeLayer(self, record, channels, current_group) 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 = SmartObjectLayer(self, record, channels, current_group) else: for key in adjustments.TYPES.keys(): if key in blocks: layer = adjustments.TYPES[key]( self, record, channels, current_group ) 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), FillLayer)) and shape_condition: layer = ShapeLayer(self, record, channels, current_group) if layer is None: layer = PixelLayer(self, record, channels, current_group) assert layer is not None if not end_of_group: current_group._layers.append(layer) self._compute_clipping_layers()