psd2svg 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
psd2svg/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from psd2svg.svg_document import SVGDocument, convert
2
+
3
+ __all__ = ["SVGDocument", "convert"]
psd2svg/__main__.py ADDED
@@ -0,0 +1,87 @@
1
+ import argparse
2
+ import logging
3
+
4
+ from psd2svg import convert
5
+
6
+
7
+ def parse_args() -> argparse.Namespace:
8
+ """Parse command line arguments."""
9
+ parser = argparse.ArgumentParser(description="Convert PSD file to SVG")
10
+ parser.add_argument(
11
+ "input", metavar="INPUT", type=str, help="Input PSD file path or URL"
12
+ )
13
+ parser.add_argument(
14
+ "output",
15
+ metavar="PATH",
16
+ type=str,
17
+ nargs="?",
18
+ default=".",
19
+ help="Output file.",
20
+ )
21
+ parser.add_argument(
22
+ "--image-prefix",
23
+ metavar="PATH",
24
+ type=str,
25
+ default=None,
26
+ help="Path prefix for saving extracted images relative to output.",
27
+ )
28
+ parser.add_argument(
29
+ "--no-text",
30
+ dest="enable_text",
31
+ action="store_false",
32
+ help="Disable text layer conversion (rasterize text instead).",
33
+ )
34
+ parser.add_argument(
35
+ "--no-live-shapes",
36
+ dest="enable_live_shapes",
37
+ action="store_false",
38
+ help="Disable live shape conversion (use paths instead of shape primitives).",
39
+ )
40
+ parser.add_argument(
41
+ "--no-title",
42
+ dest="enable_title",
43
+ action="store_false",
44
+ help="Disable insertion of <title> elements with layer names.",
45
+ )
46
+ parser.add_argument(
47
+ "--image-format",
48
+ metavar="FORMAT",
49
+ type=str,
50
+ choices=["webp", "png", "jpeg"],
51
+ default="webp",
52
+ help="Image format for rasterized layers (webp, png, jpeg). Default: webp",
53
+ )
54
+ parser.add_argument(
55
+ "--text-letter-spacing-offset",
56
+ metavar="OFFSET",
57
+ type=float,
58
+ default=0.0,
59
+ help="Global offset (in pixels) to add to letter-spacing values. Default: 0.0",
60
+ )
61
+ parser.add_argument(
62
+ "--loglevel",
63
+ metavar="LEVEL",
64
+ default="WARNING",
65
+ help="Logging level, default WARNING",
66
+ )
67
+ return parser.parse_args()
68
+
69
+
70
+ def main() -> None:
71
+ """Main function to convert PSD to SVG or raster image."""
72
+ args = parse_args()
73
+ logging.basicConfig(level=getattr(logging, args.loglevel.upper(), "WARNING"))
74
+ convert(
75
+ args.input,
76
+ args.output,
77
+ image_prefix=args.image_prefix,
78
+ enable_text=args.enable_text,
79
+ enable_live_shapes=args.enable_live_shapes,
80
+ enable_title=args.enable_title,
81
+ image_format=args.image_format,
82
+ text_letter_spacing_offset=args.text_letter_spacing_offset,
83
+ )
84
+
85
+
86
+ if __name__ == "__main__":
87
+ main()
File without changes
@@ -0,0 +1,106 @@
1
+ """Mixin for adjustment layers conversion.
2
+
3
+ Due to the limited support of BackgroundImage in SVG filters, our approach wraps
4
+ the backdrop elements into a symbol, and use two <use> elements to apply the filter.
5
+
6
+ When we have a layer structure like this::
7
+
8
+ <layer 1 />
9
+ <layer 2 />
10
+ <adjustment />
11
+
12
+ We convert it into the following SVG structure::
13
+
14
+ <symbol id="backdrop">
15
+ <image id="layer1" ... />
16
+ <image id="layer2" ... />
17
+ </symbol>
18
+ <filter id="adjustment"></filter>
19
+ <use href="#backdrop" />
20
+ <use href="#backdrop" filter="url(#adjustment)" />
21
+
22
+
23
+ This approach may have limitations when the backdrop has transparency, as the
24
+ stacked use elements may not produce the intended visual result.
25
+ """
26
+
27
+ import logging
28
+ import xml.etree.ElementTree as ET
29
+
30
+ from psd_tools.api import adjustments, layers
31
+
32
+ from psd2svg import svg_utils
33
+ from psd2svg.core.base import ConverterProtocol
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ class AdjustmentConverter(ConverterProtocol):
39
+ """Mixin for adjustment layers."""
40
+
41
+ def add_adjustment(
42
+ self, layer: layers.AdjustmentLayer, **attrib: str
43
+ ) -> ET.Element | None:
44
+ """Add an adjustment layer to the svg document."""
45
+ logger.warning(
46
+ f"Adjustment layer is unsupported yet: '{layer.name}' ({layer.kind})"
47
+ )
48
+ return None
49
+
50
+ def add_invert_adjustment(
51
+ self, layer: adjustments.Invert, **attrib: str
52
+ ) -> ET.Element | None:
53
+ """Add an invert adjustment layer to the svg document."""
54
+ filter, use = self._create_filter(layer, name="invert", **attrib)
55
+ fe_component = svg_utils.create_node(
56
+ "feComponentTransfer", parent=filter, color_interpolation_filters="sRGB"
57
+ )
58
+ svg_utils.create_node(
59
+ "feFuncR", parent=fe_component, type="table", tableValues="1 0"
60
+ )
61
+ svg_utils.create_node(
62
+ "feFuncG", parent=fe_component, type="table", tableValues="1 0"
63
+ )
64
+ svg_utils.create_node(
65
+ "feFuncB", parent=fe_component, type="table", tableValues="1 0"
66
+ )
67
+ return use
68
+
69
+ def _create_filter(
70
+ self, layer: layers.AdjustmentLayer, name: str, **attrib: str
71
+ ) -> tuple[ET.Element, ET.Element]:
72
+ """Create SVG filter structure for the adjustment layer."""
73
+ wrapper = self._wrap_backdrop("symbol", id=self.auto_id("backdrop"))
74
+ filter = self.create_node("filter", id=self.auto_id(name))
75
+ # Backdrop use.
76
+ self.create_node(
77
+ "use",
78
+ href=f"#{wrapper.get('id')}",
79
+ )
80
+ # Apply filter to the use.
81
+ use = self.create_node(
82
+ "use",
83
+ href=f"#{wrapper.get('id')}",
84
+ filter=f"url(#{filter.get('id')})",
85
+ class_=name,
86
+ **attrib, # type: ignore[arg-type] # Clipping context etc.
87
+ )
88
+ self.set_layer_attributes(layer, use)
89
+ use = self.apply_mask(layer, use)
90
+ return filter, use
91
+
92
+ def _wrap_backdrop(self, tag: str = "symbol", **attrib: str) -> ET.Element:
93
+ """Wrap previous nodes into a container node for adjustment application."""
94
+ # TODO: Find the appropriate container in the clipping context, as the parent is mask or clipPath.
95
+ if self.current.tag == "clipPath" or self.current.tag == "mask":
96
+ logger.warning(
97
+ "Wrapping backdrop inside clipping/mask context is not supported yet."
98
+ )
99
+ siblings = list(self.current)
100
+ if not siblings:
101
+ logger.warning("No backdrop elements found to wrap for adjustment.")
102
+ wrapper = self.create_node(tag, **attrib) # type: ignore[arg-type]
103
+ for node in siblings:
104
+ self.current.remove(node)
105
+ wrapper.append(node)
106
+ return wrapper
psd2svg/core/base.py ADDED
@@ -0,0 +1,135 @@
1
+ import contextlib
2
+ import xml.etree.ElementTree as ET
3
+ from typing import TYPE_CHECKING, Any, Iterator, Protocol
4
+
5
+ from PIL import Image
6
+ from psd_tools import PSDImage
7
+ from psd_tools.api import adjustments, layers
8
+ from psd_tools.constants import BlendMode
9
+ from psd_tools.psd.descriptor import Descriptor
10
+
11
+ if TYPE_CHECKING:
12
+ from psd2svg.core.font_utils import FontInfo
13
+
14
+
15
+ class ConverterProtocol(Protocol):
16
+ """Converter state protocol."""
17
+
18
+ psd: PSDImage
19
+ svg: ET.Element
20
+ current: ET.Element
21
+ images: list[Image.Image]
22
+ fonts: dict[str, "FontInfo"]
23
+
24
+ # Flags to control the conversion.
25
+ enable_live_shapes: bool
26
+ enable_text: bool
27
+ enable_title: bool
28
+
29
+ def add_layer(self, layer: layers.Layer, **attrib: str) -> ET.Element | None: ...
30
+ def add_group(self, layer: layers.Group, **attrib: str) -> ET.Element | None: ...
31
+ def add_pixel(self, layer: layers.Layer, **attrib: str) -> ET.Element | None: ...
32
+ def add_shape(
33
+ self, layer: layers.ShapeLayer, **attrib: str
34
+ ) -> ET.Element | None: ...
35
+ def add_adjustment(
36
+ self, layer: layers.AdjustmentLayer, **attrib: str
37
+ ) -> ET.Element | None: ...
38
+ def add_text(self, layer: layers.TypeLayer, **attrib: str) -> ET.Element | None: ...
39
+ def add_fill(
40
+ self,
41
+ layer: adjustments.SolidColorFill
42
+ | adjustments.GradientFill
43
+ | adjustments.PatternFill,
44
+ **attrib: str,
45
+ ) -> ET.Element | None: ...
46
+
47
+ # Layer attributes
48
+ def set_layer_attributes(self, layer: layers.Layer, node: ET.Element) -> None: ...
49
+ def set_isolation(self, layer: layers.Layer, node: ET.Element) -> None: ...
50
+ def apply_mask(self, layer: layers.Layer, node: ET.Element) -> ET.Element: ...
51
+ def set_opacity(self, opacity: float, node: ET.Element) -> None: ...
52
+ def set_blend_mode(self, psd_mode: bytes | BlendMode, node: ET.Element) -> None: ...
53
+ def add_linear_gradient(self, setting: Descriptor) -> ET.Element: ...
54
+ def add_radial_gradient(self, setting: Descriptor) -> ET.Element: ...
55
+ def add_pattern(self, psdimage: PSDImage, descriptor: Descriptor) -> ET.Element: ...
56
+
57
+ # Shape methods
58
+ def create_shape(self, layer: layers.ShapeLayer, **attrib: Any) -> ET.Element: ...
59
+
60
+ # Text methods
61
+ def create_text_node(self, layer: layers.TypeLayer) -> ET.Element: ...
62
+
63
+ # Paint methods
64
+ def apply_vector_fill(
65
+ self, layer: layers.ShapeLayer | adjustments.FillLayer, target: ET.Element
66
+ ) -> None: ...
67
+ def apply_vector_stroke(
68
+ self, layer: layers.ShapeLayer | adjustments.FillLayer, target: ET.Element
69
+ ) -> None: ...
70
+ def set_fill(
71
+ self, layer: layers.ShapeLayer | adjustments.FillLayer, node: ET.Element
72
+ ) -> None: ...
73
+ def set_stroke(
74
+ self, layer: layers.ShapeLayer | adjustments.FillLayer, node: ET.Element
75
+ ) -> None: ...
76
+
77
+ # Layer effects
78
+ def apply_background_effects(
79
+ self, layer: layers.Layer, target: ET.Element, insert_before_target: bool = True
80
+ ) -> None: ...
81
+ def apply_overlay_effects(
82
+ self, layer: layers.Layer, target: ET.Element
83
+ ) -> None: ...
84
+ def apply_drop_shadow_effect(
85
+ self,
86
+ layer: layers.Layer,
87
+ target: ET.Element,
88
+ insert_before_target: bool = False,
89
+ ) -> None: ...
90
+ def apply_outer_glow_effect(
91
+ self,
92
+ layer: layers.Layer,
93
+ target: ET.Element,
94
+ insert_before_target: bool = False,
95
+ ) -> None: ...
96
+ def apply_color_overlay_effect(
97
+ self, layer: layers.Layer, target: ET.Element
98
+ ) -> None: ...
99
+ def apply_gradient_overlay_effect(
100
+ self, layer: layers.Layer, target: ET.Element
101
+ ) -> None: ...
102
+ def apply_pattern_overlay_effect(
103
+ self, layer: layers.Layer, target: ET.Element
104
+ ) -> None: ...
105
+ def apply_inner_shadow_effect(
106
+ self, layer: layers.Layer, target: ET.Element
107
+ ) -> None: ...
108
+ def apply_inner_glow_effect(
109
+ self, layer: layers.Layer, target: ET.Element
110
+ ) -> None: ...
111
+ def apply_satin_effect(self, layer: layers.Layer, target: ET.Element) -> None: ...
112
+ def apply_bevel_emboss_effect(
113
+ self, layer: layers.Layer, target: ET.Element
114
+ ) -> None: ...
115
+ def apply_stroke_effect(self, layer: layers.Layer, target: ET.Element) -> None: ...
116
+
117
+ # Adjustments
118
+ def add_invert_adjustment(
119
+ self, layer: adjustments.Invert, **attrib: str
120
+ ) -> ET.Element | None: ...
121
+
122
+ # Utilities
123
+ def auto_id(self, prefix: str = "") -> str: ...
124
+ def create_node(
125
+ self,
126
+ tag: str,
127
+ parent: ET.Element | None = None,
128
+ class_: str = "",
129
+ title: str = "",
130
+ text: str = "",
131
+ desc: str = "",
132
+ **kwargs: Any,
133
+ ) -> ET.Element: ...
134
+ @contextlib.contextmanager
135
+ def set_current(self, node: ET.Element) -> Iterator[None]: ...
@@ -0,0 +1,82 @@
1
+ import logging
2
+ from typing import Sequence
3
+
4
+ from psd_tools.psd.descriptor import Descriptor
5
+ from psd_tools.terminology import Klass, Enum
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def rgb2hex(values: Sequence[int]) -> str:
11
+ """Convert RGB color to hex string."""
12
+ assert len(values) == 3
13
+ return (
14
+ f"#{clip_int(values[0]):02x}{clip_int(values[1]):02x}{clip_int(values[2]):02x}"
15
+ )
16
+
17
+
18
+ def rgba2hex(values: Sequence[int], alpha: float = 1.0) -> str:
19
+ """Convert RGBA color to hex string, considering alpha channel."""
20
+ assert len(values) == 3
21
+ if alpha >= 1.0:
22
+ return rgb2hex(values)
23
+ r = clip_int(values[0])
24
+ g = clip_int(values[1])
25
+ b = clip_int(values[2])
26
+ a = max(0, min(255, int(alpha * 255)))
27
+ return f"#{r:02x}{g:02x}{b:02x}{a:02x}"
28
+
29
+
30
+ def cmyk2rgb(values: Sequence[float]) -> tuple[int, int, int]:
31
+ """Convert CMYK color to RGB color."""
32
+ assert len(values) == 4
33
+ return (
34
+ clip_int(2.55 * (1.0 - values[0]) * (1.0 - values[3])),
35
+ clip_int(2.55 * (1.0 - values[1]) * (1.0 - values[3])),
36
+ clip_int(2.55 * (1.0 - values[2]) * (1.0 - values[3])),
37
+ )
38
+
39
+
40
+ def descriptor2hex(desc: Descriptor, fallback: str = "transparent") -> str:
41
+ """Convert a color descriptor to an RGB hex string."""
42
+
43
+ if desc.classID == Klass.RGBColor:
44
+ if Enum.Red in desc:
45
+ r = desc.get(Enum.Red, 0)
46
+ g = desc.get(Enum.Green, 0)
47
+ b = desc.get(Enum.Blue, 0)
48
+ return f"#{int(r):02x}{int(g):02x}{int(b):02x}"
49
+ elif "redFloat" in desc:
50
+ r = float2uint8(desc.get("redFloat", 0))
51
+ g = float2uint8(desc.get("greenFloat", 0))
52
+ b = float2uint8(desc.get("blueFloat", 0))
53
+ return f"#{r:02x}{g:02x}{b:02x}"
54
+ else:
55
+ raise ValueError(f"Unsupported RGB color format: {desc}")
56
+
57
+ if desc.classID == Klass.CMYKColor:
58
+ c = desc.get(Enum.Cyan, 0)
59
+ m = desc.get(Enum.Magenta, 0)
60
+ y = desc.get(Enum.Yellow, 0)
61
+ k = desc.get(Enum.Black, 0)
62
+ r, g, b = cmyk2rgb((c / 100, m / 100, y / 100, k / 100))
63
+ return f"#{r:02x}{g:02x}{b:02x}"
64
+
65
+ if desc.classID == Klass.Grayscale:
66
+ gray = desc.get(Enum.Gray, 0)
67
+ assert isinstance(gray, float)
68
+ gray = float2uint8(gray)
69
+ return f"#{gray:02x}{gray:02x}{gray:02x}"
70
+
71
+ logger.warning("Unsupported color mode: %s", desc.classID)
72
+ return fallback
73
+
74
+
75
+ def float2uint8(v: float) -> int:
76
+ """Convert a float in the range [0.0, 1.0] to an integer in the range [0, 255]."""
77
+ return clip_int(255 * v)
78
+
79
+
80
+ def clip_int(value: int | float, min_value: int = 0, max_value: int = 255) -> int:
81
+ """Clip an int value to the specified range."""
82
+ return max(min_value, min(max_value, int(value)))
@@ -0,0 +1,98 @@
1
+ from typing import Union
2
+
3
+ from psd_tools.constants import BlendMode
4
+ from psd_tools.terminology import Enum
5
+
6
+ # https://helpx.adobe.com/photoshop/using/blending-modes.html
7
+ BLEND_MODE: dict[Union[BlendMode, bytes], str] = {
8
+ # Layer modes.
9
+ BlendMode.PASS_THROUGH: "pass-through",
10
+ BlendMode.NORMAL: "normal",
11
+ BlendMode.DISSOLVE: "normal",
12
+ BlendMode.DARKEN: "darken",
13
+ BlendMode.MULTIPLY: "multiply",
14
+ BlendMode.COLOR_BURN: "color-burn",
15
+ BlendMode.LINEAR_BURN: "plus-darker",
16
+ BlendMode.DARKER_COLOR: "darken",
17
+ BlendMode.LIGHTEN: "lighten",
18
+ BlendMode.SCREEN: "screen",
19
+ BlendMode.COLOR_DODGE: "color-dodge",
20
+ BlendMode.LINEAR_DODGE: "plus-lighter",
21
+ BlendMode.LIGHTER_COLOR: "lighten",
22
+ BlendMode.OVERLAY: "overlay",
23
+ BlendMode.SOFT_LIGHT: "soft-light",
24
+ BlendMode.HARD_LIGHT: "hard-light",
25
+ BlendMode.VIVID_LIGHT: "lighten",
26
+ BlendMode.LINEAR_LIGHT: "darken",
27
+ BlendMode.PIN_LIGHT: "normal",
28
+ BlendMode.HARD_MIX: "normal",
29
+ BlendMode.DIFFERENCE: "difference",
30
+ BlendMode.EXCLUSION: "exclusion",
31
+ BlendMode.SUBTRACT: "difference",
32
+ BlendMode.DIVIDE: "difference",
33
+ BlendMode.HUE: "hue",
34
+ BlendMode.SATURATION: "saturation",
35
+ BlendMode.COLOR: "color",
36
+ BlendMode.LUMINOSITY: "luminosity",
37
+ # Descriptor values.
38
+ Enum.Normal: "normal",
39
+ Enum.Dissolve: "normal",
40
+ Enum.Darken: "darken",
41
+ Enum.Multiply: "multiply",
42
+ Enum.ColorBurn: "color-burn",
43
+ b"linearBurn": "plus-darker",
44
+ b"darkerColor": "darken",
45
+ Enum.Lighten: "lighten",
46
+ Enum.Screen: "screen",
47
+ Enum.ColorDodge: "color-dodge",
48
+ b"linearDodge": "plus-lighter",
49
+ b"lighterColor": "lighten",
50
+ Enum.Overlay: "overlay",
51
+ Enum.SoftLight: "soft-light",
52
+ Enum.HardLight: "hard-light",
53
+ b"vividLight": "lighten",
54
+ b"linearLight": "darken",
55
+ b"pinLight": "normal",
56
+ b"hardMix": "normal",
57
+ Enum.Difference: "difference",
58
+ Enum.Exclusion: "exclusion",
59
+ Enum.Subtract: "difference",
60
+ b"blendDivide": "difference",
61
+ Enum.Hue: "hue",
62
+ Enum.Saturation: "saturation",
63
+ Enum.Color: "color",
64
+ Enum.Luminosity: "luminosity",
65
+ }
66
+
67
+ # Blend modes that are not accurately supported in SVG and are mapped to approximations.
68
+ # These will trigger warnings when used.
69
+ INACCURATE_BLEND_MODES: set[Union[BlendMode, bytes]] = {
70
+ # Dissolve mode uses random pixel patterns, not supported in SVG
71
+ BlendMode.DISSOLVE,
72
+ Enum.Dissolve,
73
+ # Linear burn uses plus-darker which has limited browser support
74
+ BlendMode.LINEAR_BURN,
75
+ b"linearBurn",
76
+ # Linear dodge uses plus-lighter which has limited browser support
77
+ BlendMode.LINEAR_DODGE,
78
+ b"linearDodge",
79
+ # Darker/Lighter Color modes compare color values, approximated with darken/lighten
80
+ BlendMode.DARKER_COLOR,
81
+ b"darkerColor",
82
+ BlendMode.LIGHTER_COLOR,
83
+ b"lighterColor",
84
+ # Advanced light modes approximated with simpler modes
85
+ BlendMode.VIVID_LIGHT,
86
+ b"vividLight",
87
+ BlendMode.LINEAR_LIGHT,
88
+ b"linearLight",
89
+ BlendMode.PIN_LIGHT,
90
+ b"pinLight",
91
+ BlendMode.HARD_MIX,
92
+ b"hardMix",
93
+ # Subtract and Divide modes approximated with difference
94
+ BlendMode.SUBTRACT,
95
+ Enum.Subtract,
96
+ BlendMode.DIVIDE,
97
+ b"blendDivide",
98
+ }
@@ -0,0 +1,173 @@
1
+ import contextlib
2
+ import logging
3
+ import xml.etree.ElementTree as ET
4
+ from typing import Any, Iterator
5
+
6
+ from PIL import Image
7
+ from psd_tools import PSDImage
8
+
9
+ from psd2svg import svg_utils
10
+ from psd2svg.core.adjustment import AdjustmentConverter
11
+ from psd2svg.core.counter import AutoCounter
12
+ from psd2svg.core.effects import EffectConverter
13
+ from psd2svg.core.font_utils import FontInfo
14
+ from psd2svg.core.layer import LayerConverter
15
+ from psd2svg.core.paint import PaintConverter
16
+ from psd2svg.core.shape import ShapeConverter
17
+ from psd2svg.core.text import TextConverter
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class Converter(
23
+ AdjustmentConverter,
24
+ LayerConverter,
25
+ PaintConverter,
26
+ ShapeConverter,
27
+ TextConverter,
28
+ EffectConverter,
29
+ ):
30
+ """Converter main class.
31
+
32
+ Example usage:
33
+
34
+ from psd2svg.core.converter import Converter
35
+
36
+ Converter.convert("example.psd", "output.svg")
37
+
38
+ Example usage:
39
+
40
+ from psd_tools import PSDImage
41
+ from psd2svg.core.conveter import Converter
42
+
43
+ psd = PSDImage.open("example.psd")
44
+ converter = Converter(psd)
45
+ document = converter.build()
46
+ document.embed_images() # or document.export_images("output/image_%02d")
47
+ svg_string = document.export()
48
+
49
+ Args:
50
+ psdimage: Source PSDImage to convert.
51
+ enable_live_shapes: Enable live shape conversion when possible.
52
+ enable_text: Enable text layer conversion when possible.
53
+ enable_title: Enable insertion of <title> elements with layer names. When True
54
+ (default), each layer in the SVG will have a <title> element containing the
55
+ Photoshop layer name for accessibility and debugging. Set to False to omit
56
+ title elements and reduce file size.
57
+ text_letter_spacing_offset: Global offset (in pixels) to add to all letter-spacing
58
+ values. This can be used to compensate for differences between Photoshop's
59
+ text rendering and SVG's text rendering. Typical values range from -0.02 to 0.02.
60
+ Default is 0.0 (no offset).
61
+ """
62
+
63
+ _id_counter: AutoCounter | None = None
64
+
65
+ def __init__(
66
+ self,
67
+ psdimage: PSDImage,
68
+ enable_live_shapes: bool = True,
69
+ enable_text: bool = True,
70
+ enable_title: bool = True,
71
+ text_letter_spacing_offset: float = 0.0,
72
+ ) -> None:
73
+ """Initialize the converter internal state."""
74
+
75
+ # Source PSD image.
76
+ if not isinstance(psdimage, PSDImage):
77
+ raise TypeError("psdimage must be an instance of PSDImage")
78
+ self.psd = psdimage
79
+ self.enable_live_shapes = enable_live_shapes
80
+ self.enable_text = enable_text
81
+ self.enable_title = enable_title
82
+ self.text_letter_spacing_offset = text_letter_spacing_offset
83
+
84
+ # Initialize the SVG root element.
85
+ self.svg = svg_utils.create_node(
86
+ "svg",
87
+ xmlns=svg_utils.NAMESPACE,
88
+ width=psdimage.width,
89
+ height=psdimage.height,
90
+ viewBox=svg_utils.seq2str([0, 0, psdimage.width, psdimage.height], sep=" "),
91
+ )
92
+ self.images: list[Image.Image] = [] # Store PIL images here.
93
+ self.fonts: dict[
94
+ str, FontInfo
95
+ ] = {} # Store font info keyed by postscript name.
96
+
97
+ # Initialize the current node pointer.
98
+ self.current = self.svg
99
+
100
+ def build(self) -> None:
101
+ """Build the SVG structure and internally save the result."""
102
+ assert self.psd is not None, "PSD image is not set."
103
+
104
+ if len(self.psd) == 0 and self.psd.has_preview():
105
+ # Special case: No layers, just a flat image.
106
+ self.create_node(
107
+ "image",
108
+ width=self.psd.width,
109
+ height=self.psd.height,
110
+ )
111
+ self.images.append(self.psd.composite())
112
+ else:
113
+ self.add_children(self.psd)
114
+
115
+ def auto_id(self, prefix: str = "") -> str:
116
+ """Generate a unique ID for SVG elements."""
117
+ if self._id_counter is None:
118
+ self._id_counter = AutoCounter()
119
+ return self._id_counter.get_id(prefix)
120
+
121
+ def create_node(
122
+ self,
123
+ tag: str,
124
+ parent: ET.Element | None = None,
125
+ class_: str = "",
126
+ title: str = "",
127
+ text: str = "",
128
+ desc: str = "",
129
+ **kwargs: Any,
130
+ ) -> ET.Element:
131
+ """Create an SVG node with the current element as default parent.
132
+
133
+ This is a convenience wrapper around svg_utils.create_node that automatically
134
+ uses self.current as the parent if no parent is specified.
135
+
136
+ Args:
137
+ tag: The XML tag name.
138
+ parent: Optional parent element. Defaults to self.current.
139
+ class_: Optional class attribute.
140
+ title: Optional title element.
141
+ text: Optional text content.
142
+ desc: Optional description element.
143
+ **kwargs: Additional attributes to pass to svg_utils.create_node.
144
+
145
+ Returns:
146
+ The created XML element.
147
+ """
148
+ if parent is None:
149
+ parent = self.current
150
+
151
+ # Conditionally suppress title based on enable_title flag
152
+ if not self.enable_title:
153
+ title = ""
154
+
155
+ return svg_utils.create_node(
156
+ tag,
157
+ parent=parent,
158
+ class_=class_,
159
+ title=title,
160
+ text=text,
161
+ desc=desc,
162
+ **kwargs,
163
+ )
164
+
165
+ @contextlib.contextmanager
166
+ def set_current(self, node: ET.Element) -> Iterator[None]:
167
+ """Set the current node for the converter."""
168
+ previous = self.current
169
+ self.current = node
170
+ try:
171
+ yield
172
+ finally:
173
+ self.current = previous