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
weasyprint/stacking.py CHANGED
@@ -45,7 +45,7 @@ class StackingContext:
45
45
  child_contexts = [cls.from_box(child, page) for child in page.children]
46
46
  # Children are sub-contexts, remove them from the "normal" tree.
47
47
  page = page.copy_with_children([])
48
- return cls(page, child_contexts, [], [], [], page)
48
+ return cls(page, child_contexts, [], [], {}, page)
49
49
 
50
50
  @classmethod
51
51
  def from_box(cls, box, page, child_contexts=None):
@@ -60,7 +60,7 @@ class StackingContext:
60
60
  # context, not this new one."
61
61
  blocks = []
62
62
  floats = []
63
- blocks_and_cells = []
63
+ blocks_and_cells = {}
64
64
  box = _dispatch_children(
65
65
  box, page, child_contexts, blocks, floats, blocks_and_cells)
66
66
  return cls(box, children, blocks, floats, blocks_and_cells, page)
@@ -99,22 +99,21 @@ def _dispatch(box, page, child_contexts, blocks, floats, blocks_and_cells):
99
99
  else:
100
100
  if isinstance(box, boxes.BlockLevelBox):
101
101
  blocks_index = len(blocks)
102
- blocks_and_cells_index = len(blocks_and_cells)
102
+ box_blocks_and_cells = {}
103
+ box = _dispatch_children(
104
+ box, page, child_contexts, blocks, floats, box_blocks_and_cells)
105
+ blocks.insert(blocks_index, box)
106
+ blocks_and_cells[box] = box_blocks_and_cells
103
107
  elif isinstance(box, boxes.TableCellBox):
104
- blocks_index = None
105
- blocks_and_cells_index = len(blocks_and_cells)
108
+ box_blocks_and_cells = {}
109
+ box = _dispatch_children(
110
+ box, page, child_contexts, blocks, floats, box_blocks_and_cells)
111
+ blocks_and_cells[box] = box_blocks_and_cells
106
112
  else:
107
113
  blocks_index = None
108
- blocks_and_cells_index = None
109
-
110
- box = _dispatch_children(
111
- box, page, child_contexts, blocks, floats, blocks_and_cells)
112
-
113
- # Insert at the positions before dispatch the children.
114
- if blocks_index is not None:
115
- blocks.insert(blocks_index, box)
116
- if blocks_and_cells_index is not None:
117
- blocks_and_cells.insert(blocks_and_cells_index, box)
114
+ box_blocks_and_cells = None
115
+ box = _dispatch_children(
116
+ box, page, child_contexts, blocks, floats, blocks_and_cells)
118
117
 
119
118
  return box
120
119
 
@@ -92,6 +92,7 @@ class Node:
92
92
  self._wrapper = wrapper
93
93
  self._etree_node = wrapper.etree_element
94
94
  self._style = style
95
+ self._children = None
95
96
 
96
97
  self.attrib = wrapper.etree_element.attrib.copy()
97
98
 
@@ -175,13 +176,16 @@ class Node:
175
176
  child._wrapper.etree_children = [
176
177
  child._etree_node for child in children]
177
178
 
178
-
179
179
  def __iter__(self):
180
180
  """Yield node children, handling cascade."""
181
- for wrapper in self._wrapper:
182
- child = Node(wrapper, self._style)
183
- self.cascade(child)
184
- yield child
181
+ if self._children is None:
182
+ children = []
183
+ for wrapper in self._wrapper:
184
+ child = Node(wrapper, self._style)
185
+ self.cascade(child)
186
+ children.append(child)
187
+ self._children = children
188
+ return iter(self._children)
185
189
 
186
190
  def get_viewbox(self):
187
191
  """Get node viewBox as a tuple of floats."""
@@ -210,7 +214,7 @@ class Node:
210
214
  return re.sub('[\n\r\t]', ' ', string)
211
215
  else:
212
216
  string = re.sub('[\n\r]', '', string)
213
- string = re.sub('\t', ' ', string)
217
+ string = string.replace('\t', ' ')
214
218
  return re.sub(' +', ' ', string)
215
219
 
216
220
  def get_child(self, id_):
@@ -320,14 +324,14 @@ class Node:
320
324
  svg.inner_diagonal = hypot(svg.inner_width, svg.inner_height) / sqrt(2)
321
325
 
322
326
 
323
-
324
327
  class SVG:
325
328
  """An SVG document."""
326
329
 
327
- def __init__(self, tree, url):
330
+ def __init__(self, tree, url, font_config):
328
331
  wrapper = ElementWrapper.from_xml_root(tree)
329
332
  style = parse_stylesheets(wrapper, url)
330
333
  self.tree = Node(wrapper, style)
334
+ self.font_config = font_config
331
335
  self.url = url
332
336
  self.filters = {}
333
337
  self.gradients = {}
@@ -401,6 +405,9 @@ class SVG:
401
405
 
402
406
  original_streams = []
403
407
 
408
+ call_fill_stroke = fill_stroke and node.tag in (
409
+ 'circle', 'ellipse', 'line', 'path', 'polyline', 'polygon', 'rect')
410
+
404
411
  if fill_stroke:
405
412
  self.stream.push_state()
406
413
 
@@ -410,16 +417,17 @@ class SVG:
410
417
  apply_filters(self, node, filter_, font_size)
411
418
 
412
419
  # Apply transform attribute
413
- self.transform(node.get('transform'), font_size)
420
+ self.transform(node, font_size)
414
421
 
415
422
  # Create substream for opacity
416
423
  opacity = alpha_value(node.get('opacity', 1))
417
424
  if fill_stroke and 0 <= opacity < 1:
418
425
  original_streams.append(self.stream)
419
- box = self.calculate_bounding_box(node, font_size)
420
- if not is_valid_bounding_box(box):
421
- box = (0, 0, self.inner_width, self.inner_height)
422
- self.stream = self.stream.add_group(*box)
426
+ self.stream = self.stream.add_group(0, 0, 0, 0) # BBox set after drawing
427
+
428
+ # Set graphical state
429
+ if call_fill_stroke:
430
+ self.set_graphical_state(node, font_size)
423
431
 
424
432
  # Clip
425
433
  clip_path = parse_url(node.get('clip-path')).fragment
@@ -515,7 +523,7 @@ class SVG:
515
523
  paint_mask(self, node, mask, opacity)
516
524
 
517
525
  # Fill and stroke
518
- if fill_stroke:
526
+ if call_fill_stroke:
519
527
  self.fill_stroke(node, font_size)
520
528
 
521
529
  # Draw markers
@@ -523,6 +531,12 @@ class SVG:
523
531
 
524
532
  # Apply opacity stream and restore original stream
525
533
  if fill_stroke and 0 <= opacity < 1:
534
+ box = self.calculate_bounding_box(node, font_size)
535
+ if not is_valid_bounding_box(box):
536
+ box = (0, 0, self.inner_width, self.inner_height)
537
+ x, y, width, height = box
538
+ self.stream.extra['BBox'][:] = x, y, x + width, y + height
539
+
526
540
  group_id = self.stream.id
527
541
  self.stream = original_streams.pop()
528
542
  self.stream.set_alpha(opacity, stroke=True, fill=True)
@@ -665,37 +679,30 @@ class SVG:
665
679
 
666
680
  return source, color
667
681
 
668
- def fill_stroke(self, node, font_size, text=False):
669
- """Paint fill and stroke for a node."""
670
- if node.tag in ('text', 'textPath', 'a') and not text:
671
- return
672
-
682
+ def set_graphical_state(self, node, font_size, text=False):
683
+ """Set stroke and fill colors, and line options."""
673
684
  # Get fill data
674
685
  fill_source, fill_color = self.get_paint(node.get('fill', 'black'))
675
686
  fill_opacity = alpha_value(node.get('fill-opacity', 1))
676
- fill_drawn = draw_gradient_or_pattern(
677
- self, node, fill_source, font_size, fill_opacity, stroke=False)
678
- if fill_color and not fill_drawn:
687
+ fill_in_gradient = fill_source in self.gradients
688
+ fill_in_pattern = fill_source in self.patterns
689
+ if fill_color and not (fill_in_gradient or fill_in_pattern):
679
690
  stream_color = color(fill_color)
680
691
  stream_color.alpha *= fill_opacity
681
692
  self.stream.set_color(stream_color)
682
- fill = fill_color or fill_drawn
683
693
 
684
694
  # Get stroke data
685
695
  stroke_source, stroke_color = self.get_paint(node.get('stroke'))
686
696
  stroke_opacity = alpha_value(node.get('stroke-opacity', 1))
687
- stroke_drawn = draw_gradient_or_pattern(
688
- self, node, stroke_source, font_size, stroke_opacity, stroke=True)
689
- if stroke_color and not stroke_drawn:
697
+ stroke_in_gradient = stroke_source in self.gradients
698
+ stroke_in_pattern = stroke_source in self.patterns
699
+ if stroke_color and not (stroke_in_gradient or stroke_in_pattern):
690
700
  stream_color = color(stroke_color)
691
701
  stream_color.alpha *= stroke_opacity
692
702
  self.stream.set_color(stream_color, stroke=True)
693
- stroke = stroke_color or stroke_drawn
694
703
  stroke_width = self.length(node.get('stroke-width', '1px'), font_size)
695
704
  if stroke_width:
696
705
  self.stream.set_line_width(stroke_width)
697
- else:
698
- stroke = None
699
706
 
700
707
  # Apply dash array
701
708
  dash_array = tuple(
@@ -738,6 +745,23 @@ class SVG:
738
745
  miter_limit = 4
739
746
  self.stream.set_miter_limit(miter_limit)
740
747
 
748
+ def fill_stroke(self, node, font_size, text=False):
749
+ """Paint fill and stroke for a node."""
750
+ # Get fill data
751
+ fill_source, fill_color = self.get_paint(node.get('fill', 'black'))
752
+ fill_opacity = alpha_value(node.get('fill-opacity', 1))
753
+ fill_drawn = draw_gradient_or_pattern(
754
+ self, node, fill_source, font_size, fill_opacity, stroke=False)
755
+ fill = fill_color or fill_drawn
756
+
757
+ # Get stroke data
758
+ stroke_source, stroke_color = self.get_paint(node.get('stroke'))
759
+ stroke_opacity = alpha_value(node.get('stroke-opacity', 1))
760
+ stroke_drawn = draw_gradient_or_pattern(
761
+ self, node, stroke_source, font_size, stroke_opacity, stroke=True)
762
+ stroke_width = self.length(node.get('stroke-width', '1px'), font_size)
763
+ stroke = (stroke_color or stroke_drawn) and stroke_width
764
+
741
765
  # Fill and stroke
742
766
  even_odd = node.get('fill-rule') == 'evenodd'
743
767
  if text:
@@ -760,12 +784,15 @@ class SVG:
760
784
  else:
761
785
  self.stream.end()
762
786
 
763
- def transform(self, transform_string, font_size):
787
+ def transform(self, node, font_size):
764
788
  """Apply a transformation string to the node."""
789
+ transform_origin = node.get('transform-origin')
790
+ transform_string = node.get('transform')
765
791
  if not transform_string:
766
792
  return
767
793
 
768
- matrix = transform(transform_string, font_size, self.inner_diagonal)
794
+ matrix = transform(
795
+ transform_string, transform_origin, font_size, self.inner_diagonal)
769
796
  if matrix.determinant:
770
797
  self.stream.transform(*matrix.values)
771
798
 
@@ -813,7 +840,7 @@ class SVG:
813
840
  class Pattern(SVG):
814
841
  """SVG node applied as a pattern."""
815
842
  def __init__(self, tree, svg):
816
- super().__init__(tree._etree_node, svg.url)
843
+ super().__init__(tree._etree_node, svg.url, svg.font_config)
817
844
  self.svg = svg
818
845
  self.tree = tree
819
846
 
@@ -206,7 +206,7 @@ def bounding_box_path(svg, node, font_size):
206
206
 
207
207
  def bounding_box_text(svg, node, font_size):
208
208
  """Bounding box for text node."""
209
- return node.get('text_bounding_box')
209
+ return getattr(node, 'text_bounding_box', None)
210
210
 
211
211
 
212
212
  def bounding_box_g(svg, node, font_size):
@@ -229,7 +229,9 @@ def bounding_box_use(svg, node, font_size):
229
229
  if (tree := get_use_tree(svg, node, font_size)) is None:
230
230
  return EMPTY_BOUNDING_BOX
231
231
  else:
232
- return bounding_box(svg, tree, font_size, True)
232
+ x, y = svg.point(node.get('x'), node.get('y'), font_size)
233
+ box = bounding_box(svg, tree, font_size, True)
234
+ return box[0] + x, box[1] + y, box[2], box[3]
233
235
 
234
236
 
235
237
  def _bounding_box_elliptical_arc(x1, y1, rx, ry, phi, large, sweep, x, y):
weasyprint/svg/defs.py CHANGED
@@ -44,12 +44,6 @@ def get_use_tree(svg, node, font_size):
44
44
 
45
45
  def use(svg, node, font_size):
46
46
  """Draw use tags."""
47
- x, y = svg.point(node.get('x'), node.get('y'), font_size)
48
-
49
- for attribute in ('x', 'y', 'viewBox', 'mask'):
50
- if attribute in node.attrib:
51
- del node.attrib[attribute]
52
-
53
47
  if (tree := get_use_tree(svg, node, font_size)) is None:
54
48
  return
55
49
 
@@ -69,6 +63,7 @@ def use(svg, node, font_size):
69
63
 
70
64
  node.cascade(tree)
71
65
  node.override_iter(iter((tree,)))
66
+ x, y = svg.point(node.get('x'), node.get('y'), font_size)
72
67
  svg.stream.transform(e=x, f=y)
73
68
 
74
69
 
@@ -137,7 +132,7 @@ def draw_gradient(svg, node, gradient, font_size, opacity, stroke):
137
132
 
138
133
  if 'gradientTransform' in gradient.attrib:
139
134
  transform_matrix = transform(
140
- gradient.get('gradientTransform'), font_size,
135
+ gradient.get('gradientTransform'), '0 0', font_size,
141
136
  svg.normalized_diagonal)
142
137
  matrix = transform_matrix @ matrix
143
138
 
@@ -407,7 +402,7 @@ def spread_radial_gradient(spread, positions, colors, fx, fy, fr, cx, cy, r,
407
402
  average_positions = [position, ratio, ratio, next_position]
408
403
  zero_color = gradient_average_color(
409
404
  average_colors, average_positions)
410
- colors = [zero_color] + original_colors[-(i - 1):] + colors
405
+ colors = [zero_color, *original_colors[-(i - 1):], *colors]
411
406
  new_positions = [
412
407
  position - 1 - full_repeat for position
413
408
  in original_positions[-(i - 1):]]
@@ -450,7 +445,7 @@ def draw_pattern(svg, node, pattern, font_size, opacity, stroke):
450
445
 
451
446
  if 'patternTransform' in pattern.attrib:
452
447
  transform_matrix = transform(
453
- pattern.get('patternTransform'), font_size, svg.inner_diagonal)
448
+ pattern.get('patternTransform'), '0 0', font_size, svg.inner_diagonal)
454
449
  matrix = transform_matrix @ matrix
455
450
 
456
451
  matrix = matrix @ svg.stream.ctm
weasyprint/svg/images.py CHANGED
@@ -58,8 +58,15 @@ def image(svg, node, font_size):
58
58
  intrinsic_width = intrinsic_ratio * intrinsic_height
59
59
  elif intrinsic_height is None:
60
60
  intrinsic_height = intrinsic_width / intrinsic_ratio
61
- width = width or intrinsic_width
62
- height = height or intrinsic_height
61
+
62
+ # Calculate final dimensions while preserving aspect ratio
63
+ if width and not height:
64
+ height = width / intrinsic_ratio
65
+ elif height and not width:
66
+ width = height * intrinsic_ratio
67
+ else:
68
+ width = width or intrinsic_width
69
+ height = height or intrinsic_height
63
70
 
64
71
  scale_x, scale_y, translate_x, translate_y = preserve_ratio(
65
72
  svg, node, font_size, width, height,
@@ -69,8 +76,9 @@ def image(svg, node, font_size):
69
76
  svg.stream.end()
70
77
  svg.stream.push_state()
71
78
  svg.stream.transform(a=scale_x, d=scale_y, e=translate_x, f=translate_y)
79
+ # TODO: pass real style instead of dict.
72
80
  image.draw(
73
81
  svg.stream, intrinsic_width, intrinsic_height,
74
- image_rendering=node.attrib.get('image-rendering', 'auto'),
82
+ {'image_rendering': node.attrib.get('image-rendering', 'auto')},
75
83
  )
76
84
  svg.stream.pop_state()
weasyprint/svg/text.py CHANGED
@@ -18,6 +18,10 @@ class TextBox:
18
18
  return self.pango_layout.text
19
19
 
20
20
 
21
+ class Style(dict):
22
+ """Dummy class to store dict."""
23
+
24
+
21
25
  def text(svg, node, font_size):
22
26
  """Draw text node."""
23
27
  from ..css.properties import INITIAL_VALUES
@@ -25,7 +29,9 @@ def text(svg, node, font_size):
25
29
  from ..text.line_break import split_first_line
26
30
 
27
31
  # TODO: use real computed values
28
- style = INITIAL_VALUES.copy()
32
+ style = Style()
33
+ style.update(INITIAL_VALUES)
34
+ style.font_config = svg.font_config
29
35
  style['font_family'] = [
30
36
  font.strip('"\'') for font in
31
37
  node.get('font-family', 'sans-serif').split(',')]
@@ -123,6 +129,7 @@ def text(svg, node, font_size):
123
129
  return
124
130
 
125
131
  svg.stream.push_state()
132
+ svg.set_graphical_state(node, font_size, text=True)
126
133
  svg.stream.begin_text()
127
134
  emoji_lines = []
128
135
 
@@ -166,4 +173,6 @@ def text(svg, node, font_size):
166
173
  svg.stream.pop_state()
167
174
 
168
175
  for font_size, x, y, emojis in emoji_lines:
169
- draw_emojis(svg.stream, font_size, x, y, emojis)
176
+ # TODO: pass real style instead of dict.
177
+ style = {'font_size': font_size}
178
+ draw_emojis(svg.stream, style, x, y, emojis)
weasyprint/svg/utils.py CHANGED
@@ -5,7 +5,7 @@ from contextlib import suppress
5
5
  from math import cos, radians, sin, tan
6
6
  from urllib.parse import urlparse
7
7
 
8
- from tinycss2.color4 import parse_color
8
+ from tinycss2.color5 import parse_color
9
9
 
10
10
  from ..matrix import Matrix
11
11
 
@@ -25,7 +25,7 @@ def normalize(string):
25
25
 
26
26
  def size(string, font_size=None, percentage_reference=None):
27
27
  """Compute size from string, resolving units and percentages."""
28
- from ..css.utils import LENGTHS_TO_PIXELS
28
+ from ..css.units import LENGTHS_TO_PIXELS
29
29
 
30
30
  if not string:
31
31
  return 0
@@ -94,7 +94,10 @@ def preserve_ratio(svg, node, font_size, width, height, viewbox=None):
94
94
  scale_x = width / viewbox_width if viewbox_width else 1
95
95
  scale_y = height / viewbox_height if viewbox_height else 1
96
96
 
97
- aspect_ratio = node.get('preserveAspectRatio', 'xMidYMid').split()
97
+ if viewbox:
98
+ aspect_ratio = node.get('preserveAspectRatio', 'xMidYMid').split()
99
+ else:
100
+ aspect_ratio = ('none',)
98
101
  align = aspect_ratio[0]
99
102
  if align == 'none':
100
103
  x_position = 'min'
@@ -149,13 +152,17 @@ def color(string):
149
152
  return parse_color(string or '') or parse_color('black')
150
153
 
151
154
 
152
- def transform(transform_string, font_size, normalized_diagonal):
155
+ def transform(transform_string, transform_origin, font_size, normalized_diagonal):
153
156
  """Get a matrix corresponding to the transform string."""
154
157
  # TODO: merge with gather_anchors and css.validation.properties.transform
155
- transformations = re.findall(
156
- r'(\w+) ?\( ?(.*?) ?\)', normalize(transform_string))
157
- matrix = Matrix()
158
158
 
159
+ origin_x, origin_y = 0, 0
160
+ size_strings = normalize(transform_origin).split()
161
+ if len(size_strings) == 2:
162
+ origin_x, origin_y = size(size_strings[0]), size(size_strings[1])
163
+ matrix = Matrix(e=origin_x, f=origin_y)
164
+
165
+ transformations = re.findall(r'(\w+) ?\( ?(.*?) ?\)', normalize(transform_string))
159
166
  for transformation_type, transformation in transformations:
160
167
  values = [
161
168
  size(value, font_size, normalized_diagonal)
@@ -196,4 +203,4 @@ def transform(transform_string, font_size, normalized_diagonal):
196
203
  if transformation_type in ('scaleY', 'scale'):
197
204
  matrix = Matrix(d=values.pop(0)) @ matrix
198
205
 
199
- return matrix
206
+ return Matrix(e=-origin_x, f=-origin_y) @ matrix
@@ -462,7 +462,7 @@ LANG_QUOTES = {
462
462
  }
463
463
 
464
464
 
465
- @lru_cache()
465
+ @lru_cache
466
466
  def get_lang_quotes(lang):
467
467
  if lang in LANG_QUOTES:
468
468
  return LANG_QUOTES[lang]
weasyprint/text/ffi.py CHANGED
@@ -22,6 +22,7 @@ ffi.cdef('''
22
22
  hb_blob_t * hb_face_reference_blob (hb_face_t *face);
23
23
  unsigned int hb_face_get_index (const hb_face_t *face);
24
24
  unsigned int hb_face_get_upem (const hb_face_t *face);
25
+ unsigned int hb_face_get_glyph_count (const hb_face_t *face);
25
26
  hb_blob_t * hb_face_reference_table (const hb_face_t *face, hb_tag_t tag);
26
27
  const char * hb_blob_get_data (hb_blob_t *blob, unsigned int *length);
27
28
  unsigned int hb_blob_get_length (hb_blob_t *blob);
@@ -450,8 +451,8 @@ def _dlopen(ffi, *names, allow_fail=False):
450
451
  return ffi.dlopen(name, flags)
451
452
  if allow_fail:
452
453
  return
453
- # Re-raise the exception.
454
- print(
454
+ # Print error message and re-raise the exception.
455
+ print( # noqa: T201, logger is not configured yet
455
456
  '\n-----\n\n'
456
457
  'WeasyPrint could not import some external libraries. Please '
457
458
  'carefully follow the installation steps before reporting an issue:\n'
@@ -474,7 +475,7 @@ if hasattr(os, 'add_dll_directory') and not hasattr(sys, 'frozen'): # pragma: n
474
475
 
475
476
  gobject = _dlopen(
476
477
  ffi, 'libgobject-2.0-0', 'gobject-2.0-0', 'gobject-2.0',
477
- 'libgobject-2.0.so.0', 'libgobject-2.0.dylib', 'libgobject-2.0-0.dll')
478
+ 'libgobject-2.0.so.0', 'libgobject-2.0.0.dylib', 'libgobject-2.0-0.dll')
478
479
  pango = _dlopen(
479
480
  ffi, 'libpango-1.0-0', 'pango-1.0-0', 'pango-1.0', 'libpango-1.0.so.0',
480
481
  'libpango-1.0.dylib', 'libpango-1.0-0.dll')
weasyprint/text/fonts.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  from hashlib import md5
4
4
  from io import BytesIO
5
+ from locale import getpreferredencoding
5
6
  from pathlib import Path
6
7
  from shutil import rmtree
7
8
  from tempfile import mkdtemp
@@ -11,7 +12,7 @@ from xml.etree.ElementTree import Element, SubElement, tostring
11
12
  from fontTools.ttLib import TTFont, woff2
12
13
 
13
14
  from ..logger import LOGGER
14
- from ..urls import FILESYSTEM_ENCODING, fetch
15
+ from ..urls import fetch
15
16
 
16
17
  from .constants import ( # isort:skip
17
18
  CAPS_KEYS, EAST_ASIAN_KEYS, FONTCONFIG_STRETCH, FONTCONFIG_STYLE, FONTCONFIG_WEIGHT,
@@ -20,6 +21,8 @@ from .ffi import ( # isort:skip
20
21
  FROM_UNITS, TO_UNITS, ffi, fontconfig, gobject, harfbuzz, pango, pangoft2,
21
22
  unicode_to_char_p)
22
23
 
24
+ PREFERRED_ENCODING = getpreferredencoding(False)
25
+
23
26
 
24
27
  def _check_font_configuration(font_config): # pragma: no cover
25
28
  """Check whether the given font_config has fonts.
@@ -105,6 +108,10 @@ class FontConfiguration:
105
108
  # Temporary folder storing fonts.
106
109
  self._folder = None
107
110
 
111
+ # Cache.
112
+ self.strut_layouts = {}
113
+ self.font_features = {}
114
+
108
115
  def add_font_face(self, rule_descriptors, url_fetcher):
109
116
  """Add a font face to the Fontconfig configuration."""
110
117
 
@@ -150,7 +157,7 @@ class FontConfiguration:
150
157
  if font_name.lower() == name.lower():
151
158
  fontconfig.FcPatternGetString(
152
159
  matching_pattern, b'file', 0, string)
153
- path = ffi.string(string[0]).decode(FILESYSTEM_ENCODING)
160
+ path = ffi.string(string[0]).decode(PREFERRED_ENCODING)
154
161
  url = Path(path).as_uri()
155
162
  break
156
163
  else:
@@ -191,7 +198,8 @@ class FontConfiguration:
191
198
  match = SubElement(root, 'match', target='scan')
192
199
  test = SubElement(match, 'test', name='file', compare='eq')
193
200
  SubElement(test, 'string').text = str(font_path)
194
- edit = SubElement(match, 'edit', name='family', mode=mode)
201
+ # Prepend, as replacing the font family breaks Pango, see #2510.
202
+ edit = SubElement(match, 'edit', name='family', mode='prepend')
195
203
  SubElement(edit, 'string').text = rule_descriptors['font_family']
196
204
  if 'font_style' in rule_descriptors:
197
205
  edit = SubElement(match, 'edit', name='slant', mode=mode)
@@ -235,7 +243,7 @@ class FontConfiguration:
235
243
  # too as explained in Behdad's blog entry.
236
244
  fontconfig.FcConfigParseAndLoadFromMemory(self._config, xml, True)
237
245
  font_added = fontconfig.FcConfigAppFontAddFile(
238
- self._config, str(font_path).encode(FILESYSTEM_ENCODING))
246
+ self._config, str(font_path).encode(PREFERRED_ENCODING))
239
247
  if font_added:
240
248
  return pangoft2.pango_fc_font_map_config_changed(
241
249
  ffi.cast('PangoFcFontMap *', self.font_map))
@@ -374,7 +382,7 @@ def get_pango_font_key(pango_font):
374
382
  # TODO: This value is stable for a given Pango font in a given Pango map, but can’t
375
383
  # be cached with just the Pango font as a key because two Pango fonts could point to
376
384
  # the same address for two different Pango maps. We should cache it in the
377
- # FontConfiguration object. See https://github.com/Kozea/WeasyPrint/issues/2144
385
+ # FontConfiguration object. See issue #2144.
378
386
  description = ffi.gc(
379
387
  pango.pango_font_describe(pango_font), pango.pango_font_description_free)
380
388
  font_size = pango.pango_font_description_get_size(description) * FROM_UNITS