Source code for psd_tools.api.shape

"""
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"))