-
-
Notifications
You must be signed in to change notification settings - Fork 150
/
helpers.py
390 lines (314 loc) · 12.4 KB
/
helpers.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
"""
Surface helpers.
"""
import re
from math import atan2, cos, hypot, radians, sin, tan
from .surface import cairo
from .url import parse_url
UNITS = {
'mm': 1 / 25.4,
'cm': 1 / 2.54,
'in': 1,
'pt': 1 / 72,
'pc': 1 / 6,
'px': None,
}
PAINT_URL = re.compile(r'(url\(.+\)) *(.*)')
PATH_LETTERS = 'achlmqstvzACHLMQSTVZ'
RECT = re.compile(r'rect\( ?(.+?) ?\)')
class PointError(Exception):
"""Exception raised when parsing a point fails."""
def distance(x1, y1, x2, y2):
"""Get the distance between two points."""
return hypot(x2 - x1, y2 - y1)
def paint(value):
"""Extract from value an uri and a color.
See http://www.w3.org/TR/SVG/painting.html#SpecifyingPaint
"""
if not value:
return None, None
value = value.strip()
match = PAINT_URL.search(value)
if match:
source = parse_url(match.group(1)).fragment
color = match.group(2) or None
else:
source = None
color = value or None
return (source, color)
def node_format(surface, node, reference=True):
"""Return ``(width, height, viewbox)`` of ``node``.
If ``reference`` is ``True``, we can rely on surface size to resolve
percentages.
"""
reference_size = 'xy' if reference else (0, 0)
width = size(surface, node.get('width', '100%'), reference_size[0])
height = size(surface, node.get('height', '100%'), reference_size[1])
viewbox = node.get('viewBox')
if viewbox:
viewbox = re.sub('[ \n\r\t,]+', ' ', viewbox)
viewbox = tuple(float(position) for position in viewbox.split())
width = width or viewbox[2]
height = height or viewbox[3]
return width, height, viewbox
def normalize(string):
"""Normalize a string corresponding to an array of various values."""
string = string.replace('E', 'e')
string = re.sub('(?<!e)-', ' -', string)
string = re.sub('[ \n\r\t,]+', ' ', string)
string = re.sub(r'(\.[0-9-]+)(?=\.)', r'\1 ', string)
return string.strip()
def point(surface, string):
"""Return ``(x, y, trailing_text)`` from ``string``."""
match = re.match('(.*?) (.*?)(?: |$)', string)
if match:
x, y = match.group(1, 2)
string = string[match.end():]
return (size(surface, x, 'x'), size(surface, y, 'y'), string)
else:
raise PointError
def point_angle(cx, cy, px, py):
"""Return angle between x axis and point knowing given center."""
return atan2(py - cy, px - cx)
def preserve_ratio(surface, node, width=None, height=None):
"""Manage the ratio preservation."""
if node.tag == 'marker':
width = width or size(surface, node.get('markerWidth', '3'), 'x')
height = height or size(surface, node.get('markerHeight', '3'), 'y')
_, _, viewbox = node_format(surface, node)
viewbox_width, viewbox_height = viewbox[2:]
elif node.tag in ('svg', 'image', 'g'):
node_width, node_height, _ = node_format(surface, node)
width = width or node_width
height = height or node_height
viewbox_width, viewbox_height = node.image_width, node.image_height
else:
raise TypeError(
f'Root node is {node.tag}. Should be one of '
'marker, svg, image, or g.'
)
translate_x = 0
translate_y = 0
scale_x = width / viewbox_width if viewbox_width > 0 else 1
scale_y = height / viewbox_height if viewbox_height > 0 else 1
aspect_ratio = node.get('preserveAspectRatio', 'xMidYMid').split()
align = aspect_ratio[0]
if align == 'none':
x_position = 'min'
y_position = 'min'
else:
meet_or_slice = aspect_ratio[1] if len(aspect_ratio) > 1 else None
if meet_or_slice == 'slice':
scale_value = max(scale_x, scale_y)
else:
scale_value = min(scale_x, scale_y)
scale_x = scale_y = scale_value
x_position = align[1:4].lower()
y_position = align[5:].lower()
if node.tag == 'marker':
translate_x = -size(surface, node.get('refX', '0'), 'x')
translate_y = -size(surface, node.get('refY', '0'), 'y')
else:
translate_x = 0
if x_position == 'mid':
translate_x = (width / scale_x - viewbox_width) / 2
elif x_position == 'max':
translate_x = width / scale_x - viewbox_width
translate_y = 0
if y_position == 'mid':
translate_y += (height / scale_y - viewbox_height) / 2
elif y_position == 'max':
translate_y += height / scale_y - viewbox_height
return scale_x, scale_y, translate_x, translate_y
def clip_marker_box(surface, node, scale_x, scale_y):
"""Get the clip ``(x, y, width, height)`` of the marker box."""
width = size(surface, node.get('markerWidth', '3'), 'x')
height = size(surface, node.get('markerHeight', '3'), 'y')
_, _, viewbox = node_format(surface, node)
viewbox_width, viewbox_height = viewbox[2:]
align = node.get('preserveAspectRatio', 'xMidYMid').split(' ')[0]
x_position = 'min' if align == 'none' else align[1:4].lower()
y_position = 'min' if align == 'none' else align[5:].lower()
clip_x = viewbox[0]
if x_position == 'mid':
clip_x += (viewbox_width - width / scale_x) / 2.
elif x_position == 'max':
clip_x += viewbox_width - width / scale_x
clip_y = viewbox[1]
if y_position == 'mid':
clip_y += (viewbox_height - height / scale_y) / 2.
elif y_position == 'max':
clip_y += viewbox_height - height / scale_y
return clip_x, clip_y, width / scale_x, height / scale_y
def quadratic_points(x1, y1, x2, y2, x3, y3):
"""Return the quadratic points to create quadratic curves."""
xq1 = x2 * 2 / 3 + x1 / 3
yq1 = y2 * 2 / 3 + y1 / 3
xq2 = x2 * 2 / 3 + x3 / 3
yq2 = y2 * 2 / 3 + y3 / 3
return xq1, yq1, xq2, yq2, x3, y3
def rotate(x, y, angle):
"""Rotate a point of an angle around the origin point."""
return x * cos(angle) - y * sin(angle), y * cos(angle) + x * sin(angle)
def transform(surface, transform_string, gradient=None, transform_origin=None):
"""Transform ``surface`` or ``gradient`` if supplied using ``string``.
See http://www.w3.org/TR/SVG/coords.html#TransformAttribute
"""
if not transform_string:
return
transformations = re.findall(
r'(\w+) ?\( ?(.*?) ?\)', normalize(transform_string))
matrix = cairo.Matrix()
if transform_origin:
origin = transform_origin.split(' ')
origin_x = origin[0]
if len(origin) == 1:
if origin_x in ('top', 'bottom'):
origin_y = origin_x
origin_x = surface.width / 2
else:
origin_y = surface.height / 2
elif len(origin) > 1:
if origin_x in ('top', 'bottom'):
origin_y = origin_x
origin_x = origin[1]
else:
origin_y = origin[1]
else:
return
if origin_x == 'center':
origin_x = surface.width / 2
elif origin_x == 'left':
origin_x = 0
elif origin_x == 'right':
origin_x = surface.width
else:
origin_x = size(surface, origin_x, 'x')
if origin_y == 'center':
origin_y = surface.height / 2
elif origin_y == 'top':
origin_y = 0
elif origin_y == 'bottom':
origin_y = surface.height
else:
origin_y = size(surface, origin_y, 'y')
matrix.translate(float(origin_x), float(origin_y))
for transformation_type, transformation in transformations:
values = [size(surface, value) for value in transformation.split(' ')]
if transformation_type == 'matrix':
matrix = cairo.Matrix(*values).multiply(matrix)
elif transformation_type == 'rotate':
angle = radians(float(values.pop(0)))
x, y = values or (0, 0)
matrix.translate(x, y)
matrix.rotate(angle)
matrix.translate(-x, -y)
elif transformation_type == 'skewX':
tangent = tan(radians(float(values[0])))
matrix = cairo.Matrix(1, 0, tangent, 1, 0, 0).multiply(matrix)
elif transformation_type == 'skewY':
tangent = tan(radians(float(values[0])))
matrix = cairo.Matrix(1, tangent, 0, 1, 0, 0).multiply(matrix)
elif transformation_type == 'translate':
if len(values) == 1:
values += (0,)
matrix.translate(*values)
elif transformation_type == 'scale':
if len(values) == 1:
values = 2 * values
matrix.scale(*values)
if transform_origin:
matrix.translate(-float(origin_x), -float(origin_y))
try:
matrix.invert()
except cairo.Error:
# Matrix not invertible, clip the surface to an empty path
active_path = surface.context.copy_path()
surface.context.new_path()
surface.context.clip()
surface.context.append_path(active_path)
else:
if gradient:
# When applied on gradient use already inverted matrix (mapping
# from user space to gradient space)
matrix_now = gradient.get_matrix()
gradient.set_matrix(matrix_now.multiply(matrix))
else:
matrix.invert()
surface.context.transform(matrix)
def clip_rect(string):
"""Parse the rect value of a clip."""
match = RECT.search(normalize(string or ''))
return match.group(1).split(' ') if match else []
def rotations(node):
"""Retrieves the original rotations of a `text` or `tspan` node."""
if 'rotate' in node:
original_rotate = [
float(i) for i in normalize(node['rotate']).strip().split(' ')]
return original_rotate
return []
def pop_rotation(node, original_rotate, rotate):
"""Removes the rotations of a node that are already used."""
node['rotate'] = ' '.join(
str(rotate.pop(0) if rotate else original_rotate[-1])
for i in range(len(node.text)))
def zip_letters(xl, yl, dxl, dyl, rl, word):
"""Returns a list with the current letter's positions (x, y and rotation).
E.g.: for letter 'L' with positions x = 10, y = 20 and rotation = 30:
>>> [[10, 20, 30], 'L']
Store the last value of each position and pop the first one in order to
avoid setting an x,y or rotation value that have already been used.
"""
return (
([pl.pop(0) if pl else None for pl in (xl, yl, dxl, dyl, rl)], char)
for char in word)
def flatten(node):
"""Flatten the text of a node and its children."""
flattened_text = [node.text or '']
for child in list(node):
flattened_text.append(flatten(child))
flattened_text.append(child.tail or '')
node.remove(child)
return ''.join(flattened_text)
def size(surface, string, reference='xy'):
"""Replace a ``string`` with units by a float value.
If ``reference`` is a float, it is used as reference for percentages. If it
is ``'x'``, we use the viewport width as reference. If it is ``'y'``, we
use the viewport height as reference. If it is ``'xy'``, we use
``hypot(viewport_width, viewport_height) / 2 ** .5`` as reference.
"""
if not string:
return 0
try:
return float(string)
except ValueError:
# Not a float, try something else
pass
# No surface (for parsing only)
if surface is None:
return 0
string = normalize(string).split(' ', 1)[0]
if string.endswith('%'):
if reference == 'x':
reference = surface.context_width or 0
elif reference == 'y':
reference = surface.context_height or 0
elif reference == 'xy':
reference = (
hypot(surface.context_width, surface.context_height) / 2 ** .5
)
return float(string[:-1]) * reference / 100
elif string.endswith('em'):
return surface.font_size * float(string[:-2])
elif string.endswith('ex'):
# Assume that 1em == 2ex
return surface.font_size * float(string[:-2]) / 2
elif string.endswith('ch'):
# A '0' must be assumed to be 0.5em wide.
return surface.font_size * float(string[:-2]) / 2
for unit, coefficient in UNITS.items():
if string.endswith(unit):
number = float(string[:-len(unit)])
return number * (surface.dpi * coefficient if coefficient else 1)
# Unknown size
return 0