"""
Mixin module for paint methods, such as fill and stroke.
Photoshop supports the following paint types:
- Solid color
- Gradient (linear and radial)
- Pattern
This module handles paint application for:
- Shape layer fills (VECTOR_STROKE_CONTENT_DATA)
- Shape layer strokes
- Fill adjustment layers
(SOLID_COLOR_SHEET_SETTING, GRADIENT_FILL_SETTING, PATTERN_FILL_SETTING)
Note layer effects also support similar paint types, but the data
structures and descriptors are different.
"""
import logging
import xml.etree.ElementTree as ET
from psd_tools import PSDImage
from psd_tools.api import adjustments, layers, pil_io
from psd_tools.constants import Tag
from psd_tools.psd.descriptor import Descriptor, UnitFloat
from psd_tools.terminology import Enum, Key, Klass, Unit
from psd2svg import svg_utils
from psd2svg.core import color_utils
from psd2svg.core.base import ConverterProtocol
from psd2svg.core.gradient import GradientInterpolation
logger = logging.getLogger(__name__)
[docs]
class PaintConverter(ConverterProtocol):
"""Mixin for paint methods."""
[docs]
def apply_vector_fill(
self, layer: layers.ShapeLayer | adjustments.FillLayer, target: ET.Element
) -> None:
"""Apply fill effects to the target element."""
if (
layer.has_stroke()
and layer.stroke is not None
and not layer.stroke.fill_enabled
):
logger.debug(f"Fill is disabled for layer: '{layer.name}'")
return
use = self.create_node("use", href=svg_utils.get_uri(target), class_="fill")
self.set_fill(layer, use)
self.set_blend_mode(layer.blend_mode, use)
[docs]
def apply_vector_stroke(
self, layer: layers.ShapeLayer | adjustments.FillLayer, target: ET.Element
) -> None:
"""Apply stroke effects to the target element."""
if not layer.has_stroke() or layer.stroke is None or not layer.stroke.enabled:
logger.debug(f"Layer has no stroke: '{layer.name}'")
return
if "stroke" in target.attrib:
logger.debug(f"Stroke already set for layer: '{layer.name}'")
use = self.create_node(
"use",
href=svg_utils.get_uri(target),
fill="none",
class_="stroke",
)
self.set_stroke(layer, use)
if layer.stroke.blend_mode != Enum.Normal:
self.set_blend_mode(layer.stroke.blend_mode, use)
[docs]
def set_fill(
self, layer: layers.ShapeLayer | adjustments.FillLayer, node: ET.Element
) -> None:
"""Set fill attribute to the given element."""
# No fill when stroke is enabled but fill is disabled.
if (
layer.has_stroke()
and layer.stroke is not None
and not layer.stroke.fill_enabled
):
logger.debug("Fill is disabled; setting fill to none.")
svg_utils.set_attribute(node, "fill", "none")
return
# Shapes have the following tagged blocks for fill content.
if Tag.VECTOR_STROKE_CONTENT_DATA in layer.tagged_blocks:
if isinstance(layer, layers.ShapeLayer):
self.set_fill_stroke_content(layer, node)
return
# Fill layers have a dedicated tagged block.
self.set_fill_setting(layer, node)
[docs]
def set_fill_stroke_content(
self, layer: layers.ShapeLayer, node: ET.Element
) -> None:
"""Set fill or stroke content from VECTOR_STROKE_CONTENT_DATA."""
content_data = layer.tagged_blocks.get_data(Tag.VECTOR_STROKE_CONTENT_DATA)
if Key.Color in content_data:
color = color_utils.descriptor2hex(content_data[Key.Color])
svg_utils.set_attribute(node, "fill", color)
elif Key.Gradient in content_data:
gradient = self.add_gradient_definition(layer, content_data)
if gradient is not None:
svg_utils.set_attribute(node, "fill", svg_utils.get_funciri(gradient))
elif Enum.Pattern in content_data:
pattern = self.add_pattern(self.psd, content_data[Enum.Pattern])
if pattern is not None:
self.set_pattern_transform(layer, content_data, pattern)
svg_utils.set_attribute(node, "fill", svg_utils.get_funciri(pattern))
else:
logger.warning(f"Unsupported fill content: {content_data}")
self.set_fill_opacity(layer, node)
[docs]
def set_fill_setting(
self, layer: adjustments.FillLayer | layers.ShapeLayer, node: ET.Element
) -> None:
"""Set fill attribute from fill settings tagged blocks."""
if Tag.SOLID_COLOR_SHEET_SETTING in layer.tagged_blocks:
setting = layer.tagged_blocks.get_data(Tag.SOLID_COLOR_SHEET_SETTING)
color = color_utils.descriptor2hex(setting[Klass.Color])
svg_utils.set_attribute(node, "fill", color)
elif Tag.PATTERN_FILL_SETTING in layer.tagged_blocks:
# classID is null for pattern fill setting.
setting = layer.tagged_blocks.get_data(Tag.PATTERN_FILL_SETTING)
if Enum.Pattern not in setting:
raise ValueError(f"No pattern found in setting: {setting}.")
pattern = self.add_pattern(self.psd, setting[Enum.Pattern])
if pattern is not None:
self.set_pattern_transform(layer, setting, pattern)
svg_utils.set_attribute(node, "fill", svg_utils.get_funciri(pattern))
elif Tag.GRADIENT_FILL_SETTING in layer.tagged_blocks:
# classID is null for gradient fill setting.
setting = layer.tagged_blocks.get_data(Tag.GRADIENT_FILL_SETTING)
gradient = self.add_gradient_definition(layer, setting)
if gradient is not None:
svg_utils.set_attribute(node, "fill", svg_utils.get_funciri(gradient))
else:
logger.debug(f"No fill information found: {layer}.")
self.set_fill_opacity(layer, node)
[docs]
def set_fill_opacity(self, layer: layers.Layer, node: ET.Element) -> None:
"""Set fill opacity to the given element."""
fill_opacity = layer.tagged_blocks.get_data(Tag.BLEND_FILL_OPACITY, 255) / 255.0
if fill_opacity < 1.0:
svg_utils.set_attribute(node, "fill-opacity", fill_opacity)
[docs]
def set_stroke(self, layer: layers.Layer, node: ET.Element) -> None:
"""Add stroke style to the path node."""
if not layer.has_stroke() or layer.stroke is None or not layer.stroke.enabled:
logger.debug("Layer has no stroke: %s", layer.name)
return
stroke = layer.stroke
if stroke.line_alignment != "center":
logger.warning("Inner or outer stroke is not supported yet.")
# TODO: Perhaps use clipPath to simulate this.
if stroke.line_width != 1.0:
svg_utils.set_attribute(node, "stroke-width", stroke.line_width)
if stroke.content.classID == b"patternLayer":
if Enum.Pattern not in stroke.content:
raise ValueError(
f"No pattern found in stroke content: {stroke.content}."
)
pattern = self.add_pattern(self.psd, stroke.content[Enum.Pattern])
if pattern is not None:
self.set_pattern_transform(layer, stroke.content, pattern)
svg_utils.set_attribute(node, "stroke", svg_utils.get_funciri(pattern))
elif stroke.content.classID == b"gradientLayer":
gradient = self.add_gradient_definition(layer, stroke.content)
if gradient is not None:
svg_utils.set_attribute(node, "stroke", svg_utils.get_funciri(gradient))
elif stroke.content.classID == b"solidColorLayer":
color = color_utils.descriptor2hex(stroke.content[Klass.Color])
svg_utils.set_attribute(node, "stroke", color)
else:
logger.warning(f"Unsupported stroke content: {stroke.content}")
if not stroke.fill_enabled:
svg_utils.set_attribute(node, "fill", "none")
if stroke.opacity.value < 100:
svg_utils.set_attribute(
node, "stroke-opacity", stroke.opacity.value / 100.0
)
if stroke.line_cap_type != "butt":
svg_utils.set_attribute(node, "stroke-linecap", stroke.line_cap_type)
if stroke.line_join_type != "miter":
svg_utils.set_attribute(node, "stroke-linejoin", stroke.line_join_type)
if stroke.line_dash_set:
line_dash_set = [
float(x.value) * stroke.line_width for x in stroke.line_dash_set
]
svg_utils.set_attribute(node, "stroke-dasharray", line_dash_set)
svg_utils.set_attribute(node, "stroke-dashoffset", stroke.line_dash_offset)
# NOTE: Stroke blend mode is handled in apply_vector_stroke() for the <use>
# element. The strokeStyleBlendMode field exists in PSD format but is not
# settable via Photoshop UI (as of recent versions), so it typically remains
# Normal. The implementation supports it for future-proofing and compatibility
# with programmatically-generated PSD files.
[docs]
def add_gradient_definition(
self, layer: layers.Layer, descriptor: Descriptor
) -> ET.Element | None:
"""Add gradient definition to the SVG document."""
if Key.Gradient not in descriptor:
raise ValueError(f"No gradient found in descriptor: {descriptor}")
if descriptor[Key.Type].enum == Enum.Linear:
node = self.add_linear_gradient(descriptor[Key.Gradient])
elif descriptor[Key.Type].enum == Enum.Radial:
node = self.add_radial_gradient(descriptor[Key.Gradient])
else:
logger.warning("Only linear and radial gradients are supported yet.")
return None
self.set_gradient_attributes(layer, descriptor, node)
return node
[docs]
def add_linear_gradient(self, gradient: Descriptor) -> ET.Element:
"""Add linear gradient definition to the SVG document."""
node = self.create_node("linearGradient", id=self.auto_id("gradient"))
self.set_gradient_stops(gradient, node)
return node
[docs]
def add_radial_gradient(self, gradient: Descriptor) -> ET.Element:
"""Add radial gradient definition to the SVG document."""
node = self.create_node("radialGradient", id=self.auto_id("gradient"))
self.set_gradient_stops(gradient, node)
return node
[docs]
def set_gradient_stops(self, gradient: Descriptor, node: ET.Element) -> ET.Element:
"""Set gradient stops to the given gradient element."""
interpolator = GradientInterpolation(gradient)
with self.set_current(node):
for location, color, opacity in interpolator:
self.create_node(
"stop",
offset=f"{location:.0%}",
stop_color=color_utils.descriptor2hex(color),
stop_opacity=f"{opacity:.0%}",
)
# TODO: Midpoint support?
if any(stop[Key.Midpoint] != 50 for stop in gradient[Key.Colors]) or any(
stop[Key.Midpoint] != 50 for stop in gradient[Key.Transparency]
):
logger.debug("Gradient midpoint is not supported.")
return node
[docs]
def set_gradient_attributes(
self, layer: layers.Layer, setting: Descriptor, gradient: ET.Element
) -> None:
"""Set gradient settings such as angle to the gradient element."""
transforms = []
# Coordinate system for gradient.
aligned = Key.Alignment not in setting or setting[Key.Alignment].value is True
if aligned:
# Gradient aligned to layer bounds.
landscape = layer.width >= layer.height
# Adjust the object coordinates.
if layer.width != layer.height:
if landscape:
scale_x = svg_utils.num2str(layer.height / layer.width, digit=4)
transforms.append(f"scale({scale_x} 1)")
else:
scale_y = svg_utils.num2str(layer.width / layer.height, digit=4)
transforms.append(f"scale(1 {scale_y})")
reference = (0.5, 0.5)
else:
# Gradient defined in user space (canvas).
svg_utils.set_attribute(gradient, "gradientUnits", "userSpaceOnUse")
landscape = self.psd.width >= self.psd.height
reference = (self.psd.width / 2, self.psd.height / 2)
# Set the base gradient direction based on the shorter edge.
if landscape:
# Vertical gradient.
svg_utils.set_attribute(gradient, "x2", "0%")
svg_utils.set_attribute(gradient, "y2", "100%")
else:
# Horizontal gradient.
svg_utils.set_attribute(gradient, "x2", "100%")
svg_utils.set_attribute(gradient, "y2", "0%")
# Apply angle, scale transforms.
angle = -float(
setting.get(Key.Angle, UnitFloat(unit=Unit.Angle, value=0.0)).value
)
if landscape:
angle -= 90
if Key.Reverse in setting and setting[Key.Reverse].value:
angle += 180
if angle != 0:
transforms.append(f"rotate({svg_utils.num2str(angle)})")
scale = float(
setting.get(Key.Scale, UnitFloat(unit=Unit.Percent, value=100.0)).value
)
if scale != 100:
if landscape:
transforms.append(
f"scale(1 {svg_utils.num2str(scale / 100.0, digit=4)})"
)
else:
transforms.append(
f"scale({svg_utils.num2str(scale / 100.0, digit=4)} 1)"
)
# Apply offset transform.
# Note: Offset must be applied AFTER rotation/scale with transform-origin,
# as it operates in a different coordinate space.
has_offset = Key.Offset in setting
if has_offset:
# Offset is given in percentage of layer size.
offset = (
setting[Key.Offset][Key.Horizontal].value / 100.0,
setting[Key.Offset][Key.Vertical].value / 100.0,
)
if not aligned:
offset = (offset[0] * layer._psd.width, offset[1] * layer._psd.height)
# Prepend the offset translate to the transform list
svg_utils.append_attribute(
gradient,
"gradientTransform",
"translate(%s)" % svg_utils.seq2str(offset, digit=4),
)
if transforms:
# Use transform-origin for rotate/scale, appended after offset
svg_utils.set_transform_with_origin(
gradient, "gradientTransform", transforms, reference
)
if b"gradientsInterpolationMethod" in setting:
method = setting[b"gradientsInterpolationMethod"]
if method.enum == Enum.Perceptual:
logger.info("Perceptual gradient interpolation is not accurate.")
elif method.enum == Enum.Linear:
logger.info("Linear gradient interpolation is not accurate.")
elif method.enum == b"GIMs": # Stripes
logger.warning("Stripes gradient interpolation is not supported yet.")
elif method.enum == b"Gcls": # Classic
pass # Default is classic
elif method.enum == Key.Smooth: # Smooth
logger.info("Smooth gradient interpolation is not accurate.")
[docs]
def add_pattern(self, psdimage: PSDImage, descriptor: Descriptor) -> ET.Element:
"""Add pattern definition to the SVG document."""
assert descriptor.classID == Enum.Pattern
pattern_id = descriptor[Key.ID].value.rstrip("\x00")
pattern_data = psdimage._get_pattern(pattern_id)
if pattern_data is None:
raise ValueError(f"Pattern data not found: {pattern_id}")
image = pil_io.convert_pattern_to_pil(pattern_data)
image_id = self.auto_id("image")
node = self.create_node(
"pattern",
id=self.auto_id("pattern"),
width=image.width,
height=image.height,
patternUnits="userSpaceOnUse",
)
with self.set_current(node):
self.create_node(
"image",
id=image_id,
width=image.width,
height=image.height,
)
# We will later fill in the href attribute when embedding images.
self.images[image_id] = image
return node