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
@@ -12,6 +12,9 @@ import sys
12
12
  from functools import cache
13
13
  from math import inf
14
14
 
15
+ from ..css import resolve_math
16
+ from ..css.functions import check_math
17
+ from ..css.validation import validate_non_shorthand
15
18
  from ..formatting_structure import boxes
16
19
  from ..text.line_break import can_break_text, split_first_line
17
20
  from .replaced import default_image_sizing
@@ -90,7 +93,7 @@ def max_content_width(context, box, outer=True):
90
93
  def _block_content_width(context, box, function, outer):
91
94
  """Helper to create ``block_*_content_width.``"""
92
95
  width = box.style['width']
93
- if width == 'auto' or width.unit == '%':
96
+ if width == 'auto' or check_math(width) or width.unit == '%':
94
97
  # "percentages on the following properties are treated instead as
95
98
  # though they were the following: width: auto"
96
99
  # https://dbaron.org/css/intrinsic/#outer-intrinsic
@@ -99,7 +102,7 @@ def _block_content_width(context, box, function, outer):
99
102
  if not child.is_absolutely_positioned()]
100
103
  width = max(children_widths) if children_widths else 0
101
104
  else:
102
- assert width.unit == 'px'
105
+ assert width.unit.lower() == 'px'
103
106
  width = width.value
104
107
 
105
108
  return adjust(box, outer, width)
@@ -109,11 +112,13 @@ def min_max(box, width):
109
112
  """Get box width from given width and box min- and max-widths."""
110
113
  min_width = box.style['min_width']
111
114
  max_width = box.style['max_width']
112
- if min_width == 'auto' or min_width.unit == '%':
115
+ min_pending = check_math(min_width)
116
+ max_pending = check_math(max_width)
117
+ if min_width == 'auto' or min_pending or min_width.unit == '%':
113
118
  min_width = 0
114
119
  else:
115
120
  min_width = min_width.value
116
- if max_width == 'auto' or max_width.unit == '%':
121
+ if max_width == 'auto' or max_pending or max_width.unit == '%':
117
122
  max_width = inf
118
123
  else:
119
124
  max_width = max_width.value
@@ -123,10 +128,12 @@ def min_max(box, width):
123
128
  1, box.style['font_size'])
124
129
  if ratio is not None:
125
130
  min_height = box.style['min_height']
126
- if min_height != 'auto' and min_height.unit != '%':
127
- min_width = max(min_width, min_height.value * ratio)
128
131
  max_height = box.style['max_height']
129
- if max_height != 'auto' and max_height.unit != '%':
132
+ min_pending = check_math(min_height)
133
+ max_pending = check_math(max_height)
134
+ if min_height != 'auto' and not min_pending and min_height.unit != '%':
135
+ min_width = max(min_width, min_height.value * ratio)
136
+ if max_height != 'auto' and not min_pending and max_height.unit != '%':
130
137
  max_width = min(max_width, max_height.value * ratio)
131
138
 
132
139
  return max(min_width, min(width, max_width))
@@ -146,7 +153,7 @@ def margin_width(box, width, left=True, right=True):
146
153
  ):
147
154
  style_value = box.style[value]
148
155
  if style_value != 'auto':
149
- if style_value.unit == 'px':
156
+ if style_value.unit.lower() == 'px':
150
157
  width += style_value.value
151
158
  else:
152
159
  assert style_value.unit == '%'
@@ -236,7 +243,7 @@ def column_group_content_width(context, box):
236
243
  if width == 'auto' or width.unit == '%':
237
244
  width = 0
238
245
  else:
239
- assert width.unit == 'px'
246
+ assert width.unit.lower() == 'px'
240
247
  width = width.value
241
248
 
242
249
  return adjust(box, False, width)
@@ -270,37 +277,51 @@ def table_cell_min_max_content_width(context, box, outer=True):
270
277
 
271
278
  def inline_line_widths(context, box, outer, is_line_start, minimum, skip_stack=None,
272
279
  first_line=False):
273
- if isinstance(box, boxes.LineBox) and box.style['text_indent'].unit != '%':
274
- text_indent = box.style['text_indent'].value
275
- else:
276
- text_indent = 0
277
-
280
+ """Yield line width for each line."""
281
+
282
+ # Set text indent.
283
+ text_indent = 0
284
+ if isinstance(box, boxes.LineBox):
285
+ indent_token = box.style['text_indent']
286
+ if check_math(indent_token):
287
+ # Ignore percentages by setting refer_to to 0.
288
+ result = resolve_math(indent_token, box.style, 'text_indent', refer_to=0)
289
+ value = validate_non_shorthand((result,), 'text-indent')[0][1]
290
+ if value and value.unit != '%':
291
+ text_indent = value.value
292
+ elif indent_token.unit != '%':
293
+ text_indent = box.style['text_indent'].value
294
+
295
+ # Yield widths for each line.
278
296
  current_line = 0
279
297
  if skip_stack is None:
280
298
  skip = 0
281
299
  else:
282
300
  (skip, skip_stack), = skip_stack.items()
283
301
  for child in box.children[skip:]:
302
+ # Skip absolutely positioned elements.
284
303
  if child.is_absolutely_positioned():
285
- continue # Skip
304
+ continue
286
305
 
306
+ # None is used in "lines" to track line breaks, transformed to 0 when yielded.
287
307
  if isinstance(child, boxes.InlineBox):
308
+ # Inline box, call function recursively.
288
309
  lines = inline_line_widths(
289
- context, child, outer, is_line_start, minimum, skip_stack,
290
- first_line)
310
+ context, child, outer, is_line_start, minimum, skip_stack, first_line)
291
311
  if first_line:
292
- lines = [next(lines)]
312
+ lines = [next(lines) or None]
293
313
  else:
294
- lines = list(lines)
314
+ lines = [line or None for line in lines]
295
315
  if len(lines) == 1:
296
- lines[0] = adjust(child, outer, lines[0])
316
+ lines[0] = adjust(child, outer, lines[0] or 0)
297
317
  else:
298
- lines[0] = adjust(child, outer, lines[0], right=False)
299
- lines[-1] = adjust(child, outer, lines[-1], left=False)
318
+ lines[0] = adjust(child, outer, lines[0] or 0, right=False) or None
319
+ lines[-1] = adjust(child, outer, lines[-1] or 0, left=False) or None
300
320
  elif isinstance(child, boxes.TextBox):
301
- space_collapse = child.style['white_space'] in (
302
- 'normal', 'nowrap', 'pre-line')
303
- text_wrap = child.style['white_space'] in ('normal', 'pre-wrap', 'pre-line')
321
+ # Text box, split into lines.
322
+ white_space = child.style['white_space']
323
+ space_collapse = white_space in ('normal', 'nowrap', 'pre-line')
324
+ text_wrap = white_space in ('normal', 'pre-wrap', 'pre-line')
304
325
  if skip_stack is None:
305
326
  skip = 0
306
327
  else:
@@ -318,18 +339,21 @@ def inline_line_widths(context, box, outer, is_line_start, minimum, skip_stack=N
318
339
  child_text[resume_index:].decode(), child.style, context, max_width,
319
340
  child.justification_spacing, is_line_start=is_line_start,
320
341
  minimum=True)
321
- lines.append(width)
342
+ lines.append(width or None)
322
343
  if first_line:
323
344
  break
324
345
  if first_line and new_resume_index:
325
- current_line += lines[0]
346
+ # We only need the first line, break early.
347
+ current_line += lines[0] or 0
326
348
  break
327
349
  # TODO: use the real next character instead of 'a' to detect line breaks.
328
- can_break = can_break_text(
329
- child_text.decode()[-1:] + 'a', child.style['lang'])
350
+ last_letter = child_text.decode()[-1:]
351
+ can_break = can_break_text(last_letter + 'a', child.style['lang'])
330
352
  if minimum and text_wrap and can_break:
331
- lines.append(0)
353
+ # Add all possible line breaks for minimal width.
354
+ lines.append(None)
332
355
  else:
356
+ # Replaced elements, inline blocks…
333
357
  # https://www.w3.org/TR/css-text-3/#overflow-wrap
334
358
  # "The line breaking behavior of a replaced element
335
359
  # or other atomic inline is equivalent to that
@@ -338,20 +362,20 @@ def inline_line_widths(context, box, outer, is_line_start, minimum, skip_stack=N
338
362
  # "By default, there is a break opportunity
339
363
  # both before and after any inline object."
340
364
  if minimum:
341
- lines = [0, min_content_width(context, child), 0]
365
+ lines = [None, min_content_width(context, child), None]
342
366
  else:
343
367
  lines = [max_content_width(context, child)]
344
- # The first text line goes on the current line
345
- current_line += lines[0]
368
+ # The first text line goes on the current line.
369
+ current_line += lines[0] or 0
346
370
  if len(lines) > 1:
347
- # Forced line break
371
+ # Forced line break(s).
348
372
  yield current_line + text_indent
349
373
  text_indent = 0
350
374
  if len(lines) > 2:
351
375
  for line in lines[1:-1]:
352
- yield line
353
- current_line = lines[-1]
354
- is_line_start = lines[-1] == 0
376
+ yield line or 0
377
+ current_line = lines[-1] or 0
378
+ is_line_start = lines[-1] is None
355
379
  skip_stack = None
356
380
  yield current_line + text_indent
357
381
 
@@ -362,15 +386,18 @@ def _percentage_contribution(box):
362
386
  https://dbaron.org/css/intrinsic/#pct-contrib
363
387
 
364
388
  """
389
+ min_width = box.style['min_width']
365
390
  min_width = (
366
- box.style['min_width'].value if box.style['min_width'] != 'auto' and
367
- box.style['min_width'].unit == '%' else 0)
391
+ min_width.value if min_width != 'auto' and
392
+ not check_math(min_width) and min_width.unit == '%' else 0)
393
+ max_width = box.style['max_width']
368
394
  max_width = (
369
- box.style['max_width'].value if box.style['max_width'] != 'auto' and
370
- box.style['max_width'].unit == '%' else inf)
395
+ max_width.value if max_width != 'auto' and
396
+ not check_math(max_width) and max_width.unit == '%' else inf)
397
+ width = box.style['width']
371
398
  width = (
372
- box.style['width'].value if box.style['width'] != 'auto' and
373
- box.style['width'].unit == '%' else 0)
399
+ width.value if width != 'auto' and
400
+ not check_math(width) and width.unit == '%' else 0)
374
401
  return max(min_width, min(width, max_width))
375
402
 
376
403
 
@@ -548,6 +575,7 @@ def table_and_columns_preferred_widths(context, box, outer=True):
548
575
  for cell in zipped_grid[i]:
549
576
  if (cell and cell.colspan == 1 and
550
577
  cell.style['width'] != 'auto' and
578
+ not check_math(cell.style['width']) and
551
579
  cell.style['width'].unit != '%'):
552
580
  constrainedness[i] = True
553
581
  break
@@ -618,7 +646,7 @@ def table_and_columns_preferred_widths(context, box, outer=True):
618
646
  sum(max_content_widths), large_percentage_contribution,
619
647
  *small_percentage_contributions]))
620
648
 
621
- if table.style['width'] != 'auto' and table.style['width'].unit == 'px':
649
+ if table.style['width'] != 'auto' and table.style['width'].unit.lower() == 'px':
622
650
  # "percentages on the following properties are treated instead as
623
651
  # though they were the following: width: auto"
624
652
  # https://dbaron.org/css/intrinsic/#outer-intrinsic
@@ -656,10 +684,9 @@ def replaced_min_content_width(box, outer=True):
656
684
  if height == 'auto' or height.unit == '%':
657
685
  height = 'auto'
658
686
  else:
659
- assert height.unit == 'px'
687
+ assert height.unit.lower() == 'px'
660
688
  height = height.value
661
- if (box.style['max_width'] != 'auto' and
662
- box.style['max_width'].unit == '%'):
689
+ if box.style['max_width'] != 'auto' and box.style['max_width'].unit == '%':
663
690
  # See https://drafts.csswg.org/css-sizing/#intrinsic-contribution
664
691
  width = 0
665
692
  else:
@@ -667,17 +694,14 @@ def replaced_min_content_width(box, outer=True):
667
694
  intrinsic_width, intrinsic_height, intrinsic_ratio = (
668
695
  image.get_intrinsic_size(
669
696
  box.style['image_resolution'], box.style['font_size']))
670
- if intrinsic_ratio and not intrinsic_width and not intrinsic_height:
671
- width = 0
672
- else:
673
- width, _ = default_image_sizing(
674
- intrinsic_width, intrinsic_height, intrinsic_ratio, 'auto',
675
- height, default_width=300, default_height=150)
697
+ width, _ = default_image_sizing(
698
+ intrinsic_width, intrinsic_height, intrinsic_ratio, 'auto',
699
+ height, default_width=0, default_height=0)
676
700
  elif box.style['width'].unit == '%':
677
701
  # See https://drafts.csswg.org/css-sizing/#intrinsic-contribution
678
702
  width = 0
679
703
  else:
680
- assert width.unit == 'px'
704
+ assert width.unit.lower() == 'px'
681
705
  width = width.value
682
706
  return adjust(box, outer, width)
683
707
 
@@ -690,7 +714,7 @@ def replaced_max_content_width(box, outer=True):
690
714
  if height == 'auto' or height.unit == '%':
691
715
  height = 'auto'
692
716
  else:
693
- assert height.unit == 'px'
717
+ assert height.unit.lower() == 'px'
694
718
  height = height.value
695
719
  image = box.replacement
696
720
  intrinsic_width, intrinsic_height, intrinsic_ratio = (
@@ -703,7 +727,7 @@ def replaced_max_content_width(box, outer=True):
703
727
  # See https://drafts.csswg.org/css-sizing/#intrinsic-contribution
704
728
  width = 0
705
729
  else:
706
- assert width.unit == 'px'
730
+ assert width.unit.lower() == 'px'
707
731
  width = width.value
708
732
  return adjust(box, outer, width)
709
733
 
@@ -743,17 +767,30 @@ def trailing_whitespace_size(context, box):
743
767
  """Return the size of the trailing whitespace of ``box``."""
744
768
  from .inline import split_first_line, split_text_box
745
769
 
770
+ # Find last box child, keep last parent to remove nested trailing spaces.
771
+ last_parent = None
746
772
  while isinstance(box, (boxes.InlineBox, boxes.LineBox)):
747
773
  if not box.children:
748
774
  return 0
749
- box = box.children[-1]
750
- if not (isinstance(box, boxes.TextBox) and box.text and
751
- box.style['white_space'] in ('normal', 'nowrap', 'pre-line')):
775
+ last_parent, box = box, box.children[-1]
776
+
777
+ # Return early if possible.
778
+ if not isinstance(box, boxes.TextBox) or not box.text:
779
+ # There’s no text in last child.
752
780
  return 0
753
- stripped_text = box.text.rstrip(' ')
754
- if box.style['font_size'] == 0 or len(stripped_text) == len(box.text):
781
+ elif box.style['white_space'] not in ('normal', 'nowrap', 'pre-line'):
782
+ # Spaces don’t collapse.
755
783
  return 0
756
- if stripped_text:
784
+ elif box.style['font_size'] == 0:
785
+ # Trailing spaces take no space.
786
+ return 0
787
+ elif not box.text.endswith(' '):
788
+ # No trailing space.
789
+ return 0
790
+
791
+ # Strip text.
792
+ if stripped_text := box.text.rstrip(' '):
793
+ # Stripped text is not empty, calculate width difference.
757
794
  resume = 0
758
795
  while resume is not None:
759
796
  old_resume = resume
@@ -763,12 +800,17 @@ def trailing_whitespace_size(context, box):
763
800
  stripped_box, resume, _ = split_text_box(
764
801
  context, stripped_box, None, old_resume)
765
802
  if stripped_box is None:
766
- # old_box split just before the trailing spaces
803
+ # Old box is split just before the trailing spaces.
767
804
  return old_box.width
768
805
  else:
806
+ # Return difference between old width and stripped width.
769
807
  assert resume is None
770
808
  return old_box.width - stripped_box.width
771
809
  else:
810
+ # Stripped text is empty, render spaces to get width.
772
811
  _, _, _, width, _, _ = split_first_line(
773
812
  box.text, box.style, context, None, box.justification_spacing)
813
+ # Remove possible trailing spaces from previous child.
814
+ if last_parent and len(last_parent.children) >= 2:
815
+ width += trailing_whitespace_size(context, last_parent.children[-2])
774
816
  return width
@@ -117,8 +117,8 @@ def replacedbox_layout(box):
117
117
  ref_x = box.width - draw_width
118
118
  ref_y = box.height - draw_height
119
119
 
120
- position_x = percentage(position_x, ref_x)
121
- position_y = percentage(position_y, ref_y)
120
+ position_x = percentage(position_x, box.style, ref_x)
121
+ position_y = percentage(position_y, box.style, ref_y)
122
122
  if origin_x == 'right':
123
123
  position_x = ref_x - position_x
124
124
  if origin_y == 'bottom':
@@ -281,10 +281,13 @@ def block_replaced_box_layout(context, box, containing_block):
281
281
  block_replaced_width(box, containing_block)
282
282
  replaced_box_height(box)
283
283
 
284
- # Don't collide with floats
285
- # https://www.w3.org/TR/CSS21/visuren.html#floats
286
- box.position_x, box.position_y, _ = avoid_collisions(
287
- context, box, containing_block, outer=False)
284
+ # TODO: flex items shouldn't be block boxes, this condition
285
+ # would then be useless when this is fixed.
286
+ if not box.is_flex_item:
287
+ # Don't collide with floats
288
+ # https://www.w3.org/TR/CSS21/visuren.html#floats
289
+ box.position_x, box.position_y, _ = avoid_collisions(
290
+ context, box, containing_block, outer=False)
288
291
  resume_at = None
289
292
  next_page = {'break': 'any', 'page': None}
290
293
  adjoining_margins = []
@@ -2,7 +2,7 @@
2
2
 
3
3
  from math import inf
4
4
 
5
- import tinycss2.color4
5
+ import tinycss2.color5
6
6
 
7
7
  from ..formatting_structure import boxes
8
8
  from ..logger import LOGGER
@@ -184,14 +184,16 @@ def table_layout(context, table, bottom_space, skip_stack, containing_block,
184
184
  context, cell, bottom_space, cell_skip_stack,
185
185
  page_is_empty=page_is_empty, absolute_boxes=absolute_boxes,
186
186
  fixed_boxes=fixed_boxes, adjoining_margins=None,
187
- discard=False, max_lines=None)
187
+ first_letter_style=None, first_line_style=None, discard=False,
188
+ max_lines=None)
188
189
  cell.style = original_style
189
190
  if new_cell is None:
190
191
  cell = cell.copy_with_children([])
191
192
  cell, _, _, _, _, _ = block_container_layout(
192
193
  context, cell, bottom_space, cell_skip_stack,
193
194
  page_is_empty=True, absolute_boxes=[], fixed_boxes=[],
194
- adjoining_margins=None, discard=False, max_lines=None)
195
+ adjoining_margins=None, first_letter_style=None,
196
+ first_line_style=None, discard=False, max_lines=None)
195
197
  cell_resume_at = {0: None}
196
198
  else:
197
199
  cell = new_cell
@@ -414,9 +416,13 @@ def table_layout(context, table, bottom_space, skip_stack, containing_block,
414
416
  if avoid_page_break(page_break, context):
415
417
  earlier_page_break = find_earlier_page_break(
416
418
  context, new_table_children, absolute_boxes, fixed_boxes)
417
- if earlier_page_break is not None:
418
- new_table_children, resume_at = earlier_page_break
419
- break
419
+ if earlier_page_break is None:
420
+ remove_placeholders(
421
+ context, new_table_children, absolute_boxes,
422
+ fixed_boxes)
423
+ return None, None, next_page, position_y
424
+ new_table_children, resume_at = earlier_page_break
425
+ break
420
426
  resume_at = {index_group: None}
421
427
  else:
422
428
  return None, None, next_page, position_y
@@ -538,7 +544,8 @@ def table_layout(context, table, bottom_space, skip_stack, containing_block,
538
544
  # We could not fit any content, drop the footer.
539
545
  footer = None
540
546
 
541
- assert not (header or footer)
547
+ assert not header
548
+ assert not footer
542
549
  new_table_children, resume_at, next_page, end_position_y = (
543
550
  body_groups_layout(skip_stack, position_y, bottom_space, page_is_empty))
544
551
  return header, new_table_children, footer, end_position_y, resume_at, next_page
@@ -782,7 +789,7 @@ def auto_table_layout(context, box, containing_block):
782
789
 
783
790
  if assignable_width < sum(max_content_guess):
784
791
  # Default values shouldn't be used, but we never know.
785
- # See https://github.com/Kozea/WeasyPrint/issues/770
792
+ # See issue #770.
786
793
  lower_guess = guesses[0]
787
794
  upper_guess = guesses[-1]
788
795
 
@@ -933,7 +940,7 @@ def distribute_excess_width(context, grid, excess_width, column_widths, constrai
933
940
  column_widths[i] += excess_width / len(columns)
934
941
 
935
942
 
936
- TRANSPARENT = tinycss2.color4.parse_color('transparent')
943
+ TRANSPARENT = tinycss2.color5.parse_color('transparent')
937
944
 
938
945
 
939
946
  def collapse_table_borders(table, grid_width, grid_height):
@@ -3,22 +3,24 @@
3
3
  from importlib.resources import files
4
4
 
5
5
  import pydyf
6
- from tinycss2.color4 import D50, D65
6
+ from tinycss2.color5 import D50, D65
7
7
 
8
8
  from .. import VERSION, Attachment
9
9
  from ..html import W3C_DATE_RE
10
10
  from ..logger import LOGGER, PROGRESS_LOGGER
11
11
  from ..matrix import Matrix
12
- from . import debug, pdfa, pdfua
12
+ from . import debug, pdfa, pdfua, pdfx
13
13
  from .fonts import build_fonts_dictionary
14
14
  from .stream import Stream
15
+ from .tags import add_tags
15
16
 
16
17
  from .anchors import ( # isort:skip
17
18
  add_annotations, add_forms, add_links, add_outlines, resolve_links,
18
19
  write_pdf_attachment)
19
20
 
20
21
  VARIANTS = {
21
- name: data for variants in (pdfa.VARIANTS, pdfua.VARIANTS, debug.VARIANTS)
22
+ name: data
23
+ for variants in (pdfa.VARIANTS, pdfua.VARIANTS, pdfx.VARIANTS, debug.VARIANTS)
22
24
  for (name, data) in variants.items()}
23
25
 
24
26
 
@@ -52,16 +54,16 @@ def _w3c_date_to_pdf(string, attr_name):
52
54
  return f'D:{pdf_date}'
53
55
 
54
56
 
55
- def _reference_resources(pdf, resources, images, fonts):
57
+ def _reference_resources(pdf, resources, images, fonts, color_profiles):
56
58
  if 'Font' in resources:
57
59
  assert resources['Font'] is None
58
60
  resources['Font'] = fonts
59
- _use_references(pdf, resources, images)
61
+ _use_references(pdf, resources, images, color_profiles)
60
62
  pdf.add_object(resources)
61
63
  return resources.reference
62
64
 
63
65
 
64
- def _use_references(pdf, resources, images):
66
+ def _use_references(pdf, resources, images, color_profiles):
65
67
  # XObjects
66
68
  for key, x_object in resources.get('XObject', {}).items():
67
69
  # Images
@@ -90,7 +92,8 @@ def _use_references(pdf, resources, images):
90
92
  # Resources
91
93
  if 'Resources' in x_object.extra:
92
94
  x_object.extra['Resources'] = _reference_resources(
93
- pdf, x_object.extra['Resources'], images, resources['Font'])
95
+ pdf, x_object.extra['Resources'], images, resources['Font'],
96
+ color_profiles)
94
97
 
95
98
  # Patterns
96
99
  for key, pattern in resources.get('Pattern', {}).items():
@@ -98,7 +101,8 @@ def _use_references(pdf, resources, images):
98
101
  resources['Pattern'][key] = pattern.reference
99
102
  if 'Resources' in pattern.extra:
100
103
  pattern.extra['Resources'] = _reference_resources(
101
- pdf, pattern.extra['Resources'], images, resources['Font'])
104
+ pdf, pattern.extra['Resources'], images, resources['Font'],
105
+ color_profiles)
102
106
 
103
107
  # Shadings
104
108
  for key, shading in resources.get('Shading', {}).items():
@@ -117,16 +121,18 @@ def generate_pdf(document, target, zoom, **options):
117
121
 
118
122
  PROGRESS_LOGGER.info('Step 6 - Creating PDF')
119
123
 
124
+ compress = not options['uncompressed_pdf']
125
+
120
126
  # Set properties according to PDF variants
121
- mark = False
122
127
  srgb = options['srgb']
128
+ pdf_tags = options['pdf_tags']
123
129
  variant = options['pdf_variant']
124
130
  if variant:
125
131
  variant_function, properties = VARIANTS[variant]
126
- if 'mark' in properties:
127
- mark = properties['mark']
128
132
  if 'srgb' in properties:
129
133
  srgb = properties['srgb']
134
+ if 'pdf_tags' in properties:
135
+ pdf_tags = properties['pdf_tags']
130
136
 
131
137
  pdf = pydyf.PDF()
132
138
  images = {}
@@ -140,6 +146,17 @@ def generate_pdf(document, target, zoom, **options):
140
146
  'Range': pydyf.Array((-125, 125, -125, 125)),
141
147
  }))),
142
148
  })
149
+ # Custom color profiles
150
+ for key, color_profile in document.color_profiles.items():
151
+ if key == 'device-cmyk':
152
+ # Device CMYK profile is stored as OutputIntent.
153
+ continue
154
+ profile = pydyf.Stream(
155
+ [color_profile.content],
156
+ pydyf.Dictionary({'N': len(color_profile.components)}),
157
+ compress=compress)
158
+ pdf.add_object(profile)
159
+ color_space[key] = pydyf.Array(('/ICCBased', profile.reference))
143
160
  pdf.add_object(color_space)
144
161
  resources = pydyf.Dictionary({
145
162
  'ExtGState': pydyf.Dictionary(),
@@ -156,9 +173,10 @@ def generate_pdf(document, target, zoom, **options):
156
173
 
157
174
  annot_files = {}
158
175
  pdf_pages, page_streams = [], []
159
- compress = not options['uncompressed_pdf']
160
176
  for page_number, (page, links_and_anchors) in enumerate(
161
177
  zip(document.pages, page_links_and_anchors)):
178
+ tags = {} if pdf_tags else None
179
+
162
180
  # Draw from the top-left corner
163
181
  matrix = Matrix(scale, 0, 0, -scale, 0, page.height * scale)
164
182
 
@@ -175,7 +193,8 @@ def generate_pdf(document, target, zoom, **options):
175
193
  left / scale, top / scale,
176
194
  (right - left) / scale, (bottom - top) / scale)
177
195
  stream = Stream(
178
- document.fonts, page_rectangle, resources, images, mark, compress=compress)
196
+ document.fonts, page_rectangle, resources, images, tags,
197
+ document.color_profiles, compress=compress)
179
198
  stream.transform(d=-1, f=(page.height * scale))
180
199
  pdf.add_object(stream)
181
200
  page_streams.append(stream)
@@ -187,13 +206,13 @@ def generate_pdf(document, target, zoom, **options):
187
206
  'Contents': stream.reference,
188
207
  'Resources': resources.reference,
189
208
  })
190
- if mark:
209
+ if pdf_tags:
191
210
  pdf_page['Tabs'] = '/S'
192
211
  pdf_page['StructParents'] = page_number
193
212
  pdf.add_page(pdf_page)
194
213
  pdf_pages.append(pdf_page)
195
214
 
196
- add_links(links_and_anchors, matrix, pdf, pdf_page, pdf_names, mark)
215
+ add_links(links_and_anchors, matrix, pdf, pdf_page, pdf_names, tags)
197
216
  add_annotations(
198
217
  links_and_anchors[0], matrix, document, pdf, pdf_page, annot_files,
199
218
  compress)
@@ -293,7 +312,7 @@ def generate_pdf(document, target, zoom, **options):
293
312
  pdf.add_object(dingbats)
294
313
  pdf_fonts['ZaDb'] = dingbats.reference
295
314
  resources['Font'] = pdf_fonts.reference
296
- _use_references(pdf, resources, images)
315
+ _use_references(pdf, resources, images, document.color_profiles)
297
316
 
298
317
  # Anchors
299
318
  if pdf_names:
@@ -307,8 +326,25 @@ def generate_pdf(document, target, zoom, **options):
307
326
  pdf.catalog['Names'] = pydyf.Dictionary()
308
327
  pdf.catalog['Names']['Dests'] = dests
309
328
 
310
- if srgb:
311
- # Add ICC profile.
329
+ # Add output ICC profile.
330
+ # TODO: we should allow the user or the PDF variant code to set custom values in
331
+ # OutputIntents and remove the "srgb" option. See PDF 2.0 chapter 14.11.5, and
332
+ # https://www.color.org/chardata/drsection1.xalter for a list of "standard
333
+ # production conditions".
334
+ if 'device-cmyk' in document.color_profiles:
335
+ color_profile = document.color_profiles['device-cmyk']
336
+ profile = pydyf.Stream(
337
+ [color_profile.content], pydyf.Dictionary({'N': 4}), compress=compress)
338
+ pdf.add_object(profile)
339
+ pdf.catalog['OutputIntents'] = pydyf.Array([
340
+ pydyf.Dictionary({
341
+ 'Type': '/OutputIntent',
342
+ 'S': '/GTS_PDFX',
343
+ 'OutputConditionIdentifier': pydyf.String(color_profile.name),
344
+ 'DestOutputProfile': profile.reference,
345
+ }),
346
+ ])
347
+ elif srgb:
312
348
  profile = pydyf.Stream(
313
349
  [(files(__package__) / 'sRGB2014.icc').read_bytes()],
314
350
  pydyf.Dictionary({'N': 3, 'Alternate': '/DeviceRGB'}),
@@ -323,6 +359,10 @@ def generate_pdf(document, target, zoom, **options):
323
359
  }),
324
360
  ])
325
361
 
362
+ # Add tags
363
+ if pdf_tags:
364
+ add_tags(pdf, document, page_streams)
365
+
326
366
  # Apply PDF variants functions
327
367
  if variant:
328
368
  variant_function(