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
@@ -1,48 +1,19 @@
1
- """Utils for CSS properties."""
1
+ """CSS tokens parsers."""
2
2
 
3
3
  import functools
4
- import math
5
4
  from abc import ABC, abstractmethod
6
- from urllib.parse import unquote, urljoin
5
+ from math import e, inf, nan, pi
7
6
 
8
- from tinycss2.color4 import parse_color
7
+ from tinycss2.ast import DimensionToken, IdentToken, NumberToken, PercentageToken
8
+ from tinycss2.color5 import parse_color
9
9
 
10
- from .. import LOGGER
11
- from ..urls import iri_to_uri, url_is_absolute
10
+ from ..logger import LOGGER
11
+ from ..urls import get_url_tuple
12
+ from . import functions
13
+ from .functions import check_math
12
14
  from .properties import Dimension
15
+ from .units import ANGLE_TO_RADIANS, LENGTH_UNITS, RESOLUTION_TO_DPPX
13
16
 
14
- # https://drafts.csswg.org/css-values-3/#angles
15
- # 1<unit> is this many radians.
16
- ANGLE_TO_RADIANS = {
17
- 'rad': 1,
18
- 'turn': 2 * math.pi,
19
- 'deg': math.pi / 180,
20
- 'grad': math.pi / 200,
21
- }
22
-
23
- # How many CSS pixels is one <unit>?
24
- # https://www.w3.org/TR/CSS21/syndata.html#length-units
25
- LENGTHS_TO_PIXELS = {
26
- 'px': 1,
27
- 'pt': 1. / 0.75,
28
- 'pc': 16., # LENGTHS_TO_PIXELS['pt'] * 12
29
- 'in': 96., # LENGTHS_TO_PIXELS['pt'] * 72
30
- 'cm': 96. / 2.54, # LENGTHS_TO_PIXELS['in'] / 2.54
31
- 'mm': 96. / 25.4, # LENGTHS_TO_PIXELS['in'] / 25.4
32
- 'q': 96. / 25.4 / 4, # LENGTHS_TO_PIXELS['mm'] / 4
33
- }
34
-
35
- # https://drafts.csswg.org/css-values/#resolution
36
- RESOLUTION_TO_DPPX = {
37
- 'dppx': 1,
38
- 'dpi': 1 / LENGTHS_TO_PIXELS['in'],
39
- 'dpcm': 1 / LENGTHS_TO_PIXELS['cm'],
40
- }
41
-
42
- # Sets of possible length units
43
- LENGTH_UNITS = set(LENGTHS_TO_PIXELS) | set(['ex', 'em', 'ch', 'rem'])
44
-
45
- # Constants about background positions
46
17
  ZERO_PERCENT = Dimension(0, '%')
47
18
  FIFTY_PERCENT = Dimension(50, '%')
48
19
  HUNDRED_PERCENT = Dimension(100, '%')
@@ -54,14 +25,13 @@ BACKGROUND_POSITION_PERCENTAGES = {
54
25
  'right': HUNDRED_PERCENT,
55
26
  }
56
27
 
57
- # Direction keywords used for gradients
58
28
  DIRECTION_KEYWORDS = {
59
- # ('angle', radians) 0 upwards, then clockwise
29
+ # ('angle', radians), 0 upwards, then clockwise.
60
30
  ('to', 'top'): ('angle', 0),
61
- ('to', 'right'): ('angle', math.pi / 2),
62
- ('to', 'bottom'): ('angle', math.pi),
63
- ('to', 'left'): ('angle', math.pi * 3 / 2),
64
- # ('corner', keyword)
31
+ ('to', 'right'): ('angle', pi / 2),
32
+ ('to', 'bottom'): ('angle', pi),
33
+ ('to', 'left'): ('angle', pi * 3 / 2),
34
+ # ('corner', keyword).
65
35
  ('to', 'top', 'left'): ('corner', 'top_left'),
66
36
  ('to', 'left', 'top'): ('corner', 'top_left'),
67
37
  ('to', 'top', 'right'): ('corner', 'top_right'),
@@ -72,29 +42,23 @@ DIRECTION_KEYWORDS = {
72
42
  ('to', 'right', 'bottom'): ('corner', 'bottom_right'),
73
43
  }
74
44
 
75
- # Default fallback values used in attr() functions
76
- ATTR_FALLBACKS = {
77
- 'string': ('string', ''),
78
- 'color': ('ident', 'currentcolor'),
79
- 'url': ('external', 'about:invalid'),
80
- 'integer': ('number', 0),
81
- 'number': ('number', 0),
82
- '%': ('number', 0),
83
- }
84
- for unit in LENGTH_UNITS:
85
- ATTR_FALLBACKS[unit] = ('length', Dimension('0', unit))
86
- for unit in ANGLE_TO_RADIANS:
87
- ATTR_FALLBACKS[unit] = ('angle', Dimension('0', unit))
45
+ E = NumberToken(0, 0, e, None, 'e')
46
+ PI = NumberToken(0, 0, pi, None, 'π')
47
+ PLUS_INFINITY = NumberToken(0, 0, inf, None, '')
48
+ MINUS_INFINITY = NumberToken(0, 0, -inf, None, '-∞')
49
+ NAN = NumberToken(0, 0, nan, None, 'NaN')
88
50
 
89
51
 
90
52
  class InvalidValues(ValueError): # noqa: N818
91
53
  """Invalid or unsupported values for a known CSS property."""
92
54
 
93
55
 
94
- class CenterKeywordFakeToken:
95
- type = 'ident'
96
- lower_value = 'center'
97
- unit = None
56
+ class PercentageInMath(ValueError): # noqa: N818
57
+ """Percentage in math function without reference length."""
58
+
59
+
60
+ class FontUnitInMath(ValueError): # noqa: N818
61
+ """Font-relative unit in math function without reference style."""
98
62
 
99
63
 
100
64
  class Pending(ABC):
@@ -118,136 +82,65 @@ class Pending(ABC):
118
82
  # properties and expanders.
119
83
  raise InvalidValues('no value')
120
84
  return self.validate(tokens, wanted_key)
121
- except InvalidValues as exc:
85
+ except InvalidValues as exception:
122
86
  if self._reported_error:
123
- raise exc
87
+ raise exception
124
88
  source_line = self.tokens[0].source_line
125
89
  source_column = self.tokens[0].source_column
126
90
  value = ' '.join(token.serialize() for token in tokens)
127
- message = (exc.args and exc.args[0]) or 'invalid value'
91
+ message = exception.args[0] if exception.args else 'invalid value'
128
92
  LOGGER.warning(
129
93
  'Ignored `%s: %s` at %d:%d, %s.',
130
94
  self.name, value, source_line, source_column, message)
131
95
  self._reported_error = True
132
- raise exc
133
-
134
-
135
- def split_on_comma(tokens):
136
- """Split a list of tokens on commas, ie ``LiteralToken(',')``.
137
-
138
- Only "top-level" comma tokens are splitting points, not commas inside a
139
- function or blocks.
140
-
141
- """
142
- parts = []
143
- this_part = []
144
- for token in tokens:
145
- if token.type == 'literal' and token.value == ',':
146
- parts.append(this_part)
147
- this_part = []
148
- else:
149
- this_part.append(token)
150
- parts.append(this_part)
151
- return tuple(parts)
152
-
153
-
154
- def split_on_optional_comma(tokens):
155
- """Split a list of tokens on optional commas, ie ``LiteralToken(',')``."""
156
- parts = []
157
- for split_part in split_on_comma(tokens):
158
- if not split_part:
159
- # Happens when there's a comma at the beginning, at the end, or
160
- # when two commas are next to each other.
161
- return
162
- for part in split_part:
163
- parts.append(part)
164
- return parts
165
-
166
-
167
- def remove_whitespace(tokens):
168
- """Remove any top-level whitespace and comments in a token list."""
169
- return tuple(
170
- token for token in tokens
171
- if token.type not in ('whitespace', 'comment'))
172
-
173
-
174
- def safe_urljoin(base_url, url):
175
- if url_is_absolute(url):
176
- return iri_to_uri(url)
177
- elif base_url:
178
- return iri_to_uri(urljoin(base_url, url))
179
- else:
180
- raise InvalidValues(
181
- f'Relative URI reference without a base URI: {url!r}')
182
-
183
-
184
- def comma_separated_list(function):
185
- """Decorator for validators that accept a comma separated list."""
186
- @functools.wraps(function)
187
- def wrapper(tokens, *args):
188
- results = []
189
- for part in split_on_comma(tokens):
190
- result = function(remove_whitespace(part), *args)
191
- if result is None:
192
- return None
193
- results.append(result)
194
- return tuple(results)
195
- wrapper.single_value = function
196
- return wrapper
197
-
198
-
199
- def get_keyword(token):
200
- """If ``token`` is a keyword, return its lowercase name.
201
-
202
- Otherwise return ``None``.
203
-
204
- """
205
- if token.type == 'ident':
206
- return token.lower_value
96
+ raise exception
207
97
 
208
98
 
209
- def get_custom_ident(token):
210
- """If ``token`` is a keyword, return its name.
211
-
212
- Otherwise return ``None``.
213
-
214
- """
215
- if token.type == 'ident':
216
- return token.value
99
+ def parse_color_hint(tokens):
100
+ if len(tokens) == 1:
101
+ return get_length(tokens[0], percentage=True)
217
102
 
218
103
 
219
- def get_single_keyword(tokens):
220
- """If ``values`` is a 1-element list of keywords, return its name.
104
+ def parse_color_stop(tokens):
105
+ if len(tokens) == 1:
106
+ color = parse_color(tokens[0])
107
+ if color == 'currentcolor':
108
+ # TODO: return the current color instead
109
+ return parse_color('black'), None
110
+ if color is not None:
111
+ return color, None
112
+ elif len(tokens) == 2:
113
+ color = parse_color(tokens[0])
114
+ position = get_length(tokens[1], negative=True, percentage=True)
115
+ if color is not None and position is not None:
116
+ return color, position
117
+ raise InvalidValues
221
118
 
222
- Otherwise return ``None``.
223
119
 
224
- """
225
- if len(tokens) == 1:
226
- token = tokens[0]
227
- if token.type == 'ident':
228
- return token.lower_value
120
+ def parse_color_stops_and_hints(color_stops_hints):
121
+ if not color_stops_hints:
122
+ raise InvalidValues
229
123
 
124
+ color_stops = [parse_color_stop(color_stops_hints[0])]
125
+ color_hints = []
126
+ previous_was_color_stop = True
230
127
 
231
- def single_keyword(function):
232
- """Decorator for validators that only accept a single keyword."""
233
- @functools.wraps(function)
234
- def keyword_validator(tokens):
235
- """Wrap a validator to call get_single_keyword on tokens."""
236
- keyword = get_single_keyword(tokens)
237
- if function(keyword):
238
- return keyword
239
- return keyword_validator
128
+ for tokens in color_stops_hints[1:]:
129
+ if hint := parse_color_hint(tokens):
130
+ color_hints.append(hint)
131
+ previous_was_color_stop = False
132
+ elif previous_was_color_stop:
133
+ color_hints.append(FIFTY_PERCENT)
134
+ color_stops.append(parse_color_stop(tokens))
135
+ previous_was_color_stop = True
136
+ else:
137
+ color_stops.append(parse_color_stop(tokens))
138
+ previous_was_color_stop = True
240
139
 
140
+ if not previous_was_color_stop:
141
+ raise InvalidValues
241
142
 
242
- def single_token(function):
243
- """Decorator for validators that only accept a single token."""
244
- @functools.wraps(function)
245
- def single_token_validator(tokens, *args):
246
- """Validate a property whose token is single."""
247
- if len(tokens) == 1:
248
- return function(tokens[0], *args)
249
- single_token_validator.__func__ = function
250
- return single_token_validator
143
+ return color_stops, color_hints
251
144
 
252
145
 
253
146
  def parse_linear_gradient_parameters(arguments):
@@ -260,13 +153,13 @@ def parse_linear_gradient_parameters(arguments):
260
153
  result = DIRECTION_KEYWORDS.get(tuple(map(get_keyword, first_arg)))
261
154
  if result is not None:
262
155
  return result, arguments[1:]
263
- return ('angle', math.pi), arguments # Default direction is 'to bottom'
156
+ return ('angle', pi), arguments # Default direction is 'to bottom'
264
157
 
265
158
 
266
159
  def parse_2d_position(tokens):
267
160
  """Common syntax of background-position and transform-origin."""
268
161
  if len(tokens) == 1:
269
- tokens = [tokens[0], CenterKeywordFakeToken]
162
+ tokens = [tokens[0], IdentToken(0, 0, 'center')]
270
163
  elif len(tokens) != 2:
271
164
  return None
272
165
 
@@ -383,166 +276,93 @@ def parse_radial_gradient_parameters(arguments):
383
276
  arguments[1:])
384
277
 
385
278
 
386
- def parse_color_stop(tokens):
387
- if len(tokens) == 1:
388
- color = parse_color(tokens[0])
389
- if color == 'currentcolor':
390
- # TODO: return the current color instead
391
- return parse_color('black'), None
392
- if color is not None:
393
- return color, None
394
- elif len(tokens) == 2:
395
- color = parse_color(tokens[0])
396
- position = get_length(tokens[1], negative=True, percentage=True)
397
- if color is not None and position is not None:
398
- return color, position
399
- raise InvalidValues
279
+ def split_on_comma(tokens):
280
+ """Split a list of tokens on commas, ie ``LiteralToken(',')``.
400
281
 
282
+ Only "top-level" comma tokens are splitting points, not commas inside a
283
+ function or blocks.
401
284
 
402
- def parse_function(function_token):
403
- """Parse functional notation.
285
+ """
286
+ parts = []
287
+ this_part = []
288
+ for token in tokens:
289
+ if token.type == 'literal' and token.value == ',':
290
+ parts.append(this_part)
291
+ this_part = []
292
+ else:
293
+ this_part.append(token)
294
+ parts.append(this_part)
295
+ return tuple(parts)
404
296
 
405
- Return ``(name, args)`` if the given token is a function with comma- or
406
- space-separated arguments. Return ``None`` otherwise.
407
297
 
408
- """
409
- if function_token.type != 'function':
410
- return
298
+ def remove_whitespace(tokens):
299
+ """Remove any top-level whitespace and comments in a token list."""
300
+ return tuple(
301
+ token for token in tokens
302
+ if token.type not in ('whitespace', 'comment'))
411
303
 
412
- content = list(remove_whitespace(function_token.arguments))
413
- arguments = []
414
- last_is_comma = False
415
- while content:
416
- token = content.pop(0)
417
- is_comma = token.type == 'literal' and token.value == ','
418
- if last_is_comma and is_comma:
419
- return
420
- if is_comma:
421
- last_is_comma = True
422
- else:
423
- last_is_comma = False
424
- if token.type == 'function':
425
- argument_function = parse_function(token)
426
- if argument_function is None:
427
- return
428
- arguments.append(token)
429
- if last_is_comma:
430
- return
431
- return function_token.lower_name, arguments
432
304
 
305
+ def get_keyword(token):
306
+ """If ``token`` is a keyword, return its lowercase name.
433
307
 
434
- def check_attr_function(token, allowed_type=None):
435
- function = parse_function(token)
436
- if function is None:
437
- return
438
- name, args = function
439
- if name == 'attr' and len(args) in (1, 2, 3):
440
- if args[0].type != 'ident':
441
- return
442
- attr_name = args[0].value
443
- if len(args) == 1:
444
- type_or_unit = 'string'
445
- fallback = ''
446
- else:
447
- if args[1].type != 'ident':
448
- return
449
- type_or_unit = args[1].value
450
- if type_or_unit not in ATTR_FALLBACKS:
451
- return
452
- if len(args) == 2:
453
- fallback = ATTR_FALLBACKS[type_or_unit]
454
- else:
455
- fallback_type = args[2].type
456
- if fallback_type == 'string':
457
- fallback = args[2].value
458
- else:
459
- # TODO: handle other fallback types
460
- return
461
- if allowed_type in (None, type_or_unit):
462
- return ('attr()', (attr_name, type_or_unit, fallback))
308
+ Otherwise return ``None``.
463
309
 
310
+ """
311
+ if token.type == 'ident':
312
+ return token.lower_value
464
313
 
465
- def check_counter_function(token, allowed_type=None):
466
- from .validation.properties import list_style_type
467
314
 
468
- function = parse_function(token)
469
- if function is None:
470
- return
471
- name, args = function
472
- arguments = []
473
- if (name == 'counter' and len(args) in (1, 2)) or (
474
- name == 'counters' and len(args) in (2, 3)):
475
- ident = args.pop(0)
476
- if ident.type != 'ident':
477
- return
478
- arguments.append(ident.value)
315
+ def get_custom_ident(token):
316
+ """If ``token`` is a keyword, return its name.
479
317
 
480
- if name == 'counters':
481
- string = args.pop(0)
482
- if string.type != 'string':
483
- return
484
- arguments.append(string.value)
318
+ Otherwise return ``None``.
485
319
 
486
- if args:
487
- counter_style = list_style_type((args.pop(0),))
488
- if counter_style is None:
489
- return
490
- arguments.append(counter_style)
491
- else:
492
- arguments.append('decimal')
320
+ """
321
+ if token.type == 'ident':
322
+ return token.value
493
323
 
494
- return (f'{name}()', tuple(arguments))
495
324
 
325
+ def get_single_keyword(tokens):
326
+ """If ``values`` is a 1-element list of keywords, return its name.
496
327
 
497
- def check_content_function(token):
498
- function = parse_function(token)
499
- if function is None:
500
- return
501
- name, args = function
502
- if name == 'content':
503
- if len(args) == 0:
504
- return ('content()', 'text')
505
- elif len(args) == 1:
506
- ident = args.pop(0)
507
- if ident.type == 'ident' and ident.lower_value in (
508
- 'text', 'before', 'after', 'first-letter', 'marker'):
509
- return ('content()', ident.lower_value)
510
-
511
-
512
- def check_string_or_element_function(string_or_element, token):
513
- function = parse_function(token)
514
- if function is None:
515
- return
516
- name, args = function
517
- if name == string_or_element and len(args) in (1, 2):
518
- custom_ident = args.pop(0)
519
- if custom_ident.type != 'ident':
520
- return
521
- custom_ident = custom_ident.value
328
+ Otherwise return ``None``.
522
329
 
523
- if args:
524
- ident = args.pop(0)
525
- if ident.type != 'ident' or ident.lower_value not in (
526
- 'first', 'start', 'last', 'first-except'):
527
- return
528
- ident = ident.lower_value
529
- else:
530
- ident = 'first'
330
+ """
331
+ if len(tokens) == 1:
332
+ token = tokens[0]
333
+ if token.type == 'ident':
334
+ return token.lower_value
531
335
 
532
- return (f'{string_or_element}()', (custom_ident, ident))
533
336
 
337
+ def get_number(token, negative=True, integer=False):
338
+ """Parse a <number> token."""
339
+ from . import resolve_math
534
340
 
535
- def check_var_function(token):
536
- if function := parse_function(token):
537
- name, args = function
538
- if name == 'var' and args:
539
- ident = args.pop(0)
540
- # TODO: we should check authorized tokens
541
- # https://drafts.csswg.org/css-syntax-3/#typedef-declaration-value
542
- return ident.type == 'ident' and ident.value.startswith('--')
543
- for arg in args:
544
- if check_var_function(arg):
545
- return True
341
+ if check_math(token):
342
+ try:
343
+ resolved = resolve_math(token)
344
+ except (PercentageInMath, FontUnitInMath):
345
+ return
346
+ else:
347
+ if resolved is None:
348
+ return
349
+ if resolved.type != 'number':
350
+ return
351
+ value = resolved.value
352
+ if not negative and value < 0:
353
+ value = 0
354
+ if integer:
355
+ # TODO: always round x.5 to +inf, see
356
+ # https://drafts.csswg.org/css-values-4/#combine-integers.
357
+ value = round(value)
358
+ return Dimension(value, None)
359
+ elif token.type == 'number':
360
+ if integer:
361
+ if token.int_value is not None:
362
+ if negative or token.int_value >= 0:
363
+ return Dimension(token.int_value, None)
364
+ elif negative or token.value >= 0:
365
+ return Dimension(token.value, None)
546
366
 
547
367
 
548
368
  def get_string(token):
@@ -551,39 +371,83 @@ def get_string(token):
551
371
  return ('string', token.value)
552
372
  if token.type == 'function':
553
373
  if token.name == 'attr':
554
- return check_attr_function(token, 'string')
374
+ return functions.check_attr(token, 'string')
555
375
  elif token.name in ('counter', 'counters'):
556
- return check_counter_function(token)
376
+ return functions.check_counter(token)
557
377
  elif token.name == 'content':
558
- return check_content_function(token)
378
+ return functions.check_content(token)
559
379
  elif token.name == 'string':
560
- return check_string_or_element_function('string', token)
380
+ return functions.check_string_or_element('string', token)
381
+
382
+
383
+ def get_percentage(token, negative=True):
384
+ """Parse a <percentage> token."""
385
+ from . import resolve_math
386
+
387
+ if check_math(token):
388
+ try:
389
+ token = resolve_math(token) or token
390
+ except (PercentageInMath, FontUnitInMath):
391
+ return
392
+ else:
393
+ # Range clamp.
394
+ if not negative:
395
+ token.value = max(0, token.value)
396
+ if token.type == 'percentage' and (negative or token.value >= 0):
397
+ return Dimension(token.value, '%')
561
398
 
562
399
 
563
400
  def get_length(token, negative=True, percentage=False):
564
401
  """Parse a <length> token."""
402
+ from . import resolve_math
403
+
404
+ if check_math(token):
405
+ try:
406
+ token = resolve_math(token) or token
407
+ except PercentageInMath:
408
+ # PercentageInMath is raised in priority to help discarding percentages for
409
+ # properties that don’t allow them.
410
+ return token if percentage else None
411
+ except FontUnitInMath:
412
+ return token
413
+ else:
414
+ # Range clamp.
415
+ if not negative and token.type not in ('function', 'number'):
416
+ token.value = max(0, token.value)
565
417
  if percentage and token.type == 'percentage':
566
418
  if negative or token.value >= 0:
567
419
  return Dimension(token.value, '%')
568
- if token.type == 'dimension' and token.unit in LENGTH_UNITS:
420
+ if token.type == 'dimension' and token.unit.lower() in LENGTH_UNITS:
569
421
  if negative or token.value >= 0:
570
- return Dimension(token.value, token.unit)
422
+ return Dimension(token.value, token.unit.lower())
571
423
  if token.type == 'number' and token.value == 0:
572
424
  return Dimension(0, None)
573
425
 
574
426
 
575
427
  def get_angle(token):
576
428
  """Parse an <angle> token in radians."""
429
+ from . import resolve_math
430
+
431
+ try:
432
+ token = resolve_math(token) or token
433
+ except (PercentageInMath, FontUnitInMath):
434
+ return
577
435
  if token.type == 'dimension':
578
- factor = ANGLE_TO_RADIANS.get(token.unit)
436
+ factor = ANGLE_TO_RADIANS.get(token.unit.lower())
579
437
  if factor is not None:
580
438
  return token.value * factor
581
439
 
582
440
 
583
441
  def get_resolution(token):
584
- """Parse a <resolution> token in ddpx."""
442
+ """Parse a <resolution> token in dppx."""
443
+ from . import resolve_math
444
+
445
+ try:
446
+ token = resolve_math(token) or token
447
+ except (PercentageInMath, FontUnitInMath):
448
+ return
585
449
  if token.type == 'dimension':
586
- factor = RESOLUTION_TO_DPPX.get(token.unit)
450
+ factor = RESOLUTION_TO_DPPX.get(token.unit.lower())
587
451
  if factor is not None:
588
452
  return token.value * factor
589
453
 
@@ -592,22 +456,21 @@ def get_image(token, base_url):
592
456
  """Parse an <image> token."""
593
457
  from ..images import LinearGradient, RadialGradient
594
458
 
595
- parsed_url = get_url(token, base_url)
596
- if parsed_url:
459
+ if parsed_url := get_url(token, base_url):
597
460
  assert parsed_url[0] == 'url'
598
461
  if parsed_url[1][0] == 'external':
599
462
  return 'url', parsed_url[1][1]
600
- if token.type != 'function':
463
+ function = functions.Function(token)
464
+ arguments = function.split_comma(single_tokens=False)
465
+ if not arguments:
601
466
  return
602
- arguments = split_on_comma(remove_whitespace(token.arguments))
603
- name = token.lower_name
604
- if name in ('linear-gradient', 'repeating-linear-gradient'):
467
+ repeating = function.name.startswith('repeating-')
468
+ if function.name in ('linear-gradient', 'repeating-linear-gradient'):
605
469
  direction, color_stops = parse_linear_gradient_parameters(arguments)
606
- if color_stops:
607
- return 'linear-gradient', LinearGradient(
608
- [parse_color_stop(stop) for stop in color_stops],
609
- direction, 'repeating' in name)
610
- elif name in ('radial-gradient', 'repeating-radial-gradient'):
470
+ color_stops, color_hints = parse_color_stops_and_hints(color_stops)
471
+ return 'linear-gradient', LinearGradient(
472
+ color_stops, direction, repeating, color_hints)
473
+ elif function.name in ('radial-gradient', 'repeating-radial-gradient'):
611
474
  result = parse_radial_gradient_parameters(arguments)
612
475
  if result is not None:
613
476
  shape, size, position, color_stops = result
@@ -616,30 +479,31 @@ def get_image(token, base_url):
616
479
  size = 'keyword', 'farthest-corner'
617
480
  position = 'left', FIFTY_PERCENT, 'top', FIFTY_PERCENT
618
481
  color_stops = arguments
619
- if color_stops:
620
- return 'radial-gradient', RadialGradient(
621
- [parse_color_stop(stop) for stop in color_stops],
622
- shape, size, position, 'repeating' in name)
623
-
624
-
625
- def _get_url_tuple(string, base_url):
626
- if string.startswith('#'):
627
- return ('url', ('internal', unquote(string[1:])))
628
- else:
629
- return ('url', ('external', safe_urljoin(base_url, string)))
482
+ color_stops, color_hints = parse_color_stops_and_hints(color_stops)
483
+ return 'radial-gradient', RadialGradient(
484
+ color_stops, shape, size, position, repeating, color_hints)
630
485
 
631
486
 
632
487
  def get_url(token, base_url):
633
488
  """Parse an <url> token."""
634
489
  if token.type == 'url':
635
- return _get_url_tuple(token.value, base_url)
490
+ url = get_url_tuple(token.value, base_url)
636
491
  elif token.type == 'function':
637
492
  if token.name == 'attr':
638
- return check_attr_function(token, 'url')
493
+ return functions.check_attr(token, 'url')
639
494
  elif token.name == 'url' and len(token.arguments) in (1, 2):
640
495
  # Ignore url modifiers
641
496
  # See https://drafts.csswg.org/css-values-3/#urls
642
- return _get_url_tuple(token.arguments[0].value, base_url)
497
+ url = get_url_tuple(token.arguments[0].value, base_url)
498
+ else:
499
+ return
500
+ else:
501
+ return
502
+
503
+ if url is None:
504
+ raise InvalidValues(f'Relative URI reference without a base URI: {url!r}')
505
+
506
+ return ('url', url)
643
507
 
644
508
 
645
509
  def get_quote(token):
@@ -653,29 +517,23 @@ def get_quote(token):
653
517
 
654
518
  def get_target(token, base_url):
655
519
  """Parse a <target> token."""
656
- function = parse_function(token)
657
- if function is None:
658
- return
659
- name, args = function
660
- args = split_on_optional_comma(args)
661
- if not args:
662
- return
663
-
664
- if name == 'target-counter':
665
- if len(args) not in (2, 3):
520
+ function = functions.Function(token)
521
+ arguments = function.split_comma()
522
+ if function.name == 'target-counter':
523
+ if len(arguments) not in (2, 3):
666
524
  return
667
- elif name == 'target-counters':
668
- if len(args) not in (3, 4):
525
+ elif function.name == 'target-counters':
526
+ if len(arguments) not in (3, 4):
669
527
  return
670
- elif name == 'target-text':
671
- if len(args) not in (1, 2):
528
+ elif function.name == 'target-text':
529
+ if len(arguments) not in (1, 2):
672
530
  return
673
531
  else:
674
532
  return
675
533
 
676
534
  values = []
677
535
 
678
- link = args.pop(0)
536
+ link = arguments.pop(0)
679
537
  string_link = get_string(link)
680
538
  if string_link is None:
681
539
  url = get_url(link, base_url)
@@ -685,54 +543,49 @@ def get_target(token, base_url):
685
543
  else:
686
544
  values.append(string_link)
687
545
 
688
- if name.startswith('target-counter'):
689
- if not args:
690
- return
691
-
692
- ident = args.pop(0)
546
+ if function.name.startswith('target-counter'):
547
+ ident = arguments.pop(0)
693
548
  if ident.type != 'ident':
694
549
  return
695
550
  values.append(ident.value)
696
551
 
697
- if name == 'target-counters':
698
- string = get_string(args.pop(0))
552
+ if function.name == 'target-counters':
553
+ string = get_string(arguments.pop(0))
699
554
  if string is None:
700
555
  return
701
556
  values.append(string)
702
557
 
703
- if args:
704
- counter_style = get_keyword(args.pop(0))
558
+ if arguments:
559
+ counter_style = get_keyword(arguments.pop(0))
705
560
  else:
706
561
  counter_style = 'decimal'
707
562
  values.append(counter_style)
708
563
  else:
709
- if args:
710
- content = get_keyword(args.pop(0))
564
+ if arguments:
565
+ content = get_keyword(arguments.pop(0))
711
566
  if content not in ('content', 'before', 'after', 'first-letter'):
712
567
  return
713
568
  else:
714
569
  content = 'content'
715
570
  values.append(content)
716
571
 
717
- return (f'{name}()', tuple(values))
572
+ return (f'{function.name}()', tuple(values))
718
573
 
719
574
 
720
575
  def get_content_list(tokens, base_url):
721
576
  """Parse <content-list> tokens."""
722
577
  # See https://www.w3.org/TR/css-content-3/#typedef-content-list
723
- parsed_tokens = [
724
- get_content_list_token(token, base_url) for token in tokens]
578
+ parsed_tokens = [get_content_list_token(token, base_url) for token in tokens]
725
579
  if None not in parsed_tokens:
726
580
  return parsed_tokens
727
581
 
728
582
 
729
583
  def get_content_list_token(token, base_url):
730
584
  """Parse one of the <content-list> tokens."""
731
- # See https://www.w3.org/TR/css-content-3/#typedef-content-list
585
+ # See https://drafts.csswg.org/css-content-3/#content-values.
732
586
 
733
587
  # <string>
734
- string = get_string(token)
735
- if string is not None:
588
+ if (string := get_string(token)) is not None:
736
589
  return string
737
590
 
738
591
  # contents
@@ -740,30 +593,25 @@ def get_content_list_token(token, base_url):
740
593
  return ('content()', 'text')
741
594
 
742
595
  # <uri>
743
- url = get_url(token, base_url)
744
- if url is not None:
596
+ if (url := get_url(token, base_url)) is not None:
745
597
  return url
746
598
 
747
599
  # <quote>
748
- quote = get_quote(token)
749
- if quote is not None:
600
+ if (quote := get_quote(token)) is not None:
750
601
  return ('quote', quote)
751
602
 
752
603
  # <target>
753
- target = get_target(token, base_url)
754
- if target is not None:
604
+ if (target := get_target(token, base_url)) is not None:
755
605
  return target
756
606
 
757
- function = parse_function(token)
758
- if function is None:
759
- return
760
- name, args = function
607
+ function = functions.Function(token)
608
+ arguments = function.split_comma()
761
609
 
762
610
  # <leader()>
763
- if name == 'leader':
764
- if len(args) != 1:
611
+ if function.name == 'leader':
612
+ if len(arguments) != 1:
765
613
  return
766
- arg, = args
614
+ arg, = arguments
767
615
  if arg.type == 'ident':
768
616
  if arg.value == 'dotted':
769
617
  string = '.'
@@ -778,5 +626,66 @@ def get_content_list_token(token, base_url):
778
626
  return ('leader()', ('string', string))
779
627
 
780
628
  # <element()>
781
- elif name == 'element':
782
- return check_string_or_element_function('element', token)
629
+ elif function.name == 'element':
630
+ return functions.check_string_or_element('element', token)
631
+
632
+
633
+ def single_keyword(function):
634
+ """Decorator for validators that only accept a single keyword."""
635
+ @functools.wraps(function)
636
+ def keyword_validator(tokens):
637
+ """Wrap a validator to call get_single_keyword on tokens."""
638
+ keyword = get_single_keyword(tokens)
639
+ if function(keyword):
640
+ return keyword
641
+ return keyword_validator
642
+
643
+
644
+ def single_token(function):
645
+ """Decorator for validators that only accept a single token."""
646
+ @functools.wraps(function)
647
+ def single_token_validator(tokens, *args):
648
+ """Validate a property whose token is single."""
649
+ if len(tokens) == 1:
650
+ return function(tokens[0], *args)
651
+ single_token_validator.__func__ = function
652
+ return single_token_validator
653
+
654
+
655
+ def comma_separated_list(function):
656
+ """Decorator for validators that accept a comma separated list."""
657
+ @functools.wraps(function)
658
+ def wrapper(tokens, *args):
659
+ results = []
660
+ for part in split_on_comma(tokens):
661
+ result = function(remove_whitespace(part), *args)
662
+ if result is None:
663
+ return None
664
+ results.append(result)
665
+ return tuple(results)
666
+ wrapper.single_value = function
667
+ return wrapper
668
+
669
+
670
+ def tokenize(item, function=None, unit=None):
671
+ """Transform a computed value result into a token."""
672
+ if isinstance(item, (DimensionToken, Dimension)):
673
+ value = function(item.value) if function else item.value
674
+ return DimensionToken(0, 0, value, None, str(value), item.unit.lower())
675
+ elif isinstance(item, PercentageToken):
676
+ value = function(item.value) if function else item.value
677
+ return PercentageToken(0, 0, value, None, str(value))
678
+ elif isinstance(item, (NumberToken, int, float)):
679
+ if isinstance(item, NumberToken):
680
+ value = item.value
681
+ else:
682
+ value = item
683
+ value = function(value) if function else value
684
+ int_value = round(value) if float(value).is_integer() else None
685
+ representation = str(int_value if float(value).is_integer() else value)
686
+ if unit is None:
687
+ return NumberToken(0, 0, value, int_value, representation)
688
+ elif unit == '%':
689
+ return PercentageToken(0, 0, value, int_value, representation)
690
+ else:
691
+ return DimensionToken(0, 0, value, int_value, representation, unit)