weasyprint 67.0__py3-none-any.whl → 68.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.
weasyprint/document.py CHANGED
@@ -18,7 +18,7 @@ from .layout import LayoutContext, layout_document
18
18
  from .logger import PROGRESS_LOGGER
19
19
  from .matrix import Matrix
20
20
  from .pdf import VARIANTS, generate_pdf
21
- from .pdf.metadata import generate_rdf_metadata
21
+ from .pdf.metadata import DocumentMetadata
22
22
  from .text.fonts import FontConfiguration
23
23
 
24
24
 
@@ -87,64 +87,6 @@ class Page:
87
87
  draw_page(self._page_box, stream)
88
88
 
89
89
 
90
- class DocumentMetadata:
91
- """Meta-information belonging to a whole :class:`Document`.
92
-
93
- New attributes may be added in future versions of WeasyPrint.
94
- """
95
- def __init__(self, title=None, authors=None, description=None, keywords=None,
96
- generator=None, created=None, modified=None, attachments=None,
97
- lang=None, custom=None, generate_rdf_metadata=generate_rdf_metadata):
98
- #: The title of the document, as a string or :obj:`None`.
99
- #: Extracted from the ``<title>`` element in HTML
100
- #: and written to the ``/Title`` info field in PDF.
101
- self.title = title
102
- #: The authors of the document, as a list of strings.
103
- #: (Defaults to the empty list.)
104
- #: Extracted from the ``<meta name=author>`` elements in HTML
105
- #: and written to the ``/Author`` info field in PDF.
106
- self.authors = authors or []
107
- #: The description of the document, as a string or :obj:`None`.
108
- #: Extracted from the ``<meta name=description>`` element in HTML
109
- #: and written to the ``/Subject`` info field in PDF.
110
- self.description = description
111
- #: Keywords associated with the document, as a list of strings.
112
- #: (Defaults to the empty list.)
113
- #: Extracted from ``<meta name=keywords>`` elements in HTML
114
- #: and written to the ``/Keywords`` info field in PDF.
115
- self.keywords = keywords or []
116
- #: The name of one of the software packages
117
- #: used to generate the document, as a string or :obj:`None`.
118
- #: Extracted from the ``<meta name=generator>`` element in HTML
119
- #: and written to the ``/Creator`` info field in PDF.
120
- self.generator = generator
121
- #: The creation date of the document, as a string or :obj:`None`.
122
- #: Dates are in one of the six formats specified in
123
- #: `W3C’s profile of ISO 8601 <https://www.w3.org/TR/NOTE-datetime>`_.
124
- #: Extracted from the ``<meta name=dcterms.created>`` element in HTML
125
- #: and written to the ``/CreationDate`` info field in PDF.
126
- self.created = created
127
- #: The modification date of the document, as a string or :obj:`None`.
128
- #: Dates are in one of the six formats specified in
129
- #: `W3C’s profile of ISO 8601 <https://www.w3.org/TR/NOTE-datetime>`_.
130
- #: Extracted from the ``<meta name=dcterms.modified>`` element in HTML
131
- #: and written to the ``/ModDate`` info field in PDF.
132
- self.modified = modified
133
- #: A list of :class:`attachments <weasyprint.Attachment>`, empty by default.
134
- #: Extracted from the ``<link rel=attachment>`` elements in HTML
135
- #: and written to the ``/EmbeddedFiles`` dictionary in PDF.
136
- self.attachments = attachments or []
137
- #: Document language as BCP 47 language tags.
138
- #: Extracted from ``<html lang=lang>`` in HTML.
139
- self.lang = lang
140
- #: Custom metadata, as a dict whose keys are the metadata names and
141
- #: values are the metadata values.
142
- self.custom = custom or {}
143
- #: Custom RDF metadata generator, which will replace the default generator.
144
- #: The function should return bytes containing an RDF XML.
145
- self.generate_rdf_metadata = generate_rdf_metadata
146
-
147
-
148
90
  class DiskCache:
149
91
  """Dict-like storing images content on disk.
150
92
 
@@ -197,8 +139,8 @@ class Document:
197
139
 
198
140
  Typically obtained from :meth:`HTML.render() <weasyprint.HTML.render>`, but
199
141
  can also be instantiated directly with a list of :class:`pages <Page>`, a
200
- set of :class:`metadata <DocumentMetadata>`, a :func:`url_fetcher
201
- <weasyprint.default_url_fetcher>` function, and a :class:`font_config
142
+ set of :class:`metadata <DocumentMetadata>`, a :class:`url_fetcher
143
+ <weasyprint.urls.URLFetcher>`, and a :class:`font_config
202
144
  <weasyprint.text.fonts.FontConfiguration>`.
203
145
 
204
146
  """
@@ -269,9 +211,7 @@ class Document:
269
211
  #: Contains information that does not belong to a specific page
270
212
  #: but to the whole document.
271
213
  self.metadata = metadata
272
- #: A function or other callable with the same signature as
273
- #: :func:`weasyprint.default_url_fetcher` called to fetch external
274
- #: resources such as stylesheets and images. (See :ref:`URL Fetchers`.)
214
+ #: A :class:`weasyprint.urls.URLFetcher` object (see :ref:`URL Fetchers`.)
275
215
  self.url_fetcher = url_fetcher
276
216
  #: A :obj:`dict` of fonts used by the document. Keys are hashes used to
277
217
  #: identify fonts, values are ``Font`` objects.
weasyprint/draw/text.py CHANGED
@@ -254,8 +254,10 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, matrix):
254
254
  tree.append(defs)
255
255
  ElementTree.SubElement(
256
256
  tree, 'use', attrib={'href': f'#glyph{glyph_id}'})
257
- image = SVGImage(tree, None, None, stream)
258
- a = d = logical_width / 1000 / font.upem * font_size
257
+ if 'viewBox' not in tree.attrib:
258
+ tree.attrib['viewBox'] = f'0 0 {font.upem} {font.upem}'
259
+ image = SVGImage(tree, None, None, None)
260
+ a = d = 1
259
261
  emojis.append([image, font, a, d, x_advance, 0])
260
262
  elif font.png:
261
263
  png_data = get_hb_object_data(font.hb_font, 'png', glyph_id)
@@ -815,7 +815,10 @@ class InlineFlexBox(FlexContainerBox, InlineLevelBox):
815
815
 
816
816
  class GridContainerBox(ParentBox):
817
817
  """A box that contains only grid-items."""
818
- advancements = None
818
+ def __init__(self, element_tag, style, element, children):
819
+ super().__init__(element_tag, style, element, children)
820
+ # TODO: we shouldn’t store this in the box but in the rendering context instead.
821
+ self.advancements = {}
819
822
 
820
823
 
821
824
  class GridBox(GridContainerBox, BlockLevelBox):
@@ -84,6 +84,8 @@ def build_formatting_structure(element_tree, style_for, get_image_from_uri,
84
84
  target_collector, counter_style, footnotes)
85
85
 
86
86
  target_collector.check_pending_targets()
87
+ process_whitespace(box)
88
+ process_text_transform(box)
87
89
 
88
90
  box.is_for_root_element = True
89
91
  # If this is changed, maybe update weasy.layout.page.make_margin_boxes()
@@ -152,9 +154,10 @@ def element_to_box(element, style_for, get_image_from_uri, base_url,
152
154
  [0], # quote_depth: single integer
153
155
  # TODO: define the footnote counter where it can be updated by page
154
156
  {'footnote': [0]}, # counter_values: name -> stacked/scoped values
155
- [{'footnote'}] # counter_scopes: element depths -> counter names
157
+ [{'footnote'}], # counter_scopes: element depths -> counter names
158
+ [] # page_groups
156
159
  )
157
- quote_depth, counter_values, counter_scopes = state
160
+ quote_depth, counter_values, counter_scopes, _page_groups = state
158
161
 
159
162
  update_counters(state, style)
160
163
 
@@ -224,10 +227,8 @@ def element_to_box(element, style_for, get_image_from_uri, base_url,
224
227
  counter_values.pop(name)
225
228
 
226
229
  box.children = children
227
- process_whitespace(box)
228
230
  set_content_lists(
229
231
  element, box, style, counter_values, target_collector, counter_style)
230
- process_text_transform(box)
231
232
 
232
233
  if marker_boxes and len(box.children) == 1:
233
234
  # See https://www.w3.org/TR/css-lists-3/#list-style-position-outside
@@ -278,7 +279,7 @@ def before_after_to_box(element, pseudo_type, state, style_for,
278
279
  return []
279
280
  box = make_box(f'{element.tag}::{pseudo_type}', style, [], element)
280
281
 
281
- quote_depth, counter_values, _counter_scopes = state
282
+ quote_depth, counter_values, _counter_scopes, _page_groups = state
282
283
  update_counters(state, style)
283
284
 
284
285
  children = []
@@ -297,7 +298,7 @@ def before_after_to_box(element, pseudo_type, state, style_for,
297
298
 
298
299
  # calculate the bookmark-label
299
300
  if style['bookmark_level'] != 'none':
300
- _quote_depth, counter_values, _counter_scopes = state
301
+ _quote_depth, counter_values, _counter_scopes, _page_groups = state
301
302
  compute_bookmark_label(
302
303
  element, box, style['bookmark_label'], counter_values,
303
304
  target_collector, counter_style)
@@ -318,7 +319,7 @@ def marker_to_box(element, state, parent_style, style_for, get_image_from_uri,
318
319
  # TODO: should be the computed value. When does the used value for
319
320
  # `display` differ from the computer value? It's at least wrong for
320
321
  # `content` where 'normal' computes as 'inhibit' for pseudo elements.
321
- quote_depth, counter_values, _counter_scopes = state
322
+ quote_depth, counter_values, _counter_scopes, _page_groups = state
322
323
 
323
324
  box = make_box(f'{element.tag}::marker', style, children, element)
324
325
 
@@ -423,9 +424,7 @@ def compute_content_list(content_list, parent_box, counter_values, css_token,
423
424
  boxes.InlineReplacedBox.anonymous_from(parent_box, image))
424
425
  elif type_ == 'content()':
425
426
  added_text = extract_text(value, parent_box)
426
- # Simulate the step of white space processing
427
- # (normally done during the layout).
428
- add_text(added_text.strip())
427
+ add_text(added_text)
429
428
  elif type_ == 'string()':
430
429
  if not in_page_context:
431
430
  # string() is currently only valid in @page context.
@@ -491,9 +490,7 @@ def compute_content_list(content_list, parent_box, counter_values, css_token,
491
490
  # TODO: 'before'- and 'after'- content referring missing
492
491
  # counters are not properly set.
493
492
  text = extract_text(text_style, target_box)
494
- # Simulate the step of white space processing
495
- # (normally done during the layout)
496
- add_text(text.strip())
493
+ add_text(text)
497
494
  else:
498
495
  break
499
496
  elif type_ == 'quote' and None not in (quote_depth, quote_style):
@@ -598,7 +595,6 @@ def compute_string_set(element, box, string_name, content_list,
598
595
  """Parse the content-list value of ``string_name`` for ``string-set``."""
599
596
  def parse_again(mixin_pagebased_counters=None):
600
597
  """Closure to parse the string-set string value all again."""
601
-
602
598
  # Neither alters the mixed-in nor the cached counter values, no
603
599
  # need to deepcopy here
604
600
  if mixin_pagebased_counters is None:
@@ -606,7 +602,6 @@ def compute_string_set(element, box, string_name, content_list,
606
602
  else:
607
603
  local_counters = mixin_pagebased_counters.copy()
608
604
  local_counters.update(box.cached_counter_values)
609
-
610
605
  compute_string_set(
611
606
  element, box, string_name, content_list, local_counters,
612
607
  target_collector, counter_style)
@@ -630,7 +625,7 @@ def compute_string_set(element, box, string_name, content_list,
630
625
  def compute_bookmark_label(element, box, content_list, counter_values,
631
626
  target_collector, counter_style):
632
627
  """Parses the content-list value for ``bookmark-label``."""
633
- def parse_again(mixin_pagebased_counters={}):
628
+ def parse_again(mixin_pagebased_counters=None):
634
629
  """Closure to parse the bookmark-label all again."""
635
630
  # Neither alters the mixed-in nor the cached counter values, no
636
631
  # need to deepcopy here
@@ -638,7 +633,6 @@ def compute_bookmark_label(element, box, content_list, counter_values,
638
633
  local_counters = {}
639
634
  else:
640
635
  local_counters = mixin_pagebased_counters.copy()
641
- local_counters = mixin_pagebased_counters.copy()
642
636
  local_counters.update(box.cached_counter_values)
643
637
  compute_bookmark_label(
644
638
  element, box, content_list, local_counters, target_collector,
@@ -662,7 +656,7 @@ def set_content_lists(element, box, style, counter_values, target_collector,
662
656
  """
663
657
  box.string_set = []
664
658
  if style['string_set'] != 'none':
665
- for i, (string_name, string_values) in enumerate(style['string_set']):
659
+ for string_name, string_values in style['string_set']:
666
660
  compute_string_set(
667
661
  element, box, string_name, string_values, counter_values,
668
662
  target_collector, counter_style)
@@ -674,7 +668,7 @@ def set_content_lists(element, box, style, counter_values, target_collector,
674
668
 
675
669
  def update_counters(state, style):
676
670
  """Handle the ``counter-*`` properties."""
677
- _quote_depth, counter_values, counter_scopes = state
671
+ _quote_depth, counter_values, counter_scopes, _page_groups = state
678
672
  sibling_scopes = counter_scopes[-1]
679
673
 
680
674
  for name, value in style['counter_reset']:
@@ -1113,45 +1107,120 @@ def process_whitespace(box, following_collapsible_space=False):
1113
1107
 
1114
1108
  else:
1115
1109
  for child in box.children:
1110
+ child_collapsible_space = process_whitespace(
1111
+ child, following_collapsible_space)
1116
1112
  if isinstance(child, (boxes.TextBox, boxes.InlineBox)):
1117
- child_collapsible_space = process_whitespace(
1118
- child, following_collapsible_space)
1119
- if box.is_in_normal_flow() and child.is_in_normal_flow():
1120
- following_collapsible_space = child_collapsible_space
1113
+ following_collapsible_space = child_collapsible_space
1121
1114
  elif child.is_in_normal_flow():
1122
1115
  following_collapsible_space = False
1123
1116
 
1124
- return following_collapsible_space and not box.is_running()
1117
+ return following_collapsible_space
1125
1118
 
1126
1119
 
1127
1120
  def process_text_transform(box):
1121
+ # Rules defined in
1122
+ # https://www.unicode.org/versions/latest/core-spec/chapter-3/#G33992
1123
+ # https://www.unicode.org/Public/UCD/latest/ucd/SpecialCasing.txt
1124
+ # https://w3c.github.io/i18n-tests/results/text-transform
1125
+ # Common transformations should be handled by common algorithm in Python, special
1126
+ # casing and tailoring shoud be done here when it depends on the language and not on
1127
+ # only on the glyphs.
1128
1128
  if isinstance(box, boxes.TextBox):
1129
1129
  text_transform = box.style['text_transform']
1130
+ lang_code = (box.style['lang'] or '').split('-')[0].lower()
1130
1131
  if text_transform != 'none':
1131
1132
  box.text = {
1132
- 'uppercase': lambda text: text.upper(),
1133
- 'lowercase': lambda text: text.lower(),
1133
+ 'uppercase': uppercase,
1134
+ 'lowercase': lowercase,
1134
1135
  'capitalize': capitalize,
1135
- 'full-width': lambda text: text.translate(ASCII_TO_WIDE),
1136
- }[text_transform](box.text)
1136
+ 'full-width': lambda text, lang_code: text.translate(ASCII_TO_WIDE),
1137
+ }[text_transform](box.text, lang_code)
1137
1138
  if box.style['hyphens'] == 'none':
1138
1139
  box.text = box.text.replace('\u00AD', '') # U+00AD is soft hyphen
1139
1140
 
1140
1141
  elif not box.is_running():
1141
1142
  for child in box.children:
1142
- if isinstance(child, (boxes.TextBox, boxes.InlineBox)):
1143
- process_text_transform(child)
1144
-
1145
-
1146
- def capitalize(text):
1143
+ process_text_transform(child)
1144
+
1145
+ def uppercase(text, lang_code):
1146
+ mapper = {}
1147
+
1148
+ if lang_code == 'el':
1149
+ # https://w3c.github.io/i18n-tests/css-text/text-transform/
1150
+ # text-transform-tailoring-003.html
1151
+ # https://en.wikiversity.org/wiki/Greek_Language/Diphthongs
1152
+ mapper = {
1153
+ 'άι': 'ΑΪ',
1154
+ 'άυ': 'ΑΫ',
1155
+ 'όι': 'ΟΪ',
1156
+ 'όυ': 'ΟΫ',
1157
+ 'έυ': 'ΗΫ',
1158
+ }
1159
+ elif lang_code in ('tr', 'az'):
1160
+ # https://github.com/unicode-org/cldr/blob/main/common/transforms/tr-Upper.xml
1161
+ mapper = {
1162
+ 'i': 'İ',
1163
+ }
1164
+
1165
+ for key, value in mapper.items():
1166
+ text = text.replace(key, value)
1167
+
1168
+ if lang_code == 'el':
1169
+ # Remove diacritics in Greek.
1170
+ # https://github.com/unicode-org/cldr/blob/main/common/transforms/el-Upper.xml
1171
+ # TODO: we should keep tonos on disjunctive eta.
1172
+ # https://w3c.github.io/i18n-tests/css-text/text-transform/
1173
+ # text-transform-tailoring-005.html
1174
+ text = unicodedata.normalize('NFD', text)
1175
+ for char in '\u0313\u0314\u0301\u0300\u0306\u0342\u0304\u0345':
1176
+ text = text.replace(char, '')
1177
+ text = unicodedata.normalize('NFC', text)
1178
+
1179
+ return text.upper()
1180
+
1181
+
1182
+ def lowercase(text, lang_code):
1183
+ mapper = {}
1184
+
1185
+ if lang_code in ('tr', 'az'):
1186
+ # https://github.com/unicode-org/cldr/blob/main/common/transforms/tr-Lower.xml
1187
+ mapper = {
1188
+ 'I': 'ı',
1189
+ 'İ': 'i',
1190
+ }
1191
+ elif lang_code == 'lt':
1192
+ # https://github.com/unicode-org/cldr/blob/main/common/transforms/lt-Lower.xml
1193
+ mapper = {
1194
+ 'Ì': 'i̇̀',
1195
+ 'Í': 'i̇́',
1196
+ 'Ĩ': 'i̇̃',
1197
+ }
1198
+
1199
+ for key, value in mapper.items():
1200
+ text = text.replace(key, value)
1201
+
1202
+ return text.lower()
1203
+
1204
+
1205
+ def capitalize(text, lang_code):
1147
1206
  """Capitalize words according to CSS’s "text-transform: capitalize"."""
1148
1207
  letter_found = False
1208
+ skip_next_letter = False
1149
1209
  output = ''
1150
- for letter in text:
1210
+ for i, letter in enumerate(text):
1211
+ if skip_next_letter:
1212
+ skip_next_letter = False
1213
+ continue
1151
1214
  category = unicodedata.category(letter)[0]
1152
1215
  if not letter_found and category in ('L', 'N'):
1153
1216
  letter_found = True
1154
- letter = letter.upper()
1217
+ if lang_code == 'nl' and text[i:i+2] == 'ij':
1218
+ skip_next_letter = True
1219
+ letter = 'IJ'
1220
+ elif lang_code in ('tr', 'az'):
1221
+ letter = uppercase(letter, lang_code)
1222
+ else:
1223
+ letter = letter.upper()
1155
1224
  elif category == 'Z':
1156
1225
  letter_found = False
1157
1226
  output += letter
@@ -1448,15 +1517,20 @@ def set_viewport_overflow(root_box):
1448
1517
 
1449
1518
 
1450
1519
  def box_text(box):
1520
+ # Stripping may not be the "right" way, but it seems to be what users usually want
1521
+ # in this case. The specification asks for the "text content", probably as defined
1522
+ # in DOM.
1523
+ box = box.deepcopy()
1524
+ process_whitespace(box)
1451
1525
  if isinstance(box, boxes.TextBox):
1452
- return box.text
1526
+ return box.text.strip()
1453
1527
  elif isinstance(box, boxes.ParentBox):
1454
1528
  return ''.join(
1455
1529
  child.text for child in box.descendants()
1456
1530
  if not child.element_tag.endswith('::before') and
1457
1531
  not child.element_tag.endswith('::after') and
1458
1532
  not child.element_tag.endswith('::marker') and
1459
- isinstance(child, boxes.TextBox))
1533
+ isinstance(child, boxes.TextBox)).strip()
1460
1534
  return ''
1461
1535
 
1462
1536
 
weasyprint/images.py CHANGED
@@ -30,12 +30,6 @@ class ImageLoadingError(ValueError):
30
30
 
31
31
  """
32
32
 
33
- @classmethod
34
- def from_exception(cls, exception):
35
- name = type(exception).__name__
36
- value = str(exception)
37
- return cls(f'{name}: {value}' if value else name)
38
-
39
33
 
40
34
  class RasterImage:
41
35
  def __init__(self, pillow_image, image_id, image_data, filename=None,
@@ -257,7 +251,7 @@ class LazyLocalImage(pydyf.Object):
257
251
  class SVGImage:
258
252
  def __init__(self, tree, base_url, url_fetcher, context):
259
253
  font_config = context.font_config if context else None
260
- self._svg = SVG(tree, base_url, font_config)
254
+ self._svg = SVG(tree, base_url, font_config, url_fetcher)
261
255
  self._base_url = base_url
262
256
  self._url_fetcher = url_fetcher
263
257
  self._context = context
@@ -284,7 +278,7 @@ class SVGImage:
284
278
  try:
285
279
  self._svg.draw(
286
280
  stream, concrete_width, concrete_height, self._base_url,
287
- self._url_fetcher, self._context)
281
+ self._context)
288
282
  except BaseException as exception:
289
283
  LOGGER.error('Failed to render SVG image %s', self._base_url)
290
284
  LOGGER.debug('Error while rendering SVG image:', exc_info=exception)
@@ -297,43 +291,40 @@ def get_image_from_uri(cache, url_fetcher, options, url, forced_mime_type=None,
297
291
  return cache[url]
298
292
 
299
293
  try:
300
- with fetch(url_fetcher, url) as result:
301
- if 'string' in result:
302
- string = result['string']
303
- else:
304
- string = result['file_obj'].read()
305
- mime_type = forced_mime_type or result['mime_type']
294
+ with fetch(url_fetcher, url) as response:
295
+ bytestring = response.read()
296
+ mime_type = forced_mime_type or response.content_type
306
297
 
307
298
  image = None
308
299
  svg_exceptions = []
309
300
  # Try to rely on given mimetype for SVG
310
301
  if mime_type == 'image/svg+xml':
311
302
  try:
312
- tree = ElementTree.fromstring(string)
303
+ tree = ElementTree.fromstring(bytestring)
313
304
  image = SVGImage(tree, url, url_fetcher, context)
314
305
  except Exception as svg_exception:
315
306
  svg_exceptions.append(svg_exception)
316
307
  # Try pillow for raster images, or for failing SVG
317
308
  if image is None:
318
309
  try:
319
- pillow_image = Image.open(BytesIO(string))
310
+ pillow_image = Image.open(BytesIO(bytestring))
320
311
  except Exception as raster_exception:
321
312
  if mime_type == 'image/svg+xml':
322
313
  # Tried SVGImage then Pillow for a SVG, abort
323
- raise ImageLoadingError.from_exception(svg_exceptions[0])
314
+ raise ImageLoadingError from svg_exceptions[0]
324
315
  try:
325
316
  # Last chance, try SVG
326
- tree = ElementTree.fromstring(string)
317
+ tree = ElementTree.fromstring(bytestring)
327
318
  image = SVGImage(tree, url, url_fetcher, context)
328
319
  except Exception:
329
320
  # Tried Pillow then SVGImage for a raster, abort
330
- raise ImageLoadingError.from_exception(raster_exception)
321
+ raise ImageLoadingError from raster_exception
331
322
  else:
332
323
  # Store image id to enable cache in Stream.add_image
333
324
  image_id = md5(url.encode(), usedforsecurity=False).hexdigest()
334
- path = result.get('path')
335
325
  image = RasterImage(
336
- pillow_image, image_id, string, path, cache, orientation, options)
326
+ pillow_image, image_id, bytestring, response.path, cache,
327
+ orientation, options)
337
328
 
338
329
  except (URLFetchingError, ImageLoadingError) as exception:
339
330
  LOGGER.error('Failed to load image at %r: %s', url, exception)
@@ -655,7 +646,7 @@ class LinearGradient(Gradient):
655
646
  positions.append(positions[-1] + step)
656
647
  last += step * stop_length
657
648
 
658
- # Add colors before last step
649
+ # Add colors before first step
659
650
  while first > 0:
660
651
  step = next(previous_steps)
661
652
  colors.insert(0, next(previous_colors))
@@ -764,14 +755,15 @@ class RadialGradient(Gradient):
764
755
  center_x, center_y / scale_y, last)
765
756
 
766
757
  if self.repeating:
767
- points, positions, colors = self._repeat(
768
- width, height, scale_y, points, positions, colors)
758
+ points, positions, colors, hints = self._repeat(
759
+ width, height, scale_y, points, positions, colors, hints)
769
760
 
770
761
  return scale_y, 'radial', points, positions, colors, hints
771
762
 
772
- def _repeat(self, width, height, scale_y, points, positions, colors):
763
+ def _repeat(self, width, height, scale_y, points, positions, colors, hints):
773
764
  # Keep original lists and values, they’re useful
774
765
  original_colors = colors.copy()
766
+ original_hints = hints.copy()
775
767
  original_positions = positions.copy()
776
768
  gradient_length = points[5] - points[2]
777
769
 
@@ -787,13 +779,14 @@ class RadialGradient(Gradient):
787
779
  # Repeat colors and extrapolate positions
788
780
  repeat = 1 + repeat_after
789
781
  colors *= repeat
782
+ hints = ([*hints, 1] * repeat)[:-1]
790
783
  positions = [
791
784
  i + position for i in range(repeat) for position in positions]
792
785
  points = (*points[:5], points[5] + gradient_length * repeat_after)
793
786
 
794
787
  if points[2] == 0:
795
788
  # Inner circle has 0 radius, no need to repeat inside, return
796
- return points, positions, colors
789
+ return points, positions, colors, hints
797
790
 
798
791
  # Find how many times we have to repeat the colors inside
799
792
  repeat_before = points[2] / gradient_length
@@ -806,6 +799,7 @@ class RadialGradient(Gradient):
806
799
  if full_repeat:
807
800
  # Repeat colors and extrapolate positions
808
801
  colors += original_colors * full_repeat
802
+ hints += [1, *original_hints] * full_repeat
809
803
  positions = [
810
804
  i - full_repeat + position for i in range(full_repeat)
811
805
  for position in original_positions] + positions
@@ -814,7 +808,7 @@ class RadialGradient(Gradient):
814
808
  partial_repeat = repeat_before - full_repeat
815
809
  if partial_repeat == 0:
816
810
  # No partial repeat, return
817
- return points, positions, colors
811
+ return points, positions, colors, hints
818
812
 
819
813
  # Iterate through positions in reverse order, from the outer
820
814
  # circle to the original inner circle, to find positions from
@@ -828,11 +822,12 @@ class RadialGradient(Gradient):
828
822
  # The center is a color of the gradient, truncate original
829
823
  # colors and positions and prepend them
830
824
  colors = original_colors[-i:] + colors
825
+ hints = [*original_hints[-i:], 1, *hints]
831
826
  new_positions = [
832
827
  position - full_repeat - 1
833
828
  for position in original_positions[-i:]]
834
829
  positions = new_positions + positions
835
- return points, positions, colors
830
+ return points, positions, colors, hints
836
831
  if position < ratio:
837
832
  # The center is between two colors of the gradient,
838
833
  # define the center color as the average of these two
@@ -842,14 +837,14 @@ class RadialGradient(Gradient):
842
837
  next_position = original_positions[-(i - 1)]
843
838
  average_colors = [color, color, next_color, next_color]
844
839
  average_positions = [position, ratio, ratio, next_position]
845
- zero_color = gradient_average_color(
846
- average_colors, average_positions)
847
- colors = [zero_color, *original_colors[-(i - 1):], *colors]
840
+ zero_color = gradient_average_color(average_colors, average_positions)
841
+ colors = [zero_color, *original_colors[-(i-1):], *colors]
842
+ hints = [1, *original_hints[-(i-1):], 1, *hints]
848
843
  new_positions = [
849
844
  position - 1 - full_repeat for position
850
845
  in original_positions[-(i - 1):]]
851
846
  positions = (ratio - 1 - full_repeat, *new_positions, *positions)
852
- return points, positions, colors
847
+ return points, positions, colors, hints
853
848
 
854
849
  def _resolve_size(self, width, height, center_x, center_y, style):
855
850
  """Resolve circle size of the radial gradient."""
@@ -60,7 +60,8 @@ def initialize_page_maker(context, root_box):
60
60
  # Shared mutable objects:
61
61
  [0], # quote_depth: single integer
62
62
  {'pages': [0]},
63
- [{'pages'}] # counter_scopes
63
+ [{'pages'}], # counter_scopes
64
+ [] # page_groups
64
65
  )
65
66
 
66
67
  # Initial values