2020from collections .abc import Iterable
2121
2222import PIL .Image
23- from typing_extensions import Literal , final , overload , override
23+ from typing_extensions import Literal , NoReturn , final , overload , override
2424
25- from svglab import graphics , models , protocols , serialize
25+ from svglab import errors , graphics , models , protocols , serialize
2626from svglab .attrparse import color , length , path_data , point , transform
2727from svglab .attrs import attrdefs , attrgroups
2828from svglab .elements import traits
@@ -36,6 +36,92 @@ def _length_or_zero(value: length.Length | None, /) -> length.Length:
3636 return value if value is not None else length .Length (0 )
3737
3838
39+ def _user_units (value : length .Length | None , / ) -> float :
40+ """Convert a length to user units, treating `None` as zero.
41+
42+ Raises:
43+ ValueError: If the length is not convertible to user units. A rotation
44+ mixes the x-axis and the y-axis, so a length whose value depends
45+ on the viewport (a percentage, for example) cannot be carried
46+ through. `ValueError` is what signals an inapplicable
47+ transformation to the reification loop.
48+
49+ """
50+ try :
51+ return float (_length_or_zero (value ))
52+ except errors .SvgUnitConversionError as e :
53+ msg = f"Length { value !r} is not convertible to user units"
54+ raise ValueError (msg ) from e
55+
56+
57+ def _rotate_lengths (
58+ rotation : transform .Rotate ,
59+ / ,
60+ x : length .Length | None ,
61+ y : length .Length | None ,
62+ ) -> tuple [length .Length , length .Length ]:
63+ """Rotate a point given by a pair of lengths, treating `None` as zero.
64+
65+ The resulting lengths are in user units.
66+
67+ Raises:
68+ ValueError: If a length is not convertible to user units.
69+
70+ """
71+ rotated = rotation @ point .Point (_user_units (x ), _user_units (y ))
72+
73+ return length .Length (rotated .x ), length .Length (rotated .y )
74+
75+
76+ def _unsupported_rotation (rotation : transform .Rotate , / ) -> NoReturn :
77+ msg = f"Unsupported transformation: { rotation } "
78+ raise ValueError (msg )
79+
80+
81+ def _quarter_turns (rotation : transform .Rotate , / ) -> int | None :
82+ """Express a rotation as a number of quarter turns.
83+
84+ Returns:
85+ The number of quarter turns (0-3), or `None` if the angle is not a
86+ multiple of 90 degrees.
87+
88+ Examples:
89+ >>> _quarter_turns(transform.Rotate(90))
90+ 1
91+ >>> _quarter_turns(transform.Rotate(-90))
92+ 3
93+ >>> _quarter_turns(transform.Rotate(45)) is None
94+ True
95+
96+ """
97+ turns = round (rotation .angle / 90 )
98+
99+ if not mathutils .is_close (rotation .angle , 90 * turns ):
100+ return None
101+
102+ return turns % 4
103+
104+
105+ def _rotate_path_data (
106+ element : attrdefs .DAttr , rotation : transform .Rotate , /
107+ ) -> None :
108+ """Rotate the path data of an element.
109+
110+ This is a free function so that the assignment does not widen the inferred
111+ type of the `d` attribute.
112+ """
113+ if element .d is not None :
114+ element .d = rotation @ element .d
115+
116+
117+ def _rotate_points (
118+ element : attrdefs .PointsAttr , rotation : transform .Rotate , /
119+ ) -> None :
120+ """Rotate the points of an element."""
121+ if element .points is not None :
122+ element .points = [rotation @ p for p in element .points ]
123+
124+
39125@final
40126class Path (
41127 attrgroups .ConditionalProcessingAttrs ,
@@ -46,7 +132,9 @@ class Path(
46132 traits .Shape ,
47133 traits .Element ,
48134):
49- pass
135+ @override
136+ def _reify_rotation (self , rotation : transform .Rotate , / ) -> None :
137+ _rotate_path_data (self , rotation )
50138
51139
52140def _basic_shape_to_path (basic_shape : traits .BasicShape , / ) -> Path :
@@ -258,6 +346,11 @@ def to_path_data(self) -> path_data.PathData:
258346 def to_path (self ) -> Path :
259347 return _basic_shape_to_path (self )
260348
349+ @override
350+ def _reify_rotation (self , rotation : transform .Rotate , / ) -> None :
351+ # a circle is invariant under rotation, so only the center moves
352+ self .cx , self .cy = _rotate_lengths (rotation , self .cx , self .cy )
353+
261354
262355@final
263356class ClipPath (
@@ -342,6 +435,21 @@ def to_path_data(self) -> path_data.PathData:
342435 def to_path (self ) -> Path :
343436 return _basic_shape_to_path (self )
344437
438+ @override
439+ def _reify_rotation (self , rotation : transform .Rotate , / ) -> None :
440+ # an ellipse whose axes are equal is a circle and is therefore
441+ # invariant under rotation; otherwise only quarter turns keep its axes
442+ # aligned with the axes of the coordinate system
443+ if mathutils .is_close (_user_units (self .rx ), _user_units (self .ry )):
444+ turns = 0
445+ elif (turns := _quarter_turns (rotation )) is None :
446+ _unsupported_rotation (rotation )
447+
448+ self .cx , self .cy = _rotate_lengths (rotation , self .cx , self .cy )
449+
450+ if turns % 2 :
451+ self .rx , self .ry = self .ry , self .rx
452+
345453
346454@final
347455class FeBlend (
@@ -830,6 +938,11 @@ def to_path_data(self) -> path_data.PathData:
830938 def to_path (self ) -> Path :
831939 return _basic_shape_to_path (self )
832940
941+ @override
942+ def _reify_rotation (self , rotation : transform .Rotate , / ) -> None :
943+ self .x1 , self .y1 = _rotate_lengths (rotation , self .x1 , self .y1 )
944+ self .x2 , self .y2 = _rotate_lengths (rotation , self .x2 , self .y2 )
945+
833946
834947@final
835948class LinearGradient (
@@ -952,6 +1065,10 @@ def to_path_data(self) -> path_data.PathData:
9521065 def to_path (self ) -> Path :
9531066 return _basic_shape_to_path (self )
9541067
1068+ @override
1069+ def _reify_rotation (self , rotation : transform .Rotate , / ) -> None :
1070+ _rotate_points (self , rotation )
1071+
9551072
9561073@final
9571074class Polyline (
@@ -971,6 +1088,10 @@ def to_path_data(self) -> path_data.PathData:
9711088 def to_path (self ) -> Path :
9721089 return _basic_shape_to_path (self )
9731090
1091+ @override
1092+ def _reify_rotation (self , rotation : transform .Rotate , / ) -> None :
1093+ _rotate_points (self , rotation )
1094+
9741095
9751096@final
9761097class RadialGradient (
@@ -1084,6 +1205,32 @@ def to_path_data(self) -> path_data.PathData:
10841205 .close ()
10851206 )
10861207
1208+ @override
1209+ def _reify_rotation (self , rotation : transform .Rotate , / ) -> None :
1210+ # only quarter turns map an axis-aligned box onto an axis-aligned box
1211+ turns = _quarter_turns (rotation )
1212+
1213+ if turns is None :
1214+ _unsupported_rotation (rotation )
1215+
1216+ x = _user_units (self .x )
1217+ y = _user_units (self .y )
1218+ width = _user_units (self .width )
1219+ height = _user_units (self .height )
1220+
1221+ # rotating two opposite corners is enough to recover the new box; the
1222+ # center of the rotation, if any, is already folded into the matrix
1223+ corner1 = rotation @ point .Point (x , y )
1224+ corner2 = rotation @ point .Point (x + width , y + height )
1225+
1226+ self .x = length .Length (min (corner1 .x , corner2 .x ))
1227+ self .y = length .Length (min (corner1 .y , corner2 .y ))
1228+ self .width = length .Length (abs (corner2 .x - corner1 .x ))
1229+ self .height = length .Length (abs (corner2 .y - corner1 .y ))
1230+
1231+ if turns % 2 :
1232+ self .rx , self .ry = self .ry , self .rx
1233+
10871234 @override
10881235 def to_path (self ) -> Path :
10891236 return _basic_shape_to_path (self )
@@ -1290,7 +1437,9 @@ def set_viewbox(
12901437
12911438 # skip self; this can be done in a single for loop because the
12921439 # SVG is a tree (probably)
1293- for child in self .find_all (recursive = False ):
1440+ # the list of children is materialized because reifying a child may
1441+ # replace it with a different element
1442+ for child in list (self .find_all (recursive = False )):
12941443 # this is normally done in the reify method, but we need to do it
12951444 # before we prepend the new transformations
12961445 child .decompose_transform_origin ()
0 commit comments