weasyprint 65.1__py3-none-any.whl → 67.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.
Files changed (67) hide show
  1. weasyprint/__init__.py +17 -7
  2. weasyprint/__main__.py +21 -10
  3. weasyprint/anchors.py +4 -4
  4. weasyprint/css/__init__.py +732 -67
  5. weasyprint/css/computed_values.py +65 -170
  6. weasyprint/css/counters.py +1 -1
  7. weasyprint/css/functions.py +206 -0
  8. weasyprint/css/html5_ua.css +3 -7
  9. weasyprint/css/html5_ua_form.css +2 -2
  10. weasyprint/css/media_queries.py +3 -1
  11. weasyprint/css/properties.py +6 -2
  12. weasyprint/css/{utils.py → tokens.py} +306 -397
  13. weasyprint/css/units.py +91 -0
  14. weasyprint/css/validation/__init__.py +1 -1
  15. weasyprint/css/validation/descriptors.py +47 -19
  16. weasyprint/css/validation/expanders.py +7 -8
  17. weasyprint/css/validation/properties.py +341 -357
  18. weasyprint/document.py +20 -19
  19. weasyprint/draw/__init__.py +56 -63
  20. weasyprint/draw/border.py +121 -69
  21. weasyprint/draw/color.py +1 -1
  22. weasyprint/draw/text.py +60 -41
  23. weasyprint/formatting_structure/boxes.py +24 -5
  24. weasyprint/formatting_structure/build.py +33 -45
  25. weasyprint/images.py +76 -62
  26. weasyprint/layout/__init__.py +32 -26
  27. weasyprint/layout/absolute.py +7 -6
  28. weasyprint/layout/background.py +7 -7
  29. weasyprint/layout/block.py +195 -152
  30. weasyprint/layout/column.py +19 -24
  31. weasyprint/layout/flex.py +54 -26
  32. weasyprint/layout/float.py +12 -7
  33. weasyprint/layout/grid.py +284 -90
  34. weasyprint/layout/inline.py +121 -68
  35. weasyprint/layout/page.py +45 -12
  36. weasyprint/layout/percent.py +14 -10
  37. weasyprint/layout/preferred.py +105 -63
  38. weasyprint/layout/replaced.py +9 -6
  39. weasyprint/layout/table.py +16 -9
  40. weasyprint/pdf/__init__.py +58 -18
  41. weasyprint/pdf/anchors.py +3 -4
  42. weasyprint/pdf/fonts.py +126 -69
  43. weasyprint/pdf/metadata.py +36 -4
  44. weasyprint/pdf/pdfa.py +19 -3
  45. weasyprint/pdf/pdfua.py +7 -115
  46. weasyprint/pdf/pdfx.py +83 -0
  47. weasyprint/pdf/stream.py +57 -49
  48. weasyprint/pdf/tags.py +307 -0
  49. weasyprint/stacking.py +14 -15
  50. weasyprint/svg/__init__.py +59 -32
  51. weasyprint/svg/bounding_box.py +4 -2
  52. weasyprint/svg/defs.py +4 -9
  53. weasyprint/svg/images.py +11 -3
  54. weasyprint/svg/text.py +11 -2
  55. weasyprint/svg/utils.py +15 -8
  56. weasyprint/text/constants.py +1 -1
  57. weasyprint/text/ffi.py +4 -3
  58. weasyprint/text/fonts.py +13 -5
  59. weasyprint/text/line_break.py +146 -43
  60. weasyprint/urls.py +41 -13
  61. {weasyprint-65.1.dist-info → weasyprint-67.0.dist-info}/METADATA +5 -6
  62. weasyprint-67.0.dist-info/RECORD +77 -0
  63. weasyprint/draw/stack.py +0 -13
  64. weasyprint-65.1.dist-info/RECORD +0 -74
  65. {weasyprint-65.1.dist-info → weasyprint-67.0.dist-info}/WHEEL +0 -0
  66. {weasyprint-65.1.dist-info → weasyprint-67.0.dist-info}/entry_points.txt +0 -0
  67. {weasyprint-65.1.dist-info → weasyprint-67.0.dist-info}/licenses/LICENSE +0 -0
@@ -142,6 +142,8 @@ def element_to_box(element, style_for, get_image_from_uri, base_url,
142
142
  style['display'] = ('inline', 'flow')
143
143
 
144
144
  box = make_box(element.tag, style, [], element)
145
+ box.first_letter_style = style_for(element, 'first-letter')
146
+ box.first_line_style = style_for(element, 'first-line')
145
147
 
146
148
  if state is None:
147
149
  # use a list to have a shared mutable object
@@ -162,9 +164,6 @@ def element_to_box(element, style_for, get_image_from_uri, base_url,
162
164
  # names will be in this new list
163
165
  counter_scopes.append(set())
164
166
 
165
- box.first_letter_style = style_for(element, 'first-letter')
166
- box.first_line_style = style_for(element, 'first-line')
167
-
168
167
  marker_boxes = []
169
168
  if 'list-item' in style['display']:
170
169
  marker_boxes = list(marker_to_box(
@@ -195,9 +194,10 @@ def element_to_box(element, style_for, get_image_from_uri, base_url,
195
194
  footnote = child_boxes[0]
196
195
  footnote.style['float'] = 'none'
197
196
  footnotes.append(footnote)
198
- call_style = style_for(element, 'footnote-call')
197
+ call_style = style_for(footnote.element, 'footnote-call')
199
198
  footnote_call = make_box(
200
- f'{element.tag}::footnote-call', call_style, [], element)
199
+ f'{footnote.element.tag}::footnote-call', call_style, [],
200
+ footnote.element)
201
201
  footnote_call.children = content_to_boxes(
202
202
  call_style, footnote_call, quote_depth, counter_values,
203
203
  get_image_from_uri, target_collector, counter_style)
@@ -250,7 +250,7 @@ def element_to_box(element, style_for, get_image_from_uri, base_url,
250
250
  marker = make_box(
251
251
  f'{element.tag}::footnote-marker', marker_style, [], element)
252
252
  marker.children = content_to_boxes(
253
- marker_style, box, quote_depth, counter_values, get_image_from_uri,
253
+ marker_style, marker, quote_depth, counter_values, get_image_from_uri,
254
254
  target_collector, counter_style)
255
255
  box.children.insert(0, marker)
256
256
 
@@ -344,7 +344,6 @@ def marker_to_box(element, state, parent_style, style_for, get_image_from_uri,
344
344
  if not children and style['list_style_type'] != 'none':
345
345
  counter_value = counter_values.get('list-item', [0])[-1]
346
346
  counter_type = style['list_style_type']
347
- # TODO: rtl numbered list has the dot on the left
348
347
  if marker_text := counter_style.render_marker(counter_type, counter_value):
349
348
  box = boxes.TextBox.anonymous_from(box, marker_text)
350
349
  box.style['white_space'] = 'pre-wrap'
@@ -358,13 +357,7 @@ def marker_to_box(element, state, parent_style, style_for, get_image_from_uri,
358
357
  # We can safely edit everything that can't be changed by user style
359
358
  # See https://drafts.csswg.org/css-pseudo-4/#marker-pseudo
360
359
  marker_box.style['position'] = 'absolute'
361
- if parent_style['direction'] == 'ltr':
362
- translate_x = properties.Dimension(-100, '%')
363
- else:
364
- translate_x = properties.Dimension(100, '%')
365
- translate_y = properties.ZERO_PIXELS
366
- marker_box.style['transform'] = (
367
- ('translate', (translate_x, translate_y)),)
360
+ marker_box.is_outside_marker = True
368
361
  else:
369
362
  marker_box = boxes.InlineBox.anonymous_from(box, children)
370
363
  yield marker_box
@@ -421,7 +414,7 @@ def compute_content_list(content_list, parent_box, counter_values, css_token,
421
414
  elif type_ == 'url' and get_image_from_uri is not None:
422
415
  origin, uri = value
423
416
  if origin != 'external':
424
- # Embedding internal references is impossible
417
+ # Embedding internal references is impossible.
425
418
  continue
426
419
  image = get_image_from_uri(
427
420
  url=uri, orientation=parent_box.style['image_orientation'])
@@ -431,12 +424,12 @@ def compute_content_list(content_list, parent_box, counter_values, css_token,
431
424
  elif type_ == 'content()':
432
425
  added_text = extract_text(value, parent_box)
433
426
  # Simulate the step of white space processing
434
- # (normally done during the layout)
427
+ # (normally done during the layout).
435
428
  add_text(added_text.strip())
436
429
  elif type_ == 'string()':
437
430
  if not in_page_context:
438
- # string() is currently only valid in @page context
439
- # See https://github.com/Kozea/WeasyPrint/issues/723
431
+ # string() is currently only valid in @page context.
432
+ # See issue #723.
440
433
  LOGGER.warning(
441
434
  '"string(%s)" is only allowed in page margins',
442
435
  ' '.join(value))
@@ -810,9 +803,9 @@ def table_boxes_children(box, children):
810
803
  children = [
811
804
  child
812
805
  for prev_child, child, next_child in zip(
813
- [None] + children[:-1],
806
+ [None, *children[:-1]],
814
807
  children,
815
- children[1:] + [None]
808
+ [*children[1:], None]
816
809
  )
817
810
  if not (
818
811
  # Ignore some whitespace: rule 1.4
@@ -990,6 +983,24 @@ def wrap_table(box, children):
990
983
  return wrapper
991
984
 
992
985
 
986
+ def blockify(box, layout):
987
+ """Turn an inline box into a block box."""
988
+ # See https://drafts.csswg.org/css-display-4/#blockify.
989
+ if isinstance(box, boxes.InlineBlockBox):
990
+ anonymous = boxes.BlockBox.anonymous_from(box, box.children)
991
+ elif isinstance(box, boxes.InlineReplacedBox):
992
+ replacement = box.replacement
993
+ anonymous = boxes.BlockReplacedBox.anonymous_from(box, replacement)
994
+ elif isinstance(box, boxes.InlineLevelBox):
995
+ anonymous = boxes.BlockBox.anonymous_from(box, [box])
996
+ setattr(box, f'is_{layout}_item', False)
997
+ else:
998
+ return box
999
+ anonymous.style = box.style
1000
+ setattr(anonymous, f'is_{layout}_item', True)
1001
+ return anonymous
1002
+
1003
+
993
1004
  def flex_boxes(box):
994
1005
  """Remove and add boxes according to the flex model.
995
1006
 
@@ -1019,18 +1030,7 @@ def flex_children(box, children):
1019
1030
  # affected by the white-space property"
1020
1031
  # https://www.w3.org/TR/css-flexbox-1/#flex-items
1021
1032
  continue
1022
- if isinstance(child, boxes.InlineBlockBox):
1023
- anonymous = boxes.BlockBox.anonymous_from(child, child.children)
1024
- anonymous.style = child.style
1025
- anonymous.is_flex_item = True
1026
- flex_children.append(anonymous)
1027
- elif isinstance(child, boxes.InlineLevelBox):
1028
- anonymous = boxes.BlockBox.anonymous_from(child, [child])
1029
- anonymous.style = child.style
1030
- anonymous.is_flex_item = True
1031
- flex_children.append(anonymous)
1032
- else:
1033
- flex_children.append(child)
1033
+ flex_children.append(blockify(child, 'flex'))
1034
1034
  return flex_children
1035
1035
  else:
1036
1036
  return children
@@ -1064,19 +1064,7 @@ def grid_children(box, children):
1064
1064
  # affected by the white-space property"
1065
1065
  # https://drafts.csswg.org/css-grid-2/#grid-item
1066
1066
  continue
1067
- if isinstance(child, boxes.InlineBlockBox):
1068
- anonymous = boxes.BlockBox.anonymous_from(child, child.children)
1069
- anonymous.style = child.style
1070
- anonymous.is_grid_item = True
1071
- grid_children.append(anonymous)
1072
- elif isinstance(child, boxes.InlineLevelBox):
1073
- anonymous = boxes.BlockBox.anonymous_from(child, [child])
1074
- anonymous.style = child.style
1075
- child.is_grid_item = False
1076
- anonymous.is_grid_item = True
1077
- grid_children.append(anonymous)
1078
- else:
1079
- grid_children.append(child)
1067
+ grid_children.append(blockify(child, 'grid'))
1080
1068
  return grid_children
1081
1069
  else:
1082
1070
  return children
weasyprint/images.py CHANGED
@@ -6,15 +6,12 @@ import struct
6
6
  from hashlib import md5
7
7
  from io import BytesIO
8
8
  from itertools import cycle
9
- from math import inf
10
9
  from pathlib import Path
11
- from urllib.parse import urlparse
12
- from urllib.request import url2pathname
13
10
  from xml.etree import ElementTree
14
11
 
15
12
  import pydyf
16
13
  from PIL import Image, ImageFile, ImageOps
17
- from tinycss2.color4 import parse_color
14
+ from tinycss2.color5 import parse_color
18
15
 
19
16
  from . import DEFAULT_OPTIONS
20
17
  from .layout.percent import percentage
@@ -65,12 +62,12 @@ class RasterImage:
65
62
  self.mode = pillow_image.mode
66
63
  self.width = pillow_image.width
67
64
  self.height = pillow_image.height
68
- self.ratio = (self.width / self.height) if self.height != 0 else inf
65
+ self.ratio = (self.width / self.height) if self.height != 0 else math.inf
69
66
  self.optimize = optimize = options['optimize_images']
70
67
 
71
68
  # The presence of the APP14 segment indicates an Adobe image with
72
69
  # inverted CMYK data. Specify a Decode Array to invert it again back to
73
- # normal. See https://github.com/Kozea/WeasyPrint/pull/2179.
70
+ # normal. See PR #2179.
74
71
  app14 = getattr(original_pillow_image, 'app', {}).get('APP14')
75
72
  self.invert_colors = self.mode == 'CMYK' and app14 is not None
76
73
 
@@ -96,10 +93,11 @@ class RasterImage:
96
93
  def get_intrinsic_size(self, resolution, font_size):
97
94
  return self.width / resolution, self.height / resolution, self.ratio
98
95
 
99
- def draw(self, stream, concrete_width, concrete_height, image_rendering):
96
+ def draw(self, stream, concrete_width, concrete_height, style):
100
97
  if self.width <= 0 or self.height <= 0:
101
98
  return
102
99
 
100
+ image_rendering = style['image_rendering']
103
101
  interpolate = image_rendering == 'auto'
104
102
  ratio = 1
105
103
  if self._dpi:
@@ -258,7 +256,8 @@ class LazyLocalImage(pydyf.Object):
258
256
 
259
257
  class SVGImage:
260
258
  def __init__(self, tree, base_url, url_fetcher, context):
261
- self._svg = SVG(tree, base_url)
259
+ font_config = context.font_config if context else None
260
+ self._svg = SVG(tree, base_url, font_config)
262
261
  self._base_url = base_url
263
262
  self._url_fetcher = url_fetcher
264
263
  self._context = context
@@ -281,7 +280,7 @@ class SVGImage:
281
280
  ratio = 1
282
281
  return width, height, ratio
283
282
 
284
- def draw(self, stream, concrete_width, concrete_height, image_rendering):
283
+ def draw(self, stream, concrete_width, concrete_height, _style):
285
284
  try:
286
285
  self._svg.draw(
287
286
  stream, concrete_width, concrete_height, self._base_url,
@@ -299,11 +298,6 @@ def get_image_from_uri(cache, url_fetcher, options, url, forced_mime_type=None,
299
298
 
300
299
  try:
301
300
  with fetch(url_fetcher, url) as result:
302
- parsed_url = urlparse(result.get('redirected_url'))
303
- if parsed_url.scheme == 'file':
304
- filename = url2pathname(parsed_url.path)
305
- else:
306
- filename = None
307
301
  if 'string' in result:
308
302
  string = result['string']
309
303
  else:
@@ -337,9 +331,9 @@ def get_image_from_uri(cache, url_fetcher, options, url, forced_mime_type=None,
337
331
  else:
338
332
  # Store image id to enable cache in Stream.add_image
339
333
  image_id = md5(url.encode(), usedforsecurity=False).hexdigest()
334
+ path = result.get('path')
340
335
  image = RasterImage(
341
- pillow_image, image_id, string, filename, cache,
342
- orientation, options)
336
+ pillow_image, image_id, string, path, cache, orientation, options)
343
337
 
344
338
  except (URLFetchingError, ImageLoadingError) as exception:
345
339
  LOGGER.error('Failed to load image at %r: %s', url, exception)
@@ -374,8 +368,8 @@ def rotate_pillow_image(pillow_image, orientation):
374
368
  return pillow_image
375
369
 
376
370
 
377
- def process_color_stops(vector_length, positions):
378
- """Give color stops positions on the gradient vector.
371
+ def process_color_stops(vector_length, positions, hints, style):
372
+ """Give color stops positions and hints on the gradient vector.
379
373
 
380
374
  ``vector_length`` is the distance between the starting point and ending
381
375
  point of the vector gradient.
@@ -388,16 +382,17 @@ def process_color_stops(vector_length, positions):
388
382
  Return processed color stops, as a list of floats in px.
389
383
 
390
384
  """
391
- # Resolve percentages
392
- positions = [percentage(position, vector_length) for position in positions]
385
+ # Resolve percentages.
386
+ positions = [percentage(position, style, vector_length) for position in positions]
387
+ hints = [percentage(hint, style, vector_length) / vector_length for hint in hints]
393
388
 
394
- # First and last default to 100%
389
+ # First and last default to 100%.
395
390
  if positions[0] is None:
396
391
  positions[0] = 0
397
392
  if positions[-1] is None:
398
393
  positions[-1] = vector_length
399
394
 
400
- # Make sure positions are increasing
395
+ # Make sure positions are increasing.
401
396
  previous_pos = positions[0]
402
397
  for i, position in enumerate(positions):
403
398
  if position is not None:
@@ -406,7 +401,7 @@ def process_color_stops(vector_length, positions):
406
401
  else:
407
402
  previous_pos = position
408
403
 
409
- # Assign missing values
404
+ # Assign missing values.
410
405
  previous_i = -1
411
406
  for i, position in enumerate(positions):
412
407
  if position is not None:
@@ -416,7 +411,13 @@ def process_color_stops(vector_length, positions):
416
411
  positions[j] = base + j * increment
417
412
  previous_i = i
418
413
 
419
- return positions
414
+ # Calculate exponential value for PDF hints, avoid big numbers.
415
+ hints = [
416
+ 0 if hint <= 0 else
417
+ 2 ** 32 if hint >= 1 else
418
+ min(2 ** 32, math.log(0.5, hint)) for hint in hints]
419
+
420
+ return positions, hints
420
421
 
421
422
 
422
423
  def normalize_stop_positions(positions):
@@ -473,21 +474,23 @@ def gradient_average_color(colors, positions):
473
474
 
474
475
 
475
476
  class Gradient:
476
- def __init__(self, color_stops, repeating):
477
+ def __init__(self, color_stops, repeating, color_hints):
477
478
  assert color_stops
478
479
  # List of (r, g, b, a)
479
480
  self.colors = tuple(color for color, _ in color_stops)
480
481
  # List of Dimensions
481
482
  self.stop_positions = tuple(position for _, position in color_stops)
483
+ # List of Dimensions
484
+ self.color_hints = color_hints
482
485
  # Boolean
483
486
  self.repeating = repeating
484
487
 
485
488
  def get_intrinsic_size(self, image_resolution, font_size):
486
489
  return None, None, None
487
490
 
488
- def draw(self, stream, concrete_width, concrete_height, _image_rendering):
489
- scale_y, type_, points, positions, colors = self.layout(
490
- concrete_width, concrete_height)
491
+ def draw(self, stream, concrete_width, concrete_height, style):
492
+ scale_y, type_, points, positions, colors, color_hints = self.layout(
493
+ concrete_width, concrete_height, style)
491
494
 
492
495
  if type_ == 'solid':
493
496
  stream.rectangle(0, 0, concrete_width, concrete_height)
@@ -497,11 +500,11 @@ class Gradient:
497
500
 
498
501
  alphas = [color[3] for color in colors]
499
502
  alpha_couples = [
500
- (alphas[i], alphas[i + 1])
503
+ [alphas[i], alphas[i + 1], color_hints[i]]
501
504
  for i in range(len(alphas) - 1)]
502
505
  # TODO: handle other color spaces.
503
506
  color_couples = [
504
- [colors[i].to('srgb')[:3], colors[i + 1].to('srgb')[:3], 1]
507
+ [colors[i].to('srgb')[:3], colors[i + 1].to('srgb')[:3], color_hints[i]]
505
508
  for i in range(len(colors) - 1)]
506
509
 
507
510
  # Premultiply colors
@@ -511,7 +514,7 @@ class Gradient:
511
514
  color_couples[i - 1][1] = color_couples[i - 1][0]
512
515
  if i < len(colors) - 1:
513
516
  color_couples[i][0] = color_couples[i][1]
514
- for i, (a0, a1) in enumerate(alpha_couples):
517
+ for i, (a0, a1, hint) in enumerate(alpha_couples):
515
518
  if 0 not in (a0, a1) and (a0, a1) != (1, 1):
516
519
  color_couples[i][2] = a0 / a1
517
520
 
@@ -521,8 +524,8 @@ class Gradient:
521
524
  encode = (len(colors) - 1) * (0, 1)
522
525
  bounds = positions[1:-1]
523
526
  sub_functions = (
524
- stream.create_interpolation_function((0, 1), c0, c1, n)
525
- for c0, c1, n in color_couples)
527
+ stream.create_interpolation_function((0, 1), c0, c1, hint)
528
+ for c0, c1, hint in color_couples)
526
529
  function = stream.create_stitching_function(
527
530
  domain, encode, bounds, sub_functions)
528
531
  # TODO: handle other color spaces.
@@ -536,8 +539,8 @@ class Gradient:
536
539
 
537
540
  shading_type = 2 if type_ == 'linear' else 3
538
541
  sub_functions = (
539
- stream.create_interpolation_function((0, 1), (c0,), (c1,), 1)
540
- for c0, c1 in alpha_couples)
542
+ stream.create_interpolation_function((0, 1), (c0,), (c1,), hint)
543
+ for c0, c1, hint in alpha_couples)
541
544
  function = stream.create_stitching_function(
542
545
  domain, encode, bounds, sub_functions)
543
546
  alpha_shading = alpha_stream.add_shading(
@@ -547,10 +550,11 @@ class Gradient:
547
550
 
548
551
  stream.paint_shading(shading.id)
549
552
 
550
- def layout(self, width, height):
553
+ def layout(self, width, height, style):
551
554
  """Get layout information about the gradient.
552
555
 
553
556
  width, height: Gradient box. Top-left is at coordinates (0, 0).
557
+ style: box computed style.
554
558
 
555
559
  Returns (scale_y, type_, points, positions, colors).
556
560
 
@@ -572,15 +576,15 @@ class Gradient:
572
576
 
573
577
 
574
578
  class LinearGradient(Gradient):
575
- def __init__(self, color_stops, direction, repeating):
576
- Gradient.__init__(self, color_stops, repeating)
579
+ def __init__(self, color_stops, direction, repeating, color_hints):
580
+ super().__init__(color_stops, repeating, color_hints)
577
581
  # ('corner', keyword) or ('angle', radians)
578
582
  self.direction_type, self.direction = direction
579
583
 
580
- def layout(self, width, height):
584
+ def layout(self, width, height, style):
581
585
  # Only one color, render the gradient as a solid color
582
586
  if len(self.colors) == 1:
583
- return 1, 'solid', None, [], [self.colors[0]]
587
+ return 1, 'solid', None, [], [self.colors[0]], []
584
588
 
585
589
  # Define the (dx, dy) unit vector giving the direction of the gradient.
586
590
  # Positive dx: right, positive dy: down.
@@ -606,16 +610,19 @@ class LinearGradient(Gradient):
606
610
  # Normalize colors positions
607
611
  colors = list(self.colors)
608
612
  vector_length = abs(width * dx) + abs(height * dy)
609
- positions = process_color_stops(vector_length, self.stop_positions)
613
+ positions, hints = process_color_stops(
614
+ vector_length, self.stop_positions, self.color_hints, style)
610
615
  if not self.repeating:
611
616
  # Add explicit colors at boundaries if needed, because PDF doesn’t
612
617
  # extend color stops that are not displayed
613
618
  if positions[0] == positions[1]:
614
619
  positions.insert(0, positions[0] - 1)
615
620
  colors.insert(0, colors[0])
621
+ hints.insert(0, 1)
616
622
  if positions[-2] == positions[-1]:
617
623
  positions.append(positions[-1] + 1)
618
624
  colors.append(colors[-1])
625
+ hints.append(1)
619
626
  first, last, positions = normalize_stop_positions(positions)
620
627
 
621
628
  if self.repeating:
@@ -623,7 +630,7 @@ class LinearGradient(Gradient):
623
630
  # See https://drafts.csswg.org/css-images-3/#repeating-gradients
624
631
  if first == last:
625
632
  color = gradient_average_color(colors, positions)
626
- return 1, 'solid', None, [], [color]
633
+ return 1, 'solid', None, [], [color], []
627
634
 
628
635
  # Define defined gradient length and steps between positions
629
636
  stop_length = last - first
@@ -635,13 +642,16 @@ class LinearGradient(Gradient):
635
642
  # Create cycles used to add colors
636
643
  next_steps = cycle((0, *position_steps))
637
644
  next_colors = cycle(colors)
645
+ next_hints = cycle(hints)
638
646
  previous_steps = cycle((0, *position_steps[::-1]))
639
647
  previous_colors = cycle(colors[::-1])
648
+ previous_hints = cycle(hints[::-1])
640
649
 
641
650
  # Add colors after last step
642
651
  while last < vector_length:
643
652
  step = next(next_steps)
644
653
  colors.append(next(next_colors))
654
+ hints.append(next(next_hints))
645
655
  positions.append(positions[-1] + step)
646
656
  last += step * stop_length
647
657
 
@@ -649,6 +659,7 @@ class LinearGradient(Gradient):
649
659
  while first > 0:
650
660
  step = next(previous_steps)
651
661
  colors.insert(0, next(previous_colors))
662
+ hints.insert(0, next(previous_hints))
652
663
  positions.insert(0, positions[0] - step)
653
664
  first -= step * stop_length
654
665
 
@@ -659,12 +670,12 @@ class LinearGradient(Gradient):
659
670
  start_x + dx * first, start_y + dy * first,
660
671
  start_x + dx * last, start_y + dy * last)
661
672
 
662
- return 1, 'linear', points, positions, colors
673
+ return 1, 'linear', points, positions, colors, hints
663
674
 
664
675
 
665
676
  class RadialGradient(Gradient):
666
- def __init__(self, color_stops, shape, size, center, repeating):
667
- Gradient.__init__(self, color_stops, repeating)
677
+ def __init__(self, color_stops, shape, size, center, repeating, color_hints):
678
+ super().__init__(color_stops, repeating, color_hints)
668
679
  # Center of the ending shape. (origin_x, pos_x, origin_y, pos_y)
669
680
  self.center = center
670
681
  # Type of ending shape: 'circle' or 'ellipse'
@@ -676,15 +687,15 @@ class RadialGradient(Gradient):
676
687
  # size: (radius_x, radius_y)
677
688
  self.size_type, self.size = size
678
689
 
679
- def layout(self, width, height):
690
+ def layout(self, width, height, style):
680
691
  # Only one color, render the gradient as a solid color
681
692
  if len(self.colors) == 1:
682
- return 1, 'solid', None, [], [self.colors[0]]
693
+ return 1, 'solid', None, [], [self.colors[0]], []
683
694
 
684
695
  # Define the center of the gradient
685
696
  origin_x, center_x, origin_y, center_y = self.center
686
- center_x = percentage(center_x, width)
687
- center_y = percentage(center_y, height)
697
+ center_x = percentage(center_x, style, width)
698
+ center_y = percentage(center_y, style, height)
688
699
  if origin_x == 'right':
689
700
  center_x = width - center_x
690
701
  if origin_y == 'bottom':
@@ -692,21 +703,24 @@ class RadialGradient(Gradient):
692
703
 
693
704
  # Resolve sizes and vertical scale
694
705
  size_x, size_y = self._handle_degenerate(
695
- *self._resolve_size(width, height, center_x, center_y))
706
+ *self._resolve_size(width, height, center_x, center_y, style))
696
707
  scale_y = size_y / size_x
697
708
 
698
709
  # Normalize colors positions
699
710
  colors = list(self.colors)
700
- positions = process_color_stops(size_x, self.stop_positions)
711
+ positions, hints = process_color_stops(
712
+ size_x, self.stop_positions, self.color_hints, style)
701
713
  if not self.repeating:
702
714
  # Add explicit colors at boundaries if needed, because PDF doesn’t
703
715
  # extend color stops that are not displayed
704
716
  if positions[0] > 0 and positions[0] == positions[1]:
705
717
  positions.insert(0, 0)
706
718
  colors.insert(0, colors[0])
719
+ hints.insert(0, 1)
707
720
  if positions[-2] == positions[-1]:
708
721
  positions.append(positions[-1] + 1)
709
722
  colors.append(colors[-1])
723
+ hints.append(1)
710
724
  if positions[0] < 0:
711
725
  # PDF doesn’t like negative radiuses, shift into the positive realm
712
726
  if self.repeating:
@@ -718,7 +732,7 @@ class RadialGradient(Gradient):
718
732
  # Only keep colors with position >= 0, interpolate if needed
719
733
  if positions[-1] <= 0:
720
734
  # All stops are negative, fill with the last color
721
- return 1, 'solid', None, [], [self.colors[-1]]
735
+ return 1, 'solid', None, [], [self.colors[-1]], []
722
736
  for i, position in enumerate(positions):
723
737
  if position == 0:
724
738
  # Keep colors and positions from this rank
@@ -733,8 +747,8 @@ class RadialGradient(Gradient):
733
747
  intermediate_color = gradient_average_color(
734
748
  [previous_color, previous_color, color, color],
735
749
  [previous_position, 0, 0, position])
736
- colors = [intermediate_color] + colors[i:]
737
- positions = [0] + positions[i:]
750
+ colors = [intermediate_color, *colors[i:]]
751
+ positions = [0, *positions[i:]]
738
752
  break
739
753
  first, last, positions = normalize_stop_positions(positions)
740
754
 
@@ -742,7 +756,7 @@ class RadialGradient(Gradient):
742
756
  # See https://drafts.csswg.org/css-images-3/#repeating-gradients
743
757
  if first == last and self.repeating:
744
758
  color = gradient_average_color(colors, positions)
745
- return 1, 'solid', None, [], [color]
759
+ return 1, 'solid', None, [], [color], []
746
760
 
747
761
  # Define the coordinates of the gradient circles
748
762
  points = (
@@ -753,7 +767,7 @@ class RadialGradient(Gradient):
753
767
  points, positions, colors = self._repeat(
754
768
  width, height, scale_y, points, positions, colors)
755
769
 
756
- return scale_y, 'radial', points, positions, colors
770
+ return scale_y, 'radial', points, positions, colors, hints
757
771
 
758
772
  def _repeat(self, width, height, scale_y, points, positions, colors):
759
773
  # Keep original lists and values, they’re useful
@@ -775,7 +789,7 @@ class RadialGradient(Gradient):
775
789
  colors *= repeat
776
790
  positions = [
777
791
  i + position for i in range(repeat) for position in positions]
778
- points = points[:5] + (points[5] + gradient_length * repeat_after,)
792
+ points = (*points[:5], points[5] + gradient_length * repeat_after)
779
793
 
780
794
  if points[2] == 0:
781
795
  # Inner circle has 0 radius, no need to repeat inside, return
@@ -785,7 +799,7 @@ class RadialGradient(Gradient):
785
799
  repeat_before = points[2] / gradient_length
786
800
 
787
801
  # Set the inner circle size to 0
788
- points = points[:2] + (0,) + points[3:]
802
+ points = (*points[:2], 0, *points[3:])
789
803
 
790
804
  # Find how many times the whole gradient can be repeated
791
805
  full_repeat = int(repeat_before)
@@ -830,19 +844,19 @@ class RadialGradient(Gradient):
830
844
  average_positions = [position, ratio, ratio, next_position]
831
845
  zero_color = gradient_average_color(
832
846
  average_colors, average_positions)
833
- colors = [zero_color] + original_colors[-(i - 1):] + colors
847
+ colors = [zero_color, *original_colors[-(i - 1):], *colors]
834
848
  new_positions = [
835
849
  position - 1 - full_repeat for position
836
850
  in original_positions[-(i - 1):]]
837
851
  positions = (ratio - 1 - full_repeat, *new_positions, *positions)
838
852
  return points, positions, colors
839
853
 
840
- def _resolve_size(self, width, height, center_x, center_y):
854
+ def _resolve_size(self, width, height, center_x, center_y, style):
841
855
  """Resolve circle size of the radial gradient."""
842
856
  if self.size_type == 'explicit':
843
857
  size_x, size_y = self.size
844
- size_x = percentage(size_x, width)
845
- size_y = percentage(size_y, height)
858
+ size_x = percentage(size_x, style, width)
859
+ size_y = percentage(size_y, style, height)
846
860
  return size_x, size_y
847
861
  left = abs(center_x)
848
862
  right = abs(width - center_x)