Skip to content

Commit e0c0613

Browse files
committed
Support reification of rotate transformations
1 parent 44a5ca8 commit e0c0613

6 files changed

Lines changed: 876 additions & 49 deletions

File tree

svglab/attrparse/transform.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -641,8 +641,13 @@ def __hash__(self) -> int:
641641
Transform: TypeAlias = list[TransformFunction]
642642
"""A list of transformations."""
643643

644-
Reifiable: TypeAlias = Translate | Scale
645-
"""A transformation that can be reified."""
644+
Reifiable: TypeAlias = Translate | Scale | Rotate
645+
"""A transformation that may be reifiable.
646+
647+
Whether a transformation can actually be reified depends on the element as
648+
well; a `Rotate`, for example, can be reified on a `circle`, but not on a
649+
`text`. See `Element.reify()`.
650+
"""
646651

647652

648653
def decompose_matrices(transform: Transform) -> None:

svglab/elements/elements.py

Lines changed: 153 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@
2020
from collections.abc import Iterable
2121

2222
import 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
2626
from svglab.attrparse import color, length, path_data, point, transform
2727
from svglab.attrs import attrdefs, attrgroups
2828
from 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
40126
class 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

52140
def _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
263356
class 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
347455
class 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
835948
class 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
9571074
class 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
9761097
class 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()

svglab/elements/traits.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,23 @@
1616

1717
import abc
1818

19-
from typing_extensions import Protocol
19+
from typing_extensions import Final, Protocol, override
2020

2121
from svglab import entities, graphics, models
22-
from svglab.attrparse import path_data
22+
from svglab.attrparse import path_data, transform
2323
from svglab.attrs import attrdefs, attrgroups
2424

2525

26+
_VIEWPORT_ATTRS: Final = (
27+
"viewBox",
28+
"width",
29+
"height",
30+
"markerWidth",
31+
"markerHeight",
32+
)
33+
"""Attributes whose presence means an element establishes a viewport."""
34+
35+
2636
# common attributes are defined directly on the Element class
2737
class Element(entities.Element):
2838
"""An SVG element."""
@@ -178,7 +188,8 @@ def to_path(self) -> _PathLike:
178188
attributes as the original basic shape.
179189
180190
The resulting element is detached from the element tree; its parent is
181-
`None`, and it holds deep copies of the children of this shape.
191+
`None`, and it holds deep copies of the children of this shape. Use
192+
`Element.replace_with()` to put it in the place of this shape.
182193
183194
Returns:
184195
A `Path` element representing the basic shape.
@@ -210,6 +221,19 @@ class ContainerElement(
210221
as child elements."
211222
"""
212223

224+
@override
225+
def _reify_rotation(self, rotation: transform.Rotate, /) -> None:
226+
# a container has no geometry of its own; the rotation is reified by
227+
# pushing it down to the children. that does not work for a nested
228+
# container that establishes a viewport, because the viewport clips
229+
# the content and would stay unrotated
230+
if self.parent is not None and any(
231+
getattr(self, attr, None) is not None
232+
for attr in _VIEWPORT_ATTRS
233+
):
234+
msg = f"Unsupported transformation: {rotation}"
235+
raise ValueError(msg)
236+
213237

214238
class DescriptiveElement(Element):
215239
"""A descriptive element.

0 commit comments

Comments
 (0)