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 +3 -0
- psd2svg/__main__.py +87 -0
- psd2svg/core/__init__.py +0 -0
- psd2svg/core/adjustment.py +106 -0
- psd2svg/core/base.py +135 -0
- psd2svg/core/color_utils.py +82 -0
- psd2svg/core/constants.py +98 -0
- psd2svg/core/converter.py +173 -0
- psd2svg/core/counter.py +21 -0
- psd2svg/core/effects.py +933 -0
- psd2svg/core/font_utils.py +163 -0
- psd2svg/core/gradient.py +135 -0
- psd2svg/core/layer.py +577 -0
- psd2svg/core/paint.py +425 -0
- psd2svg/core/shape.py +578 -0
- psd2svg/core/text.py +1139 -0
- psd2svg/eval.py +118 -0
- psd2svg/image_utils.py +50 -0
- psd2svg/py.typed +0 -0
- psd2svg/rasterizer/__init__.py +10 -0
- psd2svg/rasterizer/base_rasterizer.py +72 -0
- psd2svg/rasterizer/resvg_rasterizer.py +96 -0
- psd2svg/svg_document.py +313 -0
- psd2svg/svg_utils.py +398 -0
- psd2svg/version.py +1 -0
- psd2svg-0.3.0.dist-info/METADATA +317 -0
- psd2svg-0.3.0.dist-info/RECORD +30 -0
- psd2svg-0.3.0.dist-info/WHEEL +4 -0
- psd2svg-0.3.0.dist-info/entry_points.txt +2 -0
- psd2svg-0.3.0.dist-info/licenses/LICENSE +21 -0
psd2svg/__init__.py
ADDED
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()
|
psd2svg/core/__init__.py
ADDED
|
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
|