graphomaton 1.0.0 → 1.1.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -8
  3. data/README.md +426 -44
  4. data/SECURITY.md +47 -0
  5. data/docs/architecture.md +30 -0
  6. data/docs/cli.md +27 -0
  7. data/docs/custom-exporters.md +36 -0
  8. data/docs/exporters.md +17 -0
  9. data/docs/input-schema.md +26 -0
  10. data/docs/migration-1.1.md +19 -0
  11. data/docs/performance.md +19 -0
  12. data/docs/releasing.md +19 -0
  13. data/exe/graphomaton +9 -0
  14. data/lib/graphomaton/atomic_file.rb +26 -0
  15. data/lib/graphomaton/cli/config.rb +102 -0
  16. data/lib/graphomaton/cli.rb +841 -0
  17. data/lib/graphomaton/errors.rb +11 -0
  18. data/lib/graphomaton/exporter_registry.rb +127 -0
  19. data/lib/graphomaton/exporters/dot.rb +255 -18
  20. data/lib/graphomaton/exporters/mermaid.rb +705 -25
  21. data/lib/graphomaton/exporters/pdf.rb +131 -0
  22. data/lib/graphomaton/exporters/plantuml.rb +250 -13
  23. data/lib/graphomaton/exporters/png.rb +172 -0
  24. data/lib/graphomaton/exporters/svg.rb +2775 -231
  25. data/lib/graphomaton/exporters/webp.rb +185 -0
  26. data/lib/graphomaton/exporters.rb +11 -4
  27. data/lib/graphomaton/identifier_allocator.rb +33 -0
  28. data/lib/graphomaton/input_policy.rb +82 -0
  29. data/lib/graphomaton/layout/force_tree.rb +127 -0
  30. data/lib/graphomaton/model.rb +218 -0
  31. data/lib/graphomaton/process_runner.rb +154 -0
  32. data/lib/graphomaton/url_policy.rb +40 -0
  33. data/lib/graphomaton/version.rb +1 -1
  34. data/lib/graphomaton.rb +2869 -54
  35. data/sig/graphomaton.rbs +127 -0
  36. metadata +34 -24
  37. data/.codespellignore +0 -0
  38. data/.rspec +0 -1
  39. data/CODE_OF_CONDUCT.md +0 -132
  40. data/Rakefile +0 -8
  41. data/sample/basic.rb +0 -30
  42. data/sample/complex.rb +0 -32
  43. data/sample/long_names.rb +0 -20
  44. data/sample/nfa.rb +0 -28
  45. data/sample/skip_states.rb +0 -23
  46. data/spec/exporters/dot_spec.rb +0 -146
  47. data/spec/exporters/mermaid_spec.rb +0 -154
  48. data/spec/exporters/plantuml_spec.rb +0 -144
  49. data/spec/exporters/svg_spec.rb +0 -314
  50. data/spec/graphomaton_edge_cases_spec.rb +0 -322
  51. data/spec/graphomaton_spec.rb +0 -371
  52. data/spec/spec_helper.rb +0 -13
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ require_relative 'svg'
6
+
7
+ class Graphomaton
8
+ module Exporters
9
+ class Pdf
10
+ include Graphomaton::ExporterIntrospection
11
+ class ConversionError < Graphomaton::ConversionError; end
12
+
13
+ PDF_SIGNATURE = '%PDF-'
14
+ DEFAULT_CONVERTER = :auto
15
+ DEFAULT_TIMEOUT = ProcessRunner::DEFAULT_TIMEOUT
16
+ DEFAULT_MAX_OUTPUT_BYTES = ProcessRunner::DEFAULT_MAX_STDOUT_BYTES
17
+
18
+ CONVERTER_COMMANDS = {
19
+ rsvg: ['rsvg-convert', '--format', 'pdf', '-'],
20
+ magick: ['magick', 'svg:-', 'pdf:-'],
21
+ convert: ['convert', 'svg:-', 'pdf:-']
22
+ }.freeze
23
+ CONVERTER_OPTIONS = ([:auto] + CONVERTER_COMMANDS.keys).freeze
24
+
25
+ def self.available?(converter: DEFAULT_CONVERTER)
26
+ !available_command(converter: converter).nil?
27
+ end
28
+
29
+ def self.available_command(converter: DEFAULT_CONVERTER)
30
+ resolved_converter = resolve_converter(converter)
31
+ return CONVERTER_COMMANDS[resolved_converter] if resolved_converter != :auto && executable?(CONVERTER_COMMANDS[resolved_converter].first)
32
+ return nil if resolved_converter != :auto
33
+
34
+ CONVERTER_COMMANDS.values.find { |command| executable?(command.first) }
35
+ end
36
+
37
+ def initialize(automaton)
38
+ @automaton = automaton
39
+ end
40
+
41
+ def export(width = 800, height = 600, theme: Svg::DEFAULT_THEME, converter: DEFAULT_CONVERTER,
42
+ timeout: DEFAULT_TIMEOUT, max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES, **svg_options)
43
+ export_result(
44
+ width,
45
+ height,
46
+ theme: theme,
47
+ converter: converter,
48
+ timeout: timeout,
49
+ max_output_bytes: max_output_bytes,
50
+ **svg_options
51
+ ).output
52
+ end
53
+
54
+ def export_result(width = 800, height = 600, theme: Svg::DEFAULT_THEME, converter: DEFAULT_CONVERTER,
55
+ timeout: DEFAULT_TIMEOUT, max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES, **svg_options)
56
+ command = available_command(converter: converter)
57
+ raise ConversionError, missing_converter_message(converter) unless command
58
+
59
+ svg_result = Svg.new(@automaton).export_result(width, height, theme: theme, **svg_options)
60
+ pdf, error, status = ProcessRunner.capture3(
61
+ *command,
62
+ stdin_data: svg_result.output,
63
+ binmode: true,
64
+ timeout: timeout,
65
+ max_stdout_bytes: max_output_bytes
66
+ )
67
+ pdf = pdf.b
68
+
69
+ if status.success? && pdf.start_with?(PDF_SIGNATURE)
70
+ return RenderResult.new(
71
+ output: pdf.freeze,
72
+ diagnostics: svg_result.diagnostics,
73
+ bounds: svg_result.bounds,
74
+ layout: svg_result.layout
75
+ )
76
+ end
77
+ raise ConversionError, invalid_pdf_message(command, error) if status.success?
78
+
79
+ raise ConversionError, failed_conversion_message(command, error)
80
+ rescue ProcessRunner::Error => e
81
+ raise ConversionError, failed_conversion_message(command, e.message)
82
+ end
83
+
84
+ private
85
+
86
+ def available_command(converter: DEFAULT_CONVERTER)
87
+ self.class.available_command(converter: converter)
88
+ end
89
+
90
+ def self.executable?(command)
91
+ !ProcessRunner.which(command).nil?
92
+ end
93
+
94
+ def self.resolve_converter(converter)
95
+ resolved = converter.to_sym
96
+ return resolved if CONVERTER_OPTIONS.include?(resolved)
97
+
98
+ raise ArgumentError, "Unknown PDF converter: #{converter.inspect}. Available converters: #{CONVERTER_OPTIONS.join(', ')}"
99
+ end
100
+
101
+ def missing_converter_message(converter)
102
+ resolved_converter = self.class.resolve_converter(converter)
103
+ required = if resolved_converter == :auto
104
+ 'rsvg-convert, magick, or convert'
105
+ else
106
+ CONVERTER_COMMANDS[resolved_converter].first
107
+ end
108
+
109
+ "PDF export requires #{required} to be installed. #{install_hint}"
110
+ end
111
+
112
+ def install_hint
113
+ 'Install hints: macOS: brew install librsvg or imagemagick; Debian/Ubuntu: apt install librsvg2-bin or imagemagick; Windows: install ImageMagick.'
114
+ end
115
+
116
+ def failed_conversion_message(command, error)
117
+ detail = error.to_s.strip
118
+ detail = 'unknown error' if detail.empty?
119
+
120
+ "Failed to convert SVG to PDF using #{command.first}: #{detail}"
121
+ end
122
+
123
+ def invalid_pdf_message(command, error)
124
+ detail = error.to_s.strip
125
+ return "Failed to convert SVG to PDF using #{command.first}: converter did not produce PDF data" if detail.empty?
126
+
127
+ "Failed to convert SVG to PDF using #{command.first}: converter did not produce PDF data (#{detail})"
128
+ end
129
+ end
130
+ end
131
+ end
@@ -3,45 +3,282 @@
3
3
  class Graphomaton
4
4
  module Exporters
5
5
  class Plantuml
6
- def initialize(automaton)
6
+ include Graphomaton::ExporterIntrospection
7
+ DEFAULT_DIRECTION = :lr
8
+ DEFAULT_NOTES = false
9
+ DIRECTION_OPTIONS = %i[lr tb rl bt].freeze
10
+ PSEUDOSTATE_TYPES = %i[choice fork join].freeze
11
+ RESERVED_IDENTIFIERS = %w[state note skinparam hide left right top bottom direction as of].freeze
12
+
13
+ def initialize(automaton, direction: DEFAULT_DIRECTION, theme: nil, notes: DEFAULT_NOTES)
7
14
  @automaton = automaton
15
+ @direction = resolve_direction(direction)
16
+ @theme = resolve_theme(theme)
17
+ @notes = notes
18
+ @identifiers = IdentifierAllocator.new(reserved: RESERVED_IDENTIFIERS)
19
+ @state_names = allocate_state_names
20
+ @hierarchy_usable = @automaton.validation_diagnostics.none? do |diagnostic|
21
+ diagnostic.code == 'invalid-state-hierarchy'
22
+ end
8
23
  end
9
24
 
10
25
  def export
11
26
  lines = ['@startuml']
12
27
  lines << 'hide empty description'
28
+
29
+ lines << direction_keyword
30
+ lines.concat(theme_lines) if @theme
31
+ lines.concat(state_alias_lines)
32
+ lines.concat(pseudostate_lines)
33
+ lines.concat(composite_state_lines)
34
+ lines.concat(state_group_lines)
13
35
  lines << ''
14
36
 
15
37
  if @automaton.initial_state
16
- lines << "[*] --> #{sanitize_state_name(@automaton.initial_state)}"
38
+ lines << "[*] --> #{state_name(@automaton.initial_state)}"
17
39
  end
18
40
 
19
- @automaton.transitions.each do |trans|
20
- from = sanitize_state_name(trans[:from])
21
- to = sanitize_state_name(trans[:to])
22
- label = trans[:label]
41
+ @automaton.transition_records.each do |trans|
42
+ from = state_name(trans[:from])
43
+ to = state_name(trans[:to])
44
+ label = escape_label(trans[:label])
23
45
  lines << "#{from} --> #{to} : #{label}"
24
46
  end
25
47
 
26
48
  @automaton.final_states.each do |state|
27
- lines << "#{sanitize_state_name(state)} --> [*]"
49
+ lines << "#{state_name(state)} --> [*]"
28
50
  end
29
51
 
52
+ lines.concat(state_note_lines) if @notes
53
+
30
54
  lines << ''
31
55
  lines << '@enduml'
32
- lines.join("\n")
56
+ "#{lines.join("\n")}\n"
33
57
  end
34
58
 
35
59
  private
36
60
 
37
- def sanitize_state_name(name)
38
- sanitized = name.to_s.gsub(/[\s-]/, '_')
39
- if sanitized =~ /[^\x00-\x7F]/
40
- "\"#{sanitized}\""
61
+ def resolve_direction(direction)
62
+ resolved = direction.to_sym
63
+ return resolved if DIRECTION_OPTIONS.include?(resolved)
64
+
65
+ raise ArgumentError, "Unknown direction: #{direction.inspect}. Available directions: #{DIRECTION_OPTIONS.join(', ')}"
66
+ end
67
+
68
+ def resolve_theme(theme)
69
+ return nil unless theme
70
+
71
+ Graphomaton::Theme.resolve(theme, context: 'PlantUML theme')
72
+ end
73
+
74
+ def theme_lines
75
+ lines = []
76
+ lines << "skinparam backgroundColor #{@theme[:background]}" if @theme[:background]
77
+ lines << 'skinparam state {'
78
+ lines << " BackgroundColor #{@theme[:state_fill]}"
79
+ lines << " BorderColor #{@theme[:stroke]}"
80
+ lines << " FontColor #{@theme[:state_text]}"
81
+ lines << '}'
82
+ lines << "skinparam ArrowColor #{@theme[:stroke]}"
83
+ lines << "skinparam ArrowFontColor #{@theme[:transition_label]}"
84
+ lines
85
+ end
86
+
87
+ def direction_keyword
88
+ case @direction
89
+ when :tb
90
+ 'top to bottom direction'
91
+ when :bt
92
+ 'bottom to top direction'
93
+ when :rl
94
+ 'right to left direction'
41
95
  else
42
- sanitized
96
+ 'left to right direction'
97
+ end
98
+ end
99
+
100
+ def allocate_state_names
101
+ @automaton.state_records.each_key.to_h do |name|
102
+ preferred = name.to_s if valid_identifier?(name)
103
+ [name, @identifiers.allocate([:state, name], preferred: preferred, prefix: 'state')]
104
+ end
105
+ end
106
+
107
+ def valid_identifier?(name)
108
+ name.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/) && !RESERVED_IDENTIFIERS.include?(name.to_s)
109
+ end
110
+
111
+ def state_name(name)
112
+ @state_names.fetch(name) do
113
+ @identifiers.allocate([:external_state, name], prefix: 'state')
114
+ end
115
+ end
116
+
117
+ def escape_label(label)
118
+ label.to_s
119
+ .gsub('\\') { '\\\\' }
120
+ .gsub(/\r\n?|\n/) { '\\n' }
121
+ end
122
+
123
+ def state_alias_lines
124
+ @automaton.state_records.filter_map do |name, state|
125
+ next if valid_state_parent(state)
126
+ next if state_group_name(state)
127
+ next if pseudostate_type(state)
128
+
129
+ state_declaration_line(name, state)
130
+ end
131
+ end
132
+
133
+ def pseudostate_lines
134
+ @automaton.state_records.filter_map do |name, state|
135
+ type = pseudostate_type(state)
136
+ next unless type
137
+ next if valid_state_parent(state) || state_group_name(state)
138
+
139
+ "state #{state_name(name)} <<#{type}>>"
140
+ end
141
+ end
142
+
143
+ def pseudostate_type(state)
144
+ type = plantuml_metadata_value(state, :shape) ||
145
+ plantuml_metadata_value(state, :type) ||
146
+ plantuml_metadata_value(state, :kind) ||
147
+ state[:kind] ||
148
+ state_metadata_value(state, :kind) ||
149
+ state_metadata_value(state, :plantuml_shape) ||
150
+ state_metadata_value(state, :plantuml_type)
151
+ normalized = type.to_s.tr('-', '_').to_sym
152
+
153
+ PSEUDOSTATE_TYPES.include?(normalized) ? normalized : nil
154
+ end
155
+
156
+ def plantuml_metadata_value(state, key)
157
+ metadata = state[:metadata]
158
+ return nil unless metadata.is_a?(Hash)
159
+
160
+ plantuml = metadata[:plantuml] || metadata['plantuml']
161
+ return nil unless plantuml.is_a?(Hash)
162
+
163
+ plantuml[key] || plantuml[key.to_s]
164
+ end
165
+
166
+ def state_metadata_value(state, key)
167
+ metadata = state[:metadata]
168
+ return nil unless metadata.is_a?(Hash)
169
+
170
+ metadata[key] || metadata[key.to_s]
171
+ end
172
+
173
+ def state_group_lines
174
+ groups = @automaton.state_records.each_with_object({}) do |(name, state), grouped_states|
175
+ group = state_group_name(state)
176
+ next unless group
177
+ next if valid_state_parent(state)
178
+
179
+ grouped_states[group] ||= []
180
+ grouped_states[group] << [name, state]
181
+ end
182
+ return [] if groups.empty?
183
+
184
+ groups.flat_map do |group, states|
185
+ group_name = @identifiers.allocate([:group, group], prefix: 'group')
186
+ lines = ["state \"#{escape_state_label(group)}\" as #{group_name} {"]
187
+ states.each do |name, state|
188
+ lines << " #{state_declaration_line(name, state)}"
189
+ lines.concat(composite_block_lines(name, indentation: ' ')) if hierarchy_children.key?(name)
190
+ end
191
+ lines << '}'
192
+ end
193
+ end
194
+
195
+ def state_declaration_line(name, state)
196
+ label = state[:label]
197
+ state_identifier = state_name(name)
198
+ type = pseudostate_type(state)
199
+ stereotype = type ? " <<#{type}>>" : ''
200
+
201
+ "state \"#{escape_state_label(label || name)}\" as #{state_identifier}#{stereotype}"
202
+ end
203
+
204
+ def composite_state_lines
205
+ roots = hierarchy_children.keys.select do |parent|
206
+ parent_state = @automaton.state_records.fetch(parent)
207
+ !valid_state_parent(parent_state) && !state_group_name(parent_state)
208
+ end
209
+
210
+
211
+ roots.flat_map { |root| composite_block_lines(root, indentation: '') }
212
+ end
213
+
214
+ def composite_block_lines(parent, indentation:)
215
+ lines = ["#{indentation}state #{state_name(parent)} {"]
216
+ hierarchy_children.fetch(parent, []).each do |name, state|
217
+ child_indentation = "#{indentation} "
218
+ lines << "#{child_indentation}#{state_declaration_line(name, state)}"
219
+ lines.concat(composite_block_lines(name, indentation: child_indentation)) if hierarchy_children.key?(name)
220
+ end
221
+ lines << "#{indentation}}"
222
+ lines
223
+ end
224
+
225
+ def hierarchy_children
226
+ @hierarchy_children ||= @automaton.state_records.each_with_object({}) do |(name, state), groups|
227
+ parent = valid_state_parent(state)
228
+ next unless parent
229
+
230
+ groups[parent] ||= []
231
+ groups[parent] << [name, state]
232
+ end
233
+ end
234
+
235
+ def state_parent(state)
236
+ metadata = state[:metadata]
237
+ return nil unless metadata.is_a?(Hash)
238
+
239
+ metadata[:parent] || metadata['parent']
240
+ end
241
+
242
+ def valid_state_parent(state)
243
+ return nil unless @hierarchy_usable
244
+
245
+ parent = state_parent(state)
246
+ return nil unless parent && @automaton.state_records.key?(parent)
247
+
248
+ parent
249
+ end
250
+
251
+ def state_group_name(state)
252
+ metadata = state[:metadata]
253
+ return nil unless metadata.is_a?(Hash)
254
+
255
+ metadata[:group] || metadata['group'] || metadata[:cluster] || metadata['cluster']
256
+ end
257
+
258
+ def state_note_lines
259
+ @automaton.state_records.filter_map do |name, state|
260
+ note = state_note(state)
261
+ next unless note
262
+
263
+ "note right of #{state_name(name)} : #{escape_label(note)}"
43
264
  end
44
265
  end
266
+
267
+ def state_note(state)
268
+ metadata = state[:metadata]
269
+ return nil unless metadata.is_a?(Hash)
270
+
271
+ metadata[:note] || metadata['note'] ||
272
+ metadata[:description] || metadata['description'] ||
273
+ metadata[:tooltip] || metadata['tooltip']
274
+ end
275
+
276
+ def escape_state_label(label)
277
+ label.to_s
278
+ .gsub('\\') { '\\\\' }
279
+ .gsub('"') { '\\"' }
280
+ .gsub(/\r\n?|\n/) { '\\n' }
281
+ end
45
282
  end
46
283
  end
47
284
  end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ require_relative 'svg'
6
+
7
+ class Graphomaton
8
+ module Exporters
9
+ class Png
10
+ include Graphomaton::ExporterIntrospection
11
+ class ConversionError < Graphomaton::ConversionError; end
12
+
13
+ PNG_SIGNATURE = "\x89PNG\r\n\x1A\n".b.freeze
14
+ DEFAULT_SCALE = 1.0
15
+ DEFAULT_CONVERTER = :auto
16
+ DEFAULT_TIMEOUT = ProcessRunner::DEFAULT_TIMEOUT
17
+ DEFAULT_MAX_OUTPUT_BYTES = ProcessRunner::DEFAULT_MAX_STDOUT_BYTES
18
+
19
+ CONVERTER_COMMANDS = {
20
+ rsvg: ['rsvg-convert', '--format', 'png', '-'],
21
+ magick: ['magick', 'svg:-', 'png:-'],
22
+ convert: ['convert', 'svg:-', 'png:-']
23
+ }.freeze
24
+ CONVERTER_OPTIONS = ([:auto] + CONVERTER_COMMANDS.keys).freeze
25
+
26
+ def self.available?(converter: DEFAULT_CONVERTER)
27
+ !available_command(converter: converter).nil?
28
+ end
29
+
30
+ def self.available_command(converter: DEFAULT_CONVERTER)
31
+ resolved_converter = resolve_converter(converter)
32
+ return CONVERTER_COMMANDS[resolved_converter] if resolved_converter != :auto && executable?(CONVERTER_COMMANDS[resolved_converter].first)
33
+ return nil if resolved_converter != :auto
34
+
35
+ CONVERTER_COMMANDS.values.find { |command| executable?(command.first) }
36
+ end
37
+
38
+ def initialize(automaton)
39
+ @automaton = automaton
40
+ end
41
+
42
+ def export(width = 800, height = 600, theme: Svg::DEFAULT_THEME, scale: DEFAULT_SCALE, converter: DEFAULT_CONVERTER,
43
+ timeout: DEFAULT_TIMEOUT, max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES, **svg_options)
44
+ export_result(
45
+ width,
46
+ height,
47
+ theme: theme,
48
+ scale: scale,
49
+ converter: converter,
50
+ timeout: timeout,
51
+ max_output_bytes: max_output_bytes,
52
+ **svg_options
53
+ ).output
54
+ end
55
+
56
+ def export_result(width = 800, height = 600, theme: Svg::DEFAULT_THEME, scale: DEFAULT_SCALE, converter: DEFAULT_CONVERTER,
57
+ timeout: DEFAULT_TIMEOUT, max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES, **svg_options)
58
+ command = available_command(converter: converter)
59
+ raise ConversionError, missing_converter_message(converter) unless command
60
+
61
+ resolved_scale = resolve_scale(scale)
62
+ svg_result = Svg.new(@automaton).export_result(width, height, theme: theme, **svg_options)
63
+ svg = scale_svg_dimensions(svg_result.output, resolved_scale)
64
+ png, error, status = ProcessRunner.capture3(
65
+ *command,
66
+ stdin_data: svg,
67
+ binmode: true,
68
+ timeout: timeout,
69
+ max_stdout_bytes: max_output_bytes
70
+ )
71
+ png = png.b
72
+
73
+ if status.success? && png.start_with?(PNG_SIGNATURE)
74
+ return RenderResult.new(
75
+ output: png.freeze,
76
+ diagnostics: svg_result.diagnostics,
77
+ bounds: svg_result.bounds,
78
+ layout: svg_result.layout
79
+ )
80
+ end
81
+ raise ConversionError, invalid_png_message(command, error) if status.success?
82
+
83
+ raise ConversionError, failed_conversion_message(command, error)
84
+ rescue ProcessRunner::Error => e
85
+ raise ConversionError, failed_conversion_message(command, e.message)
86
+ end
87
+
88
+ private
89
+
90
+ def available_command(converter: DEFAULT_CONVERTER)
91
+ self.class.available_command(converter: converter)
92
+ end
93
+
94
+ def executable?(command)
95
+ self.class.send(:executable?, command)
96
+ end
97
+
98
+ def self.executable?(command)
99
+ !ProcessRunner.which(command).nil?
100
+ end
101
+
102
+ def self.resolve_converter(converter)
103
+ resolved = converter.to_sym
104
+ return resolved if CONVERTER_OPTIONS.include?(resolved)
105
+
106
+ raise ArgumentError, "Unknown PNG converter: #{converter.inspect}. Available converters: #{CONVERTER_OPTIONS.join(', ')}"
107
+ end
108
+
109
+ def scaled_dimension(value, scale)
110
+ scaled = value.to_f * scale
111
+ return scaled.to_i if scaled == scaled.to_i
112
+
113
+ scaled
114
+ end
115
+
116
+ def resolve_scale(scale)
117
+ unless scale.is_a?(Numeric) && scale.finite? && scale.positive?
118
+ raise ArgumentError, 'PNG scale must be a positive finite number'
119
+ end
120
+
121
+ scale.to_f
122
+ end
123
+
124
+ def scale_svg_dimensions(svg, scale)
125
+ document = REXML::Document.new(svg)
126
+ root = document.root
127
+ view_box = root.attributes['viewBox'].to_s.split.map(&:to_f)
128
+ logical_width = numeric_svg_dimension(root.attributes['width'], view_box[2])
129
+ logical_height = numeric_svg_dimension(root.attributes['height'], view_box[3])
130
+ root.attributes['width'] = scaled_dimension(logical_width, scale).to_s
131
+ root.attributes['height'] = scaled_dimension(logical_height, scale).to_s
132
+ document.to_s
133
+ end
134
+
135
+ def numeric_svg_dimension(value, fallback)
136
+ dimension = Float(value)
137
+ dimension.positive? ? dimension : fallback
138
+ rescue ArgumentError, TypeError
139
+ fallback
140
+ end
141
+
142
+ def missing_converter_message(converter)
143
+ resolved_converter = self.class.resolve_converter(converter)
144
+ required = if resolved_converter == :auto
145
+ 'rsvg-convert, magick, or convert'
146
+ else
147
+ CONVERTER_COMMANDS[resolved_converter].first
148
+ end
149
+
150
+ "PNG export requires #{required} to be installed. #{install_hint}"
151
+ end
152
+
153
+ def install_hint
154
+ 'Install hints: macOS: brew install librsvg or imagemagick; Debian/Ubuntu: apt install librsvg2-bin or imagemagick; Windows: install ImageMagick.'
155
+ end
156
+
157
+ def failed_conversion_message(command, error)
158
+ detail = error.to_s.strip
159
+ detail = 'unknown error' if detail.empty?
160
+
161
+ "Failed to convert SVG to PNG using #{command.first}: #{detail}"
162
+ end
163
+
164
+ def invalid_png_message(command, error)
165
+ detail = error.to_s.strip
166
+ return "Failed to convert SVG to PNG using #{command.first}: converter did not produce PNG data" if detail.empty?
167
+
168
+ "Failed to convert SVG to PNG using #{command.first}: converter did not produce PNG data (#{detail})"
169
+ end
170
+ end
171
+ end
172
+ end