Skip to content

Commit

Permalink
Merge pull request #107 from psd-tools/1.8.14
Browse files Browse the repository at this point in the history
1.8.14
  • Loading branch information
kyamagu committed Apr 12, 2019
2 parents dce0cad + 95cb538 commit 4952b57
Show file tree
Hide file tree
Showing 18 changed files with 196 additions and 46 deletions.
11 changes: 11 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
1.8.14 (2019-04-12)
-------------------

- add dependency to aggdraw;
- support bezier curves in vector masks;
- support path operations;
- fix `compose(force=True)` behavior;
- fix default background color in composer;
- improve pattern overlay parameters support;
- fix gradient map generation for a single stop.

1.8.13 (2019-04-05)
-------------------

Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def get_version():
'attrs',
'Pillow!=6.0.0',
'enum34;python_version<"3.4"',
'aggdraw',
],
keywords="photoshop psd pil pillow",
package_dir={'': 'src'},
Expand Down
185 changes: 144 additions & 41 deletions src/psd_tools/api/composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def intersect(*bboxes):
return result


def _blend(target, image, offset, mask=None):
def _blend(target, image, offset):
if offset[0] < 0:
if image.width <= -offset[0]:
return target
Expand Down Expand Up @@ -121,10 +121,11 @@ def _default_filter(layer):
# Alpha must be forced to correctly blend.
mode = get_pil_mode(valid_layers[0]._psd.color_mode, True)
result = Image.new(
mode, (bbox[2] - bbox[0], bbox[3] - bbox[1]), color=color,
mode, (bbox[2] - bbox[0], bbox[3] - bbox[1]),
color=color if color is not None else 'white',
)
result.putalpha(0)

initial_layer = True
for layer in valid_layers:
if intersect(layer.bbox, bbox) == (0, 0, 0, 0):
continue
Expand All @@ -135,11 +136,7 @@ def _default_filter(layer):

logger.debug('Composing %s' % layer)
offset = (layer.left - bbox[0], layer.top - bbox[1])
if initial_layer:
result.paste(image, offset)
initial_layer = False
else:
result = _blend(result, image, offset)
result = _blend(result, image, offset)

return result

Expand All @@ -151,7 +148,9 @@ def compose_layer(layer, force=False, **kwargs):

image = layer.topil(**kwargs)
if image is None or force:
image = create_fill(layer)
texture = create_fill(layer)
if texture is not None:
image = texture

if image is None:
return image
Expand All @@ -173,10 +172,12 @@ def compose_layer(layer, force=False, **kwargs):
# What should we do here? There are two alpha channels.
pass
image.putalpha(mask)
elif layer.has_vector_mask() and not layer.has_pixels():
elif layer.has_vector_mask() and (force or not layer.has_pixels()):
mask = draw_vector_mask(layer)
# TODO: Stroke drawing.
image.putalpha(mask)
texture = image
image = Image.new(image.mode, image.size, 'white')
image.paste(texture, mask=mask)

# Apply layer fill effects.
apply_effect(layer, image)
Expand Down Expand Up @@ -210,16 +211,23 @@ def compose_layer(layer, force=False, **kwargs):
def create_fill(layer):
from PIL import Image
mode = get_pil_mode(layer._psd.color_mode, True)
image = Image.new(mode, (layer.width, layer.height))
image = None
if 'SOLID_COLOR_SHEET_SETTING' in layer.tagged_blocks:
image = Image.new(mode, (layer.width, layer.height), 'white')
setting = layer.tagged_blocks.get_data(
'SOLID_COLOR_SHEET_SETTING'
)
draw_solid_color_fill(image, setting)
draw_solid_color_fill(image, setting, blend=False)
elif 'VECTOR_STROKE_CONTENT_DATA' in layer.tagged_blocks:
image = Image.new(mode, (layer.width, layer.height), 'white')
setting = layer.tagged_blocks.get_data('VECTOR_STROKE_CONTENT_DATA')
draw_solid_color_fill(image, setting, blend=False)
elif 'PATTERN_FILL_SETTING' in layer.tagged_blocks:
image = Image.new(mode, (layer.width, layer.height), 'white')
setting = layer.tagged_blocks.get_data('PATTERN_FILL_SETTING')
draw_pattern_fill(image, layer._psd, setting)
draw_pattern_fill(image, layer._psd, setting, blend=False)
elif 'GRADIENT_FILL_SETTING' in layer.tagged_blocks:
image = Image.new(mode, (layer.width, layer.height), 'white')
setting = layer.tagged_blocks.get_data('GRADIENT_FILL_SETTING')
draw_gradient_fill(image, setting, blend=False)
return image
Expand Down Expand Up @@ -257,42 +265,129 @@ def apply_effect(layer, image):


def draw_vector_mask(layer):
from PIL import Image, ImageDraw
from PIL import Image, ImageChops
width = layer._psd.width
height = layer._psd.height
color = layer.vector_mask.initial_fill_rule * 255
color = 255 * layer.vector_mask.initial_fill_rule

mask = Image.new('L', (width, height), color)
draw = ImageDraw.Draw(mask)
first = True
for subpath in layer.vector_mask.paths:
path = [(
int(knot.anchor[1] * width),
int(knot.anchor[0] * height),
) for knot in subpath]
# TODO: Use bezier curve instead of polygon. Perhaps aggdraw module.
draw.polygon(path, fill=(255 - color))
plane = _draw_subpath(subpath, width, height)
if subpath.operation == 0:
mask = ImageChops.difference(mask, plane)
elif subpath.operation == 1:
mask = ImageChops.lighter(mask, plane)
elif subpath.operation == 2:
if first:
mask = ImageChops.invert(mask)
mask = ImageChops.subtract(mask, plane)
elif subpath.operation == 3:
if first:
mask = ImageChops.invert(mask)
mask = ImageChops.darker(mask, plane)
first = False
return mask.crop(layer.bbox)


def _draw_subpath(subpath, width, height):
from PIL import Image
import aggdraw
mask = Image.new('L', (width, height), 0)
path = ' '.join(map(str, _generate_symbol(subpath, width, height)))
draw = aggdraw.Draw(mask)
brush = aggdraw.Brush(255)
symbol = aggdraw.Symbol(path)
draw.symbol((0, 0), symbol, None, brush)
draw.flush()
del draw
return mask

return mask.crop(layer.bbox)

def _generate_symbol(path, width, height, command='C'):
"""Sequence generator for SVG path."""
if len(path) == 0:
return

# Initial point.
yield 'M'
yield path[0].anchor[1] * width
yield path[0].anchor[0] * height
yield command

# Closed path or open path
points = (zip(path, path[1:] + path[0:1]) if path.is_closed()
else zip(path, path[1:]))

# Rest of the points.
for p1, p2 in points:
yield p1.leaving[1] * width
yield p1.leaving[0] * height
yield p2.preceding[1] * width
yield p2.preceding[0] * height
yield p2.anchor[1] * width
yield p2.anchor[0] * height

def draw_solid_color_fill(image, setting):
from PIL import ImageDraw
if path.is_closed():
yield 'Z'


def draw_solid_color_fill(image, setting, blend=True):
from PIL import Image, ImageDraw, ImageChops
color = tuple(int(x) for x in setting.get(b'Clr ').values())
draw = ImageDraw.Draw(image)
draw.rectangle((0, 0, image.width, image.height), fill=color)
canvas = Image.new(image.mode, image.size)
draw = ImageDraw.Draw(canvas)
draw.rectangle((0, 0, canvas.width, canvas.height), fill=color)
del draw
if blend:
canvas.putalpha(image.getchannel('A'))
_blend(image, canvas, (0, 0))
else:
image.paste(canvas)


def draw_pattern_fill(image, psd, setting, blend=True):
"""
Draw pattern fill on the image.
def draw_pattern_fill(image, psd, setting):
:param image: Image to be filled.
:param psd: :py:class:`PSDImage`.
:param setting: Descriptor containing pattern fill.
:param blend: Blend the fill or ignore. Effects blend.
"""
from PIL import Image
pattern_id = setting[b'Ptrn'][b'Idnt'].value.rstrip('\x00')
pattern = psd._get_pattern(pattern_id)
if not pattern:
logger.error('Pattern not found: %s' % (pattern_id))
return None
panel = convert_pattern_to_pil(pattern, psd._record.header.version)
for left in range(0, image.width, panel.width):
for top in range(0, image.height, panel.height):
image.paste(panel, (left, top))

scale = setting.get(b'Scl ', 100) / 100.
if scale != 1.:
panel = panel.resize((
int(panel.width * scale),
int(panel.height * scale)
))

opacity = int(setting.get(b'Opct', 100) / 100. * 255)
if opacity != 255:
panel.putalpha(opacity)

pattern_image = Image.new(image.mode, image.size)
mask = image.getchannel('A') if blend else Image.new('L', image.size, 255)

for left in range(0, pattern_image.width, panel.width):
for top in range(0, pattern_image.height, panel.height):
panel_mask = mask.crop(
(left, top, left + panel.width, top + panel.height)
)
pattern_image.paste(panel, (left, top), panel_mask)

if blend:
image.paste(_blend(image, pattern_image, (0, 0)))
else:
image.paste(pattern_image)


def draw_gradient_fill(image, setting, blend=True):
Expand All @@ -313,6 +408,7 @@ def draw_gradient_fill(image, setting, blend=True):

gradient_image = _apply_color_map(image.mode, setting.get(b'Grad'), Z)
if blend:
gradient_image.putalpha(image.getchannel('A'))
_blend(image, gradient_image, offset=(0, 0))
else:
image.paste(gradient_image)
Expand Down Expand Up @@ -345,13 +441,17 @@ def _apply_color_map(mode, grad, Z):
scalar = {
'RGB': 1.0, 'L': 2.55, 'CMYK': 2.55,
}.get(mode, 1.0)

X = [stop.get(b'Lctn').value / 4096. for stop in stops]
Y = [
tuple(int(scalar * x.value) for x in stop.get(b'Clr ').values())
for stop in stops
]
if len(stops) == 1:
X = [0., 1.]
Y = [Y[0], Y[0]]
G = interpolate.interp1d(
[stop.get(b'Lctn').value / 4096. for stop in stops],
[
tuple(int(scalar * x.value) for x in stop.get(b'Clr ').values())
for stop in stops
],
axis=0, fill_value='extrapolate'
X, Y, axis=0, fill_value='extrapolate'
)
pixels = G(Z).astype(np.uint8)
if pixels.shape[-1] == 1:
Expand All @@ -360,10 +460,13 @@ def _apply_color_map(mode, grad, Z):
image = Image.fromarray(pixels, mode.rstrip('A'))
if b'Trns' in grad and mode.endswith('A'):
stops = grad.get(b'Trns')
X = [stop.get(b'Lctn').value / 4096 for stop in stops]
Y = [stop.get(b'Opct').value * 2.55 for stop in stops]
if len(stops) == 1:
X = [0., 1.]
Y = [Y[0], Y[0]]
G_opacity = interpolate.interp1d(
[stop.get(b'Lctn').value / 4096 for stop in stops],
[stop.get(b'Opct').value * 2.55 for stop in stops],
axis=0, fill_value='extrapolate'
X, Y, axis=0, fill_value='extrapolate'
)
alpha = G_opacity(Z).astype(np.uint8)
image.putalpha(Image.fromarray(alpha, 'L'))
Expand Down
6 changes: 5 additions & 1 deletion src/psd_tools/api/pil_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,11 @@ def convert_pattern_to_pil(pattern, version=1):
]
if len(channels) == len(mode) + 1:
mode += 'A' # TODO: Perhaps doesn't work for some modes.
image = Image.merge(mode, channels)
if mode == 'P':
image = channels[0]
image.putpalette([x for rgb in pattern.color_table for x in rgb])
else:
image = Image.merge(mode, channels)
if mode == 'CMYK':
image = image.point(lambda x: 255 - x)
return image
Expand Down
4 changes: 3 additions & 1 deletion src/psd_tools/api/psd_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ def compose(self, force=False, bbox=None, **kwargs):
if not force or len(self) == 0:
image = self.topil(**kwargs)
if image is None:
image = compose(self, bbox=bbox or self.viewbox, **kwargs)
image = compose(
self, bbox=bbox or self.viewbox, force=force, **kwargs
)
return image

def is_visible(self):
Expand Down
6 changes: 4 additions & 2 deletions src/psd_tools/psd/vector.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@ class Subpath(ListElement):
`int` value indicating how multiple subpath should be combined:
1: Or (union), 2: Not-Or, 3: And (intersect), 4: Xor (exclude),
-1: Subtract?
1: Or (union), 2: Not-Or, 3: And (intersect), 0: Xor (exclude)
The first path element is applied to the background surface.
Intersection does not have strokes.
.. py:attribute:: index
Expand Down
2 changes: 1 addition & 1 deletion src/psd_tools/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '1.8.13'
__version__ = '1.8.14'
Binary file added tests/psd_files/path-operations/combine.psd
Binary file not shown.
Binary file added tests/psd_files/path-operations/exclude-first.psd
Binary file not shown.
Binary file added tests/psd_files/path-operations/exclude.psd
Binary file not shown.
Binary file added tests/psd_files/path-operations/intersect-all.psd
Binary file not shown.
Binary file not shown.
Binary file added tests/psd_files/path-operations/subtract-all.psd
Binary file not shown.
Binary file not shown.
Binary file not shown.
17 changes: 17 additions & 0 deletions tests/psd_tools/api/test_composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ def test_compose_quality(filename):
assert _calculate_hash_error(preview, rendered) <= 0.1


@pytest.mark.parametrize(("filename",), [
('path-operations/combine.psd',),
('path-operations/exclude-first.psd',),
('path-operations/exclude.psd',),
('path-operations/intersect-all.psd',),
('path-operations/intersect-first.psd',),
('path-operations/subtract-all.psd',),
('path-operations/subtract-first.psd',),
('path-operations/subtract-second.psd',),
])
def test_compose_quality_rgb(filename):
psd = PSDImage.open(full_name(filename))
preview = psd.topil().convert('RGB')
rendered = psd.compose(force=True).convert('RGB')
assert _calculate_hash_error(preview, rendered) <= 0.1


@pytest.mark.parametrize('filename', [
'smartobject-layer.psd',
'type-layer.psd',
Expand Down
10 changes: 10 additions & 0 deletions tests/psd_tools/api/test_pil_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from psd_tools.api import pil_io
from psd_tools.constants import ColorMode
from psd_tools.psd.patterns import Pattern
from ..utils import TEST_ROOT, full_name


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -36,3 +38,11 @@ def test_get_color_mode(mode):
])
def test_get_pil_mode(mode, alpha, expected):
assert pil_io.get_pil_mode(mode.name, alpha) == expected


def test_convert_pattern_to_pil():
filepath = os.path.join(TEST_ROOT, 'tagged_blocks', 'Patt_1.dat')
with open(filepath, 'rb') as f:
pattern = Pattern.read(f)

assert pil_io.convert_pattern_to_pil(pattern)
Binary file added tests/tagged_blocks/Patt_1.dat
Binary file not shown.

0 comments on commit 4952b57

Please sign in to comment.