prawn-svg 0.38.1 → 0.39.0

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/README.md +3 -1
  4. data/lib/prawn/svg/attributes/mask.rb +16 -0
  5. data/lib/prawn/svg/attributes.rb +1 -1
  6. data/lib/prawn/svg/document.rb +8 -0
  7. data/lib/prawn/svg/elements/anchor.rb +9 -0
  8. data/lib/prawn/svg/elements/base.rb +6 -0
  9. data/lib/prawn/svg/elements/mask.rb +153 -0
  10. data/lib/prawn/svg/elements/text_component.rb +34 -13
  11. data/lib/prawn/svg/elements/text_node.rb +97 -6
  12. data/lib/prawn/svg/elements.rb +3 -3
  13. data/lib/prawn/svg/font_metrics.rb +27 -16
  14. data/lib/prawn/svg/gradient_renderer.rb +11 -3
  15. data/lib/prawn/svg/link_renderer.rb +40 -0
  16. data/lib/prawn/svg/properties.rb +1 -0
  17. data/lib/prawn/svg/renderer.rb +29 -2
  18. data/lib/prawn/svg/state.rb +1 -1
  19. data/lib/prawn/svg/version.rb +1 -1
  20. data/lib/prawn-svg.rb +1 -0
  21. data/spec/integration_spec.rb +20 -0
  22. data/spec/prawn/svg/elements/mask_spec.rb +382 -0
  23. data/spec/prawn/svg/elements/text_spec.rb +133 -131
  24. data/spec/prawn/svg/font_metrics_spec.rb +14 -0
  25. data/spec/sample_svg/links.svg +42 -2
  26. data/spec/sample_svg/mask3.svg +13 -0
  27. data/spec/sample_svg/mask3b.svg +13 -0
  28. data/spec/sample_svg/mask_basic.svg +11 -0
  29. data/spec/sample_svg/mask_contentUnits_objectBoundingBox.svg +11 -0
  30. data/spec/sample_svg/mask_gradient.svg +13 -0
  31. data/spec/sample_svg/mask_image.svg +10 -0
  32. data/spec/sample_svg/mask_multiple.svg +15 -0
  33. data/spec/sample_svg/mask_nested.svg +15 -0
  34. data/spec/sample_svg/mask_opacity.svg +21 -0
  35. data/spec/sample_svg/mask_text.svg +10 -0
  36. data/spec/sample_svg/mask_text_gradient.svg +31 -0
  37. data/spec/sample_svg/mask_units_objectBoundingBox.svg +11 -0
  38. data/spec/sample_svg/mask_units_userSpaceOnUse.svg +10 -0
  39. data/spec/sample_svg/mask_with_transform.svg +11 -0
  40. data/spec/sample_svg/multilingual.svg +7 -0
  41. metadata +23 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48e3c463c4c8684671c20e26e701c51d07fd58322f2a7557d03b1ccccd1621c3
4
- data.tar.gz: 12e77f6409d625d9e2682f1efdd9519f983a8dc24e8dcc7371ff79febe2a43ea
3
+ metadata.gz: 3c524e2dc0d6754bcd60ca3d4e056f64069621b4412ff90b2f07c01123837e80
4
+ data.tar.gz: 3edb96730b5a94d0c5db17ac5cd47e6dd4ab4af4e93b409555fc94e69b12dde4
5
5
  SHA512:
6
- metadata.gz: 9f212b78477d9211a1ca009f7667d05f27f0138e467939d19d1c252a017a416e2a0267e613426142c50b77e83a613cc12e2e65af8d38e3e7c71bcf0816cc062a
7
- data.tar.gz: fb5f095597da89d3d6127429b51f4fe8cc125ef14c01f3046bd0a7fced4379abb9ad2bf89a35ec2f38c96e4d06775a2c8dd7edec85bca947e385c90e00b6435a
6
+ metadata.gz: 344edee9e2156c94d4d82aa995d38df4a73cad64966979d70da1220a30875da3837dbc962d968347b52d1bc55ee843e141275f62e013de31362ea064fbc24f92
7
+ data.tar.gz: a65858effa7cabdff362876b3e877bed5d1e35a9b9dcc70fbc68d23849faea060f14f63fe710baf510e011e5f5433014b365146ac47acef4fd35f2ad02221fbd
data/.gitignore CHANGED
@@ -1,5 +1,6 @@
1
1
  .DS_Store
2
2
  spec/sample_output/*.pdf
3
+ spec/sample_ttf/NotoSansJP-Regular.ttf
3
4
  prawn-svg-*.gem
4
5
  .rvmrc
5
6
  .*.swp
data/README.md CHANGED
@@ -77,6 +77,8 @@ prawn-svg supports most but not all of the full SVG 1.1 specification. It curre
77
77
 
78
78
  - `<clipPath>`
79
79
 
80
+ - `<mask>` with attributes `maskUnits` and `maskContentUnits`
81
+
80
82
  - `<marker>`
81
83
 
82
84
  - `<linearGradient>` and `<radialGradient>` are implemented on Prawn 2.2.0+ with attributes `gradientUnits` and
@@ -120,7 +122,7 @@ Pseudo-elements and the other pseudo-classes are not supported.
120
122
 
121
123
  ## Not supported
122
124
 
123
- prawn-svg does not support hyperlinks, patterns, masks or filters.
125
+ prawn-svg does not support patterns or filters.
124
126
 
125
127
  It does not support text in the clip area, but you can clip shapes and text by any shape.
126
128
 
@@ -0,0 +1,16 @@
1
+ module Prawn::SVG::Attributes::Mask
2
+ def parse_mask_attribute_and_call
3
+ return unless (mask = properties.mask)
4
+ return if mask == 'none'
5
+
6
+ mask_element = extract_element_from_url_id_reference(mask, 'mask')
7
+
8
+ if mask_element.nil?
9
+ document.warnings << 'Could not resolve mask URI to a mask element'
10
+ else
11
+ add_call_and_enter 'save_graphics_state'
12
+ mask_calls = mask_element.build_mask_calls(self)
13
+ @calls << ['soft_mask', [], {}, mask_calls]
14
+ end
15
+ end
16
+ end
@@ -1,6 +1,6 @@
1
1
  module Prawn::SVG::Attributes
2
2
  end
3
3
 
4
- %w[transform opacity clip_path stroke space].each do |name|
4
+ %w[transform opacity clip_path mask stroke space].each do |name|
5
5
  require "prawn/svg/attributes/#{name}"
6
6
  end
@@ -54,6 +54,14 @@ class Prawn::SVG::Document
54
54
  sizing.calculate
55
55
  end
56
56
 
57
+ def with_sizing(temporary_sizing)
58
+ original = @sizing
59
+ @sizing = temporary_sizing
60
+ yield
61
+ ensure
62
+ @sizing = original
63
+ end
64
+
57
65
  private
58
66
 
59
67
  def load_color_mode
@@ -0,0 +1,9 @@
1
+ class Prawn::SVG::Elements::Anchor < Prawn::SVG::Elements::Base
2
+ def parse
3
+ state.anchor_href = href_attribute
4
+ end
5
+
6
+ def container?
7
+ true
8
+ end
9
+ end
@@ -8,6 +8,7 @@ class Prawn::SVG::Elements::Base
8
8
  include Prawn::SVG::Attributes::Transform
9
9
  include Prawn::SVG::Attributes::Opacity
10
10
  include Prawn::SVG::Attributes::ClipPath
11
+ include Prawn::SVG::Attributes::Mask
11
12
  include Prawn::SVG::Attributes::Stroke
12
13
  include Prawn::SVG::Attributes::Space
13
14
 
@@ -56,6 +57,10 @@ class Prawn::SVG::Elements::Base
56
57
  apply_calls_from_standard_attributes
57
58
  apply
58
59
 
60
+ if state.anchor_href && bounding_box
61
+ add_call('svg:add_link', state.anchor_href, bounding_box)
62
+ end
63
+
59
64
  process_child_elements if container?
60
65
 
61
66
  append_calls_to_parent unless computed_properties.display == 'none'
@@ -154,6 +159,7 @@ class Prawn::SVG::Elements::Base
154
159
  parse_transform_attribute_and_call
155
160
  parse_opacity_attributes_and_call
156
161
  parse_clip_path_attribute_and_call
162
+ parse_mask_attribute_and_call
157
163
  apply_colors
158
164
  parse_stroke_attributes_and_call
159
165
  apply_drawing_call
@@ -0,0 +1,153 @@
1
+ class Prawn::SVG::Elements::Mask < Prawn::SVG::Elements::Base
2
+ def parse
3
+ properties.display = 'none'
4
+ computed_properties.display = 'none'
5
+ end
6
+
7
+ def container?
8
+ true
9
+ end
10
+
11
+ def build_mask_calls(element)
12
+ bbox = element.bounding_box
13
+ mask_units = attributes['maskUnits'] || 'objectBoundingBox'
14
+ content_units = attributes['maskContentUnits'] || 'userSpaceOnUse'
15
+
16
+ if content_units == 'objectBoundingBox' && bbox.nil?
17
+ document.warnings << 'mask with maskContentUnits="objectBoundingBox" requires element to have a bounding box'
18
+ return []
19
+ end
20
+
21
+ calls = []
22
+
23
+ calls.concat(build_clip_calls(bbox, mask_units)) if bbox || mask_units == 'userSpaceOnUse'
24
+
25
+ if content_units == 'objectBoundingBox'
26
+ calls.concat(build_object_bounding_box_calls(bbox))
27
+ else
28
+ calls.concat(duplicate_calls(base_calls))
29
+ end
30
+
31
+ calls
32
+ end
33
+
34
+ private
35
+
36
+ def build_clip_calls(bbox, mask_units)
37
+ if mask_units == 'objectBoundingBox'
38
+ mask_x = Float(attributes['x'] || '-0.1')
39
+ mask_y = Float(attributes['y'] || '-0.1')
40
+ mask_w = Float(attributes['width'] || '1.2')
41
+ mask_h = Float(attributes['height'] || '1.2')
42
+
43
+ bbox_left = bbox[0]
44
+ bbox_top = bbox[1]
45
+ bbox_right = bbox[2]
46
+ bbox_bottom = bbox[3]
47
+ bbox_w = bbox_right - bbox_left
48
+ bbox_h = bbox_top - bbox_bottom
49
+
50
+ clip_left = bbox_left + (mask_x * bbox_w)
51
+ clip_top = bbox_top - (mask_y * bbox_h)
52
+ clip_width = mask_w * bbox_w
53
+ clip_height = mask_h * bbox_h
54
+ else
55
+ clip_left = x_pixels(attributes['x'] || '-10%')
56
+ clip_top = y(attributes['y'] || '-10%')
57
+ clip_width = x_pixels(attributes['width'] || '120%')
58
+ clip_height = y_pixels(attributes['height'] || '120%')
59
+ end
60
+
61
+ [
62
+ ['rectangle', [[clip_left, clip_top], clip_width, clip_height], {}, []],
63
+ ['clip', [], {}, []]
64
+ ]
65
+ end
66
+
67
+ def build_object_bounding_box_calls(bbox)
68
+ bbox_left = bbox[0]
69
+ bbox_top = bbox[1]
70
+ bbox_right = bbox[2]
71
+ bbox_bottom = bbox[3]
72
+ bbox_w = bbox_right - bbox_left
73
+ bbox_h = bbox_top - bbox_bottom
74
+
75
+ # Prawn's soft_mask doesn't support transformation_matrix inside the block,
76
+ # so we must produce calls with final Prawn coordinates.
77
+ #
78
+ # Set up sizing so that:
79
+ # - viewport = 1x1 (objectBoundingBox fractions resolve as-is for unitless values)
80
+ # - output_height = bbox_top, so y(frac) = bbox_top - frac = correct when bbox_h=1
81
+ #
82
+ # Then scale x by bbox_w, offset x by bbox_left, and scale y displacement by bbox_h.
83
+
84
+ unit_sizing = Prawn::SVG::Calculators::DocumentSizing.new([1, 1])
85
+ unit_sizing.document_width = 1
86
+ unit_sizing.document_height = 1
87
+ unit_sizing.calculate
88
+
89
+ result_calls = document.with_sizing(unit_sizing) do
90
+ new_state = state.dup
91
+ new_state.viewport_sizing = unit_sizing
92
+ new_state.inside_use = true
93
+
94
+ container = Prawn::SVG::Elements::Container.new(document, source, [], new_state)
95
+ container.process
96
+
97
+ container.base_calls
98
+ end
99
+
100
+ scale_calls_to_bbox(duplicate_calls(result_calls), bbox_left, bbox_top, bbox_w, bbox_h)
101
+ end
102
+
103
+ def scale_calls_to_bbox(calls, bbox_left, bbox_top, bbox_w, bbox_h)
104
+ calls.map do |name, args, kwargs, children|
105
+ new_args = case name
106
+ when 'rectangle'
107
+ point, width, height = args
108
+ [scale_point(point, bbox_left, bbox_top, bbox_w, bbox_h), width * bbox_w, height * bbox_h]
109
+ when 'rounded_rectangle'
110
+ point, width, height, radius = args
111
+ [scale_point(point, bbox_left, bbox_top, bbox_w, bbox_h), width * bbox_w, height * bbox_h, radius * bbox_w]
112
+ when 'move_to', 'line_to'
113
+ [scale_point(args[0], bbox_left, bbox_top, bbox_w, bbox_h)]
114
+ when 'circle'
115
+ point, radius = args
116
+ scaled_point = scale_point(point, bbox_left, bbox_top, bbox_w, bbox_h)
117
+ if bbox_w == bbox_h
118
+ [scaled_point, radius * bbox_w]
119
+ else
120
+ new_children = children.any? ? scale_calls_to_bbox(children, bbox_left, bbox_top, bbox_w, bbox_h) : children
121
+ next ['ellipse', [scaled_point, radius * bbox_w, radius * bbox_h], kwargs, new_children]
122
+ end
123
+ when 'ellipse'
124
+ point, rx, ry = args
125
+ [scale_point(point, bbox_left, bbox_top, bbox_w, bbox_h), rx * bbox_w, ry * bbox_h]
126
+ when 'curve_to'
127
+ dest = scale_point(args[0], bbox_left, bbox_top, bbox_w, bbox_h)
128
+ [dest]
129
+ else
130
+ args
131
+ end
132
+
133
+ new_kwargs = if name == 'curve_to' && kwargs[:bounds]
134
+ b = kwargs[:bounds]
135
+ { bounds: b.map { |p| scale_point(p, bbox_left, bbox_top, bbox_w, bbox_h) } }
136
+ else
137
+ kwargs
138
+ end
139
+
140
+ new_children = children.any? ? scale_calls_to_bbox(children, bbox_left, bbox_top, bbox_w, bbox_h) : children
141
+ [name, new_args, new_kwargs, new_children]
142
+ end
143
+ end
144
+
145
+ def scale_point(point, bbox_left, bbox_top, bbox_w, bbox_h)
146
+ # point is [x, 1.0 - svg_y] from unit sizing where output_height = 1
147
+ # We need [bbox_left + svg_x * bbox_w, bbox_top - svg_y * bbox_h]
148
+ # svg_x = point[0], svg_y = 1.0 - point[1]
149
+ x = bbox_left + (point[0] * bbox_w)
150
+ y = bbox_top - ((1.0 - point[1]) * bbox_h)
151
+ [x, y]
152
+ end
153
+ end
@@ -2,7 +2,7 @@ module Prawn::SVG
2
2
  class Elements::TextComponent < Elements::DirectRenderBase
3
3
  attr_reader :children, :parent_component
4
4
  attr_reader :x_values, :y_values, :dx, :dy, :rotation, :text_length, :length_adjust
5
- attr_reader :font
5
+ attr_reader :font, :fallback_fonts
6
6
 
7
7
  def initialize(document, source, _calls, state, parent_component = nil)
8
8
  if parent_component.nil? && source.name != 'text'
@@ -24,7 +24,7 @@ module Prawn::SVG
24
24
  @text_length = normalize_length(attributes['textLength'])
25
25
  @length_adjust = attributes['lengthAdjust']
26
26
 
27
- @font = select_font
27
+ @font, @fallback_fonts = select_fonts
28
28
 
29
29
  @children = svg_text_children.flat_map do |child|
30
30
  if child.node_type == :text
@@ -42,9 +42,8 @@ module Prawn::SVG
42
42
  end
43
43
 
44
44
  def lay_out(prawn)
45
- @children.each do |child|
46
- prawn.save_font do
47
- prawn.font(font.name, style: font.subfamily) if font
45
+ with_svg_fonts(prawn) do
46
+ @children.each do |child|
48
47
  child.lay_out(prawn)
49
48
  end
50
49
  end
@@ -73,9 +72,7 @@ module Prawn::SVG
73
72
  y_offset = -height / 2.0
74
73
  end
75
74
 
76
- prawn.save_font do
77
- prawn.font(font.name, style: font.subfamily) if font
78
-
75
+ with_svg_fonts(prawn) do
79
76
  children.each do |child|
80
77
  case child
81
78
  when Elements::TextNode
@@ -165,18 +162,42 @@ module Prawn::SVG
165
162
  end
166
163
  end
167
164
 
168
- def select_font
169
- font_families = [computed_properties.font_family, document.fallback_font_name]
165
+ def select_fonts
170
166
  font_style = :italic if computed_properties.font_style == 'italic'
171
167
  font_weight = computed_properties.font_weight
168
+ fonts = []
172
169
 
173
- font_families.compact.each do |name|
170
+ font_family_names.each do |name|
174
171
  font = document.font_registry.load(name, font_weight, font_style)
175
- return font if font
172
+ next unless font
173
+ next if fonts.any? { |existing| existing.name == font.name && existing.subfamily == font.subfamily }
174
+
175
+ fonts << font
176
176
  end
177
177
 
178
+ _, *fallback_font_names = fonts.map(&:name).uniq
179
+
180
+ return [fonts.first, fallback_font_names] if fonts.any?
181
+
178
182
  warnings << "Font family '#{computed_properties.font_family}' style '#{computed_properties.font_style}' is not a known font, and the fallback font could not be found."
179
- nil
183
+ []
184
+ end
185
+
186
+ def with_svg_fonts(prawn)
187
+ prawn.save_font do
188
+ if font
189
+ prawn.font(font.name, style: font.subfamily)
190
+ prawn.fallback_fonts(fallback_fonts)
191
+ end
192
+
193
+ yield
194
+ end
195
+ end
196
+
197
+ def font_family_names
198
+ names = CSS::FontFamilyParser.parse(computed_properties.font_family.to_s)
199
+ names << document.fallback_font_name if document.fallback_font_name
200
+ names.map(&:strip).reject(&:empty?)
180
201
  end
181
202
 
182
203
  def total_flexible_and_fixed_width
@@ -1,6 +1,6 @@
1
1
  module Prawn::SVG
2
2
  class Elements::TextNode
3
- Chunk = Struct.new(:text, :x, :y, :dx, :dy, :rotate, :base_width, :offset, :fixed_width)
3
+ Chunk = Struct.new(:text, :x, :y, :dx, :dy, :rotate, :base_width, :offset, :fixed_width, :font_runs)
4
4
 
5
5
  attr_reader :component, :chunks
6
6
  attr_accessor :text
@@ -75,12 +75,15 @@ module Prawn::SVG
75
75
 
76
76
  opts = { size: component.computed_properties.numeric_font_size, kerning: true }
77
77
 
78
+ fallback_fonts = component.fallback_fonts
79
+ font_runs = fallback_fonts&.any? ? split_into_font_runs(prawn, text_to_draw, fallback_fonts) : nil
80
+
78
81
  total_spacing = text_to_draw.length > 1 ? (component.letter_spacing_pixels || 0) * (text_to_draw.length - 1) : 0
79
- base_width = prawn.width_of(text_to_draw, opts) + total_spacing
82
+ base_width = width_of_text(prawn, text_to_draw, font_runs, opts) + total_spacing
80
83
 
81
84
  offset = dx ? [0, dx].max : 0
82
85
 
83
- @chunks << Chunk.new(text_to_draw, x, y, dx, dy, rotate, base_width, offset, nil)
86
+ @chunks << Chunk.new(text_to_draw, x, y, dx, dy, rotate, base_width, offset, nil, font_runs)
84
87
 
85
88
  if remaining
86
89
  remaining_text = remaining_text[1..]
@@ -110,9 +113,12 @@ module Prawn::SVG
110
113
  cursor.y = chunk.y if chunk.y
111
114
  cursor.y -= chunk.dy if chunk.dy
112
115
 
113
- render_underline(prawn, size, cursor, y_offset, chunk.fixed_width || chunk.base_width) if component.computed_properties.text_decoration == 'underline'
116
+ width = chunk.fixed_width || chunk.base_width
117
+
118
+ render_underline(prawn, size, cursor, y_offset, width) if component.computed_properties.text_decoration == 'underline'
119
+ render_link_annotation(prawn, size, cursor, y_offset, width)
114
120
 
115
- opts = { size: size, at: [cursor.x, cursor.y + (y_offset || 0)] }
121
+ opts = { size: size, at: [cursor.x, cursor.y + (y_offset || 0)], kerning: true }
116
122
  opts[:rotate] = chunk.rotate if chunk.rotate
117
123
 
118
124
  scaling =
@@ -137,7 +143,7 @@ module Prawn::SVG
137
143
  prawn.horizontal_text_scaling(scaling) do
138
144
  prawn.character_spacing(spacing || component.letter_spacing_pixels || prawn.character_spacing) do
139
145
  prawn.text_rendering_mode(calculate_text_rendering_mode) do
140
- prawn.draw_text(chunk.text, **opts)
146
+ render_text_directly(prawn, chunk.text, chunk.font_runs, opts)
141
147
  end
142
148
  end
143
149
  end
@@ -150,12 +156,97 @@ module Prawn::SVG
150
156
  end
151
157
  end
152
158
 
159
+ def width_of_text(prawn, text, font_runs, opts)
160
+ if font_runs.nil?
161
+ prawn.width_of(text, **opts)
162
+ else
163
+ font_runs.sum(0.0) do |font_name, run_text|
164
+ if font_name
165
+ width = nil
166
+ prawn.font(font_name) { width = prawn.width_of(run_text, **opts) }
167
+ width
168
+ else
169
+ prawn.width_of(run_text, **opts)
170
+ end
171
+ end
172
+ end
173
+ end
174
+
175
+ def render_text_directly(prawn, text, font_runs, opts)
176
+ if font_runs.nil?
177
+ prawn.draw_text(text, **opts)
178
+ else
179
+ x = opts[:at][0]
180
+ font_runs.each do |font_name, run_text|
181
+ run_opts = opts.merge(at: [x, opts[:at][1]])
182
+ if font_name
183
+ prawn.font(font_name) do
184
+ prawn.draw_text(run_text, **run_opts)
185
+ x += prawn.width_of(run_text, size: opts[:size], kerning: true)
186
+ end
187
+ else
188
+ prawn.draw_text(run_text, **run_opts)
189
+ x += prawn.width_of(run_text, size: opts[:size], kerning: true)
190
+ end
191
+ end
192
+ end
193
+ end
194
+
195
+ def split_into_font_runs(prawn, text, fallback_fonts)
196
+ original_font = prawn.font.family
197
+ runs = []
198
+ current_font = nil
199
+ current_text = +''
200
+
201
+ prawn.save_font do
202
+ text.each_char do |char|
203
+ font_for_char = font_for_glyph(prawn, char, original_font, fallback_fonts)
204
+
205
+ if font_for_char != current_font && !current_text.empty?
206
+ runs << [current_font, current_text]
207
+ current_text = +''
208
+ end
209
+ current_font = font_for_char
210
+ current_text << char
211
+ end
212
+ end
213
+
214
+ runs << [current_font, current_text] unless current_text.empty?
215
+ runs
216
+ end
217
+
218
+ def font_for_glyph(prawn, char, original_font, fallback_fonts)
219
+ prawn.font(original_font)
220
+ return nil if prawn.font.glyph_present?(char)
221
+
222
+ fallback_fonts.each do |fb|
223
+ prawn.font(fb)
224
+ return fb if prawn.font.glyph_present?(char)
225
+ end
226
+
227
+ nil
228
+ end
229
+
153
230
  def render_underline(prawn, size, cursor, y_offset, width)
154
231
  offset, thickness = FontMetrics.underline_metrics(prawn, size)
155
232
 
156
233
  prawn.fill_rectangle [cursor.x, cursor.y + (y_offset || 0) + offset + (thickness / 2.0)], width, thickness
157
234
  end
158
235
 
236
+ def render_link_annotation(prawn, size, cursor, y_offset, width)
237
+ href = component.state.anchor_href
238
+ return unless href
239
+
240
+ text_bottom = cursor.y + (y_offset || 0) - scaled_font_size(prawn, :descender, size)
241
+ font_height = scaled_font_size(prawn, :height, size)
242
+
243
+ LinkRenderer.new(href, [cursor.x, text_bottom + font_height, cursor.x + width, text_bottom]).render(prawn)
244
+ end
245
+
246
+ def scaled_font_size(prawn, method_name, size)
247
+ (prawn.font.public_send(method_name) / prawn.font_size) * size
248
+ end
249
+
159
250
  def calculate_text_rendering_mode
160
251
  fill = !component.computed_properties.fill.none? # rubocop:disable Style/InverseMethods
161
252
  stroke = !component.computed_properties.stroke.none? # rubocop:disable Style/InverseMethods
@@ -4,7 +4,7 @@ end
4
4
 
5
5
  require 'prawn/svg/elements/call_duplicator'
6
6
 
7
- %w[base direct_render_base root container clip_path viewport text text_component text_node line polyline polygon circle ellipse
7
+ %w[base direct_render_base root anchor container clip_path mask viewport text text_component text_node line polyline polygon circle ellipse
8
8
  rect path use image gradient marker ignored].each do |filename|
9
9
  require "prawn/svg/elements/#{filename}"
10
10
  end
@@ -14,7 +14,7 @@ module Prawn::SVG::Elements
14
14
  g: Prawn::SVG::Elements::Container,
15
15
  symbol: Prawn::SVG::Elements::Container,
16
16
  defs: Prawn::SVG::Elements::Container,
17
- a: Prawn::SVG::Elements::Container,
17
+ a: Prawn::SVG::Elements::Anchor,
18
18
  clipPath: Prawn::SVG::Elements::ClipPath,
19
19
  switch: Prawn::SVG::Elements::Container,
20
20
  svg: Prawn::SVG::Elements::Viewport,
@@ -38,6 +38,6 @@ module Prawn::SVG::Elements
38
38
  foreignObject: Prawn::SVG::Elements::Ignored,
39
39
  'font-face': Prawn::SVG::Elements::Ignored,
40
40
  filter: Prawn::SVG::Elements::Ignored, # unsupported
41
- mask: Prawn::SVG::Elements::Ignored # unsupported
41
+ mask: Prawn::SVG::Elements::Mask
42
42
  }.freeze
43
43
  end
@@ -4,33 +4,44 @@ class Prawn::SVG::FontMetrics
4
4
  DEFAULT_X_HEIGHT_RATIO = 0.5
5
5
 
6
6
  def x_height_in_points(pdf, font_size)
7
- @x_height_cache ||= {}
8
-
9
- cache_key = cache_key_for(pdf)
10
-
11
- @x_height_cache[cache_key] ||= calculate_x_height_ratio(pdf)
12
- @x_height_cache[cache_key] * font_size
7
+ x_height = cache(:x_height, pdf.font) { calculate_x_height_ratio(pdf) }
8
+ x_height * font_size
13
9
  end
14
10
 
15
11
  def underline_metrics(pdf, size)
16
- @underline_metrics_cache ||= {}
17
-
18
- cache_key = cache_key_for(pdf)
19
-
20
- @underline_metrics_cache[cache_key] ||= fetch_underline_metrics(pdf, size)
21
- @underline_metrics_cache[cache_key]
12
+ cache(:underline, pdf.font, size) do
13
+ fetch_underline_metrics(pdf, size)
14
+ end
22
15
  end
23
16
 
24
17
  private
25
18
 
26
- def cache_key_for(pdf)
27
- return 'default' unless pdf && pdf.font.is_a?(Prawn::Fonts::TTF)
19
+ def cache(name, *args)
20
+ @font_metrics_cache ||= {}
28
21
 
29
- ttf = pdf.font.ttf
22
+ cache_key = generate_cache_key([name, *args])
23
+ return @font_metrics_cache[cache_key] if @font_metrics_cache.key?(cache_key)
24
+
25
+ @font_metrics_cache[cache_key] = yield
26
+ @font_metrics_cache[cache_key]
27
+ end
28
+
29
+ def generate_cache_key(key)
30
+ case
31
+ when key.is_a?(Prawn::Fonts::TTF) then font_cache_key(key)
32
+ when key.is_a?(Prawn::Font) then 'default'
33
+ when key.is_a?(Array) then key.map { |element| generate_cache_key(element) }.join('/')
34
+ when key.respond_to?(:to_a) then generate_cache_key(key.to_a)
35
+ else key.to_s
36
+ end.to_s
37
+ end
38
+
39
+ def font_cache_key(font)
40
+ ttf = font.ttf
30
41
  return 'default' unless ttf
31
42
 
32
43
  # Use font family name from TTF metadata, which doesn't include size
33
- ttf.name&.font_family&.first || pdf&.font&.name || 'default'
44
+ ttf.name&.font_family&.first || font&.name || 'default'
34
45
  end
35
46
 
36
47
  def calculate_x_height_ratio(pdf)
@@ -194,9 +194,17 @@ class Prawn::SVG::GradientRenderer
194
194
  end
195
195
 
196
196
  def current_pdf_transform
197
- @current_pdf_transform ||= load_matrix(
198
- prawn.current_transformation_matrix_with_translation(*prawn.bounds.anchor)
199
- )
197
+ @current_pdf_transform ||= if prawn.state.page.in_stamp_stream?
198
+ # Inside a soft_mask Form XObject, the CTM resets to identity but Prawn's
199
+ # internal tracking still reflects the page CTM. The geometry coordinates
200
+ # only have the bounds offset applied (no scaling), so the pattern matrix
201
+ # should match by using only the bounds translation.
202
+ current_pdf_translation
203
+ else
204
+ load_matrix(
205
+ prawn.current_transformation_matrix_with_translation(*prawn.bounds.anchor)
206
+ )
207
+ end
200
208
  end
201
209
 
202
210
  def current_pdf_translation
@@ -0,0 +1,40 @@
1
+ class Prawn::SVG::LinkRenderer
2
+ include Prawn::SVG::PDFMatrix
3
+
4
+ def initialize(href, bounding_box)
5
+ @href = href
6
+ @bounding_box = bounding_box
7
+ end
8
+
9
+ def render(prawn)
10
+ prawn.link_annotation(transformed_bounding_box(prawn), {
11
+ Border: [0, 0, 0],
12
+ A: { Type: :Action, S: :URI, URI: PDF::Core::LiteralString.new(href) }
13
+ })
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :href, :bounding_box
19
+
20
+ def transformed_bounding_box(prawn)
21
+ x0, y0, x1, y1 = bounding_box
22
+
23
+ matrix = load_matrix(prawn.current_transformation_matrix_with_translation(*prawn.bounds.anchor))
24
+
25
+ corners = [
26
+ matrix * Vector[x0, y0, 1.0],
27
+ matrix * Vector[x0, y1, 1.0],
28
+ matrix * Vector[x1, y0, 1.0],
29
+ matrix * Vector[x1, y1, 1.0]
30
+ ]
31
+
32
+ xs = corners.map { |c| c[0] }
33
+ ys = corners.map { |c| c[1] }
34
+
35
+ tx0, tx1 = xs.minmax
36
+ ty0, ty1 = ys.minmax
37
+
38
+ [tx0, ty0, tx1, ty1]
39
+ end
40
+ end
@@ -32,6 +32,7 @@ module Prawn::SVG
32
32
  'marker-end' => Config.new('none', true, [:funciri, 'none']),
33
33
  'marker-mid' => Config.new('none', true, [:funciri, 'none']),
34
34
  'marker-start' => Config.new('none', true, [:funciri, 'none']),
35
+ 'mask' => Config.new('none', false, [:funciri, 'none']),
35
36
  'opacity' => Config.new(1.0, false, [:number]),
36
37
  'overflow' => Config.new('visible', false, %w[visible hidden scroll auto]),
37
38
  'stop-color' => Config.new(Color.black, false, [:color_with_icc, 'currentcolor']),