-
-
Notifications
You must be signed in to change notification settings - Fork 706
/
Copy pathimages.py
883 lines (762 loc) · 34.7 KB
/
images.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
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
"""Fetch and decode images in various formats."""
import io
import math
import struct
from hashlib import md5
from io import BytesIO
from itertools import cycle
from math import inf
from pathlib import Path
from urllib.parse import urlparse
from urllib.request import url2pathname
from xml.etree import ElementTree
import pydyf
from PIL import Image, ImageFile, ImageOps
from tinycss2.color4 import parse_color
from . import DEFAULT_OPTIONS
from .layout.percent import percentage
from .logger import LOGGER
from .svg import SVG
from .urls import URLFetchingError, fetch
# Don’t crash when converting truncated images
ImageFile.LOAD_TRUNCATED_IMAGES = True
class ImageLoadingError(ValueError):
"""An error occured when loading an image.
The image data is probably corrupted or in an invalid format.
"""
@classmethod
def from_exception(cls, exception):
name = type(exception).__name__
value = str(exception)
return cls(f'{name}: {value}' if value else name)
class RasterImage:
def __init__(self, pillow_image, image_id, image_data, filename=None,
cache=None, orientation='none', options=DEFAULT_OPTIONS):
# Transpose image
original_pillow_image = pillow_image
pillow_image = rotate_pillow_image(pillow_image, orientation)
if original_pillow_image is not pillow_image:
# Keep image format as it is discarded by transposition
pillow_image.format = original_pillow_image.format
# Discard original data, as the image has been transformed
image_data = filename = None
self.id = image_id
self._cache = {} if cache is None else cache
self._jpeg_quality = jpeg_quality = options['jpeg_quality']
self._dpi = options['dpi']
if 'transparency' in pillow_image.info:
pillow_image = pillow_image.convert('RGBA')
elif pillow_image.mode in ('1', 'P', 'I'):
pillow_image = pillow_image.convert('RGB')
self.mode = pillow_image.mode
self.width = pillow_image.width
self.height = pillow_image.height
self.ratio = (self.width / self.height) if self.height != 0 else inf
self.optimize = optimize = options['optimize_images']
# The presence of the APP14 segment indicates an Adobe image with
# inverted CMYK data. Specify a Decode Array to invert it again back to
# normal. See https://github.com/Kozea/WeasyPrint/pull/2179.
app14 = getattr(original_pillow_image, 'app', {}).get('APP14')
self.invert_colors = self.mode == 'CMYK' and app14 is not None
if pillow_image.format in ('JPEG', 'MPO'):
self.format = 'JPEG'
if image_data is None or optimize or jpeg_quality is not None:
image_file = io.BytesIO()
options = {'format': 'JPEG', 'optimize': optimize}
if self._jpeg_quality is not None:
options['quality'] = self._jpeg_quality
pillow_image.save(image_file, **options)
image_data = image_file.getvalue()
filename = None
else:
self.format = 'PNG'
if image_data is None or optimize or pillow_image.format != 'PNG':
image_file = io.BytesIO()
pillow_image.save(image_file, format='PNG', optimize=optimize)
image_data = image_file.getvalue()
filename = None
self.image_data = self.cache_image_data(image_data, filename)
def get_intrinsic_size(self, resolution, font_size):
return self.width / resolution, self.height / resolution, self.ratio
def draw(self, stream, concrete_width, concrete_height, image_rendering):
if self.width <= 0 or self.height <= 0:
return
interpolate = image_rendering == 'auto'
ratio = 1
if self._dpi:
pt_to_in = 4 / 3 / 96
width_inches = abs(concrete_width * stream.ctm[0][0] * pt_to_in)
height_inches = abs(concrete_height * stream.ctm[1][1] * pt_to_in)
dpi = max(self.width / width_inches, self.height / height_inches)
if dpi > self._dpi:
ratio = self._dpi / dpi
image_name = stream.add_image(self, interpolate, ratio)
stream.transform(
concrete_width, 0, 0, -concrete_height, 0, concrete_height)
stream.draw_x_object(image_name)
def cache_image_data(self, data, filename=None, slot='source'):
if filename:
return LazyLocalImage(filename)
else:
key = f'{self.id}-{slot}-{self._dpi or ""}'
return LazyImage(self._cache, key, data)
def get_x_object(self, interpolate, dpi_ratio):
if dpi_ratio == 1:
width, height = self.width, self.height
else:
thumbnail = Image.open(io.BytesIO(self.image_data.data))
width = max(1, int(round(self.width * dpi_ratio)))
height = max(1, int(round(self.height * dpi_ratio)))
thumbnail.thumbnail((width, height))
image_file = io.BytesIO()
thumbnail.save(
image_file, format=thumbnail.format, optimize=self.optimize)
width, height = thumbnail.width, thumbnail.height
self.image_data = self.cache_image_data(image_file.getvalue())
if self.mode in ('RGB', 'RGBA'):
color_space = '/DeviceRGB'
elif self.mode in ('L', 'LA'):
color_space = '/DeviceGray'
elif self.mode == 'CMYK':
color_space = '/DeviceCMYK'
else:
LOGGER.warning('Unknown image mode: %s', self.mode)
color_space = '/DeviceRGB'
extra = pydyf.Dictionary({
'Type': '/XObject',
'Subtype': '/Image',
'Width': width,
'Height': height,
'ColorSpace': color_space,
'BitsPerComponent': 8,
'Interpolate': 'true' if interpolate else 'false',
})
if self.format == 'JPEG':
if self.invert_colors:
extra['Decode'] = pydyf.Array((1, 0) * 4)
extra['Filter'] = '/DCTDecode'
return pydyf.Stream([self.image_data], extra)
extra['Filter'] = '/FlateDecode'
extra['DecodeParms'] = pydyf.Dictionary({
# Predictor 15 specifies that we're providing PNG data,
# ostensibly using an "optimum predictor", but doesn't actually
# matter as long as the predictor value is 10+ according to the
# spec. (Other PNG predictor values assert that we're using
# specific predictors that we don't want to commit to, but
# "optimum" can vary.)
'Predictor': 15,
'Columns': width,
})
if self.mode in ('RGB', 'RGBA'):
# Defaults to 1.
extra['DecodeParms']['Colors'] = 3
if self.mode in ('RGBA', 'LA'):
# Remove alpha channel from image
pillow_image = Image.open(io.BytesIO(self.image_data.data))
alpha = pillow_image.getchannel('A')
pillow_image = pillow_image.convert(self.mode[:-1])
png_data = self._get_png_data(pillow_image)
# Save alpha channel as mask
alpha_data = self._get_png_data(alpha)
stream = self.cache_image_data(alpha_data, slot='streamalpha')
extra['SMask'] = pydyf.Stream([stream], extra={
'Filter': '/FlateDecode',
'Type': '/XObject',
'Subtype': '/Image',
'DecodeParms': pydyf.Dictionary({
'Predictor': 15,
'Columns': width,
}),
'Width': width,
'Height': height,
'ColorSpace': '/DeviceGray',
'BitsPerComponent': 8,
'Interpolate': 'true' if interpolate else 'false',
})
else:
png_data = self._get_png_data(
Image.open(io.BytesIO(self.image_data.data)))
return pydyf.Stream([self.cache_image_data(png_data, slot='stream')], extra)
@staticmethod
def _get_png_data(pillow_image):
image_file = BytesIO()
pillow_image.save(image_file, format='PNG')
# Read the PNG header, then discard it because we know it's a PNG. If
# this weren't just output from Pillow, we should actually check it.
image_file.seek(8)
png_data = []
raw_chunk_length = image_file.read(4)
# PNG files consist of a series of chunks.
while raw_chunk_length:
# Each chunk begins with its data length (four bytes, may be zero),
# then its type (four ASCII characters), then the data, then four
# bytes of a CRC.
chunk_length, = struct.unpack('!I', raw_chunk_length)
chunk_type = image_file.read(4)
if chunk_type == b'IDAT':
png_data.append(image_file.read(chunk_length))
else:
image_file.seek(chunk_length, io.SEEK_CUR)
# We aren't checking the CRC, we assume this is a valid PNG.
image_file.seek(4, io.SEEK_CUR)
raw_chunk_length = image_file.read(4)
return b''.join(png_data)
class LazyImage(pydyf.Object):
def __init__(self, cache, key, data):
super().__init__()
self._key = key
self._cache = cache
cache[key] = data
@property
def data(self):
return self._cache[self._key]
class LazyLocalImage(pydyf.Object):
def __init__(self, filename):
super().__init__()
self._filename = filename
@property
def data(self):
return Path(self._filename).read_bytes()
class SVGImage:
def __init__(self, tree, base_url, url_fetcher, context):
self._svg = SVG(tree, base_url)
self._base_url = base_url
self._url_fetcher = url_fetcher
self._context = context
def get_intrinsic_size(self, image_resolution, font_size):
width, height = self._svg.get_intrinsic_size(font_size)
if None in (width, height):
viewbox = self._svg.get_viewbox()
if viewbox and viewbox[2] and viewbox[3]:
ratio = viewbox[2] / viewbox[3]
if width:
height = width / ratio
elif height:
width = height * ratio
else:
ratio = None
elif width and height:
ratio = width / height
else:
ratio = 1
return width, height, ratio
def draw(self, stream, concrete_width, concrete_height, image_rendering):
try:
self._svg.draw(
stream, concrete_width, concrete_height, self._base_url,
self._url_fetcher, self._context)
except BaseException as exception:
LOGGER.error('Failed to render SVG image %s', self._base_url)
LOGGER.debug('Error while rendering SVG image:', exc_info=exception)
def get_image_from_uri(cache, url_fetcher, options, url, forced_mime_type=None,
context=None, orientation='from-image'):
"""Get an Image instance from an image URI."""
if url in cache:
return cache[url]
try:
with fetch(url_fetcher, url) as result:
parsed_url = urlparse(result.get('redirected_url'))
if parsed_url.scheme == 'file':
filename = url2pathname(parsed_url.path)
else:
filename = None
if 'string' in result:
string = result['string']
else:
string = result['file_obj'].read()
mime_type = forced_mime_type or result['mime_type']
image = None
svg_exceptions = []
# Try to rely on given mimetype for SVG
if mime_type == 'image/svg+xml':
try:
tree = ElementTree.fromstring(string)
image = SVGImage(tree, url, url_fetcher, context)
except Exception as svg_exception:
svg_exceptions.append(svg_exception)
# Try pillow for raster images, or for failing SVG
if image is None:
try:
pillow_image = Image.open(BytesIO(string))
except Exception as raster_exception:
if mime_type == 'image/svg+xml':
# Tried SVGImage then Pillow for a SVG, abort
raise ImageLoadingError.from_exception(svg_exceptions[0])
try:
# Last chance, try SVG
tree = ElementTree.fromstring(string)
image = SVGImage(tree, url, url_fetcher, context)
except Exception:
# Tried Pillow then SVGImage for a raster, abort
raise ImageLoadingError.from_exception(raster_exception)
else:
# Store image id to enable cache in Stream.add_image
image_id = md5(url.encode(), usedforsecurity=False).hexdigest()
image = RasterImage(
pillow_image, image_id, string, filename, cache,
orientation, options)
except (URLFetchingError, ImageLoadingError) as exception:
LOGGER.error('Failed to load image at %r: %s', url, exception)
LOGGER.debug('Error while loading image:', exc_info=exception)
image = None
cache[url] = image
return image
def rotate_pillow_image(pillow_image, orientation):
"""Return a copy of a Pillow image with modified orientation.
If orientation is not changed, return the same image.
"""
image_format = pillow_image.format
if orientation == 'from-image':
if 'exif' in pillow_image.info:
pillow_image = ImageOps.exif_transpose(pillow_image)
elif orientation != 'none':
angle, flip = orientation
if angle > 0:
rotation = getattr(Image.Transpose, f'ROTATE_{angle}')
pillow_image = pillow_image.transpose(rotation)
if flip:
pillow_image = pillow_image.transpose(
Image.Transpose.FLIP_LEFT_RIGHT)
# Keep image format as it is discarded by transposition
pillow_image.format = image_format
return pillow_image
def process_color_stops(vector_length, positions):
"""Give color stops positions on the gradient vector.
``vector_length`` is the distance between the starting point and ending
point of the vector gradient.
``positions`` is a list of ``None``, or ``Dimension`` in px or %. 0 is the
starting point, 1 the ending point.
See https://drafts.csswg.org/css-images-3/#color-stop-syntax.
Return processed color stops, as a list of floats in px.
"""
# Resolve percentages
positions = [percentage(position, vector_length) for position in positions]
# First and last default to 100%
if positions[0] is None:
positions[0] = 0
if positions[-1] is None:
positions[-1] = vector_length
# Make sure positions are increasing
previous_pos = positions[0]
for i, position in enumerate(positions):
if position is not None:
if position < previous_pos:
positions[i] = previous_pos
else:
previous_pos = position
# Assign missing values
previous_i = -1
for i, position in enumerate(positions):
if position is not None:
base = positions[previous_i]
increment = (position - base) / (i - previous_i)
for j in range(previous_i + 1, i):
positions[j] = base + j * increment
previous_i = i
return positions
def normalize_stop_positions(positions):
"""Normalize stop positions between 0 and 1.
Return ``(first, last, positions)``.
first: original position of the first position.
last: original position of the last position.
positions: list of positions between 0 and 1.
"""
first, last = positions[0], positions[-1]
total_length = last - first
if total_length == 0:
positions = [0] * len(positions)
else:
positions = [(pos - first) / total_length for pos in positions]
return first, last, positions
def gradient_average_color(colors, positions):
"""
https://drafts.csswg.org/css-images-3/#gradient-average-color
"""
# TODO: handle color spaces.
nb_stops = len(positions)
assert nb_stops > 1
assert nb_stops == len(colors)
total_length = positions[-1] - positions[0]
if total_length == 0:
positions = list(range(nb_stops))
total_length = nb_stops - 1
premul_r = [r * a for r, g, b, a in colors]
premul_g = [g * a for r, g, b, a in colors]
premul_b = [b * a for r, g, b, a in colors]
alpha = [a for r, g, b, a in colors]
result_r = result_g = result_b = result_a = 0
total_weight = 2 * total_length
for i, position in enumerate(positions[1:], 1):
weight = (position - positions[i - 1]) / total_weight
for j in (i - 1, i):
result_r += premul_r[j] * weight
result_g += premul_g[j] * weight
result_b += premul_b[j] * weight
result_a += alpha[j] * weight
# Un-premultiply.
if result_a == 0:
return parse_color('transparent')
else:
return parse_color(
f'rgb({result_r / result_a * 255} {result_g / result_a * 255} '
f'{result_b / result_a * 255}/{ result_a })')
class Gradient:
def __init__(self, color_stops, repeating):
assert color_stops
# List of (r, g, b, a)
self.colors = tuple(color for color, _ in color_stops)
# List of Dimensions
self.stop_positions = tuple(position for _, position in color_stops)
# Boolean
self.repeating = repeating
def get_intrinsic_size(self, image_resolution, font_size):
return None, None, None
def draw(self, stream, concrete_width, concrete_height, _image_rendering):
scale_y, type_, points, positions, colors = self.layout(
concrete_width, concrete_height)
if type_ == 'solid':
stream.rectangle(0, 0, concrete_width, concrete_height)
stream.set_color(colors[0])
stream.fill()
return
alphas = [color[3] for color in colors]
alpha_couples = [
(alphas[i], alphas[i + 1])
for i in range(len(alphas) - 1)]
# TODO: handle other color spaces.
color_couples = [
[colors[i].to('srgb')[:3], colors[i + 1].to('srgb')[:3], 1]
for i in range(len(colors) - 1)]
# Premultiply colors
for i, alpha in enumerate(alphas):
if alpha == 0:
if i > 0:
color_couples[i - 1][1] = color_couples[i - 1][0]
if i < len(colors) - 1:
color_couples[i][0] = color_couples[i][1]
for i, (a0, a1) in enumerate(alpha_couples):
if 0 not in (a0, a1) and (a0, a1) != (1, 1):
color_couples[i][2] = a0 / a1
shading_type = 2 if type_ == 'linear' else 3
domain = (positions[0], positions[-1])
extend = not self.repeating
encode = (len(colors) - 1) * (0, 1)
bounds = positions[1:-1]
sub_functions = (
stream.create_interpolation_function((0, 1), c0, c1, n)
for c0, c1, n in color_couples)
function = stream.create_stitching_function(
domain, encode, bounds, sub_functions)
# TODO: handle other color spaces.
shading = stream.add_shading(
shading_type, 'RGB', domain, points, extend, function)
stream.transform(d=scale_y)
if any(alpha != 1 for alpha in alphas):
alpha_stream = stream.set_alpha_state(
0, 0, concrete_width, concrete_height)
shading_type = 2 if type_ == 'linear' else 3
sub_functions = (
stream.create_interpolation_function((0, 1), (c0,), (c1,), 1)
for c0, c1 in alpha_couples)
function = stream.create_stitching_function(
domain, encode, bounds, sub_functions)
alpha_shading = alpha_stream.add_shading(
shading_type, 'Gray', domain, points, extend, function)
alpha_stream.transform(d=scale_y)
alpha_stream.stream = [f'/{alpha_shading.id} sh']
stream.paint_shading(shading.id)
def layout(self, width, height):
"""Get layout information about the gradient.
width, height: Gradient box. Top-left is at coordinates (0, 0).
Returns (scale_y, type_, points, positions, colors).
scale_y: vertical scale of the gradient. float, used for ellipses
radial gradients. 1 otherwise.
type_: gradient type.
points: coordinates of useful points, depending on type_:
'solid': None.
'linear': (x0, y0, x1, y1)
coordinates of the starting and ending points.
'radial': (cx0, cy0, radius0, cx1, cy1, radius1)
coordinates of the starting end ending circles
positions: positions of the color stops. list of floats in between 0
and 1 (0 at the starting point, 1 at the ending point).
colors: list of (r, g, b, a).
"""
raise NotImplementedError
class LinearGradient(Gradient):
def __init__(self, color_stops, direction, repeating):
Gradient.__init__(self, color_stops, repeating)
# ('corner', keyword) or ('angle', radians)
self.direction_type, self.direction = direction
def layout(self, width, height):
# Only one color, render the gradient as a solid color
if len(self.colors) == 1:
return 1, 'solid', None, [], [self.colors[0]]
# Define the (dx, dy) unit vector giving the direction of the gradient.
# Positive dx: right, positive dy: down.
if self.direction_type == 'corner':
y, x = self.direction.split('_')
factor_x = -1 if x == 'left' else 1
factor_y = -1 if y == 'top' else 1
diagonal = math.hypot(width, height)
# Note the direction swap: dx based on height, dy based on width
# The gradient line is perpendicular to a diagonal.
dx = factor_x * height / diagonal
dy = factor_y * width / diagonal
else:
assert self.direction_type == 'angle'
angle = self.direction # 0 upwards, then clockwise
dx = math.sin(angle)
dy = -math.cos(angle)
# Round dx and dy to avoid floating points errors caused by
# trigonometry and angle units conversions
dx, dy = round(dx, 9), round(dy, 9)
# Normalize colors positions
colors = list(self.colors)
vector_length = abs(width * dx) + abs(height * dy)
positions = process_color_stops(vector_length, self.stop_positions)
if not self.repeating:
# Add explicit colors at boundaries if needed, because PDF doesn’t
# extend color stops that are not displayed
if positions[0] == positions[1]:
positions.insert(0, positions[0] - 1)
colors.insert(0, colors[0])
if positions[-2] == positions[-1]:
positions.append(positions[-1] + 1)
colors.append(colors[-1])
first, last, positions = normalize_stop_positions(positions)
if self.repeating:
# Render as a solid color if the first and last positions are equal
# See https://drafts.csswg.org/css-images-3/#repeating-gradients
if first == last:
color = gradient_average_color(colors, positions)
return 1, 'solid', None, [], [color]
# Define defined gradient length and steps between positions
stop_length = last - first
assert stop_length > 0
position_steps = [
positions[i + 1] - positions[i]
for i in range(len(positions) - 1)]
# Create cycles used to add colors
next_steps = cycle((0, *position_steps))
next_colors = cycle(colors)
previous_steps = cycle((0, *position_steps[::-1]))
previous_colors = cycle(colors[::-1])
# Add colors after last step
while last < vector_length:
step = next(next_steps)
colors.append(next(next_colors))
positions.append(positions[-1] + step)
last += step * stop_length
# Add colors before last step
while first > 0:
step = next(previous_steps)
colors.insert(0, next(previous_colors))
positions.insert(0, positions[0] - step)
first -= step * stop_length
# Define the coordinates of the starting and ending points
start_x = (width - dx * vector_length) / 2
start_y = (height - dy * vector_length) / 2
points = (
start_x + dx * first, start_y + dy * first,
start_x + dx * last, start_y + dy * last)
return 1, 'linear', points, positions, colors
class RadialGradient(Gradient):
def __init__(self, color_stops, shape, size, center, repeating):
Gradient.__init__(self, color_stops, repeating)
# Center of the ending shape. (origin_x, pos_x, origin_y, pos_y)
self.center = center
# Type of ending shape: 'circle' or 'ellipse'
self.shape = shape
# size_type: 'keyword'
# size: 'closest-corner', 'farthest-corner',
# 'closest-side', or 'farthest-side'
# size_type: 'explicit'
# size: (radius_x, radius_y)
self.size_type, self.size = size
def layout(self, width, height):
# Only one color, render the gradient as a solid color
if len(self.colors) == 1:
return 1, 'solid', None, [], [self.colors[0]]
# Define the center of the gradient
origin_x, center_x, origin_y, center_y = self.center
center_x = percentage(center_x, width)
center_y = percentage(center_y, height)
if origin_x == 'right':
center_x = width - center_x
if origin_y == 'bottom':
center_y = height - center_y
# Resolve sizes and vertical scale
size_x, size_y = self._handle_degenerate(
*self._resolve_size(width, height, center_x, center_y))
scale_y = size_y / size_x
# Normalize colors positions
colors = list(self.colors)
positions = process_color_stops(size_x, self.stop_positions)
if not self.repeating:
# Add explicit colors at boundaries if needed, because PDF doesn’t
# extend color stops that are not displayed
if positions[0] > 0 and positions[0] == positions[1]:
positions.insert(0, 0)
colors.insert(0, colors[0])
if positions[-2] == positions[-1]:
positions.append(positions[-1] + 1)
colors.append(colors[-1])
if positions[0] < 0:
# PDF doesn’t like negative radiuses, shift into the positive realm
if self.repeating:
# Add vector lengths to first position until positive
vector_length = positions[-1] - positions[0]
offset = vector_length * (1 + (-positions[0] // vector_length))
positions = [position + offset for position in positions]
else:
# Only keep colors with position >= 0, interpolate if needed
if positions[-1] <= 0:
# All stops are negative, fill with the last color
return 1, 'solid', None, [], [self.colors[-1]]
for i, position in enumerate(positions):
if position == 0:
# Keep colors and positions from this rank
colors, positions = colors[i:], positions[i:]
break
if position > 0:
# Interpolate with previous rank to get color at 0
color = colors[i]
previous_color = colors[i - 1]
previous_position = positions[i - 1]
assert previous_position < 0
intermediate_color = gradient_average_color(
[previous_color, previous_color, color, color],
[previous_position, 0, 0, position])
colors = [intermediate_color] + colors[i:]
positions = [0] + positions[i:]
break
first, last, positions = normalize_stop_positions(positions)
# Render as a solid color if the first and last positions are the same
# See https://drafts.csswg.org/css-images-3/#repeating-gradients
if first == last and self.repeating:
color = gradient_average_color(colors, positions)
return 1, 'solid', None, [], [color]
# Define the coordinates of the gradient circles
points = (
center_x, center_y / scale_y, first,
center_x, center_y / scale_y, last)
if self.repeating:
points, positions, colors = self._repeat(
width, height, scale_y, points, positions, colors)
return scale_y, 'radial', points, positions, colors
def _repeat(self, width, height, scale_y, points, positions, colors):
# Keep original lists and values, they’re useful
original_colors = colors.copy()
original_positions = positions.copy()
gradient_length = points[5] - points[2]
# Get the maximum distance between the center and the corners, to find
# how many times we have to repeat the colors outside
max_distance = max(
math.hypot(width - points[0], height / scale_y - points[1]),
math.hypot(width - points[0], -points[1] * scale_y),
math.hypot(-points[0], height / scale_y - points[1]),
math.hypot(-points[0], -points[1] * scale_y))
repeat_after = math.ceil((max_distance - points[5]) / gradient_length)
if repeat_after > 0:
# Repeat colors and extrapolate positions
repeat = 1 + repeat_after
colors *= repeat
positions = [
i + position for i in range(repeat) for position in positions]
points = points[:5] + (points[5] + gradient_length * repeat_after,)
if points[2] == 0:
# Inner circle has 0 radius, no need to repeat inside, return
return points, positions, colors
# Find how many times we have to repeat the colors inside
repeat_before = points[2] / gradient_length
# Set the inner circle size to 0
points = points[:2] + (0,) + points[3:]
# Find how many times the whole gradient can be repeated
full_repeat = int(repeat_before)
if full_repeat:
# Repeat colors and extrapolate positions
colors += original_colors * full_repeat
positions = [
i - full_repeat + position for i in range(full_repeat)
for position in original_positions] + positions
# Find the ratio of gradient that must be added to reach the center
partial_repeat = repeat_before - full_repeat
if partial_repeat == 0:
# No partial repeat, return
return points, positions, colors
# Iterate through positions in reverse order, from the outer
# circle to the original inner circle, to find positions from
# the inner circle (including full repeats) to the center
assert (original_positions[0], original_positions[-1]) == (0, 1)
assert 0 < partial_repeat < 1
reverse = original_positions[::-1]
ratio = 1 - partial_repeat
for i, position in enumerate(reverse, start=1):
if position == ratio:
# The center is a color of the gradient, truncate original
# colors and positions and prepend them
colors = original_colors[-i:] + colors
new_positions = [
position - full_repeat - 1
for position in original_positions[-i:]]
positions = new_positions + positions
return points, positions, colors
if position < ratio:
# The center is between two colors of the gradient,
# define the center color as the average of these two
# gradient colors
color = original_colors[-i]
next_color = original_colors[-(i - 1)]
next_position = original_positions[-(i - 1)]
average_colors = [color, color, next_color, next_color]
average_positions = [position, ratio, ratio, next_position]
zero_color = gradient_average_color(
average_colors, average_positions)
colors = [zero_color] + original_colors[-(i - 1):] + colors
new_positions = [
position - 1 - full_repeat for position
in original_positions[-(i - 1):]]
positions = (ratio - 1 - full_repeat, *new_positions, *positions)
return points, positions, colors
def _resolve_size(self, width, height, center_x, center_y):
"""Resolve circle size of the radial gradient."""
if self.size_type == 'explicit':
size_x, size_y = self.size
size_x = percentage(size_x, width)
size_y = percentage(size_y, height)
return size_x, size_y
left = abs(center_x)
right = abs(width - center_x)
top = abs(center_y)
bottom = abs(height - center_y)
pick = min if self.size.startswith('closest') else max
if self.size.endswith('side'):
if self.shape == 'circle':
size_xy = pick(left, right, top, bottom)
return size_xy, size_xy
# else: ellipse
return pick(left, right), pick(top, bottom)
# else: corner
if self.shape == 'circle':
size_xy = pick(math.hypot(left, top), math.hypot(left, bottom),
math.hypot(right, top), math.hypot(right, bottom))
return size_xy, size_xy
# else: ellipse
corner_x, corner_y = pick(
(left, top), (left, bottom), (right, top), (right, bottom),
key=lambda a: math.hypot(*a))
return corner_x * math.sqrt(2), corner_y * math.sqrt(2)
def _handle_degenerate(self, size_x, size_y):
"""Handle degenerate radial gradients.
See https://drafts.csswg.org/css-images-3/#degenerate-radials
"""
if size_x == size_y == 0:
size_x = size_y = 1e-7
elif size_x == 0:
size_x = 1e-7
size_y = 1e7
elif size_y == 0:
size_x = 1e7
size_y = 1e-7
return size_x, size_y