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,841 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require_relative '../graphomaton'
5
+ require_relative 'cli/config'
6
+
7
+ class Graphomaton
8
+ class CLI
9
+ EXIT_SUCCESS = 0
10
+ EXIT_USAGE = 2
11
+ EXIT_INPUT = 3
12
+ EXIT_VALIDATION = 4
13
+ EXIT_LAYOUT = 5
14
+ EXIT_EXPORT = 6
15
+ EXIT_SECURITY = 7
16
+ COMMANDS = %w[render validate themes list doctor completion man].freeze
17
+ COMPLETION_SHELLS = %w[bash zsh fish].freeze
18
+ COMPLETION_WORDS = %w[
19
+ render validate themes list doctor completion man formats layouts converters
20
+ --input --input-format --output --format --config --no-clobber --force --validate
21
+ --no-validate --diagnostics --fail-on-warning --strict-semantics --layout-warnings
22
+ --width --height --theme --theme-file --layout --direction --fit --padding
23
+ --node-spacing --rank-spacing --force-iterations --layout-seed --graphviz-command
24
+ --max-metadata-depth --max-label-length --max-group-depth
25
+ --responsive --state-radius --state-shape --edge-style --wrap-labels --title
26
+ --description --cdn --offline --inline-mermaid --inline-mathjax --self-contained
27
+ --nonce --csp --csp-policy --mermaid-sha256 --mathjax-sha256 --version --help
28
+ ].freeze
29
+
30
+ def initialize(stdin: $stdin, stdout: $stdout, stderr: $stderr)
31
+ @stdin = stdin
32
+ @stdout = stdout
33
+ @stderr = stderr
34
+ end
35
+
36
+ def run(arguments = ARGV)
37
+ @debug = arguments.include?('--debug')
38
+ catch(:graphomaton_cli_exit) do
39
+ execute(arguments.dup)
40
+ EXIT_SUCCESS
41
+ end
42
+ rescue OptionParser::ParseError => e
43
+ report_exception(e)
44
+ EXIT_USAGE
45
+ rescue JSON::ParserError, Psych::Exception, ArgumentError, SystemCallError => e
46
+ report_exception(e)
47
+ EXIT_INPUT
48
+ rescue Graphomaton::SecurityError => e
49
+ report_exception(e)
50
+ EXIT_SECURITY
51
+ rescue Graphomaton::LayoutError => e
52
+ report_exception(e)
53
+ EXIT_LAYOUT
54
+ rescue Graphomaton::Error => e
55
+ report_exception(e)
56
+ EXIT_EXPORT
57
+ end
58
+
59
+ private
60
+
61
+ def halt(status)
62
+ throw :graphomaton_cli_exit, status
63
+ end
64
+
65
+ def warn(message)
66
+ @stderr.puts(message)
67
+ end
68
+
69
+ def puts(message)
70
+ @stdout.puts(message)
71
+ end
72
+
73
+ def report_exception(error, prefix: nil)
74
+ message = @debug ? error.full_message : error.message
75
+ message = "#{prefix}: #{message}" if prefix
76
+ warn(message)
77
+ end
78
+
79
+ def load_theme_file(path)
80
+ File.open(path, 'rb') do |theme_input|
81
+ case File.extname(path).downcase
82
+ when '.json'
83
+ Graphomaton.theme_from_json(theme_input)
84
+ when '.yml', '.yaml'
85
+ Graphomaton.theme_from_yaml(theme_input)
86
+ else
87
+ raise ArgumentError, 'Theme file must use .json, .yml, or .yaml extension'
88
+ end
89
+ end
90
+ rescue JSON::ParserError, Psych::Exception, ArgumentError, SystemCallError => e
91
+ report_exception(e, prefix: 'Theme input error')
92
+ halt(EXIT_INPUT)
93
+ end
94
+
95
+ def parse_automaton(source, format, limits)
96
+ case format.to_s.delete_prefix('.').downcase
97
+ when 'json'
98
+ Graphomaton.from_json(source, **limits)
99
+ when 'yml', 'yaml'
100
+ Graphomaton.from_yaml(source, **limits)
101
+ else
102
+ raise ArgumentError, 'Input format must be json, yml, or yaml'
103
+ end
104
+ end
105
+
106
+ def load_automaton(path, input_format:, limits:)
107
+ if path == '-'
108
+ payload = @stdin.read(limits.fetch(:max_input_bytes) + 1)
109
+ detected_format = input_format || (payload.lstrip.start_with?('{', '[') ? :json : :yaml)
110
+ return parse_automaton(payload, detected_format, limits)
111
+ end
112
+
113
+ extension = File.extname(path)
114
+ if input_format.nil? && !%w[.json .yml .yaml].include?(extension.downcase)
115
+ raise ArgumentError, 'Input file must use .json, .yml, or .yaml extension'
116
+ end
117
+ format = input_format || extension
118
+ File.open(path, 'rb') { |source| parse_automaton(source, format, limits) }
119
+ end
120
+
121
+ def validate_cli_numeric_options!(options)
122
+ positive = %i[
123
+ width height scale timeout max_output_bytes max_input_bytes max_states max_transitions
124
+ max_metadata_depth max_label_length max_group_depth
125
+ state_radius min_state_radius max_state_radius
126
+ state_stroke_width transition_stroke_width arrow_size initial_arrow_length final_arrow_length
127
+ ]
128
+ nonnegative = %i[
129
+ padding node_spacing rank_spacing force_iterations max_transition_label_width
130
+ max_state_label_width label_padding label_radius
131
+ ]
132
+
133
+ positive.each do |name|
134
+ value = options[name]
135
+ next if value.nil?
136
+
137
+ valid = value.is_a?(Numeric) && value.real? && value.to_f.finite? && value.positive?
138
+ raise OptionParser::InvalidArgument, "--#{name.to_s.tr('_', '-')} must be positive and finite" unless valid
139
+ end
140
+ nonnegative.each do |name|
141
+ value = options[name]
142
+ next if value.nil?
143
+
144
+ valid = value.is_a?(Numeric) && value.real? && value.to_f.finite? && value >= 0
145
+ raise OptionParser::InvalidArgument, "--#{name.to_s.tr('_', '-')} must be non-negative and finite" unless valid
146
+ end
147
+ end
148
+
149
+ def validate_format_options!(options, format)
150
+ svg_backed = %i[svg png pdf webp]
151
+ converted = %i[png pdf webp]
152
+ support = {}
153
+ %i[
154
+ layout_warnings layout fit padding node_spacing rank_spacing force_iterations layout_seed
155
+ graphviz_command auto_density_spacing initial_position final_position responsive state_radius
156
+ auto_state_radius min_state_radius max_state_radius state_stroke_width transition_stroke_width
157
+ state_shape edge_style arrow_shape arrow_size state_effect font_family state_font_weight
158
+ transition_font_weight preserve_manual_positions auto_size xml_declaration pretty minify
159
+ css_variables embed_styles svg_id wrap max_transition_label_width state_wrap
160
+ max_state_label_width label_tooltips html_tooltips sort_labels rotate_labels label_padding
161
+ label_radius label_border label_background initial_arrow_length initial_arrow_label
162
+ final_arrow_length final_arrow_label show_final_arrows scc_groups fold_groups
163
+ highlight_unreachable unreachable_zone highlight_dead_states highlight_initial_state
164
+ highlight_final_states highlight_transitions loop_position merge_parallel_transitions description
165
+ ].each { |name| support[name] = svg_backed }
166
+ support[:theme] = svg_backed + %i[html dot plantuml]
167
+ support[:theme_file] = svg_backed + %i[dot plantuml]
168
+ support[:direction] = svg_backed + %i[html mermaid dot plantuml]
169
+ support[:title] = svg_backed + [:html]
170
+ %i[converter timeout max_output_bytes].each { |name| support[name] = converted }
171
+ support[:scale] = [:png]
172
+ %i[
173
+ cdn offline inline_mermaid lang show_source pan_zoom mathjax mathjax_cdn inline_mathjax
174
+ self_contained nonce csp mermaid_sha256 mathjax_sha256
175
+ ].each { |name| support[name] = [:html] }
176
+ support[:notes] = %i[html mermaid plantuml]
177
+ support[:class_defs] = %i[html mermaid]
178
+ support[:rank_constraints] = [:dot]
179
+
180
+ unsupported = support.each_key.select { |name| options.key?(name) && !support.fetch(name).include?(format) }
181
+ unless unsupported.empty?
182
+ flags = unsupported.map { |name| "--#{name.to_s.tr('_', '-')}" }.join(', ')
183
+ raise OptionParser::InvalidArgument, "#{flags} not supported for #{format} output"
184
+ end
185
+ if format == :html && options[:theme].is_a?(Hash)
186
+ raise OptionParser::InvalidArgument, 'custom theme mappings are not supported for html output'
187
+ end
188
+ end
189
+
190
+ def extract_command(arguments)
191
+ return :render unless COMMANDS.include?(arguments.first)
192
+
193
+ arguments.shift.to_sym
194
+ end
195
+
196
+ def option_value(arguments, long, short = nil)
197
+ arguments.each_with_index do |argument, index|
198
+ return argument.split('=', 2).last if argument.start_with?("#{long}=")
199
+ return arguments[index + 1] if argument == long || (short && argument == short)
200
+ end
201
+ nil
202
+ end
203
+
204
+ def format_hint(arguments)
205
+ explicit = option_value(arguments, '--format', '-f')
206
+ return Graphomaton::EXPORTERS.resolve(explicit) if explicit
207
+
208
+ output = option_value(arguments, '--output', '-o')
209
+ return Graphomaton::EXPORTERS.resolve(File.extname(output)) if output && output != '-'
210
+
211
+ arguments.reject { |argument| argument.start_with?('-') }.reverse_each do |candidate|
212
+ return Graphomaton::EXPORTERS.resolve(File.extname(candidate))
213
+ rescue ArgumentError
214
+ next
215
+ end
216
+ nil
217
+ rescue ArgumentError
218
+ nil
219
+ end
220
+
221
+ def config_path(arguments)
222
+ explicit = option_value(arguments, '--config')
223
+ return [explicit, true] if explicit
224
+
225
+ environment_path = ENV['GRAPHOMATON_CONFIG']
226
+ return [environment_path, true] if environment_path && !environment_path.empty?
227
+
228
+ [Config::DEFAULT_PATH, false]
229
+ end
230
+
231
+ def environment_options
232
+ mappings = {
233
+ 'GRAPHOMATON_FORMAT' => [:format, ->(value) { value.to_sym }],
234
+ 'GRAPHOMATON_THEME' => [:theme, ->(value) { value.to_sym }],
235
+ 'GRAPHOMATON_LAYOUT' => [:layout, ->(value) { value.to_sym }],
236
+ 'GRAPHOMATON_WIDTH' => [:width, ->(value) { Integer(value, 10) }],
237
+ 'GRAPHOMATON_HEIGHT' => [:height, ->(value) { Integer(value, 10) }]
238
+ }
239
+ mappings.each_with_object({}) do |(environment_name, (option_name, parser)), options|
240
+ value = ENV[environment_name]
241
+ options[option_name] = parser.call(value) if value && !value.empty?
242
+ end
243
+ rescue ArgumentError => e
244
+ raise OptionParser::InvalidArgument, "Invalid Graphomaton environment option: #{e.message}"
245
+ end
246
+
247
+ def execute_list(arguments)
248
+ target = arguments.shift
249
+ unless arguments.empty? || target.nil?
250
+ warn "Unexpected arguments: #{arguments.join(' ')}"
251
+ halt(EXIT_USAGE)
252
+ end
253
+
254
+ values = case target
255
+ when 'formats' then Graphomaton::EXPORTERS.formats
256
+ when 'layouts' then Graphomaton::LAYOUT_OPTIONS
257
+ when 'themes' then Graphomaton::Theme.available_names
258
+ when 'converters' then %w[rsvg magick convert]
259
+ else
260
+ warn 'Usage: graphomaton list formats|layouts|themes|converters'
261
+ halt(EXIT_USAGE)
262
+ end
263
+ puts values.join("\n")
264
+ end
265
+
266
+ def execute_doctor(arguments)
267
+ unless arguments.empty?
268
+ warn "Unexpected arguments: #{arguments.join(' ')}"
269
+ halt(EXIT_USAGE)
270
+ end
271
+
272
+ checks = {
273
+ graphomaton: Graphomaton::VERSION,
274
+ ruby: RUBY_DESCRIPTION,
275
+ graphviz: renderer_health('dot', '-V'),
276
+ rsvg: renderer_health('rsvg-convert', '--version'),
277
+ imagemagick: renderer_health('magick', '-version', fallback: 'convert'),
278
+ mermaid: renderer_health('mmdc', '--version'),
279
+ plantuml: renderer_health('plantuml', '-version'),
280
+ png: Graphomaton::Exporters::Png.available? ? 'available' : 'missing',
281
+ pdf: Graphomaton::Exporters::Pdf.available? ? 'available' : 'missing',
282
+ webp: Graphomaton::Exporters::Webp.available? ? 'available' : 'missing'
283
+ }
284
+ checks.each { |name, value| puts "#{name}: #{value}" }
285
+ end
286
+
287
+ def execute_themes(arguments)
288
+ unless arguments.empty?
289
+ warn "Unexpected arguments: #{arguments.join(' ')}"
290
+ halt(EXIT_USAGE)
291
+ end
292
+
293
+ puts Graphomaton::Theme.available_names.join("\n")
294
+ end
295
+
296
+ def execute_completion(arguments)
297
+ shell = arguments.shift
298
+ unless COMPLETION_SHELLS.include?(shell) && arguments.empty?
299
+ warn "Usage: graphomaton completion #{COMPLETION_SHELLS.join('|')}"
300
+ halt(EXIT_USAGE)
301
+ end
302
+
303
+ words = COMPLETION_WORDS.join(' ')
304
+ output = case shell
305
+ when 'bash'
306
+ <<~BASH
307
+ _graphomaton_completion() {
308
+ COMPREPLY=( $(compgen -W '#{words}' -- "${COMP_WORDS[COMP_CWORD]}") )
309
+ }
310
+ complete -F _graphomaton_completion graphomaton
311
+ BASH
312
+ when 'zsh'
313
+ <<~ZSH
314
+ #compdef graphomaton
315
+ _arguments '*:graphomaton command or option:(#{words})'
316
+ ZSH
317
+ when 'fish'
318
+ COMPLETION_WORDS.map { |word| "complete -c graphomaton -f -a '#{word}'" }.join("\n") + "\n"
319
+ end
320
+ @stdout.write(output)
321
+ end
322
+
323
+ def execute_man(arguments)
324
+ unless arguments.empty?
325
+ warn "Unexpected arguments: #{arguments.join(' ')}"
326
+ halt(EXIT_USAGE)
327
+ end
328
+
329
+ @stdout.write <<~MANPAGE
330
+ .TH GRAPHOMATON 1 "2026-08-12" "Graphomaton #{Graphomaton::VERSION}" "User Commands"
331
+ .SH NAME
332
+ graphomaton \- validate, analyze, and render finite-state machines
333
+ .SH SYNOPSIS
334
+ .B graphomaton
335
+ [render] -i INPUT -o OUTPUT [options]
336
+ .br
337
+ .B graphomaton validate
338
+ INPUT [--diagnostics text|json]
339
+ .SH COMMANDS
340
+ render, validate, themes, list, doctor, completion, and man.
341
+ .SH EXIT STATUS
342
+ 0 success; 2 usage; 3 input; 4 validation; 5 layout; 6 export; 7 security.
343
+ .SH FILES
344
+ .I .graphomaton.yml
345
+ supplies defaults overridden by environment variables and command-line options.
346
+ .SH SEE ALSO
347
+ https://github.com/ydah/graphomaton
348
+ MANPAGE
349
+ end
350
+
351
+ def renderer_health(command, *version_arguments, fallback: nil)
352
+ path = Graphomaton::ProcessRunner.which(command)
353
+ path ||= Graphomaton::ProcessRunner.which(fallback) if fallback
354
+ return 'missing' unless path
355
+
356
+ stdout, stderr, status = Graphomaton::ProcessRunner.capture3(
357
+ path,
358
+ *version_arguments,
359
+ timeout: 3,
360
+ max_stdout_bytes: 64 * 1024,
361
+ max_stderr_bytes: 64 * 1024
362
+ )
363
+ version = [stdout, stderr].map(&:strip).find { |text| !text.empty? }
364
+ version = version.to_s.lines.first.to_s.strip
365
+ status.success? && !version.empty? ? "#{path} (#{version})" : "#{path} (version unavailable)"
366
+ rescue Graphomaton::ProcessRunner::Error, SystemCallError
367
+ "#{path} (version unavailable)"
368
+ end
369
+
370
+ def emit_diagnostics(diagnostics, format:, stream:)
371
+ if format.to_sym == :json
372
+ payload = diagnostics.map do |diagnostic|
373
+ {
374
+ code: diagnostic.code,
375
+ severity: diagnostic.severity,
376
+ path: diagnostic.path,
377
+ message: diagnostic.message,
378
+ hint: diagnostic.hint
379
+ }.compact
380
+ end
381
+ stream.puts(JSON.generate(payload))
382
+ else
383
+ diagnostics.each do |diagnostic|
384
+ location = diagnostic.path.empty? ? '' : " at #{diagnostic.path.join('.')}"
385
+ stream.puts("#{diagnostic.severity}: #{diagnostic.code}#{location}: #{diagnostic.message}")
386
+ stream.puts(" hint: #{diagnostic.hint}") if diagnostic.hint
387
+ end
388
+ end
389
+ end
390
+
391
+ def execute(arguments)
392
+ command = extract_command(arguments)
393
+ return execute_list(arguments) if command == :list
394
+ return execute_doctor(arguments) if command == :doctor
395
+ return execute_completion(arguments) if command == :completion
396
+ return execute_man(arguments) if command == :man
397
+ return execute_themes(arguments) if command == :themes
398
+ if arguments.include?('--version')
399
+ puts Graphomaton::VERSION
400
+ halt(EXIT_SUCCESS)
401
+ end
402
+
403
+ selected_config_path, config_required = config_path(arguments)
404
+ environment = environment_options
405
+ argument_format = format_hint(arguments)
406
+ selected_format = argument_format || environment[:format]
407
+ configured_options = Config.load(
408
+ selected_config_path,
409
+ format: selected_format,
410
+ required: config_required
411
+ )
412
+ if selected_format.nil?
413
+ configured_format = configured_options[:format]
414
+ configured_output = configured_options[:output]
415
+ configured_format ||= File.extname(configured_output) if configured_output && configured_output != '-'
416
+ if configured_format && !configured_format.to_s.empty?
417
+ selected_format = Graphomaton::EXPORTERS.resolve(configured_format)
418
+ configured_options = Config.load(selected_config_path, format: selected_format, required: config_required)
419
+ end
420
+ end
421
+ options = {
422
+ width: 800,
423
+ height: 600,
424
+ max_input_bytes: Graphomaton::DEFAULT_MAX_INPUT_BYTES,
425
+ max_states: Graphomaton::DEFAULT_MAX_STATES,
426
+ max_transitions: Graphomaton::DEFAULT_MAX_TRANSITIONS,
427
+ max_metadata_depth: Graphomaton::DEFAULT_MAX_METADATA_DEPTH,
428
+ max_label_length: Graphomaton::DEFAULT_MAX_LABEL_LENGTH,
429
+ max_group_depth: Graphomaton::DEFAULT_MAX_GROUP_DEPTH,
430
+ validate: true
431
+ }.merge(configured_options).merge(environment)
432
+ options[:format] = argument_format if argument_format
433
+
434
+ parser = OptionParser.new do |opts|
435
+ opts.banner = 'Usage: graphomaton [render] --input automaton.yml --output diagram.svg [options]'
436
+ opts.separator ' graphomaton validate automaton.yml [--diagnostics text|json]'
437
+ opts.separator ' graphomaton list formats|layouts|themes|converters'
438
+ opts.separator ' graphomaton themes | doctor'
439
+ opts.separator ' graphomaton --theme-gallery --output theme_gallery.html [options]'
440
+
441
+ opts.on('--config PATH', 'Configuration file (default: .graphomaton.yml)') { |value| options[:config] = value }
442
+ opts.on('-i', '--input PATH', 'Input JSON or YAML file') { |value| options[:input] = value }
443
+ opts.on('--input-format FORMAT', 'Input format for stdin or extension override') { |value| options[:input_format] = value.to_sym }
444
+ opts.on('-o', '--output PATH', 'Output file path') { |value| options[:output] = value }
445
+ opts.on('--no-clobber', 'Fail if the output file already exists') { options[:no_clobber] = true }
446
+ opts.on('--force', 'Allow replacing an existing output file') { options[:no_clobber] = false }
447
+ opts.on('-f', '--format FORMAT', 'Output format override') { |value| options[:format] = value.to_sym }
448
+ opts.on('--[no-]validate', 'Validate automaton references before rendering (default: enabled)') { |value| options[:validate] = value }
449
+ opts.on('--diagnostics FORMAT', 'Diagnostic output: text or json') { |value| options[:diagnostics] = value.to_sym }
450
+ opts.on('--fail-on-warning', 'Return a failure status when warnings are emitted') { options[:fail_on_warning] = true }
451
+ opts.on('--strict-semantics', 'Reject information loss in the selected output format') { options[:strict_semantics] = true }
452
+ opts.on('--debug', 'Include exception details in errors') { options[:debug] = true }
453
+ opts.on('--layout-warnings', 'Print SVG layout clipping warnings before rendering') { options[:layout_warnings] = true }
454
+ opts.on('--width WIDTH', Integer, 'Output width for size-aware formats') { |value| options[:width] = value }
455
+ opts.on('--height HEIGHT', Integer, 'Output height for size-aware formats') { |value| options[:height] = value }
456
+ opts.on('--scale SCALE', Float, 'PNG output scale') { |value| options[:scale] = value }
457
+ opts.on('--converter CONVERTER', 'SVG conversion backend for PNG, PDF, or WebP') { |value| options[:converter] = value.to_sym }
458
+ opts.on('--timeout SECONDS', Float, 'Converter timeout in seconds') { |value| options[:timeout] = value }
459
+ opts.on('--max-output-bytes BYTES', Integer, 'Maximum converter output size') { |value| options[:max_output_bytes] = value }
460
+ opts.on('--max-input-bytes BYTES', Integer, 'Maximum JSON or YAML input size') { |value| options[:max_input_bytes] = value }
461
+ opts.on('--max-states COUNT', Integer, 'Maximum parsed state count') { |value| options[:max_states] = value }
462
+ opts.on('--max-transitions COUNT', Integer, 'Maximum parsed transition count') { |value| options[:max_transitions] = value }
463
+ opts.on('--max-metadata-depth DEPTH', Integer, 'Maximum nested metadata depth') { |value| options[:max_metadata_depth] = value }
464
+ opts.on('--max-label-length BYTES', Integer, 'Maximum label size in bytes') { |value| options[:max_label_length] = value }
465
+ opts.on('--max-group-depth DEPTH', Integer, 'Maximum state hierarchy depth') { |value| options[:max_group_depth] = value }
466
+ opts.on('--theme THEME', 'Theme name') { |value| options[:theme] = value.to_sym }
467
+ opts.on('--theme-file PATH', 'Theme JSON or YAML file') { |value| options[:theme_file] = value }
468
+ opts.on('--theme-gallery', 'Write a standalone HTML gallery of built-in themes') { options[:theme_gallery] = true }
469
+ opts.on('--theme-gallery-animated', 'Animate the standalone theme gallery preview') { options[:theme_gallery_animated] = true }
470
+ opts.on('--list-themes', 'Print built-in theme names') { options[:list_themes] = true }
471
+ opts.on('--layout LAYOUT', 'SVG layout') { |value| options[:layout] = value.to_sym }
472
+ opts.on('--direction DIRECTION', 'Layout direction') { |value| options[:direction] = value.to_sym }
473
+ opts.on('--fit FIT', 'Fit mode for resolved SVG positions') { |value| options[:fit] = value.to_sym }
474
+ opts.on('--padding PADDING', Float, 'SVG layout padding') { |value| options[:padding] = value }
475
+ opts.on('--node-spacing SPACING', Float, 'SVG node spacing') { |value| options[:node_spacing] = value }
476
+ opts.on('--rank-spacing SPACING', Float, 'SVG rank spacing for layered layouts') { |value| options[:rank_spacing] = value }
477
+ opts.on('--force-iterations COUNT', Integer, 'SVG force layout iteration count') { |value| options[:force_iterations] = value }
478
+ opts.on('--layout-seed SEED', Integer, 'SVG force layout random seed') { |value| options[:layout_seed] = value }
479
+ opts.on('--graphviz-command COMMAND', 'Graphviz dot command for graphviz SVG layout') { |value| options[:graphviz_command] = value }
480
+ opts.on('--auto-density-spacing', 'Increase SVG spacing for dense graphs') { options[:auto_density_spacing] = true }
481
+ opts.on('--initial-position POSITION', 'Initial state placement mode') { |value| options[:initial_position] = value.to_sym }
482
+ opts.on('--final-position POSITION', 'Final state placement mode') { |value| options[:final_position] = value.to_sym }
483
+ opts.on('--responsive', 'Render responsive SVG width and height attributes') { options[:responsive] = true }
484
+ opts.on('--state-radius RADIUS', Float, 'SVG state radius') { |value| options[:state_radius] = value }
485
+ opts.on('--auto-state-radius', 'Grow SVG state radius from state label width') { options[:auto_state_radius] = true }
486
+ opts.on('--min-state-radius RADIUS', Float, 'Minimum SVG auto state radius') { |value| options[:min_state_radius] = value }
487
+ opts.on('--max-state-radius RADIUS', Float, 'Maximum SVG auto state radius') { |value| options[:max_state_radius] = value }
488
+ opts.on('--state-stroke-width WIDTH', Float, 'SVG state stroke width') { |value| options[:state_stroke_width] = value }
489
+ opts.on('--transition-stroke-width WIDTH', Float, 'SVG transition stroke width') { |value| options[:transition_stroke_width] = value }
490
+ opts.on('--state-shape SHAPE', 'SVG state shape') { |value| options[:state_shape] = value.to_sym }
491
+ opts.on('--edge-style STYLE', 'SVG edge style') { |value| options[:edge_style] = value.to_sym }
492
+ opts.on('--arrow-shape SHAPE', 'SVG arrowhead shape') { |value| options[:arrow_shape] = value.to_sym }
493
+ opts.on('--arrow-size SIZE', Float, 'SVG arrowhead size') { |value| options[:arrow_size] = value }
494
+ opts.on('--state-effect EFFECT', 'SVG state effect') { |value| options[:state_effect] = value.to_sym }
495
+ opts.on('--font-family FAMILY', 'SVG font family') { |value| options[:font_family] = value }
496
+ opts.on('--state-font-weight WEIGHT', 'SVG state font weight') { |value| options[:state_font_weight] = value }
497
+ opts.on('--transition-font-weight WEIGHT', 'SVG transition font weight') { |value| options[:transition_font_weight] = value }
498
+ opts.on('--no-preserve-manual-positions', 'Allow automatic layouts to reposition states with explicit coordinates') { options[:preserve_manual_positions] = false }
499
+ opts.on('--auto-size', 'Expand SVG canvas from resolved graph bounds') { options[:auto_size] = true }
500
+ opts.on('--xml-declaration', 'Include an XML declaration in SVG output') { options[:xml_declaration] = true }
501
+ opts.on('--pretty', 'Pretty-print SVG output') { options[:pretty] = true }
502
+ opts.on('--minify', 'Minify SVG output') { options[:minify] = true }
503
+ opts.on('--css-variables', 'Emit SVG theme values as CSS variables') { options[:css_variables] = true }
504
+ opts.on('--no-embed-styles', 'Skip embedded SVG style block') { options[:embed_styles] = false }
505
+ opts.on('--svg-id ID', 'Stable SVG root ID prefix') { |value| options[:svg_id] = value }
506
+ opts.on('--wrap-labels', 'Wrap long SVG transition labels') { options[:wrap] = true }
507
+ opts.on('--max-transition-label-width WIDTH', Float, 'Maximum SVG transition label width before wrapping') { |value| options[:max_transition_label_width] = value }
508
+ opts.on('--state-wrap', 'Wrap long SVG state labels') { options[:state_wrap] = true }
509
+ opts.on('--max-state-label-width WIDTH', Float, 'Maximum SVG state label width before wrapping') { |value| options[:max_state_label_width] = value }
510
+ opts.on('--label-tooltips', 'Add SVG title tooltips for labels') { options[:label_tooltips] = true }
511
+ opts.on('--html-tooltips', 'Add SVG data-tooltip attributes for HTML wrappers') { options[:html_tooltips] = true }
512
+ opts.on('--sort-labels', 'Sort merged SVG transition labels') { options[:sort_labels] = true }
513
+ opts.on('--rotate-labels', 'Rotate SVG transition labels along edges') { options[:rotate_labels] = true }
514
+ opts.on('--label-padding PADDING', Float, 'SVG transition label padding') { |value| options[:label_padding] = value }
515
+ opts.on('--label-radius RADIUS', Float, 'SVG transition label corner radius') { |value| options[:label_radius] = value }
516
+ opts.on('--label-border', 'Draw SVG transition label borders') { options[:label_border] = true }
517
+ opts.on('--no-label-background', 'Hide SVG transition label backgrounds') { options[:label_background] = false }
518
+ opts.on('--initial-arrow-length LENGTH', Float, 'SVG initial arrow length') { |value| options[:initial_arrow_length] = value }
519
+ opts.on('--initial-arrow-label LABEL', 'SVG initial arrow label') { |value| options[:initial_arrow_label] = value }
520
+ opts.on('--no-initial-arrow-label', 'Hide the SVG initial arrow label') { options[:initial_arrow_label] = nil }
521
+ opts.on('--final-arrow-length LENGTH', Float, 'SVG final arrow length') { |value| options[:final_arrow_length] = value }
522
+ opts.on('--final-arrow-label LABEL', 'SVG final arrow label') { |value| options[:final_arrow_label] = value }
523
+ opts.on('--no-final-arrow-label', 'Hide SVG final arrow labels') { options[:final_arrow_label] = nil }
524
+ opts.on('--show-final-arrows', 'Render native SVG arrows from final states') { options[:show_final_arrows] = true }
525
+ opts.on('--scc-groups', 'Render SVG groups around strongly connected components') { options[:scc_groups] = true }
526
+ opts.on('--fold-groups', 'Fold grouped SVG states into compound nodes') { options[:fold_groups] = true }
527
+ opts.on('--highlight-unreachable', 'Highlight unreachable states in SVG output') { options[:highlight_unreachable] = true }
528
+ opts.on('--unreachable-zone POSITION', 'Move unreachable SVG states to none, right, bottom, left, or top') { |value| options[:unreachable_zone] = value.to_sym }
529
+ opts.on('--highlight-dead-states', 'Highlight dead and trap states in SVG output') { options[:highlight_dead_states] = true }
530
+ opts.on('--highlight-initial-state', 'Highlight the initial state in SVG output') { options[:highlight_initial_state] = true }
531
+ opts.on('--highlight-final-states', 'Highlight accepting states in SVG output') { options[:highlight_final_states] = true }
532
+ opts.on('--highlight-transition SPEC', 'Highlight SVG transition FROM:TO[:LABEL]') do |value|
533
+ parts = value.split(':', 3)
534
+ raise OptionParser::InvalidArgument, value if parts.size < 2
535
+
536
+ target = { from: parts[0], to: parts[1] }
537
+ target[:label] = parts[2] if parts[2]
538
+ options[:highlight_transitions] ||= []
539
+ options[:highlight_transitions] << target
540
+ end
541
+ opts.on('--loop-position POSITION', 'SVG self-loop placement') { |value| options[:loop_position] = value.to_sym }
542
+ opts.on('--cdn URL_OR_PATH', 'Mermaid CDN URL or local script path for HTML output') { |value| options[:cdn] = value }
543
+ opts.on('--offline', 'Use a non-module Mermaid script tag for HTML output') { options[:offline] = true }
544
+ opts.on('--inline-mermaid', 'Inline Mermaid script from --cdn path in HTML output') { options[:inline_mermaid] = true }
545
+ opts.on('--inline-mathjax', 'Inline MathJax script from --mathjax-cdn path') { options[:inline_mathjax] = true }
546
+ opts.on('--self-contained', 'Inline all configured local HTML assets') { options[:self_contained] = true }
547
+ opts.on('--nonce NONCE', 'Add a CSP nonce to generated HTML scripts and styles') { |value| options[:nonce] = value }
548
+ opts.on('--csp', 'Add a strict Content Security Policy meta tag (requires --nonce)') { options[:csp] = true }
549
+ opts.on('--csp-policy POLICY', 'Add a custom Content Security Policy meta tag') { |value| options[:csp] = value }
550
+ opts.on('--mermaid-sha256 HEX', 'Verify an inlined Mermaid asset') { |value| options[:mermaid_sha256] = value }
551
+ opts.on('--mathjax-sha256 HEX', 'Verify an inlined MathJax asset') { |value| options[:mathjax_sha256] = value }
552
+ opts.on('--title TITLE', 'HTML page or accessible SVG title') { |value| options[:title] = value }
553
+ opts.on('--description TEXT', 'Accessible SVG description') { |value| options[:description] = value }
554
+ opts.on('--lang LANG', 'HTML language code') { |value| options[:lang] = value }
555
+ opts.on('--show-source', 'Include Mermaid source in HTML output') { options[:show_source] = true }
556
+ opts.on('--pan-zoom', 'Add pan and zoom controls to HTML output') { options[:pan_zoom] = true }
557
+ opts.on('--mathjax', 'Include MathJax support in HTML output') { options[:mathjax] = true }
558
+ opts.on('--mathjax-cdn URL_OR_PATH', 'MathJax script URL or local path for HTML output') { |value| options[:mathjax_cdn] = value }
559
+ opts.on('--notes', 'Include state metadata notes in Mermaid output') { options[:notes] = true }
560
+ opts.on('--class-defs', 'Include Mermaid class definitions in Mermaid output') { options[:class_defs] = true }
561
+ opts.on('--rank-constraints', 'Emit DOT rank constraints for initial and final states') { options[:rank_constraints] = true }
562
+ opts.on('--[no-]merge-parallel-transitions', 'Merge equivalent parallel SVG transitions') { |value| options[:merge_parallel_transitions] = value }
563
+ opts.on('--version', 'Print version') { options[:version] = true }
564
+ opts.on('-h', '--help', 'Print help') do
565
+ puts opts
566
+ halt(EXIT_SUCCESS)
567
+ end
568
+ end
569
+
570
+ begin
571
+ parser.parse!(arguments)
572
+ validate_cli_numeric_options!(options)
573
+ rescue OptionParser::ParseError => e
574
+ report_exception(e)
575
+ halt(EXIT_USAGE)
576
+ end
577
+
578
+ input_path = options[:input] || arguments.shift
579
+ output_path = options[:output]
580
+ output_path ||= arguments.shift if command == :render
581
+
582
+ unless arguments.empty?
583
+ warn "Unexpected arguments: #{arguments.join(' ')}"
584
+ halt(EXIT_USAGE)
585
+ end
586
+
587
+ if options[:version]
588
+ puts Graphomaton::VERSION
589
+ halt(EXIT_SUCCESS)
590
+ end
591
+
592
+ unless %i[text json].include?((options[:diagnostics] || :text).to_sym)
593
+ warn '--diagnostics must be text or json'
594
+ halt(EXIT_USAGE)
595
+ end
596
+
597
+ if options[:list_themes]
598
+ puts Graphomaton::Theme.available_names.join("\n")
599
+ halt(EXIT_SUCCESS)
600
+ end
601
+
602
+ if options[:theme_gallery]
603
+ if output_path.nil?
604
+ warn parser
605
+ halt(EXIT_USAGE)
606
+ end
607
+
608
+ if options[:no_clobber] && File.exist?(output_path)
609
+ warn "Output file already exists: #{output_path}"
610
+ halt(EXIT_EXPORT)
611
+ end
612
+
613
+ themes = Graphomaton::Exporters::Svg::THEMES.dup
614
+ themes = themes.merge(custom: load_theme_file(options[:theme_file])) if options[:theme_file]
615
+ Graphomaton::Theme.save_gallery_html(
616
+ output_path,
617
+ title: options[:title] || 'Graphomaton Theme Gallery',
618
+ themes: themes,
619
+ animated: options[:theme_gallery_animated]
620
+ )
621
+ halt(EXIT_SUCCESS)
622
+ end
623
+
624
+ if input_path.nil? || (command == :render && output_path.nil?)
625
+ warn parser
626
+ halt(EXIT_USAGE)
627
+ end
628
+
629
+ if command == :render && options[:no_clobber] && output_path != '-' && File.exist?(output_path)
630
+ warn "Output file already exists: #{output_path}"
631
+ halt(EXIT_EXPORT)
632
+ end
633
+
634
+ begin
635
+ automaton = load_automaton(
636
+ input_path,
637
+ input_format: options[:input_format],
638
+ limits: {
639
+ max_input_bytes: options[:max_input_bytes],
640
+ max_states: options[:max_states],
641
+ max_transitions: options[:max_transitions],
642
+ max_metadata_depth: options[:max_metadata_depth],
643
+ max_label_length: options[:max_label_length],
644
+ max_group_depth: options[:max_group_depth]
645
+ }
646
+ )
647
+ rescue JSON::ParserError, Psych::Exception, ArgumentError, SystemCallError => e
648
+ report_exception(e, prefix: 'Input error')
649
+ halt(EXIT_INPUT)
650
+ end
651
+
652
+ if command == :validate
653
+ diagnostics = automaton.validation_diagnostics(profile: :all)
654
+ emit_diagnostics(diagnostics, format: options[:diagnostics] || :text, stream: @stdout)
655
+ has_errors = diagnostics.any? { |diagnostic| diagnostic.severity == :error }
656
+ has_warnings = diagnostics.any? { |diagnostic| diagnostic.severity == :warning }
657
+ halt(EXIT_VALIDATION) if has_errors || (options[:fail_on_warning] && has_warnings)
658
+ halt(EXIT_SUCCESS)
659
+ end
660
+
661
+ if options[:validate]
662
+ begin
663
+ automaton.validate!
664
+ rescue Graphomaton::ValidationError => e
665
+ report_exception(e)
666
+ halt(EXIT_VALIDATION)
667
+ end
668
+ end
669
+
670
+ if options[:theme_file]
671
+ options[:theme] = load_theme_file(options[:theme_file])
672
+ end
673
+
674
+ format_name = options[:format] || File.extname(output_path).delete_prefix('.')
675
+ if output_path == '-' && options[:format].nil?
676
+ warn '--format is required when writing to standard output'
677
+ halt(EXIT_USAGE)
678
+ end
679
+ begin
680
+ resolved_output_format = Graphomaton::EXPORTERS.resolve(format_name)
681
+ rescue ArgumentError => e
682
+ report_exception(e)
683
+ halt(EXIT_USAGE)
684
+ end
685
+ begin
686
+ validate_format_options!(options, resolved_output_format)
687
+ rescue OptionParser::ParseError => e
688
+ report_exception(e)
689
+ halt(EXIT_USAGE)
690
+ end
691
+ svg_backed_format = %i[svg png pdf webp].include?(resolved_output_format)
692
+
693
+ save_options = {}
694
+ if options[:theme]
695
+ save_options[:theme] = options[:theme] if svg_backed_format ||
696
+ %i[dot plantuml].include?(resolved_output_format) ||
697
+ (resolved_output_format == :html && !options[:theme].is_a?(Hash))
698
+ end
699
+ save_options[:direction] = options[:direction] if options[:direction] && (svg_backed_format || %i[html mermaid dot plantuml].include?(resolved_output_format))
700
+ if svg_backed_format
701
+ save_options[:layout] = options[:layout] if options[:layout]
702
+ save_options[:title] = options[:title] if options[:title]
703
+ save_options[:description] = options[:description] if options[:description]
704
+ save_options[:merge_parallel_transitions] = options[:merge_parallel_transitions] if options.key?(:merge_parallel_transitions)
705
+ save_options[:fit] = options[:fit] if options[:fit]
706
+ save_options[:padding] = options[:padding] if options[:padding]
707
+ save_options[:node_spacing] = options[:node_spacing] if options[:node_spacing]
708
+ save_options[:rank_spacing] = options[:rank_spacing] if options[:rank_spacing]
709
+ save_options[:force_iterations] = options[:force_iterations] if options[:force_iterations]
710
+ save_options[:layout_seed] = options[:layout_seed] if options[:layout_seed]
711
+ save_options[:graphviz_command] = options[:graphviz_command] if options[:graphviz_command]
712
+ save_options[:auto_density_spacing] = options[:auto_density_spacing] if options.key?(:auto_density_spacing)
713
+ save_options[:initial_position] = options[:initial_position] if options[:initial_position]
714
+ save_options[:final_position] = options[:final_position] if options[:final_position]
715
+ save_options[:responsive] = options[:responsive] if options.key?(:responsive)
716
+ save_options[:state_radius] = options[:state_radius] if options[:state_radius]
717
+ save_options[:auto_state_radius] = options[:auto_state_radius] if options.key?(:auto_state_radius)
718
+ save_options[:min_state_radius] = options[:min_state_radius] if options[:min_state_radius]
719
+ save_options[:max_state_radius] = options[:max_state_radius] if options[:max_state_radius]
720
+ save_options[:state_stroke_width] = options[:state_stroke_width] if options[:state_stroke_width]
721
+ save_options[:transition_stroke_width] = options[:transition_stroke_width] if options[:transition_stroke_width]
722
+ save_options[:state_shape] = options[:state_shape] if options[:state_shape]
723
+ save_options[:edge_style] = options[:edge_style] if options[:edge_style]
724
+ save_options[:arrow_shape] = options[:arrow_shape] if options[:arrow_shape]
725
+ save_options[:arrow_size] = options[:arrow_size] if options[:arrow_size]
726
+ save_options[:state_effect] = options[:state_effect] if options[:state_effect]
727
+ save_options[:font_family] = options[:font_family] if options[:font_family]
728
+ save_options[:state_font_weight] = options[:state_font_weight] if options[:state_font_weight]
729
+ save_options[:transition_font_weight] = options[:transition_font_weight] if options[:transition_font_weight]
730
+ save_options[:preserve_manual_positions] = options[:preserve_manual_positions] if options.key?(:preserve_manual_positions)
731
+ save_options[:auto_size] = options[:auto_size] if options.key?(:auto_size)
732
+ save_options[:xml_declaration] = options[:xml_declaration] if options.key?(:xml_declaration)
733
+ save_options[:pretty] = options[:pretty] if options.key?(:pretty)
734
+ save_options[:minify] = options[:minify] if options.key?(:minify)
735
+ save_options[:css_variables] = options[:css_variables] if options.key?(:css_variables)
736
+ save_options[:embed_styles] = options[:embed_styles] if options.key?(:embed_styles)
737
+ save_options[:svg_id] = options[:svg_id] if options[:svg_id]
738
+ save_options[:wrap] = options[:wrap] if options.key?(:wrap)
739
+ save_options[:max_transition_label_width] = options[:max_transition_label_width] if options[:max_transition_label_width]
740
+ save_options[:state_wrap] = options[:state_wrap] if options.key?(:state_wrap)
741
+ save_options[:max_state_label_width] = options[:max_state_label_width] if options[:max_state_label_width]
742
+ save_options[:label_tooltips] = options[:label_tooltips] if options.key?(:label_tooltips)
743
+ save_options[:html_tooltips] = options[:html_tooltips] if options.key?(:html_tooltips)
744
+ save_options[:sort_labels] = options[:sort_labels] if options.key?(:sort_labels)
745
+ save_options[:rotate_labels] = options[:rotate_labels] if options.key?(:rotate_labels)
746
+ save_options[:label_padding] = options[:label_padding] if options[:label_padding]
747
+ save_options[:label_radius] = options[:label_radius] if options[:label_radius]
748
+ save_options[:label_border] = options[:label_border] if options.key?(:label_border)
749
+ save_options[:label_background] = options[:label_background] if options.key?(:label_background)
750
+ save_options[:initial_arrow_length] = options[:initial_arrow_length] if options[:initial_arrow_length]
751
+ save_options[:initial_arrow_label] = options[:initial_arrow_label] if options.key?(:initial_arrow_label)
752
+ save_options[:final_arrow_length] = options[:final_arrow_length] if options[:final_arrow_length]
753
+ save_options[:final_arrow_label] = options[:final_arrow_label] if options.key?(:final_arrow_label)
754
+ save_options[:show_final_arrows] = options[:show_final_arrows] if options.key?(:show_final_arrows)
755
+ save_options[:scc_groups] = options[:scc_groups] if options.key?(:scc_groups)
756
+ save_options[:fold_groups] = options[:fold_groups] if options.key?(:fold_groups)
757
+ save_options[:highlight_unreachable] = options[:highlight_unreachable] if options.key?(:highlight_unreachable)
758
+ save_options[:unreachable_zone] = options[:unreachable_zone] if options[:unreachable_zone]
759
+ save_options[:highlight_dead_states] = options[:highlight_dead_states] if options.key?(:highlight_dead_states)
760
+ save_options[:highlight_initial_state] = options[:highlight_initial_state] if options.key?(:highlight_initial_state)
761
+ save_options[:highlight_final_states] = options[:highlight_final_states] if options.key?(:highlight_final_states)
762
+ save_options[:highlight_transitions] = options[:highlight_transitions] if options[:highlight_transitions]
763
+ save_options[:loop_position] = options[:loop_position] if options[:loop_position]
764
+ end
765
+ if %i[png pdf webp].include?(resolved_output_format)
766
+ save_options[:converter] = options[:converter] if options[:converter]
767
+ save_options[:timeout] = options[:timeout] if options[:timeout]
768
+ save_options[:max_output_bytes] = options[:max_output_bytes] if options[:max_output_bytes]
769
+ end
770
+ save_options[:scale] = options[:scale] if options[:scale] && resolved_output_format == :png
771
+ if resolved_output_format == :html
772
+ save_options[:cdn] = options[:cdn] if options[:cdn]
773
+ save_options[:offline] = options[:offline] if options.key?(:offline)
774
+ save_options[:inline_mermaid] = options[:inline_mermaid] if options.key?(:inline_mermaid)
775
+ save_options[:inline_mathjax] = options[:inline_mathjax] if options.key?(:inline_mathjax)
776
+ save_options[:self_contained] = options[:self_contained] if options.key?(:self_contained)
777
+ save_options[:nonce] = options[:nonce] if options[:nonce]
778
+ save_options[:csp] = options[:csp] if options.key?(:csp)
779
+ save_options[:mermaid_sha256] = options[:mermaid_sha256] if options[:mermaid_sha256]
780
+ save_options[:mathjax_sha256] = options[:mathjax_sha256] if options[:mathjax_sha256]
781
+ save_options[:title] = options[:title] if options[:title]
782
+ save_options[:lang] = options[:lang] if options[:lang]
783
+ save_options[:show_source] = options[:show_source] if options.key?(:show_source)
784
+ save_options[:pan_zoom] = options[:pan_zoom] if options.key?(:pan_zoom)
785
+ save_options[:mathjax] = options[:mathjax] if options.key?(:mathjax)
786
+ save_options[:mathjax_cdn] = options[:mathjax_cdn] if options[:mathjax_cdn]
787
+ end
788
+ if %i[html mermaid plantuml].include?(resolved_output_format)
789
+ save_options[:notes] = options[:notes] if options.key?(:notes)
790
+ end
791
+ if %i[html mermaid].include?(resolved_output_format)
792
+ save_options[:class_defs] = options[:class_defs] if options.key?(:class_defs)
793
+ end
794
+ save_options[:rank_constraints] = options[:rank_constraints] if options.key?(:rank_constraints) && resolved_output_format == :dot
795
+
796
+ begin
797
+ result = automaton.render_result(
798
+ format: resolved_output_format,
799
+ width: options[:width],
800
+ height: options[:height],
801
+ strict_semantics: options[:strict_semantics] || false,
802
+ **save_options
803
+ )
804
+ diagnostics = result.diagnostics.dup
805
+ diagnostics.concat(automaton.validation_diagnostics(profile: :fsm_semantics)) if options[:fail_on_warning]
806
+ visible_diagnostics = diagnostics.select do |diagnostic|
807
+ !diagnostic.code.start_with?('state-clipped-', 'label-') || options[:layout_warnings]
808
+ end
809
+ emit_diagnostics(visible_diagnostics, format: options[:diagnostics] || :text, stream: @stderr) if visible_diagnostics.any?
810
+ if options[:fail_on_warning] && diagnostics.any? { |diagnostic| diagnostic.severity == :warning }
811
+ halt(EXIT_VALIDATION)
812
+ end
813
+
814
+ if output_path == '-'
815
+ entry = Graphomaton::EXPORTERS.fetch(resolved_output_format)
816
+ @stdout.binmode if entry.binary && @stdout.respond_to?(:binmode)
817
+ @stdout.write(result.output)
818
+ else
819
+ Graphomaton::AtomicFile.write(
820
+ output_path,
821
+ result.output,
822
+ binary: Graphomaton::EXPORTERS.fetch(resolved_output_format).binary
823
+ )
824
+ end
825
+ rescue Graphomaton::SecurityError => e
826
+ report_exception(e)
827
+ halt(EXIT_SECURITY)
828
+ rescue Graphomaton::LayoutError => e
829
+ report_exception(e)
830
+ halt(EXIT_LAYOUT)
831
+ rescue Graphomaton::Exporters::Png::ConversionError,
832
+ Graphomaton::Exporters::Pdf::ConversionError,
833
+ Graphomaton::Exporters::Webp::ConversionError,
834
+ ArgumentError,
835
+ SystemCallError => e
836
+ report_exception(e)
837
+ halt(EXIT_EXPORT)
838
+ end
839
+ end
840
+ end
841
+ end