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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +74 -8
- data/README.md +426 -44
- data/SECURITY.md +47 -0
- data/docs/architecture.md +30 -0
- data/docs/cli.md +27 -0
- data/docs/custom-exporters.md +36 -0
- data/docs/exporters.md +17 -0
- data/docs/input-schema.md +26 -0
- data/docs/migration-1.1.md +19 -0
- data/docs/performance.md +19 -0
- data/docs/releasing.md +19 -0
- data/exe/graphomaton +9 -0
- data/lib/graphomaton/atomic_file.rb +26 -0
- data/lib/graphomaton/cli/config.rb +102 -0
- data/lib/graphomaton/cli.rb +841 -0
- data/lib/graphomaton/errors.rb +11 -0
- data/lib/graphomaton/exporter_registry.rb +127 -0
- data/lib/graphomaton/exporters/dot.rb +255 -18
- data/lib/graphomaton/exporters/mermaid.rb +705 -25
- data/lib/graphomaton/exporters/pdf.rb +131 -0
- data/lib/graphomaton/exporters/plantuml.rb +250 -13
- data/lib/graphomaton/exporters/png.rb +172 -0
- data/lib/graphomaton/exporters/svg.rb +2775 -231
- data/lib/graphomaton/exporters/webp.rb +185 -0
- data/lib/graphomaton/exporters.rb +11 -4
- data/lib/graphomaton/identifier_allocator.rb +33 -0
- data/lib/graphomaton/input_policy.rb +82 -0
- data/lib/graphomaton/layout/force_tree.rb +127 -0
- data/lib/graphomaton/model.rb +218 -0
- data/lib/graphomaton/process_runner.rb +154 -0
- data/lib/graphomaton/url_policy.rb +40 -0
- data/lib/graphomaton/version.rb +1 -1
- data/lib/graphomaton.rb +2869 -54
- data/sig/graphomaton.rbs +127 -0
- metadata +34 -24
- data/.codespellignore +0 -0
- data/.rspec +0 -1
- data/CODE_OF_CONDUCT.md +0 -132
- data/Rakefile +0 -8
- data/sample/basic.rb +0 -30
- data/sample/complex.rb +0 -32
- data/sample/long_names.rb +0 -20
- data/sample/nfa.rb +0 -28
- data/sample/skip_states.rb +0 -23
- data/spec/exporters/dot_spec.rb +0 -146
- data/spec/exporters/mermaid_spec.rb +0 -154
- data/spec/exporters/plantuml_spec.rb +0 -144
- data/spec/exporters/svg_spec.rb +0 -314
- data/spec/graphomaton_edge_cases_spec.rb +0 -322
- data/spec/graphomaton_spec.rb +0 -371
- 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
|
-
|
|
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 << "[*] --> #{
|
|
38
|
+
lines << "[*] --> #{state_name(@automaton.initial_state)}"
|
|
17
39
|
end
|
|
18
40
|
|
|
19
|
-
@automaton.
|
|
20
|
-
from =
|
|
21
|
-
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 << "#{
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|