weasyprint 64.1__py3-none-any.whl → 65.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.
@@ -68,7 +68,7 @@ def float_layout(context, box, containing_block, absolute_boxes, fixed_boxes,
68
68
  context, box, bottom_space=bottom_space,
69
69
  skip_stack=skip_stack, containing_block=containing_block,
70
70
  page_is_empty=True, absolute_boxes=absolute_boxes,
71
- fixed_boxes=fixed_boxes)
71
+ fixed_boxes=fixed_boxes, discard=False)
72
72
  elif isinstance(box, boxes.GridContainerBox):
73
73
  box, resume_at, _, _, _ = grid_layout(
74
74
  context, box, bottom_space=bottom_space,
@@ -216,9 +216,11 @@ def avoid_collisions(context, box, containing_block, outer=True):
216
216
  # bound.
217
217
  position_x = max_right_bound - box_width
218
218
  else:
219
- # The position of the right border of the replaced box is at
220
- # the right bound.
221
- assert isinstance(box, boxes.BlockReplacedBox)
219
+ # The position of the right border of the replaced box or
220
+ # formatting context is at the right bound.
221
+ assert (
222
+ isinstance(box, boxes.BlockReplacedBox) or
223
+ box.establishes_formatting_context())
222
224
  position_x = max_right_bound - box_width
223
225
 
224
226
  available_width = max_right_bound - max_left_bound
@@ -218,7 +218,7 @@ def skip_first_whitespace(box, skip_stack):
218
218
  return None
219
219
 
220
220
 
221
- def remove_last_whitespace(context, box):
221
+ def remove_last_whitespace(context, line):
222
222
  """Remove in place space characters at the end of a line.
223
223
 
224
224
  This also reduces the width and position of the inline parents of the
@@ -226,6 +226,7 @@ def remove_last_whitespace(context, box):
226
226
 
227
227
  """
228
228
  ancestors = []
229
+ box = line
229
230
  while isinstance(box, (boxes.LineBox, boxes.InlineBox)):
230
231
  ancestors.append(box)
231
232
  if not box.children:
@@ -244,25 +245,16 @@ def remove_last_whitespace(context, box):
244
245
  assert resume is None
245
246
  space_width = box.width - new_box.width
246
247
  box.width = new_box.width
247
-
248
- # RTL line, the trailing space is at the left of the box. We have to
249
- # translate the box to align the stripped text with the right edge of
250
- # the box.
251
- if new_box.pango_layout.first_line_direction % 2:
252
- box.position_x -= space_width
253
- for ancestor in ancestors:
254
- ancestor.position_x -= space_width
255
248
  else:
256
249
  space_width = box.width
257
250
  box.width = 0
258
251
  box.text = ''
259
252
 
260
- # RTL line, the textbox with a trailing space is now empty at the left
261
- # of the line. We have to translate the line to align it with the right
262
- # edge of the box.
263
- line = ancestors[0]
264
- if line.style['direction'] == 'rtl':
265
- line.translate(dx=-space_width, ignore_floats=True)
253
+ # RTL line, the trailing space is at the left of the box. We have to translate the
254
+ # box to align the stripped text with the right edge of the box.
255
+ if box.pango_layout.first_line_direction % 2:
256
+ for child in line.children:
257
+ child.translate(dx=-space_width, ignore_floats=True)
266
258
 
267
259
  for ancestor in ancestors:
268
260
  ancestor.width -= space_width
@@ -370,9 +362,12 @@ def atomic_box(context, box, position_x, skip_stack, containing_block,
370
362
  if box.is_table_wrapper:
371
363
  containing_size = (containing_block.width, containing_block.height)
372
364
  table_wrapper_width(context, box, containing_size)
365
+ width, min_width, max_width = box.width, box.min_width, box.max_width
373
366
  box = inline_block_box_layout(
374
367
  context, box, position_x, skip_stack, containing_block,
375
368
  absolute_boxes, fixed_boxes)
369
+ if box.is_table_wrapper:
370
+ box.width, box.min_width, box.max_width = width, min_width, max_width
376
371
  else: # pragma: no cover
377
372
  raise TypeError(f'Layout for {type(box).__name__} not handled yet')
378
373
  return box
@@ -513,7 +508,7 @@ def split_inline_level(context, box, position_x, max_x, bottom_space,
513
508
  setattr(box, f'margin_{side}', 0)
514
509
  new_box, resume_at, _, _, _ = flex_layout(
515
510
  context, box, -inf, skip_stack, containing_block, False,
516
- absolute_boxes, fixed_boxes)
511
+ absolute_boxes, fixed_boxes, False)
517
512
  preserved_line_break = False
518
513
  first_letter = '\u2e80'
519
514
  last_letter = '\u2e80'
@@ -1049,8 +1044,7 @@ def inline_box_verticality(box, top_bottom_subtrees, baseline_y):
1049
1044
 
1050
1045
  # the child’s `top` is `child.baseline` above (lower y) its baseline.
1051
1046
  top = child_baseline_y - child.baseline
1052
- box_types = (
1053
- boxes.InlineBlockBox, boxes.InlineFlexBox, boxes.InlineGridBox)
1047
+ box_types = (boxes.InlineBlockBox, boxes.InlineFlexBox, boxes.InlineGridBox)
1054
1048
  if isinstance(child, box_types):
1055
1049
  # This also includes table wrappers for inline tables.
1056
1050
  child.translate(dy=top - child.position_y)
@@ -1119,18 +1113,17 @@ def text_align(context, line, available_width, last):
1119
1113
  def justify_line(context, line, extra_width):
1120
1114
  # TODO: We should use a better algorithm here, see
1121
1115
  # https://www.w3.org/TR/css-text-3/#justify-algos
1122
- nb_spaces = count_spaces(line)
1123
- if nb_spaces == 0:
1124
- return
1125
- add_word_spacing(context, line, extra_width / nb_spaces, 0)
1116
+ if (nb_spaces := count_expandable_spaces(line)):
1117
+ add_word_spacing(context, line, extra_width / nb_spaces, 0)
1126
1118
 
1127
1119
 
1128
- def count_spaces(box):
1120
+ def count_expandable_spaces(box):
1121
+ """Count expandable spaces (space and nbsp) for justification."""
1129
1122
  if isinstance(box, boxes.TextBox):
1130
1123
  # TODO: remove trailing spaces correctly
1131
- return box.text.count(' ')
1124
+ return box.text.count(' ') + box.text.count('\u00a0')
1132
1125
  elif isinstance(box, (boxes.LineBox, boxes.InlineBox)):
1133
- return sum(count_spaces(child) for child in box.children)
1126
+ return sum(count_expandable_spaces(child) for child in box.children)
1134
1127
  else:
1135
1128
  return 0
1136
1129
 
@@ -1139,7 +1132,7 @@ def add_word_spacing(context, box, justification_spacing, x_advance):
1139
1132
  if isinstance(box, boxes.TextBox):
1140
1133
  box.justification_spacing = justification_spacing
1141
1134
  box.position_x += x_advance
1142
- nb_spaces = count_spaces(box)
1135
+ nb_spaces = count_expandable_spaces(box)
1143
1136
  if nb_spaces > 0:
1144
1137
  layout = create_layout(
1145
1138
  box.text, box.style, context, max_width=None,
@@ -1152,7 +1145,10 @@ def add_word_spacing(context, box, justification_spacing, x_advance):
1152
1145
  elif isinstance(box, (boxes.LineBox, boxes.InlineBox)):
1153
1146
  box.position_x += x_advance
1154
1147
  previous_x_advance = x_advance
1155
- for child in box.children:
1148
+ children = box.children
1149
+ if box.style['direction'] == 'rtl':
1150
+ children = children[::-1]
1151
+ for child in children:
1156
1152
  if child.is_in_normal_flow():
1157
1153
  x_advance = add_word_spacing(
1158
1154
  context, child, justification_spacing, x_advance)
weasyprint/layout/page.py CHANGED
@@ -605,10 +605,8 @@ def make_page(context, root_box, page_type, resume_at, page_number,
605
605
  previous_resume_at = resume_at
606
606
  root_box = root_box.copy_with_children([])
607
607
 
608
- # TODO: handle cases where the root element is something else.
609
- # See https://www.w3.org/TR/CSS21/visuren.html#dis-pos-flo
610
- assert isinstance(root_box, (
611
- boxes.BlockBox, boxes.FlexContainerBox, boxes.GridContainerBox))
608
+ # https://www.w3.org/TR/css-display-4/#root
609
+ assert isinstance(root_box, boxes.BlockLevelBox)
612
610
  context.create_block_formatting_context()
613
611
  context.current_page = page_number
614
612
  context.current_page_footnotes = []
@@ -21,8 +21,7 @@ def percentage(value, refer_to):
21
21
  return refer_to * value.value / 100
22
22
 
23
23
 
24
- def resolve_one_percentage(box, property_name, refer_to,
25
- main_flex_direction=None):
24
+ def resolve_one_percentage(box, property_name, refer_to):
26
25
  """Set a used length value from a computed length value.
27
26
 
28
27
  ``refer_to`` is the length for 100%. If ``refer_to`` is not a number, it
@@ -35,9 +34,7 @@ def resolve_one_percentage(box, property_name, refer_to,
35
34
  percent = percentage(value, refer_to)
36
35
  setattr(box, property_name, percent)
37
36
  if property_name in ('min_width', 'min_height') and percent == 'auto':
38
- if (main_flex_direction is None or
39
- property_name != (f'min_{main_flex_direction}')):
40
- setattr(box, property_name, 0)
37
+ setattr(box, property_name, 0)
41
38
 
42
39
 
43
40
  def resolve_position_percentages(box, containing_block):
@@ -48,7 +45,7 @@ def resolve_position_percentages(box, containing_block):
48
45
  resolve_one_percentage(box, 'bottom', cb_height)
49
46
 
50
47
 
51
- def resolve_percentages(box, containing_block, main_flex_direction=None):
48
+ def resolve_percentages(box, containing_block):
52
49
  """Set used values as attributes of the box object."""
53
50
  if isinstance(containing_block, boxes.Box):
54
51
  # cb is short for containing block
@@ -69,8 +66,8 @@ def resolve_percentages(box, containing_block, main_flex_direction=None):
69
66
  resolve_one_percentage(box, 'padding_top', maybe_height)
70
67
  resolve_one_percentage(box, 'padding_bottom', maybe_height)
71
68
  resolve_one_percentage(box, 'width', cb_width)
72
- resolve_one_percentage(box, 'min_width', cb_width, main_flex_direction)
73
- resolve_one_percentage(box, 'max_width', cb_width, main_flex_direction)
69
+ resolve_one_percentage(box, 'min_width', cb_width)
70
+ resolve_one_percentage(box, 'max_width', cb_width)
74
71
 
75
72
  # XXX later: top, bottom, left and right on positioned elements
76
73
 
@@ -83,14 +80,12 @@ def resolve_percentages(box, containing_block, main_flex_direction=None):
83
80
  else:
84
81
  assert height.unit == 'px'
85
82
  box.height = height.value
86
- resolve_one_percentage(box, 'min_height', 0, main_flex_direction)
87
- resolve_one_percentage(box, 'max_height', inf, main_flex_direction)
83
+ resolve_one_percentage(box, 'min_height', 0)
84
+ resolve_one_percentage(box, 'max_height', inf)
88
85
  else:
89
86
  resolve_one_percentage(box, 'height', cb_height)
90
- resolve_one_percentage(
91
- box, 'min_height', cb_height, main_flex_direction)
92
- resolve_one_percentage(
93
- box, 'max_height', cb_height, main_flex_direction)
87
+ resolve_one_percentage(box, 'min_height', cb_height)
88
+ resolve_one_percentage(box, 'max_height', cb_height)
94
89
 
95
90
  collapse = box.style['border_collapse'] == 'collapse'
96
91
  # Used value == computed value
@@ -102,38 +97,8 @@ def resolve_percentages(box, containing_block, main_flex_direction=None):
102
97
  setattr(box, prop, box.style[prop])
103
98
 
104
99
  # Shrink *content* widths and heights according to box-sizing
105
- # Thanks heavens and the spec: Our validator rejects negative values
106
- # for padding and border-width
107
- if box.style['box_sizing'] == 'border-box':
108
- horizontal_delta = (
109
- box.padding_left + box.padding_right +
110
- box.border_left_width + box.border_right_width)
111
- vertical_delta = (
112
- box.padding_top + box.padding_bottom +
113
- box.border_top_width + box.border_bottom_width)
114
- elif box.style['box_sizing'] == 'padding-box':
115
- horizontal_delta = box.padding_left + box.padding_right
116
- vertical_delta = box.padding_top + box.padding_bottom
117
- else:
118
- assert box.style['box_sizing'] == 'content-box'
119
- horizontal_delta = 0
120
- vertical_delta = 0
121
-
122
- # Keep at least min_* >= 0 to prevent funny output in case box.width or
123
- # box.height become negative.
124
- # Restricting max_* seems reasonable, too.
125
- if horizontal_delta > 0:
126
- if box.width != 'auto':
127
- box.width = max(0, box.width - horizontal_delta)
128
- box.max_width = max(0, box.max_width - horizontal_delta)
129
- if box.min_width != 'auto':
130
- box.min_width = max(0, box.min_width - horizontal_delta)
131
- if vertical_delta > 0:
132
- if box.height != 'auto':
133
- box.height = max(0, box.height - vertical_delta)
134
- box.max_height = max(0, box.max_height - vertical_delta)
135
- if box.min_height != 'auto':
136
- box.min_height = max(0, box.min_height - vertical_delta)
100
+ adjust_box_sizing(box, 'width')
101
+ adjust_box_sizing(box, 'height')
137
102
 
138
103
 
139
104
  def resolve_radii_percentages(box):
@@ -154,3 +119,33 @@ def resolve_radii_percentages(box):
154
119
  rx = percentage(rx, box.border_width())
155
120
  ry = percentage(ry, box.border_height())
156
121
  setattr(box, property_name, (rx, ry))
122
+
123
+
124
+ def adjust_box_sizing(box, axis):
125
+ if box.style['box_sizing'] == 'border-box':
126
+ if axis == 'width':
127
+ delta = (
128
+ box.padding_left + box.padding_right +
129
+ box.border_left_width + box.border_right_width)
130
+ else:
131
+ delta = (
132
+ box.padding_top + box.padding_bottom +
133
+ box.border_top_width + box.border_bottom_width)
134
+ elif box.style['box_sizing'] == 'padding-box':
135
+ if axis == 'width':
136
+ delta = box.padding_left + box.padding_right
137
+ else:
138
+ delta = box.padding_top + box.padding_bottom
139
+ else:
140
+ assert box.style['box_sizing'] == 'content-box'
141
+ delta = 0
142
+
143
+ # Keep at least min_* >= 0 to prevent funny output in case box.width or
144
+ # box.height become negative.
145
+ # Restricting max_* seems reasonable, too.
146
+ if delta > 0:
147
+ if getattr(box, axis) != 'auto':
148
+ setattr(box, axis, max(0, getattr(box, axis) - delta))
149
+ setattr(box, f'max_{axis}', max(0, getattr(box, f'max_{axis}') - delta))
150
+ if getattr(box, f'min_{axis}') != 'auto':
151
+ setattr(box, f'min_{axis}', max(0, getattr(box, f'min_{axis}') - delta))
@@ -43,14 +43,12 @@ def min_content_width(context, box, outer=True):
43
43
  return table_and_columns_preferred_widths(context, box, outer)[0]
44
44
  elif isinstance(box, boxes.TableCellBox):
45
45
  return table_cell_min_content_width(context, box, outer)
46
- elif isinstance(box, (
47
- boxes.BlockContainerBox, boxes.TableColumnBox, boxes.FlexBox)):
46
+ elif isinstance(box, (boxes.BlockContainerBox, boxes.TableColumnBox)):
48
47
  return block_min_content_width(context, box, outer)
49
48
  elif isinstance(box, boxes.TableColumnGroupBox):
50
49
  return column_group_content_width(context, box)
51
50
  elif isinstance(box, (boxes.InlineBox, boxes.LineBox)):
52
- return inline_min_content_width(
53
- context, box, outer, is_line_start=True)
51
+ return inline_min_content_width(context, box, outer, is_line_start=True)
54
52
  elif isinstance(box, boxes.ReplacedBox):
55
53
  return replaced_min_content_width(box, outer)
56
54
  elif isinstance(box, boxes.FlexContainerBox):
@@ -59,8 +57,7 @@ def min_content_width(context, box, outer=True):
59
57
  # TODO: Get real grid size.
60
58
  return block_min_content_width(context, box, outer)
61
59
  else:
62
- raise TypeError(
63
- f'min-content width for {type(box).__name__} not handled yet')
60
+ raise TypeError(f'min-content width for {type(box).__name__} not handled yet')
64
61
 
65
62
 
66
63
  def max_content_width(context, box, outer=True):
@@ -73,14 +70,12 @@ def max_content_width(context, box, outer=True):
73
70
  return table_and_columns_preferred_widths(context, box, outer)[1]
74
71
  elif isinstance(box, boxes.TableCellBox):
75
72
  return table_cell_min_max_content_width(context, box, outer)[1]
76
- elif isinstance(box, (
77
- boxes.BlockContainerBox, boxes.TableColumnBox, boxes.FlexBox)):
73
+ elif isinstance(box, (boxes.BlockContainerBox, boxes.TableColumnBox)):
78
74
  return block_max_content_width(context, box, outer)
79
75
  elif isinstance(box, boxes.TableColumnGroupBox):
80
76
  return column_group_content_width(context, box)
81
77
  elif isinstance(box, (boxes.InlineBox, boxes.LineBox)):
82
- return inline_max_content_width(
83
- context, box, outer, is_line_start=True)
78
+ return inline_max_content_width(context, box, outer, is_line_start=True)
84
79
  elif isinstance(box, boxes.ReplacedBox):
85
80
  return replaced_max_content_width(box, outer)
86
81
  elif isinstance(box, boxes.FlexContainerBox):
@@ -89,8 +84,7 @@ def max_content_width(context, box, outer=True):
89
84
  # TODO: Get real grid size.
90
85
  return block_max_content_width(context, box, outer)
91
86
  else:
92
- raise TypeError(
93
- f'max-content width for {type(box).__name__} not handled yet')
87
+ raise TypeError(f'max-content width for {type(box).__name__} not handled yet')
94
88
 
95
89
 
96
90
  def _block_content_width(context, box, function, outer):
@@ -344,7 +338,7 @@ def inline_line_widths(context, box, outer, is_line_start, minimum, skip_stack=N
344
338
  # "By default, there is a break opportunity
345
339
  # both before and after any inline object."
346
340
  if minimum:
347
- lines = [0, max_content_width(context, child), 0]
341
+ lines = [0, min_content_width(context, child), 0]
348
342
  else:
349
343
  lines = [max_content_width(context, child)]
350
344
  # The first text line goes on the current line
@@ -673,9 +667,12 @@ def replaced_min_content_width(box, outer=True):
673
667
  intrinsic_width, intrinsic_height, intrinsic_ratio = (
674
668
  image.get_intrinsic_size(
675
669
  box.style['image_resolution'], box.style['font_size']))
676
- width, _ = default_image_sizing(
677
- intrinsic_width, intrinsic_height, intrinsic_ratio, 'auto',
678
- height, default_width=300, default_height=150)
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)
679
676
  elif box.style['width'].unit == '%':
680
677
  # See https://drafts.csswg.org/css-sizing/#intrinsic-contribution
681
678
  width = 0
weasyprint/pdf/fonts.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Fonts integration in PDF."""
2
2
 
3
3
  import io
4
+ import re
4
5
  from hashlib import md5
5
6
  from logging import WARNING
6
7
  from math import ceil
@@ -34,7 +35,7 @@ class Font:
34
35
  part.split('=')[0]: float(part.split('=')[1])
35
36
  for part in ffi.string(variations).decode().split(',')}
36
37
  if weight := self.variations.get('weight'):
37
- self.weight = int(round(weight))
38
+ self.weight = round(weight)
38
39
  pango.pango_font_description_set_weight(description, weight)
39
40
  else:
40
41
  self.weight = pango.pango_font_description_get_weight(description)
@@ -58,24 +59,18 @@ class Font:
58
59
  in md5(description_string, usedforsecurity=False).digest()[:6])
59
60
 
60
61
  # Set font name.
61
- fields = description_string.split(b' ')
62
- if fields and b'=' in fields[-1]:
63
- fields.pop() # Remove variations
64
- if fields:
65
- fields.pop() # Remove font size
66
- else:
67
- fields = [b'Unknown']
68
- self.name = b'/' + self.hash.encode() + b'+' + b'-'.join(fields)
62
+ name = re.split(b' [#@]', description_string)[0]
63
+ self.name = b'/' + self.hash.encode() + b'+' + name.replace(b' ', b'-')
69
64
 
70
65
  # Set ascent and descent.
71
66
  if self.font_size:
72
67
  pango_metrics = pango.pango_font_get_metrics(pango_font, ffi.NULL)
73
- self.ascent = int(round(
68
+ self.ascent = round(
74
69
  pango.pango_font_metrics_get_ascent(pango_metrics) * FROM_UNITS /
75
- self.font_size * 1000))
76
- self.descent = -int(round(
70
+ self.font_size * 1000)
71
+ self.descent = -round(
77
72
  pango.pango_font_metrics_get_descent(pango_metrics) * FROM_UNITS /
78
- self.font_size * 1000))
73
+ self.font_size * 1000)
79
74
  else:
80
75
  self.ascent = self.descent = 0
81
76
 
@@ -112,7 +107,7 @@ class Font:
112
107
  self.flags = 2 ** (3 - 1) # Symbolic, custom character set
113
108
  if self.style:
114
109
  self.flags += 2 ** (7 - 1) # Italic
115
- if b'Serif' in fields:
110
+ if b'Serif' in name.split(b' '):
116
111
  self.flags += 2 ** (2 - 1) # Serif
117
112
 
118
113
  def clean(self, cmap, hinting):
@@ -342,15 +337,19 @@ def build_fonts_dictionary(pdf, fonts, compress, subset, options):
342
337
  b'/CMapType 2 def',
343
338
  b'1 begincodespacerange',
344
339
  b'<0000> <ffff>',
345
- b'endcodespacerange',
346
- f'{len(cmap)} beginbfchar'.encode()], compress=compress)
347
- for glyph, text in cmap.items():
348
- unicode_codepoints = ''.join(
349
- f'{letter.encode("utf-16-be").hex()}' for letter in text)
350
- to_unicode.stream.append(
351
- f'<{glyph:04x}> <{unicode_codepoints}>'.encode())
340
+ b'endcodespacerange'], compress=compress)
341
+ cmap_length = len(cmap)
342
+ cmap_items = tuple(cmap.items())
343
+ for i in range(ceil(cmap_length / 100)):
344
+ batch_length = min(100, cmap_length - i * 100)
345
+ to_unicode.stream.append(f'{batch_length} beginbfchar'.encode())
346
+ for glyph, text in cmap_items[i*100:(i+1)*100]:
347
+ unicode_codepoints = ''.join(
348
+ f'{letter.encode("utf-16-be").hex()}' for letter in text)
349
+ to_unicode.stream.append(
350
+ f'<{glyph:04x}> <{unicode_codepoints}>'.encode())
351
+ to_unicode.stream.append(b'endbfchar')
352
352
  to_unicode.stream.extend([
353
- b'endbfchar',
354
353
  b'endcmap',
355
354
  b'CMapName currentdict /CMap defineresource pop',
356
355
  b'end',
@@ -561,7 +560,7 @@ def _build_vector_font_dictionary(font_dictionary, pdf, font, widths, compress,
561
560
  })
562
561
  if str(pdf_version) <= '1.4': # Cast for bytes and None
563
562
  cids = sorted(font.widths)
564
- padded_width = int(ceil((cids[-1] + 1) / 8))
563
+ padded_width = ceil((cids[-1] + 1) / 8)
565
564
  bits = ['0'] * padded_width * 8
566
565
  for cid in cids:
567
566
  bits[cid] = '1'
weasyprint/stacking.py CHANGED
@@ -82,6 +82,7 @@ def _dispatch(box, page, child_contexts, blocks, floats, blocks_and_cells):
82
82
  child_contexts.append(StackingContext.from_box(box, page))
83
83
  return
84
84
 
85
+ stacking_classes = (boxes.InlineBlockBox, boxes.InlineFlexBox, boxes.InlineGridBox)
85
86
  if style['position'] != 'static':
86
87
  assert style['z_index'] == 'auto'
87
88
  # "Fake" context: sub-contexts will go in this `child_contexts` list.
@@ -91,8 +92,7 @@ def _dispatch(box, page, child_contexts, blocks, floats, blocks_and_cells):
91
92
  child_contexts.insert(index, stacking_context)
92
93
  elif box.is_floated():
93
94
  floats.append(StackingContext.from_box(box, page, child_contexts))
94
- elif isinstance(box, (
95
- boxes.InlineBlockBox, boxes.InlineFlexBox, boxes.InlineGridBox)):
95
+ elif isinstance(box, stacking_classes):
96
96
  # Have this fake stacking context be part of the "normal" box tree,
97
97
  # because we need its position in the middle of a tree of inline boxes.
98
98
  return StackingContext.from_box(box, page, child_contexts)
@@ -467,11 +467,13 @@ class SVG:
467
467
  # Draw node children
468
468
  if node.display and node.tag not in DEF_TYPES:
469
469
  for child in node:
470
- if text_anchor_shift:
470
+ new_chunk = text_anchor_shift and (
471
+ child.tag == 'text' or 'x' in child.attrib or 'y' in child.attrib)
472
+ if new_chunk:
471
473
  new_stream = self.stream
472
474
  self.stream = original_streams[-1]
473
475
  self.draw_node(child, font_size, fill_stroke)
474
- if text_anchor_shift:
476
+ if new_chunk:
475
477
  self.stream = new_stream
476
478
  visible_text_child = (
477
479
  TAGS.get(node.tag) == text and
@@ -502,7 +504,8 @@ class SVG:
502
504
  x - font_size, y - font_size,
503
505
  x + width + font_size, y + height + font_size)
504
506
  x_align = width / 2 if text_anchor == 'middle' else width
505
- self.stream.transform(e=-x_align)
507
+ if node.tag == 'text' or 'x' in node.attrib or 'y' in node.attrib:
508
+ self.stream.transform(e=-x_align)
506
509
  self.stream.draw_x_object(group_id)
507
510
  self.stream.pop_state()
508
511
 
@@ -49,6 +49,11 @@ PANGO_VARIANT = {
49
49
  'titling-caps': pango.PANGO_VARIANT_TITLE_CAPS,
50
50
  }
51
51
 
52
+ PANGO_DIRECTION = {
53
+ 'ltr': pango.PANGO_DIRECTION_LTR,
54
+ 'rtl': pango.PANGO_DIRECTION_RTL,
55
+ }
56
+
52
57
  # Language system tags
53
58
  # From https://docs.microsoft.com/typography/opentype/spec/languagetags
54
59
  LST_TO_ISO = {
weasyprint/text/ffi.py CHANGED
@@ -166,6 +166,16 @@ ffi.cdef('''
166
166
  PANGO_ELLIPSIZE_END
167
167
  } PangoEllipsizeMode;
168
168
 
169
+ typedef enum {
170
+ PANGO_DIRECTION_LTR,
171
+ PANGO_DIRECTION_RTL,
172
+ PANGO_DIRECTION_TTB_LTR,
173
+ PANGO_DIRECTION_TTB_RTL,
174
+ PANGO_DIRECTION_WEAK_LTR,
175
+ PANGO_DIRECTION_WEAK_RTL,
176
+ PANGO_DIRECTION_NEUTRAL
177
+ } PangoDirection;
178
+
169
179
  typedef struct GSList {
170
180
  gpointer data;
171
181
  struct GSList *next;
@@ -278,6 +288,7 @@ ffi.cdef('''
278
288
  void pango_layout_set_wrap (PangoLayout *layout, PangoWrapMode wrap);
279
289
  void pango_layout_set_single_paragraph_mode (PangoLayout *layout, gboolean setting);
280
290
  void pango_layout_set_ellipsize (PangoLayout *layout, PangoEllipsizeMode ellipsize);
291
+ void pango_layout_set_auto_dir (PangoLayout *layout, gboolean auto_dir);
281
292
  int pango_layout_get_baseline (PangoLayout *layout);
282
293
  void pango_layout_line_get_extents (
283
294
  PangoLayoutLine *line, PangoRectangle *ink_rect, PangoRectangle *logical_rect);
@@ -360,6 +371,7 @@ ffi.cdef('''
360
371
  PangoLanguage * pango_language_from_string (const char *language);
361
372
  PangoLanguage * pango_language_get_default (void);
362
373
  void pango_context_set_language (PangoContext *context, PangoLanguage *language);
374
+ void pango_context_set_base_dir (PangoContext *context, PangoDirection direction);
363
375
 
364
376
  void pango_get_log_attrs (
365
377
  const char *text, int length, int level, PangoLanguage *language,
weasyprint/text/fonts.py CHANGED
@@ -208,14 +208,23 @@ class FontConfiguration:
208
208
  match = SubElement(root, 'match', target='font')
209
209
  test = SubElement(match, 'test', name='file', compare='eq')
210
210
  SubElement(test, 'string').text = str(font_path)
211
- edit = SubElement(match, 'edit', name='fontfeatures', mode=mode)
212
211
  descriptors = {
213
212
  rules[0][0].replace('-', '_'): rules[0][1] for rules in
214
213
  rule_descriptors.get('font_variant', [])}
215
214
  settings = rule_descriptors.get('font_feature_settings', 'normal')
216
215
  features = font_features(font_feature_settings=settings, **descriptors)
217
- for key, value in features.items():
218
- SubElement(edit, 'string').text = f'{key} {value}'
216
+ if features:
217
+ edit = SubElement(match, 'edit', name='fontfeatures', mode=mode)
218
+ for key, value in features.items():
219
+ SubElement(edit, 'string').text = f'{key} {value}'
220
+ if unicode_ranges := rule_descriptors.get('unicode_range'):
221
+ edit = SubElement(match, 'edit', name='charset', mode=mode)
222
+ plus = SubElement(edit, 'plus')
223
+ for unicode_range in unicode_ranges:
224
+ charset = SubElement(plus, 'charset')
225
+ range_ = SubElement(charset, 'range')
226
+ for value in (unicode_range.start, unicode_range.end):
227
+ SubElement(range_, 'int').text = f'0x{value:x}'
219
228
  header = (
220
229
  b'<?xml version="1.0"?>',
221
230
  b'<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">')
@@ -5,7 +5,7 @@ from math import inf
5
5
 
6
6
  import pyphen
7
7
 
8
- from .constants import LST_TO_ISO, PANGO_WRAP_MODE
8
+ from .constants import LST_TO_ISO, PANGO_DIRECTION, PANGO_WRAP_MODE
9
9
  from .ffi import FROM_UNITS, TO_UNITS, ffi, gobject, pango, pangoft2, unicode_to_char_p
10
10
  from .fonts import font_features, get_font_description
11
11
 
@@ -76,6 +76,8 @@ class Layout:
76
76
  pango.pango_font_map_create_context(font_map),
77
77
  gobject.g_object_unref)
78
78
  pango.pango_context_set_round_glyph_positions(pango_context, False)
79
+ pango.pango_context_set_base_dir(
80
+ pango_context, PANGO_DIRECTION[style['direction']])
79
81
 
80
82
  if style['font_language_override'] != 'normal':
81
83
  lang_p, lang = unicode_to_char_p(LST_TO_ISO.get(
@@ -96,6 +98,7 @@ class Layout:
96
98
  self.layout = ffi.gc(
97
99
  pango.pango_layout_new(pango_context),
98
100
  gobject.g_object_unref)
101
+ pango.pango_layout_set_auto_dir(self.layout, False)
99
102
  pango.pango_layout_set_font_description(self.layout, font_description)
100
103
 
101
104
  text_decoration = style['text_decoration_line']
@@ -187,13 +190,11 @@ class Layout:
187
190
  pango.pango_layout_set_text(self.layout, text, -1)
188
191
 
189
192
  space_spacing = int(word_spacing * TO_UNITS + letter_spacing)
190
- position = bytestring.find(b' ')
191
193
  # Pango gives only half of word-spacing on boundaries
192
194
  boundary_positions = (0, len(bytestring) - 1)
193
- while position != -1:
194
- factor = 1 + (position in boundary_positions)
195
- add_attr(position, position + 1, factor * space_spacing)
196
- position = bytestring.find(b' ', position + 1)
195
+ for match in re.finditer(' |\u00a0'.encode(), bytestring):
196
+ factor = 1 + (match.start() in boundary_positions)
197
+ add_attr(match.start(), match.end(), factor * space_spacing)
197
198
 
198
199
  if word_breaking:
199
200
  attr = pango.pango_attr_insert_hyphens_new(False)
@@ -213,7 +214,7 @@ class Layout:
213
214
  layout.set_text(' ' * self.style['tab_size'])
214
215
  line, _ = layout.get_first_line()
215
216
  width, _ = line_size(line, self.style)
216
- width = int(round(width))
217
+ width = round(width)
217
218
  else:
218
219
  width = int(self.style['tab_size'].value)
219
220
  # 0 is not handled correctly by Pango
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weasyprint
3
- Version: 64.1
3
+ Version: 65.0
4
4
  Summary: The Awesome Document Factory
5
5
  Keywords: html,css,pdf,converter
6
6
  Author-email: Simon Sapin <simon.sapin@exyr.org>
@@ -30,7 +30,7 @@ Requires-Dist: pydyf >=0.11.0
30
30
  Requires-Dist: cffi >=0.6
31
31
  Requires-Dist: tinyhtml5 >=2.0.0b1
32
32
  Requires-Dist: tinycss2 >=1.4.0
33
- Requires-Dist: cssselect2 >=0.1
33
+ Requires-Dist: cssselect2 >=0.8.0
34
34
  Requires-Dist: Pyphen >=0.9.1
35
35
  Requires-Dist: Pillow >=9.1.0
36
36
  Requires-Dist: fonttools[woff] >=4.0.0