prawn-svg 0.36.2 → 0.38.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.
@@ -1,250 +1,228 @@
1
- class Prawn::SVG::Elements::TextComponent < Prawn::SVG::Elements::DepthFirstBase
2
- attr_reader :commands
3
-
4
- Printable = Struct.new(:element, :text, :leading_space?, :trailing_space?)
5
- TextState = Struct.new(:parent, :x, :y, :dx, :dy, :rotation, :spacing, :mode, :text_length, :length_adjust)
6
-
7
- def parse
8
- raise SkipElementError, '<text> elements are not supported in clip paths' if state.inside_clip_path
1
+ module Prawn::SVG
2
+ class Elements::TextComponent < Elements::DirectRenderBase
3
+ attr_reader :children, :parent_component
4
+ attr_reader :x_values, :y_values, :dx, :dy, :rotation, :text_length, :length_adjust
5
+ attr_reader :font
6
+
7
+ def initialize(document, source, _calls, state, parent_component = nil)
8
+ if parent_component.nil? && source.name != 'text'
9
+ raise SkipElementError, 'attempted to <use> a component inside a text element, this is not supported'
10
+ end
9
11
 
10
- if state.text.nil?
11
- raise SkipElementError, 'attempted to <use> an component inside a text element, this is not supported'
12
+ super(document, source, [], state)
13
+ @parent_component = parent_component
12
14
  end
13
15
 
14
- state.text.x = (attributes['x'] || '').split(COMMA_WSP_REGEXP).collect { |n| x(n) }
15
- state.text.y = (attributes['y'] || '').split(COMMA_WSP_REGEXP).collect { |n| y(n) }
16
- state.text.dx = (attributes['dx'] || '').split(COMMA_WSP_REGEXP).collect { |n| x_pixels(n) }
17
- state.text.dy = (attributes['dy'] || '').split(COMMA_WSP_REGEXP).collect { |n| y_pixels(n) }
18
- state.text.rotation = (attributes['rotate'] || '').split(COMMA_WSP_REGEXP).collect(&:to_f)
19
- state.text.text_length = normalize_length(attributes['textLength'])
20
- state.text.length_adjust = attributes['lengthAdjust']
21
- state.text.spacing = calculate_character_spacing
22
- state.text.mode = calculate_text_rendering_mode
16
+ def parse
17
+ raise SkipElementError, '<text> elements are not supported in clip paths' if state.inside_clip_path
23
18
 
24
- @commands = []
19
+ @x_values = parse_wsp('x').map { |n| x(n) }
20
+ @y_values = parse_wsp('y').map { |n| y(n) }
21
+ @dx = parse_wsp('dx').map { |n| x_pixels(n) }
22
+ @dy = parse_wsp('dy').map { |n| y_pixels(n) }
23
+ @rotation = parse_wsp('rotate').map(&:to_f)
24
+ @text_length = normalize_length(attributes['textLength'])
25
+ @length_adjust = attributes['lengthAdjust']
25
26
 
26
- svg_text_children.each do |child|
27
- if child.node_type == :text
28
- append_text(child)
29
- else
30
- case child.name
31
- when 'tspan', 'tref'
32
- append_child(child)
27
+ @font = select_font
28
+
29
+ @children = svg_text_children.flat_map do |child|
30
+ if child.node_type == :text
31
+ build_text_node(child)
33
32
  else
34
- warnings << "Unknown tag '#{child.name}' inside text tag; ignoring"
33
+ case child.name
34
+ when 'tspan', 'tref'
35
+ build_child(child)
36
+ else
37
+ warnings << "Unknown tag '#{child.name}' inside text tag; ignoring"
38
+ []
39
+ end
35
40
  end
36
41
  end
37
42
  end
38
- end
39
43
 
40
- def apply
41
- raise SkipElementQuietly if computed_properties.display == 'none'
42
-
43
- font = select_font
44
- apply_font(font) if font
45
-
46
- # text_anchor and dominant_baseline aren't Prawn options; we have to do some math to support them
47
- # and so we handle them in Prawn::SVG::Interface#rewrite_call_arguments
48
- opts = {
49
- size: computed_properties.numeric_font_size,
50
- style: font&.subfamily,
51
- text_anchor: computed_properties.text_anchor
52
- }
53
-
54
- unless computed_properties.dominant_baseline == 'auto'
55
- opts[:dominant_baseline] =
56
- computed_properties.dominant_baseline
57
- end
58
- opts[:decoration] = computed_properties.text_decoration unless computed_properties.text_decoration == 'none'
59
-
60
- if state.text.parent
61
- add_call_and_enter 'character_spacing', state.text.spacing unless state.text.spacing == state.text.parent.spacing
62
- add_call_and_enter 'text_rendering_mode', state.text.mode unless state.text.mode == state.text.parent.mode
63
- else
64
- add_call_and_enter 'character_spacing', state.text.spacing unless state.text.spacing.zero?
65
- add_call_and_enter 'text_rendering_mode', state.text.mode unless state.text.mode == :fill
66
- end
67
-
68
- @commands.each do |command|
69
- case command
70
- when Printable
71
- apply_text(command.text, opts)
72
- when self.class
73
- add_call 'save'
74
- command.apply_step(calls)
75
- add_call 'restore'
76
- else
77
- raise
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
48
+ child.lay_out(prawn)
49
+ end
78
50
  end
79
- end
80
-
81
- # It's possible there was no text to render. In that case, add a 'noop' so character_spacing/text_rendering_mode
82
- # don't blow up when they find they don't have a block to execute.
83
- add_call 'noop' if calls.empty?
84
- end
85
51
 
86
- protected
52
+ if @text_length
53
+ flexible_width, fixed_width = total_flexible_and_fixed_width
87
54
 
88
- def append_text(child)
89
- if state.preserve_space
90
- text = child.value.tr("\n\t", ' ')
91
- else
92
- text = child.value.tr("\n", '').tr("\t", ' ')
93
- leading = text[0] == ' '
94
- trailing = text[-1] == ' '
95
- text = text.strip.gsub(/ {2,}/, ' ')
55
+ if flexible_width.positive?
56
+ target_width = [@text_length - fixed_width, 0].max
57
+ factor = target_width / flexible_width
58
+ apply_factor_to_base_width(factor)
59
+ end
60
+ end
96
61
  end
97
62
 
98
- @commands << Printable.new(self, text, leading, trailing)
99
- end
63
+ def render_component(prawn, renderer, cursor, translate_x = nil)
64
+ raise SkipElementQuietly if computed_properties.display == 'none'
100
65
 
101
- def append_child(child)
102
- new_state = state.dup
103
- new_state.text = TextState.new(state.text)
66
+ add_yield_call do
67
+ prawn.translate(translate_x, 0) if translate_x
104
68
 
105
- element = self.class.new(document, child, calls, new_state)
106
- @commands << element
107
- element.parse_step
108
- end
69
+ size = computed_properties.numeric_font_size
109
70
 
110
- def apply_text(text, opts)
111
- while text != ''
112
- x = y = dx = dy = rotate = nil
113
- remaining = rotation_remaining = false
114
-
115
- list = state.text
116
- while list
117
- shifted = list.x.shift
118
- x ||= shifted
119
- shifted = list.y.shift
120
- y ||= shifted
121
- shifted = list.dx.shift
122
- dx ||= shifted
123
- shifted = list.dy.shift
124
- dy ||= shifted
125
-
126
- shifted = list.rotation.length > 1 ? list.rotation.shift : list.rotation.first
127
- if shifted && rotate.nil?
128
- rotate = shifted
129
- remaining ||= list.rotation != [0]
71
+ if computed_properties.dominant_baseline == 'middle'
72
+ height = FontMetrics.x_height_in_points(prawn, size || prawn.font_size)
73
+ y_offset = -height / 2.0
130
74
  end
131
75
 
132
- remaining ||= list.x.any? || list.y.any? || list.dx.any? || list.dy.any? || (rotate && rotate != 0)
133
- rotation_remaining ||= list.rotation.length > 1
134
- list = list.parent
76
+ prawn.save_font do
77
+ prawn.font(font.name, style: font.subfamily) if font
78
+
79
+ children.each do |child|
80
+ case child
81
+ when Elements::TextNode
82
+ child.render(prawn, size, cursor, y_offset)
83
+ when self.class
84
+ prawn.save_graphics_state
85
+ child.render_component(prawn, renderer, cursor)
86
+ prawn.restore_graphics_state
87
+ else
88
+ raise
89
+ end
90
+ end
91
+ end
135
92
  end
136
93
 
137
- opts[:at] = [x || :relative, y || :relative]
138
- opts[:offset] = [dx || 0, dy || 0]
94
+ renderer.render_calls(prawn, base_calls)
95
+ end
96
+
97
+ def calculated_width
98
+ children.reduce(0) { |total, child| total + child.calculated_width }
99
+ end
139
100
 
140
- if rotate && rotate != 0
141
- opts[:rotate] = -rotate
101
+ def current_length_adjust_is_scaling?
102
+ if @text_length
103
+ @length_adjust == 'spacingAndGlyphs'
104
+ elsif parent_component
105
+ parent_component.current_length_adjust_is_scaling?
142
106
  else
143
- opts.delete(:rotate)
107
+ false
144
108
  end
109
+ end
145
110
 
146
- if state.text.text_length
147
- if state.text.length_adjust == 'spacingAndGlyphs'
148
- opts[:stretch_to_width] = state.text.text_length
149
- else
150
- opts[:pad_to_width] = state.text.text_length
151
- end
111
+ def letter_spacing_pixels
112
+ if computed_properties.letter_spacing == 'normal'
113
+ nil
114
+ else
115
+ x_pixels(computed_properties.letter_spacing)
152
116
  end
117
+ end
153
118
 
154
- if remaining
155
- add_call 'draw_text', text[0..0], **opts.dup
156
- text = text[1..]
157
- else
158
- add_call 'draw_text', text, **opts.dup
159
-
160
- # we can get to this path with rotations still pending
161
- # solve this by shifting them out by the number of
162
- # characters we've just drawn
163
- shift = text.length - 1
164
- if rotation_remaining && shift.positive?
165
- list = state.text
166
- while list
167
- count = [shift, list.rotation.length - 1].min
168
- list.rotation.shift(count) if count.positive?
169
- list = list.parent
170
- end
171
- end
119
+ protected
172
120
 
173
- break
121
+ def build_text_node(child)
122
+ if state.preserve_space
123
+ text = child.value.tr("\n\t", ' ')
124
+ else
125
+ text = child.value.tr("\n", '').tr("\t", ' ')
126
+ leading = text[0] == ' '
127
+ trailing = text[-1] == ' '
128
+ text = text.strip.gsub(/ {2,}/, ' ')
174
129
  end
130
+
131
+ Elements::TextNode.new(self, text, leading, trailing)
132
+ end
133
+
134
+ def build_child(child)
135
+ component = self.class.new(document, child, [], state.dup, self)
136
+ component.process
137
+ component
175
138
  end
176
- end
177
139
 
178
- def svg_text_children
179
- text_children.select do |child|
180
- child.node_type == :text || (
181
- child.node_type == :element && (
182
- child.namespace == SVG_NAMESPACE || child.namespace == ''
140
+ def svg_text_children
141
+ text_children.select do |child|
142
+ child.node_type == :text || (
143
+ child.node_type == :element &&
144
+ [SVG_NAMESPACE, ''].include?(child.namespace)
145
+
183
146
  )
184
- )
147
+ end
185
148
  end
186
- end
187
149
 
188
- def text_children
189
- if name == 'tref'
190
- reference = find_referenced_element
191
- reference ? reference.source.children : []
192
- else
193
- source.children
150
+ def text_children
151
+ if name == 'tref'
152
+ reference = find_referenced_element
153
+ reference ? reference.source.children : []
154
+ else
155
+ source.children
156
+ end
194
157
  end
195
- end
196
158
 
197
- def find_referenced_element
198
- href = href_attribute
159
+ def find_referenced_element
160
+ href = href_attribute
199
161
 
200
- if href && href[0..0] == '#'
201
- element = document.elements_by_id[href[1..]]
202
- element if element.name == 'text'
162
+ if href && href[0..0] == '#'
163
+ element = document.elements_by_id[href[1..]]
164
+ element if element.name == 'text'
165
+ end
203
166
  end
204
- end
205
167
 
206
- def select_font
207
- font_families = [computed_properties.font_family, document.fallback_font_name]
208
- font_style = :italic if computed_properties.font_style == 'italic'
209
- font_weight = Prawn::SVG::Font.weight_for_css_font_weight(computed_properties.font_weight)
168
+ def select_font
169
+ font_families = [computed_properties.font_family, document.fallback_font_name]
170
+ font_style = :italic if computed_properties.font_style == 'italic'
171
+ font_weight = computed_properties.font_weight
210
172
 
211
- font_families.compact.each do |name|
212
- font = document.font_registry.load(name, font_weight, font_style)
213
- return font if font
173
+ font_families.compact.each do |name|
174
+ font = document.font_registry.load(name, font_weight, font_style)
175
+ return font if font
176
+ end
177
+
178
+ 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
214
180
  end
215
181
 
216
- 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."
217
- nil
218
- end
182
+ def total_flexible_and_fixed_width
183
+ flexible = fixed = 0
184
+ @children.each do |child|
185
+ child.total_flexible_and_fixed_width.tap do |a, b|
186
+ flexible += a
187
+ fixed += b
188
+ end
189
+ end
190
+ [flexible, fixed]
191
+ end
219
192
 
220
- def apply_font(font)
221
- add_call 'font', font.name, style: font.subfamily
222
- end
193
+ def apply_factor_to_base_width(factor)
194
+ @children.each do |child|
195
+ if child.is_a?(Elements::TextNode)
196
+ child.chunks.reject(&:fixed_width).each do |chunk|
197
+ chunk.fixed_width = chunk.base_width * factor
198
+ end
199
+ elsif child.is_a?(self.class)
200
+ child.apply_factor_to_base_width(factor)
201
+ else
202
+ raise
203
+ end
204
+ end
205
+ end
223
206
 
224
- def calculate_text_rendering_mode
225
- fill = !computed_properties.fill.none? # rubocop:disable Style/InverseMethods
226
- stroke = !computed_properties.stroke.none? # rubocop:disable Style/InverseMethods
207
+ # overridden from Base, we don't want to call fill/stroke as draw_text does this for us
208
+ def apply_drawing_call; end
227
209
 
228
- if fill && stroke
229
- :fill_stroke
230
- elsif fill
231
- :fill
232
- elsif stroke
233
- :stroke
234
- else
235
- :invisible
210
+ # overridden from Base, transforms can't be applied to tspan elements
211
+ def transformable?
212
+ source.name != 'tspan'
236
213
  end
237
- end
238
214
 
239
- def calculate_character_spacing
240
- spacing = computed_properties.letter_spacing
241
- spacing == 'normal' ? 0 : pixels(spacing)
242
- end
215
+ # overridden from Base, we want the id to point to the Text element
216
+ def add_to_elements_by_id?
217
+ source.name != 'text'
218
+ end
243
219
 
244
- # overridden, we don't want to call fill/stroke as draw_text does this for us
245
- def apply_drawing_call; end
220
+ def normalize_length(length)
221
+ x_pixels(length) if length&.match(/\d/)
222
+ end
246
223
 
247
- def normalize_length(length)
248
- x_pixels(length) if length&.match(/\d/)
224
+ def parse_wsp(name)
225
+ (attributes[name] || '').split(COMMA_WSP_REGEXP)
226
+ end
249
227
  end
250
228
  end
@@ -0,0 +1,174 @@
1
+ module Prawn::SVG
2
+ class Elements::TextNode
3
+ Chunk = Struct.new(:text, :x, :y, :dx, :dy, :rotate, :base_width, :offset, :fixed_width)
4
+
5
+ attr_reader :component, :chunks
6
+ attr_accessor :text
7
+
8
+ def initialize(component, text, leading_space, trailing_space)
9
+ @component = component
10
+ @text = text
11
+ @leading_space = leading_space
12
+ @trailing_space = trailing_space
13
+ end
14
+
15
+ def leading_space?
16
+ @leading_space
17
+ end
18
+
19
+ def trailing_space?
20
+ @trailing_space
21
+ end
22
+
23
+ def calculated_width
24
+ @chunks.reduce(0) do |total, chunk|
25
+ total + (chunk.fixed_width || chunk.base_width) + chunk.offset
26
+ end
27
+ end
28
+
29
+ def total_flexible_and_fixed_width
30
+ flexible = fixed = 0
31
+ chunks.each do |chunk|
32
+ if chunk.fixed_width.nil?
33
+ flexible += chunk.base_width
34
+ fixed += chunk.offset
35
+ else
36
+ fixed += chunk.offset + chunk.fixed_width
37
+ end
38
+ end
39
+ [flexible, fixed]
40
+ end
41
+
42
+ def lay_out(prawn)
43
+ remaining_text = @text
44
+ @chunks = []
45
+
46
+ while remaining_text != ''
47
+ x = y = dx = dy = rotate = nil
48
+ remaining = rotation_remaining = false
49
+
50
+ comp = component
51
+ while comp
52
+ shifted = comp.x_values.shift
53
+ x ||= shifted
54
+ shifted = comp.y_values.shift
55
+ y ||= shifted
56
+ shifted = comp.dx.shift
57
+ dx ||= shifted
58
+ shifted = comp.dy.shift
59
+ dy ||= shifted
60
+
61
+ shifted = comp.rotation.length > 1 ? comp.rotation.shift : comp.rotation.first
62
+ if shifted && rotate.nil?
63
+ rotate = shifted
64
+ remaining ||= comp.rotation != [0]
65
+ end
66
+
67
+ remaining ||= comp.x_values.any? || comp.y_values.any? || comp.dx.any? || comp.dy.any? || (rotate && rotate != 0)
68
+ rotation_remaining ||= comp.rotation.length > 1
69
+ comp = comp.parent_component
70
+ end
71
+
72
+ rotate = (-rotate if rotate && rotate != 0)
73
+
74
+ text_to_draw = remaining ? remaining_text[0..0] : remaining_text
75
+
76
+ opts = { size: component.computed_properties.numeric_font_size, kerning: true }
77
+
78
+ 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
80
+
81
+ offset = dx ? [0, dx].max : 0
82
+
83
+ @chunks << Chunk.new(text_to_draw, x, y, dx, dy, rotate, base_width, offset, nil)
84
+
85
+ if remaining
86
+ remaining_text = remaining_text[1..]
87
+ else
88
+ # we can get to this path with rotations still pending
89
+ # solve this by shifting them out by the number of
90
+ # characters we've just drawn
91
+ shift = remaining_text.length - 1
92
+ if rotation_remaining && shift.positive?
93
+ comp = component
94
+ while comp
95
+ count = [shift, comp.rotation.length - 1].min
96
+ comp.rotation.shift(count) if count.positive?
97
+ comp = comp.parent_component
98
+ end
99
+ end
100
+
101
+ break
102
+ end
103
+ end
104
+ end
105
+
106
+ def render(prawn, size, cursor, y_offset)
107
+ chunks.each do |chunk|
108
+ cursor.x = chunk.x if chunk.x
109
+ cursor.x += chunk.dx if chunk.dx
110
+ cursor.y = chunk.y if chunk.y
111
+ cursor.y -= chunk.dy if chunk.dy
112
+
113
+ render_underline(prawn, size, cursor, y_offset, chunk.fixed_width || chunk.base_width) if component.computed_properties.text_decoration == 'underline'
114
+
115
+ opts = { size: size, at: [cursor.x, cursor.y + (y_offset || 0)] }
116
+ opts[:rotate] = chunk.rotate if chunk.rotate
117
+
118
+ scaling =
119
+ if chunk.fixed_width && component.current_length_adjust_is_scaling?
120
+ chunk.fixed_width * 100 / chunk.base_width
121
+ else
122
+ 100
123
+ end
124
+
125
+ spacing_enabled = chunk.fixed_width && !component.current_length_adjust_is_scaling? && chunk.text.length > 1
126
+
127
+ # This isn't perfect. It assumes the parent component which started the textLength context
128
+ # has a character at the end of its text nodes. If it doesn't, the last character in its
129
+ # children should not take the space. This is possible but would involve a lot more work so
130
+ # I will park it for now.
131
+ parent_spacing = spacing_enabled && !component.text_length
132
+ spacing =
133
+ if spacing_enabled
134
+ ((chunk.fixed_width - chunk.base_width) / (chunk.text.length - (parent_spacing ? 0 : 1))) + (component.letter_spacing_pixels || 0)
135
+ end
136
+
137
+ prawn.horizontal_text_scaling(scaling) do
138
+ prawn.character_spacing(spacing || component.letter_spacing_pixels || prawn.character_spacing) do
139
+ prawn.text_rendering_mode(calculate_text_rendering_mode) do
140
+ prawn.draw_text(chunk.text, **opts)
141
+ end
142
+ end
143
+ end
144
+
145
+ cursor.x += chunk.fixed_width || chunk.base_width
146
+
147
+ # If we're in a textLength context for one of our parents, we'll need to add spacing
148
+ # to the end of our string. See comment above for why this isn't quite right.
149
+ cursor.x += spacing if parent_spacing
150
+ end
151
+ end
152
+
153
+ def render_underline(prawn, size, cursor, y_offset, width)
154
+ offset, thickness = FontMetrics.underline_metrics(prawn, size)
155
+
156
+ prawn.fill_rectangle [cursor.x, cursor.y + (y_offset || 0) + offset + (thickness / 2.0)], width, thickness
157
+ end
158
+
159
+ def calculate_text_rendering_mode
160
+ fill = !component.computed_properties.fill.none? # rubocop:disable Style/InverseMethods
161
+ stroke = !component.computed_properties.stroke.none? # rubocop:disable Style/InverseMethods
162
+
163
+ if fill && stroke
164
+ :fill_stroke
165
+ elsif fill
166
+ :fill
167
+ elsif stroke
168
+ :stroke
169
+ else
170
+ :invisible
171
+ end
172
+ end
173
+ end
174
+ end
@@ -4,7 +4,7 @@ end
4
4
 
5
5
  require 'prawn/svg/elements/call_duplicator'
6
6
 
7
- %w[base depth_first_base root container clip_path viewport text text_component line polyline polygon circle ellipse
7
+ %w[base direct_render_base root container clip_path 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