"""
Composer API.
Composer takes responsibility of rendering layers as an image.
"""
from __future__ import absolute_import, unicode_literals
import logging
from psd_tools.api import deprecated
from psd_tools.constants import Tag, BlendMode
from psd_tools.api.pil_io import get_pil_mode
from psd_tools.api.layers import Group
from psd_tools.composer.blend import blend
from psd_tools.composer.effects import create_stroke_effect
from psd_tools.composer.vector import (
draw_pattern_fill, draw_gradient_fill, draw_solid_color_fill,
draw_vector_mask, draw_stroke
)
from psd_tools.terminology import Enum, Key
logger = logging.getLogger(__name__)
def union(*bboxes):
if len(bboxes) == 0:
return (0, 0, 0, 0)
lefts, tops, rights, bottoms = zip(*bboxes)
result = (min(lefts), min(tops), max(rights), max(bottoms))
if result[2] <= result[0] or result[3] <= result[1]:
return (0, 0, 0, 0)
return result
def intersect(*bboxes):
if len(bboxes) == 0:
return (0, 0, 0, 0)
lefts, tops, rights, bottoms = zip(*bboxes)
result = (max(lefts), max(tops), min(rights), min(bottoms))
if result[2] <= result[0] or result[3] <= result[1]:
return (0, 0, 0, 0)
return result
[docs]@deprecated
def compose(
layers,
force=False,
bbox=None,
layer_filter=None,
context=None,
color=None,
):
"""
Compose layers to a single :py:class:`PIL.Image`.
If the layers do not have visible pixels, the function returns `None`.
Example::
image = compose([layer1, layer2])
In order to skip some layers, pass `layer_filter` function which
should take `layer` as an argument and return `True` to keep the layer
or return `False` to skip::
image = compose(
layers,
layer_filter=lambda x: x.is_visible() and x.kind == 'type'
)
By default, visible layers are composed.
.. note:: This function is experimental and does not guarantee
Photoshop-quality rendering.
Currently the following are ignored:
- Adjustments layers
- Layer effects
- Blending modes: dissolve and darker/lighter color becomes normal
Shape drawing is inaccurate if the PSD file is not saved with
maximum compatibility.
Some of the blending modes do not reproduce photoshop blending.
:param layers: a layer, or an iterable of layers.
:param bbox: (left, top, bottom, right) tuple that specifies a region to
compose. By default, all the visible area is composed. The origin
is at the top-left corner of the PSD document.
:param context: `PIL.Image` object for the backdrop rendering context. Must
be used with the correct `bbox` size.
:param layer_filter: a callable that takes a layer and returns `bool`.
:param color: background color in `int` or `tuple`.
:param kwargs: arguments passed to underling `topil()` call.
:return: :py:class:`PIL.Image` or `None`.
"""
from PIL import Image
if not hasattr(layers, '__iter__'):
layers = [layers]
def _default_filter(layer):
return layer.is_visible()
layer_filter = layer_filter or _default_filter
valid_layers = [x for x in layers if layer_filter(x)]
if len(valid_layers) == 0:
return context
if bbox is None:
bbox = Group.extract_bbox(valid_layers)
if bbox == (0, 0, 0, 0):
return context
if context is None:
mode = get_pil_mode(valid_layers[0]._psd.color_mode, True)
context = Image.new(
mode,
(bbox[2] - bbox[0], bbox[3] - bbox[1]),
color=color if color is not None else 'white',
)
context.putalpha(0) # Alpha must be forced to correctly blend.
context.info['offset'] = (bbox[0], bbox[1])
for layer in valid_layers:
if intersect(layer.bbox, bbox) == (0, 0, 0, 0):
continue
if layer.is_group():
if layer.blend_mode == BlendMode.PASS_THROUGH:
_context = layer.compose(
force=force,
context=context,
bbox=bbox,
layer_filter=layer_filter,
color=color,
)
offset = _context.info.get('offset', (0, 0))
# TODO: group opacity is not properly considered here.
context.paste(
_context, (offset[0] - bbox[0], offset[1] - bbox[1])
)
continue
else:
image = layer.compose(layer_filter=layer_filter)
else:
image = compose_layer(layer, force=force)
if image is None:
continue
logger.debug('Composing %s' % layer)
offset = image.info.get('offset', layer.offset)
offset = (offset[0] - bbox[0], offset[1] - bbox[1])
context = blend(context, image, offset, layer.blend_mode)
logger.debug('Composing: %s' % layers)
if isinstance(layers, Group):
context = _apply_layer_ops(layers, context)
return context
def compose_layer(layer, force=False):
"""Compose a single layer with pixels."""
assert layer.bbox != (0, 0, 0, 0), 'Layer bbox is (0, 0, 0, 0)'
image = layer.topil()
if image is None or force:
texture = create_fill(layer)
if texture is not None:
image = texture
if image is None:
return image
return _apply_layer_ops(layer, image, force=force)
def _apply_layer_ops(layer, image, force=False, bbox=None):
"""Apply layer masks, effects, and clipping."""
from PIL import Image, ImageChops
# Apply vector mask.
if layer.has_vector_mask() and (force or not layer.has_pixels()):
offset = image.info.get('offset', layer.offset)
mask_box = offset + (offset[0] + image.width, offset[1] + image.height)
vector_mask = draw_vector_mask(layer, mask_box)
if image.mode.endswith('A'):
offset = vector_mask.info['offset']
vector_mask = ImageChops.darker(image.getchannel('A'), vector_mask)
vector_mask.info['offset'] = offset
image.putalpha(vector_mask)
# Apply stroke.
if layer.has_stroke() and layer.stroke.enabled:
image = draw_stroke(image, layer, vector_mask)
# Apply mask.
image = apply_mask(layer, image, bbox=bbox)
# Apply layer fill effects.
apply_opacity(
image, layer.tagged_blocks.get_data(Tag.BLEND_FILL_OPACITY, 255)
)
if layer.effects.enabled:
image = apply_effect(layer, image, image.copy())
# Clip layers.
if layer.has_clip_layers():
clip_box = Group.extract_bbox(layer.clip_layers)
offset = image.info.get('offset', layer.offset)
bbox = offset + (offset[0] + image.width, offset[1] + image.height)
if intersect(bbox, clip_box) != (0, 0, 0, 0):
clip_image = compose(
layer.clip_layers,
force=force,
bbox=bbox,
context=image.copy()
)
if image.mode.endswith('A'):
mask = image.getchannel('A')
else:
mask = Image.new('L', image.size, 255)
if clip_image.mode.endswith('A'):
mask = ImageChops.darker(clip_image.getchannel('A'), mask)
clip_image.putalpha(mask)
image = blend(image, clip_image, (0, 0))
# Apply opacity.
apply_opacity(image, layer.opacity)
return image
def create_fill(layer):
from PIL import Image
mode = get_pil_mode(layer._psd.color_mode, True)
size = (layer.width, layer.height)
fill_image = None
stroke = layer.tagged_blocks.get_data(Tag.VECTOR_STROKE_DATA)
# Apply fill.
if Tag.VECTOR_STROKE_CONTENT_DATA in layer.tagged_blocks:
setting = layer.tagged_blocks.get_data(Tag.VECTOR_STROKE_CONTENT_DATA)
if stroke and bool(stroke.get('fillEnabled', True)) is False:
fill_image = Image.new(mode, size)
elif Enum.Pattern in setting:
fill_image = draw_pattern_fill(size, layer._psd, setting)
elif Key.Gradient in setting:
fill_image = draw_gradient_fill(size, setting)
else:
fill_image = draw_solid_color_fill(size, setting)
elif Tag.SOLID_COLOR_SHEET_SETTING in layer.tagged_blocks:
setting = layer.tagged_blocks.get_data(Tag.SOLID_COLOR_SHEET_SETTING)
fill_image = draw_solid_color_fill(size, setting)
elif Tag.PATTERN_FILL_SETTING in layer.tagged_blocks:
setting = layer.tagged_blocks.get_data(Tag.PATTERN_FILL_SETTING)
fill_image = draw_pattern_fill(size, layer._psd, setting)
elif Tag.GRADIENT_FILL_SETTING in layer.tagged_blocks:
setting = layer.tagged_blocks.get_data(Tag.GRADIENT_FILL_SETTING)
fill_image = draw_gradient_fill(size, setting)
return fill_image
def apply_mask(layer, image, bbox=None):
"""
Apply raster mask to the image.
This might change the size and offset of the image. Resulting offset wrt
the psd viewport is kept in `image.info['offset']` field.
:param layer: `~psd_tools.api.layers.Layer`
:param image: PIL.Image
:return: PIL.Image
"""
from PIL import Image, ImageChops
offset = image.info.get('offset', layer.offset)
image.info['offset'] = offset
if layer.has_mask() and not layer.mask.disabled:
mask_bbox = layer.mask.bbox
if mask_bbox != (0, 0, 0, 0):
color = layer.mask.background_color
if bbox:
pass
elif color == 0:
bbox = mask_bbox
else:
bbox = layer._psd.viewbox
size = (bbox[2] - bbox[0], bbox[3] - bbox[1])
image_ = Image.new(image.mode, size)
image_.paste(image, (offset[0] - bbox[0], offset[1] - bbox[1]))
mask = Image.new('L', size, color=color)
mask_image = layer.mask.topil()
if mask_image:
mask.paste(
mask_image,
(mask_bbox[0] - bbox[0], mask_bbox[1] - bbox[1])
)
if image_.mode.endswith('A'):
mask = ImageChops.darker(image_.getchannel('A'), mask)
image_.putalpha(mask)
image_.info['offset'] = (bbox[0], bbox[1])
return image_
return image
def apply_effect(layer, backdrop, base_image):
"""Apply effect to the image.
..note: Correct effect order is the following. All the effects are first
applied to the original image then blended together.
* dropshadow
* outerglow
* (original)
* patternoverlay
* gradientoverlay
* coloroverlay
* innershadow
* innerglow
* bevelemboss
* satin
* stroke
"""
from PIL import ImageChops
for effect in layer.effects:
if effect.__class__.__name__ == 'PatternOverlay':
image = draw_pattern_fill(
base_image.size, layer._psd, effect.value
)
if base_image.mode.endswith('A'):
alpha = base_image.getchannel('A')
if image.mode.endswith('A'):
alpha = ImageChops.darker(alpha, image.getchannel('A'))
image.putalpha(alpha)
backdrop = blend(backdrop, image, (0, 0), effect.blend_mode)
for effect in layer.effects:
if effect.__class__.__name__ == 'GradientOverlay':
image = draw_gradient_fill(base_image.size, effect.value)
if base_image.mode.endswith('A'):
alpha = base_image.getchannel('A')
if image.mode.endswith('A'):
alpha = ImageChops.darker(alpha, image.getchannel('A'))
image.putalpha(alpha)
backdrop = blend(backdrop, image, (0, 0), effect.blend_mode)
for effect in layer.effects:
if effect.__class__.__name__ == 'ColorOverlay':
image = draw_solid_color_fill(base_image.size, effect.value)
if base_image.mode.endswith('A'):
alpha = base_image.getchannel('A')
if image.mode.endswith('A'):
alpha = ImageChops.darker(alpha, image.getchannel('A'))
image.putalpha(alpha)
backdrop = blend(backdrop, image, (0, 0), effect.blend_mode)
for effect in layer.effects:
if effect.__class__.__name__ == 'Stroke':
from PIL import ImageOps
if layer.has_vector_mask():
alpha = draw_vector_mask(layer)
elif base_image.mode.endswith('A'):
alpha = base_image.getchannel('A')
else:
alpha = base_image.convert('L')
alpha.info['offset'] = base_image.info['offset']
flat = alpha.getextrema()[0] < 255
# Expand the image size
setting = effect.value
size = int(setting.get(Key.SizeKey))
offset = backdrop.info['offset']
backdrop = ImageOps.expand(backdrop, size)
backdrop.info['offset'] = tuple(x - size for x in offset)
offset = alpha.info['offset']
alpha = ImageOps.expand(alpha, size)
alpha.info['offset'] = tuple(x - size for x in offset)
if not layer.has_vector_mask() and setting.get(
Key.Style
).enum == Enum.InsetFrame and flat:
image = create_stroke_effect(alpha, setting, layer._psd, True)
backdrop.paste(image)
else:
image = create_stroke_effect(alpha, setting, layer._psd)
backdrop = blend(backdrop, image, (0, 0), effect.blend_mode)
return backdrop
def apply_opacity(image, opacity):
if opacity < 255:
if image.mode.endswith('A'):
opacity /= 255.
alpha = image.getchannel('A')
alpha = alpha.point(lambda x: int(round(x * opacity)))
image.putalpha(alpha)
else:
image.putalpha(int(opacity))