"""
Shape module.
In PSD/PSB, shapes are all represented as :py:class:`VectorMask` in each
layer, and optionally there might be :py:class:`Origination` object to control
live shape properties and :py:class:`Stroke` to specify how outline is
stylized.
"""
import logging
import math
from typing import Any, Literal
from psd_tools.psd.descriptor import Descriptor, DescriptorBlock2
from psd_tools.psd.vector import (
ClipboardRecord,
InitialFillRule,
Subpath,
VectorMaskSetting,
VectorStrokeContentSetting,
)
from psd_tools.terminology import Event
logger = logging.getLogger(__name__)
[docs]
class VectorMask(object):
"""
Vector mask data.
Vector mask is a resolution-independent mask that consists of one or more
Path objects. In Photoshop, all the path objects are represented as
Bezier curves. Check :py:attr:`~psd_tools.api.shape.VectorMask.paths`
property for how to deal with path objects.
"""
def __init__(self, data: VectorMaskSetting):
self._data = data
self._build()
def _build(self) -> None:
self._paths = []
self._clipboard_record = None
self._initial_fill_rule = None
if self._data.path:
for x in self._data.path:
if isinstance(x, InitialFillRule):
self._initial_fill_rule = x
elif isinstance(x, ClipboardRecord):
self._clipboard_record = x
elif isinstance(x, Subpath):
self._paths.append(x)
@property
def inverted(self) -> bool:
"""Invert the mask."""
return self._data.invert
@property
def not_linked(self) -> bool:
"""If the knots are not linked."""
return self._data.not_link
@property
def disabled(self) -> bool:
"""If the mask is disabled."""
return self._data.disable
@property
def paths(self) -> list[Subpath]:
"""
List of :py:class:`~psd_tools.psd.vector.Subpath`. Subpath is a
list-like structure that contains one or more
:py:class:`~psd_tools.psd.vector.Knot` items. Knot contains
relative coordinates of control points for a Bezier curve.
:py:attr:`~psd_tools.psd.vector.Subpath.index` indicates which
origination item the subpath belongs, and
:py:class:`~psd_tools.psd.vector.Subpath.operation` indicates how
to combine multiple shape paths.
In PSD, path fill rule is even-odd.
Example::
for subpath in layer.vector_mask.paths:
anchors = [(
int(knot.anchor[1] * psd.width),
int(knot.anchor[0] * psd.height),
) for knot in subpath]
:return: List of Subpath.
"""
return self._paths
@property
def initial_fill_rule(self) -> int:
"""
Initial fill rule.
When 0, fill inside of the path. When 1, fill outside of the shape.
:return: `int`
"""
if self._initial_fill_rule is None:
return 0
return self._initial_fill_rule.value
@initial_fill_rule.setter
def initial_fill_rule(self, value: Literal[0, 1]) -> None:
if value not in (0, 1):
raise ValueError(f"Initial fill rule must be 0 or 1, got {value}")
if self._initial_fill_rule is not None:
self._initial_fill_rule.value = value
@property
def clipboard_record(self) -> ClipboardRecord | None:
"""
Clipboard record containing bounding box information.
Depending on the Photoshop version, this field can be `None`.
"""
return self._clipboard_record
@property
def bbox(self) -> tuple[float, float, float, float]:
"""
Bounding box tuple (left, top, right, bottom) in relative coordinates,
where top-left corner is (0., 0.) and bottom-right corner is (1., 1.).
The bounding box accounts for the full extent of all cubic Bezier
curves, not just the anchor points.
:return: `tuple`
"""
def _bezier_extrema(p0: float, p1: float, p2: float, p3: float) -> list[float]:
"""Return t values in (0, 1) where the cubic Bezier has extrema."""
a = -p0 + 3 * p1 - 3 * p2 + p3
b = 2 * (p0 - 2 * p1 + p2)
c = p1 - p0
ts = []
if abs(a) < 1e-12:
if abs(b) > 1e-12:
t = -c / b
if 0.0 < t < 1.0:
ts.append(t)
else:
disc = b * b - 4 * a * c
if disc >= -1e-12:
sq = math.sqrt(max(0.0, disc))
for t in ((-b + sq) / (2 * a), (-b - sq) / (2 * a)):
if 0.0 < t < 1.0:
ts.append(t)
return ts
def _bezier_eval(p0: float, p1: float, p2: float, p3: float, t: float) -> float:
u = 1.0 - t
return u**3 * p0 + 3 * u**2 * t * p1 + 3 * u * t**2 * p2 + t**3 * p3
xs: list[float] = []
ys: list[float] = []
for path in self.paths:
knots = list(path)
if len(knots) == 0:
continue
# For a single-knot open path there are no segments, but we still
# need to include the anchor point in the bounding box.
if len(knots) == 1 and not path.is_closed():
xs.append(knots[0].anchor[1])
ys.append(knots[0].anchor[0])
continue
pairs = (
zip(knots, knots[1:] + knots[:1])
if path.is_closed()
else zip(knots, knots[1:])
)
for k0, k1 in pairs:
# anchor = (y, x); leaving/preceding = (y, x)
x0, y0 = k0.anchor[1], k0.anchor[0]
x1, y1 = k0.leaving[1], k0.leaving[0]
x2, y2 = k1.preceding[1], k1.preceding[0]
x3, y3 = k1.anchor[1], k1.anchor[0]
xs.extend([x0, x3])
ys.extend([y0, y3])
for t in _bezier_extrema(x0, x1, x2, x3):
xs.append(_bezier_eval(x0, x1, x2, x3, t))
for t in _bezier_extrema(y0, y1, y2, y3):
ys.append(_bezier_eval(y0, y1, y2, y3, t))
if not xs:
return (0.0, 0.0, 1.0, 1.0)
return (min(xs), min(ys), max(xs), max(ys))
def __repr__(self) -> str:
bbox = self.bbox
return "%s(bbox=(%g, %g, %g, %g) paths=%d%s)" % (
self.__class__.__name__,
bbox[0],
bbox[1],
bbox[2],
bbox[3],
len(self.paths),
" disabled" if self.disabled else "",
)
[docs]
class Stroke(object):
"""
Stroke contains decorative information for strokes.
This is a thin wrapper around
:py:class:`~psd_tools.psd.descriptor.Descriptor` structure.
Check `_data` attribute to get the raw data.
"""
STROKE_STYLE_LINE_CAP_TYPES = {
b"strokeStyleButtCap": "butt",
b"strokeStyleRoundCap": "round",
b"strokeStyleSquareCap": "square",
}
STROKE_STYLE_LINE_JOIN_TYPES = {
b"strokeStyleMiterJoin": "miter",
b"strokeStyleRoundJoin": "round",
b"strokeStyleBevelJoin": "bevel",
}
STROKE_STYLE_LINE_ALIGNMENTS = {
b"strokeStyleAlignInside": "inner",
b"strokeStyleAlignOutside": "outer",
b"strokeStyleAlignCenter": "center",
}
def __init__(self, data: VectorStrokeContentSetting):
self._data = data
if self._data.classID not in (b"strokeStyle", Event.Stroke):
logger.warning("Unknown class ID found: {!r}".format(self._data.classID))
@property
def enabled(self) -> bool:
"""If the stroke is enabled."""
return bool(self._data.get(b"strokeEnabled"))
@property
def fill_enabled(self) -> bool:
"""If the stroke fill is enabled."""
return bool(self._data.get(b"fillEnabled"))
@property
def line_width(self) -> float:
"""Stroke width in float."""
return float(self._data.get(b"strokeStyleLineWidth"))
@property
def line_dash_set(self) -> list:
"""
Line dash set in list of
:py:class:`~psd_tools.decoder.actions.UnitFloat`.
:return: list
"""
return self._data.get(b"strokeStyleLineDashSet")
@property
def line_dash_offset(self) -> float:
"""
Line dash offset in float.
:return: float
"""
return self._data.get(b"strokeStyleLineDashOffset")
@property
def miter_limit(self) -> Any:
"""Miter limit in float."""
return self._data.get(b"strokeStyleMiterLimit")
@property
def line_cap_type(self) -> str:
"""Cap type, one of `butt`, `round`, `square`."""
key = self._data.get(b"strokeStyleLineCapType").enum
return self.STROKE_STYLE_LINE_CAP_TYPES.get(key, str(key))
@property
def line_join_type(self) -> str:
"""Join type, one of `miter`, `round`, `bevel`."""
key = self._data.get(b"strokeStyleLineJoinType").enum
return self.STROKE_STYLE_LINE_JOIN_TYPES.get(key, str(key))
@property
def line_alignment(self) -> str:
"""Alignment, one of `inner`, `outer`, `center`."""
key = self._data.get(b"strokeStyleLineAlignment").enum
return self.STROKE_STYLE_LINE_ALIGNMENTS.get(key, str(key))
@property
def scale_lock(self) -> Any:
return self._data.get(b"strokeStyleScaleLock")
@property
def stroke_adjust(self) -> Any:
"""Stroke adjust"""
return self._data.get(b"strokeStyleStrokeAdjust")
@property
def blend_mode(self) -> Any:
"""Blend mode."""
return self._data.get(b"strokeStyleBlendMode").enum
@property
def opacity(self) -> Any:
"""Opacity value."""
return self._data.get(b"strokeStyleOpacity")
@property
def content(self) -> Any:
"""
Fill effect.
"""
return self._data.get(b"strokeStyleContent")
def __repr__(self) -> str:
return "%s(width=%g)" % (self.__class__.__name__, self.line_width)
class Origination(object):
"""
Vector origination.
Vector origination keeps live shape properties for some of the primitive
shapes.
"""
@classmethod
def create(
kls, data: DescriptorBlock2
) -> "Invalidated | Rectangle | RoundedRectangle | Line | Ellipse":
if data.get(b"keyShapeInvalidated"):
return Invalidated(data)
origin_type = data.get(b"keyOriginType")
types = {1: Rectangle, 2: RoundedRectangle, 4: Line, 5: Ellipse}
return types.get(origin_type, kls)(data) # type: ignore
def __init__(self, data: Descriptor) -> None:
self._data = data
@property
def origin_type(self) -> int:
"""
Type of the vector shape.
* 1: :py:class:`~psd_tools.api.shape.Rectangle`
* 2: :py:class:`~psd_tools.api.shape.RoundedRectangle`
* 4: :py:class:`~psd_tools.api.shape.Line`
* 5: :py:class:`~psd_tools.api.shape.Ellipse`
:return: `int`
"""
return int(self._data.get(b"keyOriginType"))
@property
def resolution(self) -> float:
"""Resolution.
:return: `float`
"""
return float(self._data.get(b"keyOriginResolution"))
@property
def bbox(self) -> tuple[float, float, float, float]:
"""
Bounding box of the live shape.
:return: :py:class:`~psd_tools.psd.descriptor.Descriptor`
"""
bbox = self._data.get(b"keyOriginShapeBBox")
if bbox:
return (
float(bbox.get(b"Left").value),
float(bbox.get(b"Top ").value),
float(bbox.get(b"Rght").value),
float(bbox.get(b"Btom").value),
)
return (0.0, 0.0, 0.0, 0.0)
@property
def index(self) -> int:
"""
Origination item index.
:return: `int`
"""
return self._data.get(b"keyOriginIndex")
@property
def invalidated(self) -> bool:
"""
:return: `bool`
"""
return False
def __repr__(self) -> str:
bbox = self.bbox
return "%s(bbox=(%g, %g, %g, %g))" % (
self.__class__.__name__,
bbox[0],
bbox[1],
bbox[2],
bbox[3],
)
[docs]
class Invalidated(Origination):
"""
Invalidated live shape.
This equals to a primitive shape that does not provide Live shape
properties. Use :py:class:`~psd_tools.api.shape.VectorMask` to access
shape information instead of this origination object.
"""
@property
def invalidated(self) -> bool:
return True
def __repr__(self) -> str:
return "%s()" % (self.__class__.__name__)
[docs]
class Rectangle(Origination):
"""Rectangle live shape."""
pass
[docs]
class Ellipse(Origination):
"""Ellipse live shape."""
pass
[docs]
class RoundedRectangle(Origination):
"""Rounded rectangle live shape."""
@property
def radii(self) -> Any:
"""
Corner radii of rounded rectangles.
The order is top-left, top-right, bottom-left, bottom-right.
:return: :py:class:`~psd_tools.psd.descriptor.Descriptor`
"""
return self._data.get(b"keyOriginRRectRadii")
[docs]
class Line(Origination):
"""Line live shape."""
@property
def line_end(self) -> Descriptor:
"""
Line end.
:return: :py:class:`~psd_tools.psd.descriptor.Descriptor`
"""
return self._data.get(b"keyOriginLineEnd")
@property
def line_start(self) -> Descriptor:
"""
Line start.
:return: :py:class:`~psd_tools.psd.descriptor.Descriptor`
"""
return self._data.get(b"keyOriginLineStart")
@property
def line_weight(self) -> float:
"""
Line weight
:return: `float`
"""
return float(self._data.get(b"keyOriginLineWeight"))
@property
def arrow_start(self) -> bool:
"""Line arrow start.
:return: `bool`
"""
return bool(self._data.get(b"keyOriginLineArrowSt"))
@property
def arrow_end(self) -> bool:
"""
Line arrow end.
:return: `bool`
"""
return bool(self._data.get(b"keyOriginLineArrowEnd"))
@property
def arrow_width(self) -> float:
"""Line arrow width.
:return: `float`
"""
return float(self._data.get(b"keyOriginLineArrWdth"))
@property
def arrow_length(self) -> float:
"""Line arrow length.
:return: `float`
"""
return float(self._data.get(b"keyOriginLineArrLngth"))
@property
def arrow_conc(self) -> int:
"""
:return: `int`
"""
return int(self._data.get(b"keyOriginLineArrConc"))