Commits

Mikhail Korobov committed 8efc29a

user-facing API

  • Participants
  • Parent commits 0a99ab3

Comments (0)

Files changed (7)

 
 .. _specification: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/PhotoshopFileFormats.htm
 
+Installation
+------------
+
+::
+
+    pip install psd-tools
+
+There are also optional dependencies: docopt_ for command-line interface
+and PIL_ (or Pillow_) for accessing PSD layer data as PIL images::
+
+    pip install Pillow
+    pip install docopt
+
+
+.. _docopt: https://github.com/docopt/docopt
+.. _PIL: http://www.pythonware.com/products/pil/
+.. _Pillow: https://github.com/python-imaging/Pillow
+
+
+Usage
+-----
+
+Load an image::
+
+    >>> from psd_tools import PSDImage
+    >>> psd = PSDImage.load('my_image.psd')
+
+Access its layers::
+
+    >>> psd.layers
+    [<psd_tools.Group: 'Group 2', layer_count=1>,
+     <psd_tools.Group: 'Group 1', layer_count=1>,
+     <psd_tools.Layer: 'Background', size=100x200>]
+
+Work with a layer group::
+
+    >>> group2 = psd.layers[0]
+    >>> group2.name
+    Group 2
+
+    >>> group2.visible
+    True
+
+    >>> group2.closed
+    False
+
+    >>> group2.opacity
+    255
+
+    >>> from psd_tools.constants import BlendMode
+    >>> group2.blend_mode == BlendMode.NORMAL
+    True
+
+    >>> group2.layers
+    [<psd_tools.Layer: 'Shape 2', size=43x62>]
+
+Work with a layer::
+
+    >>> layer = group2.layers[0]
+    >>> layer.name
+    Shape 2
+
+    >>> layer.bbox
+    (40, 72, 83, 134)
+
+    >>> layer.width, layer.height
+    (43, 62)
+
+    >>> layer.visible, layer.opacity, layer.blend_mode
+    (True, 255, u'norm')
+
+    >>> layer.as_PIL()
+    <PIL.Image.Image image mode=RGBA size=43x62 at ...>
+
+Export a single layer::
+
+    >>> layer_image = layer.as_PIL()
+    >>> layer_image.save('layer.png')
+
+Export the merged image::
+
+    >>> merged_image = psd.composite_image()
+    >>> merged_image.save('my_image.png')
+
+
 Why yet another PSD reader?
 ---------------------------
 
 * there is a PSD reader in PIL_ library;
 * it is possible to write Python plugins for GIMP_.
 
-PSD reader in PIL is incomplete, PIL doesn't have an API for layer groups
+PIL doesn't have an API for layer groups, PSD reader in PIL is incomplete
 and contributing to PIL is somehow complicated because of the
 slow release process.
 
 (they are really fine, that's me having specific style requirements).
 
 So I finally decided to roll out yet another implementation
-that should be MIT-licensed, systematically based on the specification_
-and implemented as a set of functions; it should also have tests and
-support both Python 2.x and Python 3.x.
+that should be MIT-licensed, systematically based on the specification_;
+parser should be implemented as a set of functions; the package should
+also have tests and support both Python 2.x and Python 3.x.
 
-.. _PIL: http://www.pythonware.com/products/pil/
 .. _GIMP: http://www.gimp.org/
 
 Design overview
 
 Stage separation also means user-facing API may be opinionated:
 if somebody doesn't like it then it should possible to build an
-another API (e.g. without PIL) based on low-level decoded PSD file.
+another API (e.g. without PIL) based on lower-level decoded PSD file.
 
-.. note::
-
-    Currently (3) is not implemented.
 
 Contributing
 ------------

File psd_tools/__init__.py

+from __future__ import absolute_import
+from .user_api import PSDImage, Layer, Group

File psd_tools/cli.py

 
 import psd_tools.reader
 import psd_tools.decoder
-from psd_tools import user_api
+from psd_tools import PSDImage
 from psd_tools.user_api.layers import group_layers, composite_image_to_PIL
 
 logger = logging.getLogger('psd_tools')
         logger.setLevel(logging.INFO)
 
     if args['convert']:
-
-        with open(args['<psd_filename>'], 'rb') as f:
-            res = psd_tools.reader.parse(f)
-            decoded = psd_tools.decoder.parse(res)
-            im = composite_image_to_PIL(decoded)
-            im.save(args['<out_filename>'])
+        psd = PSDImage.load(args['<psd_filename>'])
+        im = psd.composite_image()
+        im.save(args['<out_filename>'])
 
     else:
-        decoded = user_api.parse(args['<filename>'])
+        encoding = args['--encoding']
+        with open(args['<filename>'], "rb") as f:
+            decoded = psd_tools.decoder.parse(
+                psd_tools.reader.parse(f, encoding)
+            )
 
         print(decoded.header)
         pprint.pprint(decoded.image_resource_blocks)

File psd_tools/user_api/__init__.py

 # -*- coding: utf-8 -*-
 from __future__ import absolute_import
-import psd_tools.reader
-import psd_tools.decoder
 
-from .layers import group_layers
-
-def parse(filename):
-    with open(filename, 'rb') as f:
-        decoded_data = psd_tools.decoder.parse(
-            psd_tools.reader.parse(f)
-        )
-    return decoded_data
-
-
+from psd_tools.user_api.psd_image import PSDImage, Layer, Group

File psd_tools/user_api/psd_image.py

+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, unicode_literals
+
+import weakref
+import psd_tools.reader
+import psd_tools.decoder
+from psd_tools.constants import TaggedBlock, SectionDivider
+from psd_tools.user_api.layers import (group_layers, composite_image_to_PIL,
+                                       layer_to_PIL)
+
+
+class _RawLayer(object):
+
+    parent = None
+    _psd = None
+    _index = None
+
+    @property
+    def name(self):
+        """ Layer name (as unicode). """
+        return self._tagged_blocks.get(
+            TaggedBlock.UNICODE_LAYER_NAME,
+            self._info.name
+        )
+
+    @property
+    def visible(self):
+        """ Layer visibility. Takes group visibility in account. """
+        return self._info.flags.visible and self.parent.visible
+
+    @property
+    def layer_id(self):
+        return self._tagged_blocks.get(TaggedBlock.LAYER_ID)
+
+    @property
+    def opacity(self):
+        return self._info.opacity
+
+    @property
+    def blend_mode(self):
+        return self._info.blend_mode
+
+    @property
+    def _info(self):
+        return self._psd.layer_info(self._index)
+
+    @property
+    def _tagged_blocks(self):
+        return dict(self._info.tagged_blocks)
+
+
+
+class Layer(_RawLayer):
+    """ PSD layer wrapper """
+
+    def __init__(self, parent, index):
+        self.parent = parent
+        self._psd = parent._psd
+        self._index = index
+
+    def as_PIL(self):
+        """ Returns a PIL image for this layer. """
+        return self._psd.layer_as_PIL(self._index)
+
+    @property
+    def bbox(self):
+        """ (top, left, bottom, right) tuple with layer bounding box. """
+        info = self._info
+        return info.left, info.top, info.right, info.bottom
+
+    @property
+    def width(self):
+        return self._info.width()
+
+    @property
+    def height(self):
+        return self._info.height()
+
+    def __repr__(self):
+        return "<psd_tools.Layer: %r, size=%dx%d>" % (
+            self.name, self.width, self.height)
+
+
+
+class Group(_RawLayer):
+    """ PSD layer group wrapper """
+
+    def __init__(self, parent, index, layers):
+        self.parent = parent
+        self._psd = parent._psd
+        self._index = index
+        self.layers = layers
+
+    @property
+    def closed(self):
+        divider = self._tagged_blocks.get(TaggedBlock.SECTION_DIVIDER_SETTING, None)
+        if divider is None:
+            return
+        return divider.type == SectionDivider.CLOSED_FOLDER
+
+    def _add_layer(self, child):
+        self.layers.append(child)
+
+    def __repr__(self):
+        return "<psd_tools.Group: %r, layer_count=%d>" % (
+            self.name, len(self.layers))
+
+
+
+class PSDImage(object):
+    """ PSD image wrapper """
+
+    def __init__(self, decoded_data):
+        self.header = decoded_data.header
+        self.decoded_data = decoded_data
+
+        # wrap decoded data to Layer and Group structures
+        def fill_group(group, data):
+
+            for layer in data['layers']:
+                index = layer['index']
+
+                if 'layers' in layer:
+                    # group
+                    sub_group = Group(group, index, [])
+                    fill_group(sub_group, layer)
+                    group._add_layer(sub_group)
+                else:
+                    # regular layer
+                    group._add_layer(Layer(group, index))
+
+
+        self._psd = self
+        fake_root_data = {'layers': group_layers(decoded_data), 'index': None}
+        root = _RootGroup(self, None, [])
+        fill_group(root, fake_root_data)
+
+        self._fake_root_group = root
+        self.layers = root.layers
+
+
+    @classmethod
+    def load(cls, path, encoding='utf8'):
+        """
+        Returns a new :class:`PSDImage` loaded from ``path``.
+        """
+        with open(path, 'rb') as fp:
+            return cls.from_stream(fp, encoding)
+
+    @classmethod
+    def from_stream(cls, fp, encoding='utf8'):
+        """
+        Returns a new :class:`PSDImage` loaded from stream ``fp``.
+        """
+        decoded_data = psd_tools.decoder.parse(
+            psd_tools.reader.parse(fp, encoding)
+        )
+        return cls(decoded_data)
+
+
+    def layer_info(self, index):
+        layers = self.decoded_data.layer_and_mask_data.layers.layer_records
+        return layers[index]
+
+    def layer_as_PIL(self, index):
+        return layer_to_PIL(self.decoded_data, index)
+
+    def composite_image(self):
+        return composite_image_to_PIL(self.decoded_data)
+
+
+class _RootGroup(Group):
+    """ A fake group for holding all layers """
+
+    @property
+    def visible(self):
+        return True

File tests/test_api.py

+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, unicode_literals
+from psd_tools import PSDImage, Layer, Group
+from psd_tools.constants import BlendMode
+
+from .utils import decode_psd
+
+def test_simple():
+    image = PSDImage(decode_psd('1layer.psd'))
+    assert len(image.layers) == 1
+
+    layer = image.layers[0]
+    assert layer.name == 'Фон'
+    assert layer.bbox == (0, 0, 101, 55)
+    assert layer.visible
+    assert layer.opacity == 255
+    assert layer.blend_mode == BlendMode.NORMAL

File tests/test_dimensions.py

 import pytest
 
 from .utils import load_psd, decode_psd
+
+from psd_tools import PSDImage
 from psd_tools.decoder.image_resources import ResolutionInfo
 from psd_tools.constants import DisplayResolutionUnit, DimensionUnit, ImageResourceID
 
     psd_res = dict((block.resource_id, block.data) for block in psd.image_resource_blocks)
     assert psd_res[ImageResourceID.RESOLUTION_INFO] == resolution
 
+@pytest.mark.parametrize(("filename", "size"), DIMENSIONS)
+def test_dimensions_api(filename, size):
+    psd = PSDImage(decode_psd(filename))
+    assert psd.header.width == size[0]
+    assert psd.header.height == size[1]