Source code for psd2svg.core.layer

import contextlib
import logging
from typing import Callable, Iterator
from xml.etree import ElementTree as ET

from psd_tools import PSDImage
from psd_tools.api import adjustments, layers
from psd_tools.constants import BlendMode, Tag

from psd2svg import svg_utils
from psd2svg.core.base import ConverterProtocol
from psd2svg.core.constants import BLEND_MODE, INACCURATE_BLEND_MODES
from psd2svg.resource_limits import WEBP_MAX_DIMENSION

logger = logging.getLogger(__name__)


[docs] class LayerConverter(ConverterProtocol): """Main layer converter mixin."""
[docs] def add_layer( self, layer: layers.Layer, depth: int = 0, **attrib: str ) -> ET.Element | None: """Add a layer to the svg document. Args: layer: The PSD layer to add. depth: Current nesting depth (for resource limit checking). attrib: Additional attributes to set on the created node. """ if not layer.is_visible(): # TODO: Option to include hidden layers. logger.debug(f"Layer '{layer.name}' ({layer.kind}) is invisible, skipping.") return None logger.debug(f"Adding layer: '{layer.name}' ({layer.kind})") # Simple registry-based dispatch. # Note: Type annotation simplified to avoid complex variance issues registry: dict[type, Callable] = { layers.Artboard: self.add_artboard, layers.Group: self.add_group, layers.PixelLayer: self.add_pixel, layers.ShapeLayer: self.add_shape, layers.SmartObjectLayer: self.add_pixel, layers.TypeLayer: self.add_text, adjustments.BlackAndWhite: self.add_black_and_white_adjustment, adjustments.BrightnessContrast: self.add_brightness_contrast_adjustment, adjustments.ChannelMixer: self.add_channel_mixer_adjustment, adjustments.ColorBalance: self.add_color_balance_adjustment, adjustments.ColorLookup: self.add_color_lookup_adjustment, adjustments.Curves: self.add_curves_adjustment, adjustments.Exposure: self.add_exposure_adjustment, adjustments.GradientFill: self.add_fill, adjustments.GradientMap: self.add_gradient_map_adjustment, adjustments.HueSaturation: self.add_hue_saturation_adjustment, adjustments.Invert: self.add_invert_adjustment, adjustments.Levels: self.add_levels_adjustment, adjustments.PatternFill: self.add_fill, adjustments.PhotoFilter: self.add_photo_filter_adjustment, adjustments.Posterize: self.add_posterize_adjustment, adjustments.SelectiveColor: self.add_selective_color_adjustment, adjustments.SolidColorFill: self.add_fill, adjustments.Threshold: self.add_threshold_adjustment, adjustments.Vibrance: self.add_vibrance_adjustment, } # Default layer_fn is a plain pixel layer. layer_fn = registry.get(type(layer), self.add_pixel) return layer_fn(layer, depth=depth, **attrib) # type: ignore[call-arg]
[docs] def add_artboard( self, layer: layers.Artboard, depth: int = 0, **attrib: str ) -> ET.Element | None: """Add an artboard layer to the svg document.""" node = self.create_node( "svg", class_=layer.kind, title=layer.name, x=layer.left, y=layer.top, width=layer.width, height=layer.height, viewBox=svg_utils.seq2str( [layer.left, layer.top, layer.width, layer.height] ), id=self.auto_id("artboard") if layer.has_effects() else None, **attrib, # type: ignore[arg-type] ) with self.set_current(node): self.add_children(layer, depth=depth + 1) return node
[docs] def add_group( self, layer: layers.Group, depth: int = 0, **attrib: str ) -> ET.Element | None: """Add a group layer to the svg document.""" node = self.create_node( "g", class_=layer.kind, title=layer.name, id=self.auto_id("group") if layer.has_effects() else None, **attrib, # type: ignore[arg-type] ) with self.set_current(node): self.add_children(layer, depth=depth + 1) self.apply_background_effects(layer, node, insert_before_target=True) self.apply_overlay_effects(layer, node) self.apply_stroke_effect(layer, node) self.set_layer_attributes(layer, node) node = self.apply_mask(layer, node) return node
[docs] def add_children( self, group: layers.Group | layers.Artboard | PSDImage, depth: int = 0 ) -> None: """Add child layers to the current node. Args: group: Group/Artboard/PSDImage to process. depth: Current nesting depth (for resource limit checking). Raises: ValueError: If depth exceeds resource_limits.max_layer_depth. """ # Check depth limit if ( hasattr(self, "resource_limits") and self.resource_limits and self.resource_limits.is_layer_depth_limited() ): if depth >= self.resource_limits.max_layer_depth: raise ValueError( f"Layer depth {depth} exceeds limit {self.resource_limits.max_layer_depth}. " # noqa: E501 f"PSD has deeply nested layer groups. " f"To process: set PSD2SVG_MAX_LAYER_DEPTH={depth + 50} environment variable, " # noqa: E501 f"or use ResourceLimits(max_layer_depth={depth + 50}) in Python API." # noqa: E501 ) for layer in group: if layer.clipping or not layer.is_visible(): continue if layer.has_clip_layers(visible=True): with self.add_clipping_target(layer) as attrib: for clip_layer in layer.clip_layers: self.add_layer(clip_layer, depth=depth + 1, **attrib) else: # Regular layer. self.add_layer(layer, depth=depth)
[docs] def add_pixel( self, layer: layers.Layer, depth: int = 0, **attrib: str ) -> ET.Element | None: """Add a general pixel-based layer to the svg document.""" if not layer.has_pixels(): logger.warning( f"Layer has no pixels, skipping: '{layer.name}' ({layer.kind})." ) return None # Validate image dimensions if ( hasattr(self, "resource_limits") and self.resource_limits and self.resource_limits.is_image_dimension_limited() ): max_dim = self.resource_limits.max_image_dimension if layer.width > max_dim or layer.height > max_dim: # Check if this is the WebP hard limit if max_dim == WEBP_MAX_DIMENSION: raise ValueError( f"Layer '{layer.name}' dimensions {layer.width}x{layer.height} exceed limit {max_dim}x{max_dim}. " # noqa: E501 f"WebP has a {WEBP_MAX_DIMENSION}px hard limit. " f"To process images larger than this, use image_format='png' and, if necessary, " # noqa: E501 f"increase PSD2SVG_MAX_IMAGE_DIMENSION (for example, to {max(layer.width, layer.height) + 1000})." # noqa: E501 ) else: raise ValueError( f"Layer '{layer.name}' dimensions {layer.width}x{layer.height} exceed limit {max_dim}x{max_dim}. " # noqa: E501 f"To process: set PSD2SVG_MAX_IMAGE_DIMENSION={max(layer.width, layer.height) + 1000} environment variable, " # noqa: E501 f"or use ResourceLimits(max_image_dimension={max(layer.width, layer.height) + 1000}) in Python API." # noqa: E501 ) # We will later fill in the href attribute when embedding images. image = layer.topil() if image is None: logger.warning( f"Layer has no image data, skipping: '{layer.name}' ({layer.kind})." ) return None # Generate image ID before creating the <image> element image_id = self.auto_id("image") self.images[image_id] = image.convert("RGBA") # Raster layers can have both fill opacity and overall opacity. fill_opacity = layer.tagged_blocks.get_data(Tag.BLEND_FILL_OPACITY, 255) # When the layer has effects, we need to create a separate <image> # to handle fill opacity. if layer.has_effects(): defs = self.create_node("defs") node = self.create_node( "image", parent=defs, x=layer.left, y=layer.top, width=layer.width, height=layer.height, title=layer.name, class_=layer.kind, id=image_id, **attrib, ) self.set_opacity(layer.opacity / 255, node) node = self.apply_mask(layer, node) self.apply_background_effects(layer, node, insert_before_target=False) self.apply_raster_fill(layer, node) self.apply_overlay_effects(layer, node) self.apply_stroke_effect(layer, node) else: node = self.create_node( "image", id=image_id, x=layer.left, y=layer.top, width=layer.width, height=layer.height, title=layer.name, class_=layer.kind, **attrib, # type: ignore[arg-type] ) if fill_opacity < 255: self.set_opacity(fill_opacity / 255, node) self.set_layer_attributes(layer, node) node = self.apply_mask(layer, node) return node
[docs] def add_shape( self, layer: layers.ShapeLayer, depth: int = 0, **attrib: str ) -> ET.Element | None: """Add a shape layer to the svg document.""" if ( layer.has_effects() or ( # layer.origination is not None # and any(b"Trnf" in o._data for o in layer.origination) ) ): # We need to split the shape definition and effects. defs = self.create_node("defs") with self.set_current(defs): node = self.create_shape( layer, title=layer.name, id=self.auto_id("shape"), class_=layer.kind, **attrib, ) self.set_opacity(layer.opacity / 255.0, node) node = self.apply_mask(layer, node) # We need to set stroke for the shape here when fill is none. # Otherwise, effects won't use the correct alpha. if ( layer.has_stroke() and layer.stroke is not None and layer.stroke.enabled and not layer.stroke.fill_enabled ): svg_utils.set_attribute(node, "fill", "none") self.set_stroke(layer, node) self.apply_background_effects(layer, node, insert_before_target=False) self.apply_vector_fill(layer, node) # main filled shape. self.apply_overlay_effects(layer, node) self.apply_vector_stroke(layer, node) # main stroke. self.apply_stroke_effect(layer, node) else: # We can directly create the shape. node = self.create_shape( layer, title=layer.name, class_=layer.kind, **attrib ) self.set_fill(layer, node) self.set_stroke(layer, node) self.set_layer_attributes(layer, node) node = self.apply_mask(layer, node) return node
[docs] def add_text( self, layer: layers.TypeLayer, depth: int = 0, **attrib: str ) -> ET.Element | None: """Add a text layer to the svg document.""" if not self.enable_text: return self.add_pixel(layer, depth=depth, **attrib) # Check if layer has effects if layer.has_effects(): # Create defs section and target node with ID defs = self.create_node("defs") with self.set_current(defs): node = self.create_text_node(layer) svg_utils.set_attribute(node, "id", self.auto_id("text")) if self.enable_class: svg_utils.append_attribute(node, "class", layer.kind) # Apply any additional attributes for key, value in attrib.items(): svg_utils.set_attribute(node, key, value) # Set opacity on the base text node self.set_opacity(layer.opacity / 255.0, node) node = self.apply_mask(layer, node) # Apply effects in order (text already has fill/stroke from _add_text_span) self.apply_background_effects(layer, node, insert_before_target=False) # Create main visible text using <use> self.create_node( "use", parent=self.current, class_="text-content", href=svg_utils.get_uri(node), ) self.apply_overlay_effects(layer, node) self.apply_stroke_effect(layer, node) return node else: # No effects - simple path node = self.create_text_node(layer) self.set_layer_attributes(layer, node) node = self.apply_mask(layer, node) return node
[docs] @contextlib.contextmanager def add_clipping_target(self, layer: layers.Layer | layers.Group) -> Iterator[dict]: """Context manager to handle clipping target.""" # NOTE: We decide between clip-path and mask based on content. # <clipPath> has bad interactions with <mask> in SVG renderers. if isinstance(layer, layers.ShapeLayer) and not layer.has_mask(): with self.add_clip_path(layer) as clip_attrib: yield clip_attrib else: with self.add_clip_mask(layer) as clip_attrib: yield clip_attrib
[docs] @contextlib.contextmanager def add_clip_path(self, layer: layers.ShapeLayer) -> Iterator[dict]: """Add a clipping path and associated elements. Usage:: with self.add_clip_path(layer) as clip_attrib: # Create elements inside the clipping mask. for clip_layer in layer.clip_layers: self.add_layer(clip_layer, ..., **clip_attrib) Args: layer: The shape layer to use as a clipping path. Yields: Dictionary with clip-path attribute to apply to clipped elements. NOTE: Due to the bad interactions between clip-path and masks in SVG, we recommend using add_clip_mask instead of this method. """ if not layer.has_vector_mask(): raise ValueError(f"Layer has no vector mask: '{layer.name}'") # Create a clipping path definition. defs = self.create_node("defs") with self.set_current(defs): clip_path = self.create_node( "clipPath", id=self.auto_id("clip"), class_="clipping" ) with self.set_current(clip_path): target = self.create_shape( layer, title=layer.name, id=self.auto_id("shape"), class_=f"{layer.kind} clipping-base", ) self.set_opacity(layer.opacity / 255.0, target) self.apply_mask(layer, target) # NOTE: We actually need to apply the mask to the <clipPath> node # to combine effects, but SVG viewers have poor support for that. self.apply_background_effects(layer, target, insert_before_target=False) self.apply_vector_fill(layer, target) # main filled shape. self.apply_overlay_effects(layer, target) # Yield to the context block. yield {"clip-path": svg_utils.get_funciri(clip_path)} self.apply_vector_stroke(layer, target) self.apply_stroke_effect(layer, target)
[docs] @contextlib.contextmanager def add_clip_mask(self, layer: layers.Layer | layers.Group) -> Iterator[dict]: """Add a clipping mask and associated elements. Usage:: with self.add_clip_mask(layer) as clip_attrib: # Create elements inside the clipping mask. for clip_layer in layer.clip_layers: self.add_layer(clip_layer, ..., **clip_attrib) Args: layer: The layer to use as a clipping mask. Yields: Dictionary with mask attribute to apply to clipped elements. """ # Create a clipping mask definition. defs = self.create_node("defs") with self.set_current(defs): mask = self.create_node( "mask", class_="clipping", id=self.auto_id("mask"), mask_type="alpha", ) with self.set_current(mask): target = self.add_layer(layer) if target is None: raise ValueError( f"Failed to create clipping target for layer: '{layer.name}'" ) # NOTE: Maybe move clip-path or mask out of the outer mask container? if self.enable_class: svg_utils.append_attribute(target, "class", "clipping-base") if "id" not in target.attrib: target.set("id", self.auto_id("clippingbase")) self.apply_background_effects(layer, target, insert_before_target=False) # Create a <use> element to reference the target object # in the current context (outside the mask). self.create_node("use", href=svg_utils.get_uri(target)) self.apply_overlay_effects(layer, target) # Yield to the context block. yield {"mask": svg_utils.get_funciri(mask)} self.apply_stroke_effect(layer, target)
[docs] def add_fill( self, layer: adjustments.SolidColorFill | adjustments.GradientFill | adjustments.PatternFill, **attrib: str, ) -> ET.Element | None: """Add fill node to the given element.""" logger.debug(f"Adding fill layer: '{layer.name}'") viewbox = layer.bbox if viewbox == (0, 0, 0, 0): viewbox = (0, 0, self.psd.width, self.psd.height) if layer.has_effects(): defs = self.create_node("defs", parent=self.current) node = self.create_node( "rect", parent=defs, x=viewbox[0], y=viewbox[1], width=viewbox[2] - viewbox[0], height=viewbox[3] - viewbox[1], title=layer.name, class_=layer.kind, id=self.auto_id("fill"), **attrib, ) self.set_opacity(layer.opacity / 255.0, node) node = self.apply_mask(layer, node) self.apply_background_effects(layer, node, insert_before_target=False) self.apply_vector_fill(layer, node) # main filled shape. self.apply_overlay_effects(layer, node) self.apply_vector_stroke(layer, node) self.apply_stroke_effect(layer, node) else: node = self.create_node( "rect", parent=self.current, x=viewbox[0], y=viewbox[1], width=viewbox[2] - viewbox[0], height=viewbox[3] - viewbox[1], title=layer.name, class_=layer.kind, **attrib, ) self.set_fill(layer, node) self.set_layer_attributes(layer, node) node = self.apply_mask(layer, node) return node
[docs] def apply_raster_fill(self, layer: layers.Layer, node: ET.Element) -> ET.Element: """Add a raster main fill to the svg document.""" use = self.create_node( "use", parent=self.current, class_="fill", href=svg_utils.get_uri(node), ) fill_opacity = layer.tagged_blocks.get_data(Tag.BLEND_FILL_OPACITY, 255) if fill_opacity < 255: self.set_opacity(fill_opacity / 255, use) self.set_blend_mode(layer.blend_mode, use) return use
[docs] def set_layer_attributes(self, layer: layers.Layer, node: ET.Element) -> None: """Set common layer attributes to a layer node.""" self.set_opacity(layer.opacity / 255, node) self.set_blend_mode(layer.blend_mode, node) self.set_isolation(layer, node)
[docs] def set_opacity(self, opacity: float, node: ET.Element) -> None: """Set opacity style to the node.""" if opacity < 1.0: if "opacity" in node.attrib: # Combine opacities if already set. existing_opacity = float(node.attrib["opacity"]) opacity *= existing_opacity svg_utils.set_attribute(node, "opacity", svg_utils.num2str(opacity))
[docs] def set_blend_mode(self, psd_mode: bytes | BlendMode, node: ET.Element) -> None: """Set blend mode style to the node. Args: psd_mode: The Photoshop blend mode to convert. node: The XML element to apply the blend mode to. Raises: ValueError: If the blend mode is not supported. """ if psd_mode not in BLEND_MODE: raise ValueError(f"Unsupported blend mode: {psd_mode!r}") # Warn if the blend mode is not accurately supported in SVG if psd_mode in INACCURATE_BLEND_MODES: blend_mode = BLEND_MODE[psd_mode] # Format the mode name for display mode_name = ( psd_mode.name if hasattr(psd_mode, "name") else psd_mode.decode() if isinstance(psd_mode, bytes) else str(psd_mode) ) logger.warning( f"Blend mode '{mode_name}' is not accurately supported in SVG. " f"Using approximation '{blend_mode}' instead." ) svg_mode = BLEND_MODE[psd_mode] if svg_mode not in ("normal", "pass-through"): svg_utils.add_style(node, "mix-blend-mode", svg_mode)
[docs] def set_isolation(self, layer: layers.Layer, node: ET.Element) -> None: """Add isolation to a group. NOTE: 1. The default blending mode of a PSD group is passthrough, which corresponds to SVG isolation: auto (default) 2. When the group has blending mode normal, it corresponds to SVG isolation: isolate. 3. Other blending modes also isolate the group, and in SVG setting mix-blend-mode on a <g> to a value other than normal isolates the group by default. """ if ( isinstance(layer, layers.Group) and layer.blend_mode != BlendMode.PASS_THROUGH ): svg_utils.add_style(node, "isolation", "isolate")
[docs] def apply_mask(self, layer: layers.Layer, target: ET.Element) -> ET.Element: """Add a layer mask to the target node.""" if ( not layer.has_mask() or layer.mask is None or layer.mask.disabled or layer.mask.width == 0 or layer.mask.height == 0 ): return target logger.debug(f"Adding mask: '{layer.name}' ({layer.kind})") # If the target already has a mask or a clip-path, # we need to transfer them to the content. # NOTE: Nested mask references like <mask mask="url(#id)"> # don't work reliably in browsers. # Instead, we apply the existing mask/clip-path to the mask content # elements themselves, creating a proper composition hierarchy # where masking operations combine at the content level. context: dict[str, str] = {} if "mask" in target.attrib: context["mask"] = target.attrib.pop("mask") if "clip-path" in target.attrib: context["clip-path"] = target.attrib.pop("clip-path") # Viewbox for the mask. If the mask is empty, use the full canvas. viewbox = layer.bbox if viewbox == (0, 0, 0, 0): viewbox = (0, 0, self.psd.width, self.psd.height) # Create the mask node. defs = self.create_node("defs") with self.set_current(defs): mask = self.create_node( "mask", class_="layer-mask", id=self.auto_id("mask"), ) # If the mask has a background color (invert mask), add a white rectangle first. if layer.mask.background_color > 0: with self.set_current(mask): self.create_node( "rect", x=viewbox[0], y=viewbox[1], width=viewbox[2] - viewbox[0], height=viewbox[3] - viewbox[1], fill="#ffffff", **context, # type: ignore[arg-type] ) # Mask image. mask_image = layer.mask.topil() if mask_image is not None: image_id = self.auto_id("image") self.images[image_id] = mask_image.convert("L") with self.set_current(mask): self.create_node( "image", id=image_id, x=layer.mask.left, y=layer.mask.top, width=layer.mask.width, height=layer.mask.height, **context, # type: ignore[arg-type] ) # If the target has a transform, we cannot directly apply it to the mask. if "transform" in target.attrib: if "id" not in target.attrib: target.set("id", self.auto_id(target.tag.lower())) if self.current.tag != "defs": defs = self.create_node("defs") svg_utils.wrap_element(target, self.current, defs) return self.create_node( "use", href=svg_utils.get_uri(target), mask=svg_utils.get_funciri(mask), id=self.auto_id("use"), ) svg_utils.set_attribute(target, "mask", svg_utils.get_funciri(mask)) return target