Repository: psd-tools/psd-tools
Pull request #428 (2024-08-24 00:02:18)

Layer structure edition and Group/PixelLayer Creation
- Added _update_record() function that will read the high level layers structure and recompile the LayerRecords and ChannelImageData from it. This function is called in every PSDImage function that uses the records to produce data 72dd34452802ea94c520735d96e943c052368e57
  - Added the _bounding_record and _bounding_channels for the Group layers, they hold the records with the SectionDivider.BOUNDING_SECTION_DIVIDER so that we don't have to recompute them. (Renamed from _open_record/_channels in baef8cafce452b428e276541ac94eca3a3275071)
  - Added methods for layer structure modifications 631d23f2bd4dc6de7dedfcde24fc7a69b85be98f : 
    - Addition to a group (Group.add_layer)
    - Deletion 
    - Moving to a group (different context from "addition to group", Layer.move_to_group)
    - Moving up and down within the group
- Group creation fef6ca308a66a9b0b895b449b3e83c6d9d981902 : 
  - Minimal standalone Group layer using "new", add it to the structure using the methods above.
  - Create a group from a list of layers. The group will insert itself in place of the layer at index 0 or under the "parent" layer given in parameter.
- PixelImage creation 1d8f6c758aeeb2439894721b6900f07699528ae3 :
  - PixelImage layer creation from a PIL object, for now limited to RGBA mode.

Edit : Known bug: when adding a layer, whether group or pixel layer with 4 channels, a psd with 3 channels in header might not be possible to open in Photoshop

https://github.com/psd-tools/psd-tools/issues/84


Patch:
diff --git a/docs/usage.rst b/docs/usage.rst
index 72323d7..d1fe203 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -74,8 +74,11 @@ Some of the layer attributes are editable, such as a layer name::
 
     layer.name = 'Updated layer 1'
 
-.. note:: Currently, the package does not support adding or removing of
-    a layer.
+It is possible to create a new PixelLayer from a PIL object,
+the PIL image will be converted to the color mode of the PSD File given in parameter::
+    PixelLayer.frompil(pil_image, psd_file, "Layer name", top_offset, left_offset, Compression.RLE)
+
+See the function documentation for further parameter explanations.
 
 :py:class:`~psd_tools.api.layers.Group` has internal layers::
 
@@ -84,6 +87,11 @@ Some of the layer attributes are editable, such as a layer name::
 
     first_layer = group[0]
 
+Create a new group object.::
+
+    Group.new("Group name", open_folder=True, parent=parent_group)
+
+
 :py:class:`~psd_tools.api.layers.TypeLayer` is a layer with texts::
 
     print(layer.text)
@@ -111,6 +119,98 @@ paint the entire region if there is no associated mask. Sub-classes of
 :py:class:`~psd_tools.api.layers.AdjustmentLayer` represents layer
 adjustment applied to the composed image. See :ref:`adjustment-layers`.
 
+
+Modifying the layer structure
+-----------------------------
+
+The layer structure of a PSD object can be modified through methods emulating a python list.
+
+
+The internal model of the psd layer structure will be automatically updated when saving the psd to a file or a similar operation.
+Moving a layer from a PSD to another will also automatically convert the PixelLayer to the target psd's color mode.
+
+The follwing are valid for both PSDImage and Group objects.
+
+Set an item::
+
+    group[0] = layer
+
+Add a layer to a group::
+    
+    group.append(layer)
+
+Add a list of layers to a group::
+    
+    group.extend(layers)
+
+Insert a layer to a specific index in the group::
+    
+    group.insert(3, layer)
+
+Remove a layer from the a group::
+    
+    group.remove(layer)
+
+Pop a layer from the group::
+    
+    layer = group.pop()
+
+Emptying a layer group::
+    
+    group.clear()
+
+Get the index of a layer in the group::
+    
+    group.index(layer)
+
+Count the occurences of a layer in a group::
+    
+    group.count(layer)
+
+Move a given list of layers in a newly created Group. If no parent group is given in parameter, 
+the new group will replace the first layer of the list in the PSD structure::
+    
+    Group.group_layers(layer_list, "Group Name", parent=parent_group, open_folder=True)
+
+Below an example of such an operation.::
+    
+    - PSDImage
+        - Group 1
+            - PixelLayer
+            - FillLayer
+        - PixelLayer
+        - TypeLayer
+        - SmartObjectLayer
+        - PixelLayer
+        
+    Group.group_layers(PSDImage[:2], "New Group")
+    - PSDImage
+        - New Group
+            - Group 1
+                - PixelLayer
+                - FillLayer
+            - PixelLayer
+            - TypeLayer
+        - SmartObjectLayer
+        - PixelLayer
+
+
+Some operations are available for all Layer objects.
+
+Delete a layer from its layer structure::
+    
+    layer.delete()
+
+Layers can be moved from a group to another::
+    
+    layer.move_to_group(target_group)
+
+Layers can be moved within the group to change their order::
+    
+    layer.move_up(5) # Will send the layer upward in the group
+    layer.move_down(3) # Will send the layer downward in the group
+
+
 Exporting data to PIL
 ---------------------
 
diff --git a/src/psd_tools/api/layers.py b/src/psd_tools/api/layers.py
index 5f43484..7f8bddd 100644
--- a/src/psd_tools/api/layers.py
+++ b/src/psd_tools/api/layers.py
@@ -11,7 +11,11 @@ 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, TextType
+from psd_tools.api.pil_io import get_pil_channels, get_pil_depth, get_pil_mode
+from psd_tools.constants import BlendMode, Clipping, Tag, TextType, Compression, ChannelID, SectionDivider
+from psd_tools.psd.layer_and_mask import LayerRecord, ChannelDataList, ChannelData, ChannelInfo
+from psd_tools.psd.tagged_blocks import TaggedBlocks
+
 
 logger = logging.getLogger(__name__)
 
@@ -470,8 +474,9 @@ class Layer(object):
 
     @clipping_layer.setter
     def clipping_layer(self, value):
-        self._record.clipping = Clipping.NON_BASE if value else Clipping.Base
-        self._psd._compute_clipping_layers()
+        if self._psd:
+            self._record.clipping = Clipping.NON_BASE if value else Clipping.Base
+            self._psd._compute_clipping_layers()
 
     def has_effects(self):
         """
@@ -536,6 +541,73 @@ class Layer(object):
             " effects" if self.has_effects() else "",
         )
 
+    # Structure operations, supposes unique references to layers, deep copy might be needed in the future
+    def delete_layer(self):
+        """
+        Deletes the layer and all its child layers if the layer is a group from its parent (group or psdimage).
+        """
+
+        if self.parent is not None:
+            if self in self.parent:
+                self.parent.remove(self)
+
+        self.parent._update_psd_record()
+
+        return self
+
+    def move_to_group(self, group):
+        """
+        Moves the layer to the given group, updates the tree metadata as needed.
+
+        :param group: The group the current layer will be moved into.
+        """
+
+        assert group.kind in ["group", "psdimage", "artboard"]
+        assert group is not self
+
+        if self.kind in ["group", "psdimage", "artboard"]:
+            assert group not in self.descendants(), "Cannot move group {} into its descendant {}".format(self, group)
+
+        if self.parent is not None:
+            if self in self.parent:
+                self.parent.remove(self)
+        
+        group.append(self)
+        
+        return self
+
+    def move_up(self, offset = 1):
+        """
+        Moves the layer up a certain offset within the group the layer is in.
+
+        :param offset:
+        """
+
+        assert self.parent
+
+        newindex = self._parent.index(self) + offset
+
+        if newindex < 0:
+            newindex = 0
+        elif newindex >= len(self.parent):
+            newindex = len(self.parent) - 1
+
+        self.parent.remove(self)
+        self.parent.insert(newindex, self)
+
+        self.parent._update_psd_record()
+
+        return self
+
+    def move_down(self, offset = 1):
+        """
+        Moves the layer down a certain offset within the group the layer is in.
+
+        :param offset:
+        """
+
+        return self.move_up(-1 * offset)
+
 
 class GroupMixin(object):
     @property
@@ -571,11 +643,176 @@ class GroupMixin(object):
         return self._layers.__getitem__(key)
 
     def __setitem__(self, key, value):
-        return self._layers.__setitem__(key, value)
+
+        assert value is not self, "Cannot add the group {} to itself.".format(self)
+
+        if isinstance(value, list):
+            for layer in value:
+                assert isinstance(layer, Layer)
+                if layer.kind in ["group", "psdimage", "artboard"]: 
+                    assert self not in layer.descendants(), "This operation would create a reference loop within the group between {} and {}.".format(self, layer)
+
+                layer._parent = self    
+        else:
+            assert isinstance(value, Layer)
+            if value.kind in ["group", "psdimage", "artboard"]: 
+                assert self not in value.descendants(), "This operation would create a reference loop within the group between {} and {}.".format(self, value)
+
+            value._parent = self
+        
+        setitem_res = self._layers.__setitem__(key, value)
+
+        _psd = self if self.kind == "psdimage" else self._psd
+
+        for layer in self.descendants():
+            
+            if layer._psd != _psd and _psd is not None:
+                if layer.kind == "pixel":
+                    layer._convert(_psd)
+            
+                layer._psd = _psd
+
+        self._update_psd_record()
+
+        return setitem_res
 
     def __delitem__(self, key):
+        self._update_psd_record()
         return self._layers.__delitem__(key)
 
+    def append(self, layer):
+        """
+        Add a layer to the end (top) of the group
+
+        :param layer: The layer to add
+        """
+
+        assert layer is not self
+        
+        self.extend([layer])
+
+        return self
+
+    def extend(self, layers):
+        """
+        Add a list of layers to the end (top) of the group
+
+        :param layers: The layers to add
+        """
+
+        assert layers
+        assert self not in layers, "Cannot add the group {} to itself.".format(self)
+        
+    
+        for layer in layers:
+            assert isinstance(layer, Layer)
+            if layer.kind in ["group", "psdimage", "artboard"]: 
+                assert self not in layer.descendants(), "This operation would create a reference loop within the group between {} and {}.".format(self, layer)
+
+        for layer in layers:
+            layer._parent = self
+            self._layers.append(layer)
+
+        _psd = self if self.kind == "psdimage" else self._psd
+
+        for layer in self.descendants():
+            
+            if layer._psd != _psd and _psd is not None:
+                if layer.kind == "pixel":
+                    layer._convert(_psd)
+            
+                layer._psd = _psd
+
+        self._update_psd_record()
+        
+        return self
+
+    def insert(self, index, layer):
+        """
+        Insert the given layer at the specified index.
+
+        :param index: 
+        :param layer: 
+        """
+
+        assert layer is not self
+        assert isinstance(layer, Layer)
+        
+        if layer.kind in ["group", "psdimage", "artboard"]: 
+            assert self not in layer.descendants(), "This operation would create a reference loop within the group between {} and {}.".format(self, layer)
+
+        self._layers.insert(index, layer)
+        layer._parent = self
+
+        _psd = self if self.kind == "psdimage" else self._psd
+
+        for layer in self.descendants():
+            
+            if layer._psd != _psd and _psd is not None:
+                if layer.kind == "pixel":
+                    layer._convert(_psd)
+            
+                layer._psd = _psd
+
+        self._update_psd_record()
+
+        return self
+
+    def remove(self,  layer):
+        """
+        Removes the specified layer from the group
+
+        :param layer:
+        """
+
+        self._layers.remove(layer)
+        self._update_psd_record()
+        return self
+
+    def pop(self, index=-1):
+        """
+        Removes the specified layer from the list and returns it.
+
+        :param index:
+        """
+
+        popLayer = self._layers.pop(index)
+        self._update_psd_record()
+        return popLayer
+
+    def clear(self):
+        """
+        Clears the group.
+        """
+
+        self._layers.clear()
+        self._update_psd_record()
+        return self
+
+    def index(self, layer):
+        """
+        Returns the index of the specified layer in the group.
+        
+        :param layer:
+        """
+
+        return self._layers.index(layer)
+
+    def count(self, layer):
+        """
+        Counts the number of occurences of a layer in the group.
+        
+        :param layer:
+        """
+
+        return self._layers.count(layer)
+
+    def _update_psd_record(self):
+        if self.kind == "psdimage":
+            self._updated_layers = True
+        elif self._psd is not None:
+            self._psd._updated_layers = True
+
     @deprecated
     def compose(
         self, force=False, bbox=None, layer_filter=None, context=None, color=None
@@ -666,7 +903,10 @@ class Group(GroupMixin, Layer):
 
     def __init__(self, *args):
         super(Group, self).__init__(*args)
+
         self._layers = []
+        self._bounding_record = None
+        self._bounding_channels = None
 
     @property
     def _setting(self):
@@ -728,6 +968,86 @@ class Group(GroupMixin, Layer):
             apply_icc=apply_icc,
         )
 
+    def _set_bounding_records(self, _bounding_record, _bounding_channels):
+        # Attributes that store the record for the folder divider. 
+        # Used when updating the record so that we don't need to recompute 
+        # Them from the ending layer 
+        self._bounding_record = _bounding_record
+        self._bounding_channels = _bounding_channels
+
+        return
+
+    @classmethod
+    def new(cls, name = "Group", open_folder = True, parent = None):
+        """
+        Create a new Group object with minimal records and data channels and metadata to properly include the group in the PSD file.
+
+        :param name: The display name of the group. Default to "Group".
+        :param open_folder: Boolean defining whether the folder will be open or closed in photoshop. Default to True.
+        :param parent: Optional parent folder to move the newly created group into.
+
+        :return: A :py:class:`~psd_tools.api.layers.Group` object
+        """
+
+        record = LayerRecord(top=0, left=0, bottom=0, right=0, name=name)
+        record.tagged_blocks = TaggedBlocks()
+
+        record.tagged_blocks.set_data(Tag.SECTION_DIVIDER_SETTING, SectionDivider.OPEN_FOLDER if open_folder else SectionDivider.CLOSED_FOLDER)
+        record.tagged_blocks.set_data(Tag.UNICODE_LAYER_NAME, name)
+
+        _bounding_record = LayerRecord(top=0, left=0, bottom=0, right=0, name="</Layer group>")
+        _bounding_record.tagged_blocks = TaggedBlocks()
+
+        _bounding_record.tagged_blocks.set_data(Tag.SECTION_DIVIDER_SETTING, SectionDivider.BOUNDING_SECTION_DIVIDER)
+        _bounding_record.tagged_blocks.set_data(Tag.UNICODE_LAYER_NAME, "</Layer group>")
+
+        record.channel_info = [ChannelInfo(id=i-1, length = 2) for i in range(4)]
+        _bounding_record.channel_info = [ChannelInfo(id=i-1, length = 2) for i in range(4)]
+
+        channels = ChannelDataList()
+        for i in range(4):
+            channels.append(ChannelData(compression=Compression.RAW, data=b''))
+
+        _bounding_channels = channels
+
+        group = cls(None, record, channels, None)
+        
+        group._set_bounding_records(_bounding_record, _bounding_channels)
+
+        if parent is not None:
+            group.move_to_group(parent)
+
+        return group
+
+    @classmethod
+    def group_layers(cls, layers = [], name = "Group", parent = None, open_folder = True):
+        """
+        Create a new Group object containing the given layers and moved into the parent folder.
+
+        If no parent is provided, the group will be put in place of the first layer in the given list. Example below:
+
+        :param layers: The layers to group. Can by any subclass of :py:class:`~psd_tools.api.layers.Layer`
+        :param name: The display name of the group. Default to "Group".
+        :param parent: The parent group to add the newly created Group object into.
+        :param open_folder: Boolean defining whether the folder will be open or closed in photoshop. Default to True.
+
+        :return: A :py:class:`~psd_tools.api.layers.Group`
+        """
+                
+        assert layers
+
+        if parent is None:
+            parent = layers[0]._parent
+
+        group = cls.new(name, open_folder)
+
+        for layer in layers:
+            layer.move_to_group(group)
+
+        parent.append(group)
+
+        return group
+
 
 class Artboard(Group):
     """
@@ -812,7 +1132,87 @@ class PixelLayer(Layer):
         composed_image.save('composed-layer.png')
     """
 
-    pass
+    @classmethod
+    def frompil(cls, pil_im, psd_file, layer_name = "Layer", top = 0, left = 0, compression = Compression.RLE, **kwargs):
+        """
+        Creates a PixelLayer from a PIL image for a given psd file.
+
+        :param pil_im: The :py:class:`~PIL.Image` object to convert to photoshop
+        :param psdfile: The psd file the image will be converted for.
+        :param layer_name: The name of the layer. Defaults to "Layer"
+        :param top: Pixelwise offset from the top of the canvas for the new layer.
+        :param left: Pixelwise offset from the left of the canvas for the new layer.
+        :param compression: Compression algorithm to use for the data.
+
+        :return: A :py:class:`~psd_tools.api.layers.PixelLayer` object
+        """
+
+        assert pil_im
+
+        if pil_im.mode == "1":
+            pil_im = pil_im.convert("L")
+
+        if psd_file is not None:
+            pil_im = pil_im.convert(psd_file.pil_mode)
+        else:
+            logger.warning("No psd file was provided, it will not be possible to convert it when moving to another psd. Might create corrupted psds.")
+
+        if pil_im.mode == "CMYK":
+            from PIL import ImageChops
+            pil_im = ImageChops.invert(pil_im)
+            
+
+        layer_record = LayerRecord(top=top, left=left, bottom=top + pil_im.height, right=left + pil_im.width, **kwargs)
+        channel_data_list = ChannelDataList()
+
+        layer_record.name = layer_name
+        layer_record.channel_info = [ChannelInfo(ChannelID.TRANSPARENCY_MASK, 2)]
+        
+        # Initialize the alpha channel to full opacity, photoshop sometimes didn't handle the file when not done
+        channel_data_list.append(ChannelData(compression))
+        channel_data_list[0].set_data(b'\xff'* (pil_im.width * pil_im.height), pil_im.width, pil_im.height, get_pil_depth(pil_im.mode.rstrip("A")))        
+        layer_record.channel_info[0].length = len(channel_data_list[0].data) + 2
+        
+        for channel_index in range(get_pil_channels(pil_im.mode.rstrip("A"))):
+            
+            channel_data = ChannelData(compression)
+            channel_data.set_data(pil_im.getchannel(channel_index).tobytes(), pil_im.width, pil_im.height, get_pil_depth(pil_im.mode.rstrip("A")))
+
+            channel_info = ChannelInfo(id = ChannelID(channel_index), length = len(channel_data.data) + 2)
+
+            channel_data_list.append(channel_data)
+            layer_record.channel_info.append(channel_info)
+
+        if pil_im.has_transparency_data:
+            # Need check for other types of transparency, palette for "indexed" mode
+            transparency_channel_index = pil_im.getbands().index("A")
+            
+            channel_data_list[0].set_data(pil_im.getchannel(transparency_channel_index).tobytes(), pil_im.width, pil_im.height, get_pil_depth(pil_im.mode.rstrip("A")))
+            layer_record.channel_info[0].length = len(channel_data_list[0].data) + 2
+
+        self = cls(psd_file, layer_record, channel_data_list, None)
+
+        return self
+
+    def _convert(self, target_psd):
+        
+        #assert self._psd is not None, "This layer cannot be converted because it has no psd file linked."
+
+        if self._psd is None:
+            logger.warning("This layer {} cannot be converted to the target psd".format(self))
+            return self
+
+        new_layer = PixelLayer.frompil(self.topil(),
+                                        target_psd,
+                                        self.name,
+                                        self.top,
+                                        self.left,
+                                        self._channels[0].compression)
+
+        self._record.channel_info = new_layer._record.channel_info
+        self._channels = new_layer._channels
+    
+        return self
 
 
 class SmartObjectLayer(Layer):
diff --git a/src/psd_tools/api/pil_io.py b/src/psd_tools/api/pil_io.py
index e1afcaa..9d9d1df 100644
--- a/src/psd_tools/api/pil_io.py
+++ b/src/psd_tools/api/pil_io.py
@@ -51,6 +51,21 @@ def get_pil_channels(pil_mode):
         "F": 1,
     }.get(pil_mode, 3)
 
+def get_pil_depth(pil_mode):
+    """Get the depth of image for PIL modes."""
+    return {
+        # Bitmap images are converted to grayscale when the layer is created from pil object
+        "1": 8,
+        "L": 8,
+        "P": 8,
+        "RGB": 8,
+        "CMYK": 8,
+        "YCbCr": 8,
+        "LAB": 8,
+        "HSV": 8,
+        "I": 32,
+        "F": 32,
+    }.get(pil_mode, 8)
 
 def convert_image_data_to_pil(psd, channel, apply_icc):
     """Convert ImageData to PIL Image."""
diff --git a/src/psd_tools/api/psd_image.py b/src/psd_tools/api/psd_image.py
index 9141686..256ff51 100644
--- a/src/psd_tools/api/psd_image.py
+++ b/src/psd_tools/api/psd_image.py
@@ -26,7 +26,19 @@ from psd_tools.constants import (
     SectionDivider,
     Tag,
 )
-from psd_tools.psd import PSD, FileHeader, ImageData, ImageResources
+from psd_tools.psd import (
+    PSD, FileHeader, 
+    ImageData, 
+    ImageResources, 
+    LayerAndMaskInformation, 
+    TaggedBlocks, 
+    GlobalLayerMaskInfo, 
+    LayerInfo, 
+    LayerRecords, 
+    ChannelImageData
+)
+
+from psd_tools.api.pil_io import get_pil_channels, get_pil_mode
 
 logger = logging.getLogger(__name__)
 
@@ -54,6 +66,7 @@ class PSDImage(GroupMixin):
         self._layers = []
         self._tagged_blocks = None
         self._compatibility_mode = CompatibilityMode.DEFAULT
+        self._updated_layers=False
         self._init()
 
     @classmethod
@@ -126,6 +139,9 @@ class PSDImage(GroupMixin):
             default 'macroman'.
         :param mode: file open mode, default 'wb'.
         """
+
+        self._update_record()
+
         if hasattr(fp, "write"):
             self._record.write(fp, **kwargs)
         else:
@@ -145,6 +161,8 @@ class PSDImage(GroupMixin):
         """
         from .pil_io import convert_image_data_to_pil
 
+        self._update_record()
+
         if self.has_preview():
             return convert_image_data_to_pil(self, channel, apply_icc)
         return None
@@ -216,7 +234,9 @@ class PSDImage(GroupMixin):
         """
         from psd_tools.composite import composite_pil
 
-        if not (ignore_preview or force or layer_filter) and self.has_preview():
+        self._update_record()
+
+        if not (ignore_preview or force or layer_filter) and self.has_preview() and not self._updated_layers:
             return self.topil(apply_icc=apply_icc)
         return composite_pil(
             self, color, alpha, viewport, layer_filter, force, apply_icc=apply_icc
@@ -468,6 +488,13 @@ class PSDImage(GroupMixin):
         """
         return self._compatibility_mode
 
+    @property
+    def pil_mode(self):
+
+        alpha = self.channels - get_pil_channels(get_pil_mode(self.color_mode))
+        
+        return get_pil_mode(self.color_mode, alpha)
+
     @compatibility_mode.setter
     def compatibility_mode(self, value):
         self._compatibility_mode = value
@@ -566,6 +593,9 @@ class PSDImage(GroupMixin):
             layer._has_clip_target = True
 
     def _compute_clipping_layers(self):
+
+        self._update_record()
+
         self._clear_clipping_layers()
 
         def rec_helper(layer):
@@ -615,6 +645,7 @@ class PSDImage(GroupMixin):
             ):
                 if divider.kind == SectionDivider.BOUNDING_SECTION_DIVIDER:
                     layer = Group(self, None, None, current_group)
+                    layer._set_bounding_records(record, channels)
                     group_stack.append(layer)
                 elif divider.kind in (
                     SectionDivider.OPEN_FOLDER,
@@ -672,3 +703,51 @@ class PSDImage(GroupMixin):
                 current_group._layers.append(layer)
 
         self._compute_clipping_layers()
+
+    def _update_record(self, layer_group=None):
+        """
+        Compiles the tree layer structure back into records and channels list recursively
+        """
+
+        if not self._updated_layers:
+            return
+
+        if layer_group is None:
+            layer_group = self
+
+        layer_records = LayerRecords()
+        channel_image_data = ChannelImageData()
+
+        for layer in layer_group:
+            if layer.kind in ["group", "artboard"]:
+                
+                layer_records.append(layer._bounding_record)
+                channel_image_data.append(layer._bounding_channels)
+
+                tmp_layer_records, tmp_channel_image_data = self._update_record(layer)
+
+                layer_records.extend(tmp_layer_records)
+                channel_image_data.extend(tmp_channel_image_data)
+
+            layer_records.append(layer._record)
+            channel_image_data.append(layer._channels)
+            
+        if layer_group == self:
+
+            # PSDImage.frompil doesn't create a LayerInfo attribute to LayerAndMaskInformation 
+            if not self._record.layer_and_mask_information.layer_info:
+                self._record.layer_and_mask_information.layer_info = LayerInfo()
+
+            if not self._record.layer_and_mask_information.global_layer_mask_info:
+                self._record.layer_and_mask_information.global_layer_mask_info = GlobalLayerMaskInfo()
+
+            if not self._record.layer_and_mask_information.tagged_blocks:
+                self._record.layer_and_mask_information.tagged_blocks = TaggedBlocks()
+
+            self._record.layer_and_mask_information.layer_info.layer_records = layer_records
+            self._record.layer_and_mask_information.layer_info.channel_image_data = channel_image_data
+            self._record.layer_and_mask_information.layer_info.layer_count = len(layer_records)
+
+            return
+
+        return (layer_records, channel_image_data) 
\ No newline at end of file
diff --git a/src/psd_tools/compression/_rle.pyx b/src/psd_tools/compression/_rle.pyx
index 660c233..6d2207b 100644
--- a/src/psd_tools/compression/_rle.pyx
+++ b/src/psd_tools/compression/_rle.pyx
@@ -50,7 +50,7 @@ def encode(const unsigned char[:] data) -> string:
 
     Apple PackBits RLE encoder.
     """
-
+    
     cdef unsigned char MAX_LEN = 0xFF >> 1
     cdef int length = data.shape[0]
     cdef int i = 0
@@ -87,7 +87,7 @@ def encode(const unsigned char[:] data) -> string:
                 #: could be encoded as either of the following:
                 # +2  A  B  C -1  D +1  E  F -5  G +1  H  I -1  J +0  K
                 # +6  A  B  C  D  D  E  F -5  G +4  H  I  J  J  K
-                elif ((j+2 == length) or (MAX_LEN - (j - i) <= 2)) and (data[j] == data[j+1]):
+                elif ((j+2 == length) or (MAX_LEN - (j - i) <= 2)) and not (j+1 == length) and (data[j] == data[j+1]):
                     break
                 elif j+2 < length and (data[j] == data[j+1] == data[j+2]):
                     break
diff --git a/src/psd_tools/compression/rle.py b/src/psd_tools/compression/rle.py
index d589138..8138987 100644
--- a/src/psd_tools/compression/rle.py
+++ b/src/psd_tools/compression/rle.py
@@ -76,7 +76,7 @@ def encode(data: bytes) -> bytes:
                 #: could be encoded as either of the following:
                 # +2  A  B  C -1  D +1  E  F -5  G +1  H  I -1  J +0  K
                 # +6  A  B  C  D  D  E  F -5  G +3  H  I  J  J  K
-                elif ((j + 2 == length) or (MAX_LEN - (j - i) <= 2)) and (
+                elif ((j + 2 == length) or (MAX_LEN - (j - i) <= 2)) and not (j+1 == length) and (
                     data[j] == data[j + 1]
                 ):
                     break
diff --git a/src/psd_tools/psd/__init__.py b/src/psd_tools/psd/__init__.py
index 7df67f6..4f859d6 100644
--- a/src/psd_tools/psd/__init__.py
+++ b/src/psd_tools/psd/__init__.py
@@ -16,7 +16,7 @@ from .color_mode_data import ColorModeData
 from .header import FileHeader
 from .image_data import ImageData
 from .image_resources import ImageResources
-from .layer_and_mask import LayerAndMaskInformation
+from .layer_and_mask import LayerAndMaskInformation, LayerInfo, LayerRecords, ChannelImageData, TaggedBlocks, GlobalLayerMaskInfo
 
 logger = logging.getLogger(__name__)
 


Test patch:
diff --git a/tests/psd_tools/api/test_layers.py b/tests/psd_tools/api/test_layers.py
index a32c279..7ac2b18 100644
--- a/tests/psd_tools/api/test_layers.py
+++ b/tests/psd_tools/api/test_layers.py
@@ -6,7 +6,9 @@ import pytest
 
 from psd_tools.api.layers import Group, PixelLayer, ShapeLayer
 from psd_tools.api.psd_image import PSDImage
-from psd_tools.constants import BlendMode
+from psd_tools.constants import BlendMode, Tag, SectionDivider
+
+from psd_tools.api.pil_io import get_pil_channels, get_pil_depth
 
 from ..utils import full_name
 
@@ -242,3 +244,287 @@ def test_bbox_updates():
     assert group1.bbox == (0, 0, 0, 0)
     group1.visible = True
     assert group1.bbox == (25, 34, 80, 88)
+
+
+def test_new_group(group):
+    
+    test_group = Group.new("Test Group", parent = group)
+    
+    assert test_group._parent is group
+
+    assert test_group._record.tagged_blocks.get_data(Tag.SECTION_DIVIDER_SETTING).kind is SectionDivider.OPEN_FOLDER
+    assert test_group._bounding_record.tagged_blocks.get_data(Tag.SECTION_DIVIDER_SETTING).kind is SectionDivider.BOUNDING_SECTION_DIVIDER
+
+    assert test_group._record.tagged_blocks.get_data(Tag.UNICODE_LAYER_NAME) == "Test Group"
+    assert test_group._bounding_record.tagged_blocks.get_data(Tag.UNICODE_LAYER_NAME) == "</Layer group>"
+
+    test_group = Group.new("Test Group 2", open_folder = False)
+    
+    assert test_group._parent is None
+
+    assert test_group._record.tagged_blocks.get_data(Tag.SECTION_DIVIDER_SETTING).kind is SectionDivider.CLOSED_FOLDER
+    assert test_group._bounding_record.tagged_blocks.get_data(Tag.SECTION_DIVIDER_SETTING).kind is SectionDivider.BOUNDING_SECTION_DIVIDER
+
+    assert test_group._record.tagged_blocks.get_data(Tag.UNICODE_LAYER_NAME) == "Test Group 2"
+    assert test_group._bounding_record.tagged_blocks.get_data(Tag.UNICODE_LAYER_NAME) == "</Layer group>"
+
+    
+def test_group_layers(group, pixel_layer, smartobject_layer, fill_layer, adjustment_layer):
+    
+    pix_old_parent = pixel_layer._parent
+    pix_old_psd = pixel_layer._psd
+
+    test_group = Group.group_layers([pixel_layer, smartobject_layer, fill_layer, adjustment_layer])
+
+    assert len(test_group) == 4
+
+    assert test_group[0] is pixel_layer
+    assert test_group[1] is smartobject_layer
+    assert test_group[2] is fill_layer
+    assert test_group[3] is adjustment_layer
+
+    assert test_group[0]._parent is test_group
+    assert test_group[1]._parent is test_group
+    assert test_group[2]._parent is test_group
+    assert test_group[3]._parent is test_group
+
+    assert test_group._parent is pix_old_parent
+    assert test_group._psd is pix_old_psd
+
+    assert test_group._psd is not None
+    assert test_group[0]._psd is not None
+
+
+    test_group = Group.group_layers([pixel_layer, smartobject_layer, fill_layer, adjustment_layer], parent = group)
+
+    assert len(test_group) == 4
+
+    assert test_group._parent is group
+    assert test_group._psd is group._psd
+
+    assert test_group._psd is not None
+    assert test_group[0]._psd is not None
+
+
+
+def test_pixel_layer_frompil():
+    import PIL 
+
+    pil_rgb = PIL.Image.new("RGB", (30, 30))
+    pil_rgb_a = PIL.Image.new("RGBA", (30, 30))
+    pil_lab = PIL.Image.new("LAB", (30, 30))
+    pil_grayscale_a = PIL.Image.new("LA", (30, 30))
+    pil_grayscale = PIL.Image.new("L", (30, 30))
+    pil_bitmap = PIL.Image.new("1", (30, 30))
+    pil_cmyk = PIL.Image.new("CMYK", (30, 30))
+
+    images = [pil_rgb, pil_rgb_a, pil_lab, pil_grayscale_a, pil_grayscale, pil_bitmap, pil_cmyk]
+    layers = [PixelLayer.frompil(pil_im, None) for pil_im in images]
+
+    for (layer, image) in zip(layers, images):
+
+        # Bitmap image gets converted to grayscale during layer creation so we have to convert here too
+        if image.mode == "1":
+            image = image.convert("L")
+
+        # CMYK Images needs to be inverted, for some reason
+        if image.mode == "CMYK":
+            from PIL import ImageChops
+            image = ImageChops.invert(image)
+
+        assert len(layer._record.channel_info) == get_pil_channels(image.mode.rstrip("A")) + 1
+        assert len(layer._channels) == get_pil_channels(image.mode.rstrip("A")) + 1
+
+        for channel in range(get_pil_channels(image.mode.rstrip("A"))):
+            
+            assert layer._channels[channel + 1].get_data(image.width, image.height, get_pil_depth(image.mode.rstrip("A"))) == image.getchannel(channel).tobytes()
+    
+
+def test_delete_layer(pixel_layer):    
+    
+    pixel_layer.delete_layer()
+
+    assert pixel_layer not in pixel_layer._parent
+
+
+def test_move_to_group(group, pixel_layer):
+
+    pix_old_parent = pixel_layer._parent
+
+    pixel_layer.move_to_group(group)
+
+    assert pixel_layer in group
+    assert pixel_layer._parent is group
+    assert pixel_layer._psd is group._psd
+
+    assert pixel_layer not in pix_old_parent
+    
+def test_move_up(group, pixel_layer, type_layer, smartobject_layer, fill_layer, adjustment_layer):
+
+    test_group = Group.group_layers([pixel_layer, smartobject_layer, fill_layer, adjustment_layer], parent = group)
+
+    test_group.move_up(50)
+
+    assert test_group._parent.index(test_group) == 0
+
+    pixel_layer.move_up(2)
+
+    assert test_group.index(smartobject_layer) == 0
+    assert test_group.index(fill_layer) == 1
+    assert test_group.index(pixel_layer) == 2
+    assert test_group.index(adjustment_layer) == 3
+
+    smartobject_layer.move_up(30)
+
+    assert test_group.index(fill_layer) == 0
+    assert test_group.index(pixel_layer) == 1
+    assert test_group.index(adjustment_layer) == 2
+    assert test_group.index(smartobject_layer) == 3
+
+
+def test_move_down(group, pixel_layer, type_layer, smartobject_layer, fill_layer, adjustment_layer):
+
+    test_group = Group.group_layers([pixel_layer, smartobject_layer, fill_layer, adjustment_layer], parent = group)
+
+    test_group.move_up(50)
+
+    assert test_group._parent.index(test_group) == 0
+
+    fill_layer.move_down(2)
+
+    assert test_group.index(fill_layer) == 0
+    assert test_group.index(pixel_layer) == 1
+    assert test_group.index(smartobject_layer) == 2
+    assert test_group.index(adjustment_layer) == 3
+
+    smartobject_layer.move_down(30)
+
+    assert test_group.index(smartobject_layer) == 0
+    assert test_group.index(fill_layer) == 1
+    assert test_group.index(pixel_layer) == 2
+    assert test_group.index(adjustment_layer) == 3
+
+
+def test_append(group, pixel_layer):
+    
+    pix_old_parent = pixel_layer._parent
+
+    group.append(pixel_layer)
+
+    assert pixel_layer in group
+    assert pixel_layer._parent is group
+    assert pixel_layer._psd is group._psd
+
+    
+def test_extend(group, pixel_layer, type_layer, smartobject_layer, fill_layer):
+    
+    group.extend([pixel_layer, type_layer, smartobject_layer, fill_layer])
+
+    for layer in [pixel_layer, type_layer, smartobject_layer, fill_layer]:
+
+        assert layer in group
+        assert layer._parent is group
+        assert layer._psd is group._psd
+
+
+def test_insert(group, pixel_layer, type_layer, smartobject_layer, fill_layer):
+
+    group.append(pixel_layer)
+
+    group.insert(0, fill_layer)
+    assert group[0] is fill_layer
+
+    group.insert(5, smartobject_layer)
+    assert group[-1] is smartobject_layer
+
+    group.insert(1, type_layer)
+    assert group[1] is type_layer
+
+    group.insert(-1, pixel_layer)
+    assert group[-2] is pixel_layer # Negative index insert the item before the one currently at the given index.
+
+
+def test_remove(group, pixel_layer, type_layer, smartobject_layer, fill_layer):
+    
+    group.extend([pixel_layer, type_layer, smartobject_layer]) 
+
+    group.remove(pixel_layer)
+    assert pixel_layer not in group
+
+    group.remove(smartobject_layer)
+    assert smartobject_layer not in group
+
+    with pytest.raises(ValueError, match=r'.* x not in list'):
+        group.remove(pixel_layer)
+
+    with pytest.raises(ValueError, match=r'.* x not in list'):
+        group.remove(fill_layer)
+
+
+def test_pop(group, pixel_layer, type_layer, smartobject_layer, fill_layer):
+    
+    group.extend([pixel_layer, type_layer, smartobject_layer, fill_layer]) 
+
+    assert group.pop() is fill_layer
+    assert group.pop(0) is pixel_layer
+    assert group.pop(1) is smartobject_layer
+
+    assert len(group) == 1
+
+    with pytest.raises(IndexError):
+        group.pop(5)
+
+    group.clear()
+
+    with pytest.raises(IndexError):
+        group.pop()
+
+
+def test_clear(group, pixel_layer, type_layer, smartobject_layer, fill_layer):
+
+    group.extend([pixel_layer, type_layer, smartobject_layer, fill_layer]) 
+    assert len(group) == 4
+
+    group.clear()
+    assert len(group) == 0
+
+
+def test_index(group, pixel_layer, type_layer, smartobject_layer, fill_layer):
+
+    with pytest.raises(ValueError, match=r'.* is not in list'):
+        group.index(pixel_layer)
+
+    group.extend([pixel_layer, type_layer, smartobject_layer, fill_layer])
+
+    assert group.index(pixel_layer) == 0
+    assert group.index(type_layer) == 1
+    assert group.index(smartobject_layer) == 2
+    assert group.index(fill_layer) == 3
+    
+    group.clear()
+
+    with pytest.raises(ValueError, match=r'.* is not in list'):
+        group.index(fill_layer)
+
+
+def test_count(group, pixel_layer, type_layer, smartobject_layer, fill_layer):
+    
+    group.extend([pixel_layer, type_layer, smartobject_layer, fill_layer])
+
+    assert group.count(pixel_layer) == 1
+    assert group.count(type_layer) == 1
+    assert group.count(smartobject_layer) == 1
+    assert group.count(fill_layer) == 1
+
+    group.clear()
+
+    assert group.count(pixel_layer) == 0
+    assert group.count(type_layer) == 0
+    assert group.count(smartobject_layer) == 0
+    assert group.count(fill_layer) == 0
+
+    group.append(pixel_layer)
+    group.append(pixel_layer)
+    group.append(pixel_layer)
+
+    assert group.count(pixel_layer) == 3
\ No newline at end of file
diff --git a/tests/psd_tools/api/test_psd_image.py b/tests/psd_tools/api/test_psd_image.py
index f8386ca..22061f7 100644
--- a/tests/psd_tools/api/test_psd_image.py
+++ b/tests/psd_tools/api/test_psd_image.py
@@ -7,6 +7,7 @@ import pytest
 from IPython.lib.pretty import pprint
 
 from psd_tools.api.psd_image import PSDImage
+from psd_tools.api.layers import Group
 from psd_tools.constants import ColorMode, Compression
 
 from ..utils import full_name
@@ -124,3 +125,45 @@ def test_repr_pretty(fixture):
 )
 def test_open(filename):
     assert isinstance(PSDImage.open(full_name(filename)), PSDImage)
+
+
+def test_update_record(fixture):
+
+    pixel_layer = PSDImage.open(full_name("layers/pixel-layer.psd"))[0]
+    fill_layer = PSDImage.open(full_name("layers/solid-color-fill.psd"))[0]
+    shape_layer = PSDImage.open(full_name("layers/shape-layer.psd"))[0]
+    smart_layer = PSDImage.open(full_name("layers/smartobject-layer.psd"))[0]
+    type_layer = PSDImage.open(full_name("layers/type-layer.psd"))[0]
+    group_layer = PSDImage.open(full_name("layers/group.psd"))[0]
+
+    group_layer.extend([pixel_layer, fill_layer, shape_layer, smart_layer, type_layer])
+
+    fixture.append(group_layer)
+
+    fixture._update_record()
+
+    layer_info = fixture._record.layer_and_mask_information.layer_info
+
+    assert layer_info.layer_count == 9
+
+    assert layer_info.layer_records[0] is fixture[0]._record
+    assert layer_info.layer_records[1] is fixture[1]._record
+
+    assert layer_info.layer_records[2] is group_layer._bounding_record
+    assert layer_info.layer_records[3] is pixel_layer._record
+    assert layer_info.layer_records[4] is fill_layer._record
+    assert layer_info.layer_records[5] is shape_layer._record
+    assert layer_info.layer_records[6] is smart_layer._record
+    assert layer_info.layer_records[7] is type_layer._record
+    assert layer_info.layer_records[8] is group_layer._record
+
+    assert layer_info.channel_image_data[0] is fixture[0]._channels
+    assert layer_info.channel_image_data[1] is fixture[1]._channels
+
+    assert layer_info.channel_image_data[2] is group_layer._bounding_channels
+    assert layer_info.channel_image_data[3] is pixel_layer._channels
+    assert layer_info.channel_image_data[4] is fill_layer._channels
+    assert layer_info.channel_image_data[5] is shape_layer._channels
+    assert layer_info.channel_image_data[6] is smart_layer._channels
+    assert layer_info.channel_image_data[7] is type_layer._channels
+    assert layer_info.channel_image_data[8] is group_layer._channels
\ No newline at end of file
diff --git a/tests/psd_tools/compression/test_compression.py b/tests/psd_tools/compression/test_compression.py
index 5195c5f..2b71591 100644
--- a/tests/psd_tools/compression/test_compression.py
+++ b/tests/psd_tools/compression/test_compression.py
@@ -21,6 +21,7 @@ RAW_IMAGE_2x2_16bit = b"\x00\x01\x00\x02\x00\x03\x00\x04"
 RAW_IMAGE_2x2_32bit = (
     b"\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00\x03\x00\x00\x00\x04"
 )
+EDGE_CASE_1 = b"\xf9\xfa\xf4\xff\xff\xb8\xbc\xff\x96-\xe5\xf5\xb5\xd4\xff\xc2?\x95\xff\xc8\x96\xff\xff\xdav\xe4\xff\xa5\xd5\xff\xf9\xdb\xe3\xff\xf6\xc8\xe8\xff\xfa\xce\xd2\xff\xd4v\xe9\xff\x9fz\xee\xe9b\x95\xff\xbd6\xac\xff\xc6\x82\xd8\xffa\x1c\xec\xff\xf3\xe5\xe9\xf2iD\xff\xff\xdc\xfc\xf1\x94\xc6\xff\xd9\x1e:\xff\xffd\x86\xff\xcb\x1ap\xfe\xd7\\\x90\xff\xbd\xa1\xff\xde\x00z\xff\x96\x1c\xb7\xff\xc3w\xe3\xdd\x1d\x1f\xfd\xff\xd1\x82\xf3\x8e\x040\xf3\x98\x06\xa5\xa2t"
 
 
 @pytest.mark.parametrize(
diff --git a/tests/psd_tools/compression/test_rle.py b/tests/psd_tools/compression/test_rle.py
index f29847a..f7234de 100644
--- a/tests/psd_tools/compression/test_rle.py
+++ b/tests/psd_tools/compression/test_rle.py
@@ -3,7 +3,7 @@ import pytest
 import psd_tools.compression._rle as _rle
 import psd_tools.compression.rle as rle
 
-from .test_compression import RAW_IMAGE_3x3_8bit
+from .test_compression import RAW_IMAGE_3x3_8bit, EDGE_CASE_1
 
 
 def test_identical():
@@ -16,6 +16,14 @@ def test_identical():
     assert decoded == RAW_IMAGE_3x3_8bit
     assert decoded_c == RAW_IMAGE_3x3_8bit
 
+    size = len(EDGE_CASE_1)
+    encoded = rle.encode(EDGE_CASE_1)
+    encoded_c = _rle.encode(EDGE_CASE_1)
+    assert encoded == encoded_c
+    decoded = rle.decode(encoded, size)
+    decoded_c = _rle.decode(encoded_c, size)
+    assert decoded == EDGE_CASE_1
+    assert decoded_c == EDGE_CASE_1
 
 @pytest.mark.parametrize(
     ("mod, data, size"),