graphomaton 1.1.0 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8992f0a2ab8bf57fc92a7480dc29c39fa94188e952bef61a865f421fde5a9b91
4
- data.tar.gz: 1f7b78192ad4e692cff03ce8c45bb0cacf33195d7294612ca06a9bf695b409b3
3
+ metadata.gz: 168a8c5641230bf2da705a71bbc3156757a0cc820047d47f18c1c69f3ab08f0a
4
+ data.tar.gz: 52152b9aded87cdae3bca6f47bddd5cded06c91f7c7c4ca8450c0e1b51401084
5
5
  SHA512:
6
- metadata.gz: 7360eaf0c5ce3676e505559b066d429fa1f1df0219e922c2cacc2d62578a4bf94f07ac51d385abdcf61a5c03979def0f98088ac28e7081841722a2f975b39984
7
- data.tar.gz: d8bacee19d445a50d62449970b435ab5917093da8a27b7df7c41b7d59f8aa46c419de2754ddacbddae67b18bc00060e28f204a5c8a377b95c306b8203cef1abb
6
+ metadata.gz: 3cb2d71e65478459dc6cd4968cffcaeb13681124143116f73a5c175de394f55dc14b1335b6a2cae111f07903ddbd2235aa9b62fd18fed233bcde2b3f266f0b7e
7
+ data.tar.gz: c86a638ddb2d0c98bb05a39e396abbe541e483de706dba43437a368462cc3f9d2c2d0460b0152780dccf553d3bfa2d25b9f2d2a6dae5dff7a6cfdaa33ce7b5c3
data/CHANGELOG.md CHANGED
@@ -6,6 +6,44 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.2.0] - 2026-09-08
10
+
11
+ ### Added
12
+
13
+ - Added a public GitHub Pages landing page for the project.
14
+ - Added CLI selection for reference, FSM-semantic, DFA, and combined validation
15
+ profiles.
16
+ - Added structured mixed epsilon/symbol labels that retain their semantics
17
+ through Hash, JSON, and YAML round trips.
18
+
19
+ ### Changed
20
+
21
+ - Stronger CLI validation profiles now include reference checks, and exporter
22
+ semantic diagnostics distinguish state and transition tooltips.
23
+ - SVG rendering now reuses immutable state snapshots and linear state ordering
24
+ while preserving folded-state kinds and unrelated self-loops.
25
+ - Updated the RubyGems trusted-publishing action to 1.4.1.
26
+
27
+ ### Fixed
28
+
29
+ - Corrected long-word wrapping, explicit-position tracking, duplicate SVG IDs,
30
+ numeric-root style scoping, auto-sized bounds, and stale clipping diagnostics.
31
+ - Enforced final SVG and scaled PNG dimension limits before conversion, including
32
+ overflow and non-real numeric inputs.
33
+ - Made metadata-depth checks independent of Hash insertion order and rejected
34
+ boolean state identifiers consistently at input boundaries.
35
+ - Preserved option-dependent tooltip semantics in strict rendering and retained
36
+ epsilon meaning in array transition labels.
37
+ - Made `--no-clobber` atomic, including theme-gallery output races and friendly
38
+ CLI failures.
39
+
40
+ ### Security
41
+
42
+ - Rejected escaped, comment-obfuscated, and alternate CSS resource functions in
43
+ themes and per-element SVG styles.
44
+ - Ensured timed-out Unix process groups are terminated even when the direct
45
+ parent exits first.
46
+
9
47
  ## [1.1.0] - 2026-08-13
10
48
 
11
49
  ### Added
data/README.md CHANGED
@@ -220,7 +220,8 @@ The command interface also supports structured workflows:
220
220
  ```bash
221
221
  graphomaton render automaton.yml diagram.svg
222
222
  graphomaton validate automaton.yml --diagnostics json
223
- graphomaton validate automaton.yml --fail-on-warning
223
+ graphomaton validate automaton.yml --profile fsm_semantics --fail-on-warning
224
+ graphomaton validate automaton.yml --profile dfa
224
225
  graphomaton list formats
225
226
  graphomaton list layouts
226
227
  graphomaton doctor
@@ -239,7 +240,7 @@ svg:
239
240
  wrap: true
240
241
  ```
241
242
 
242
- The CLI validates state references and hierarchy by default. `--no-validate` is available for intentionally deferred or partial diagrams. Standard input auto-detects JSON documents that start with `{` or `[`, otherwise YAML is assumed; use `--input-format` to override it. `--format` is required when `--output -` is used. Input limits can be lowered with `--max-input-bytes`, `--max-states`, `--max-transitions`, `--max-metadata-depth`, `--max-label-length`, and `--max-group-depth`. Use `--no-clobber` to protect an existing output (and `--force` to opt back into replacement). Usage, parse, validation, conversion, and security failures use distinct exit statuses and are reported without backtraces.
243
+ The CLI validates state references and hierarchy by default. The `validate` command also defaults to that `references` profile; use `--profile fsm_semantics`, `--profile dfa`, or `--profile all` for stronger checks. `--no-validate` is available for intentionally deferred or partial diagrams. Standard input auto-detects JSON documents that start with `{` or `[`, otherwise YAML is assumed; use `--input-format` to override it. `--format` is required when `--output -` is used. Input limits can be lowered with `--max-input-bytes`, `--max-states`, `--max-transitions`, `--max-metadata-depth`, `--max-label-length`, and `--max-group-depth`. Use `--no-clobber` to protect an existing output (and `--force` to opt back into replacement). Usage, parse, validation, conversion, and security failures use distinct exit statuses and are reported without backtraces.
243
244
 
244
245
  Common SVG options:
245
246
 
data/docs/cli.md CHANGED
@@ -4,9 +4,10 @@
4
4
  `graphomaton INPUT OUTPUT` command. Use `-` for stdin or stdout; stdout requires
5
5
  `--format`. Rendering validates references by default.
6
6
 
7
- `graphomaton validate INPUT --diagnostics json` validates references, hierarchy,
8
- FSM warnings, and DFA constraints without writing a diagram. Add
9
- `--fail-on-warning` for CI. `graphomaton list` reports formats, layouts, themes,
7
+ `graphomaton validate INPUT --diagnostics json` validates references and hierarchy
8
+ without writing a diagram. Select `--profile fsm_semantics`, `--profile dfa`, or
9
+ `--profile all` for stronger checks, and add `--fail-on-warning` for CI.
10
+ `graphomaton list` reports formats, layouts, themes,
10
11
  and converters; `doctor` reports the Ruby version plus discovered native renderer
11
12
  paths and bounded version probes.
12
13
 
@@ -34,3 +34,6 @@ Capability omissions are reported by `semantic_diagnostics` and rejected when
34
34
  `strict_semantics: true` is used. Set `binary: true` when output must be written in
35
35
  binary mode. A temporary registration can be removed with
36
36
  `Graphomaton::EXPORTERS.unregister(:text_graph)`.
37
+
38
+ Tooltip capabilities are `state_tooltip` and `transition_tooltip`. The legacy
39
+ `tooltip` capability continues to declare both.
data/docs/exporters.md CHANGED
@@ -3,7 +3,9 @@
3
3
  | Feature | SVG/PNG/PDF/WebP | DOT | Mermaid | PlantUML |
4
4
  | --- | --- | --- | --- | --- |
5
5
  | styles and line styles | yes | partial | partial | partial |
6
- | URL and tooltip | yes | yes | no | no |
6
+ | URL | yes in SVG; no in raster output | yes | no | no |
7
+ | state tooltip | yes in SVG; no in raster output | yes | with `notes: true` | with `notes: true` |
8
+ | transition tooltip | yes in SVG; no in raster output | yes | no | no |
7
9
  | visual groups | yes | yes | partial | partial |
8
10
  | hierarchy and pseudostates | display | partial | yes | yes |
9
11
  | edge bundles | yes | no | no | no |
data/docs/input-schema.md CHANGED
@@ -19,7 +19,8 @@ default in the CLI.
19
19
  State and transition `style` and `metadata` values must be mappings. State labels
20
20
  are scalar display text. Transition labels may be scalar text, a symbol array, or
21
21
  a structured label with an explicit `type`/`kind`; malformed collections are
22
- rejected instead of being stringified or ignored.
22
+ rejected instead of being stringified or ignored. Ruby label arrays containing
23
+ `:epsilon` preserve both meanings as a structured `alternatives` label.
23
24
 
24
25
  YAML aliases are disabled. Parsing limits input bytes, state and transition
25
26
  counts, label bytes, metadata depth, and hierarchy depth. Callers handling
@@ -4,7 +4,7 @@ require 'tempfile'
4
4
 
5
5
  class Graphomaton
6
6
  class AtomicFile
7
- def self.write(filename, content, binary: false)
7
+ def self.write(filename, content, binary: false, no_clobber: false)
8
8
  destination = File.expand_path(filename)
9
9
  directory = File.dirname(destination)
10
10
  basename = File.basename(destination)
@@ -17,7 +17,11 @@ class Graphomaton
17
17
  temporary.flush
18
18
  temporary.fsync
19
19
  temporary.close
20
- File.rename(temporary.path, destination)
20
+ if no_clobber
21
+ File.link(temporary.path, destination)
22
+ else
23
+ File.rename(temporary.path, destination)
24
+ end
21
25
  end
22
26
 
23
27
  content.bytesize
@@ -8,10 +8,10 @@ class Graphomaton
8
8
  FORMAT_SECTIONS = %i[svg png pdf webp html mermaid dot plantuml].freeze
9
9
  ENUM_KEYS = %i[
10
10
  format input_format theme layout direction fit converter initial_position final_position
11
- state_shape edge_style arrow_shape state_effect unreachable_zone loop_position diagnostics
11
+ state_shape edge_style arrow_shape state_effect unreachable_zone loop_position diagnostics profile
12
12
  ].freeze
13
13
  OPTION_KEYS = %i[
14
- input input_format output format validate no_clobber width height scale converter timeout
14
+ input input_format output format validate profile no_clobber width height scale converter timeout
15
15
  max_output_bytes max_input_bytes max_states max_transitions max_metadata_depth max_label_length
16
16
  max_group_depth theme theme_file layout_warnings
17
17
  layout direction fit padding node_spacing rank_spacing force_iterations layout_seed
@@ -18,7 +18,7 @@ class Graphomaton
18
18
  COMPLETION_WORDS = %w[
19
19
  render validate themes list doctor completion man formats layouts converters
20
20
  --input --input-format --output --format --config --no-clobber --force --validate
21
- --no-validate --diagnostics --fail-on-warning --strict-semantics --layout-warnings
21
+ --no-validate --profile --diagnostics --fail-on-warning --strict-semantics --layout-warnings
22
22
  --width --height --theme --theme-file --layout --direction --fit --padding
23
23
  --node-spacing --rank-spacing --force-iterations --layout-seed --graphviz-command
24
24
  --max-metadata-depth --max-label-length --max-group-depth
@@ -327,7 +327,7 @@ def execute_man(arguments)
327
327
  end
328
328
 
329
329
  @stdout.write <<~MANPAGE
330
- .TH GRAPHOMATON 1 "2026-08-12" "Graphomaton #{Graphomaton::VERSION}" "User Commands"
330
+ .TH GRAPHOMATON 1 "2026-09-08" "Graphomaton #{Graphomaton::VERSION}" "User Commands"
331
331
  .SH NAME
332
332
  graphomaton \- validate, analyze, and render finite-state machines
333
333
  .SH SYNOPSIS
@@ -433,7 +433,7 @@ options[:format] = argument_format if argument_format
433
433
 
434
434
  parser = OptionParser.new do |opts|
435
435
  opts.banner = 'Usage: graphomaton [render] --input automaton.yml --output diagram.svg [options]'
436
- opts.separator ' graphomaton validate automaton.yml [--diagnostics text|json]'
436
+ opts.separator ' graphomaton validate automaton.yml [--profile references|fsm_semantics|dfa|all]'
437
437
  opts.separator ' graphomaton list formats|layouts|themes|converters'
438
438
  opts.separator ' graphomaton themes | doctor'
439
439
  opts.separator ' graphomaton --theme-gallery --output theme_gallery.html [options]'
@@ -446,6 +446,7 @@ parser = OptionParser.new do |opts|
446
446
  opts.on('--force', 'Allow replacing an existing output file') { options[:no_clobber] = false }
447
447
  opts.on('-f', '--format FORMAT', 'Output format override') { |value| options[:format] = value.to_sym }
448
448
  opts.on('--[no-]validate', 'Validate automaton references before rendering (default: enabled)') { |value| options[:validate] = value }
449
+ opts.on('--profile PROFILE', 'Validation profile: references, fsm_semantics, dfa, or all') { |value| options[:profile] = value.to_sym }
449
450
  opts.on('--diagnostics FORMAT', 'Diagnostic output: text or json') { |value| options[:diagnostics] = value.to_sym }
450
451
  opts.on('--fail-on-warning', 'Return a failure status when warnings are emitted') { options[:fail_on_warning] = true }
451
452
  opts.on('--strict-semantics', 'Reject information loss in the selected output format') { options[:strict_semantics] = true }
@@ -612,12 +613,21 @@ if options[:theme_gallery]
612
613
 
613
614
  themes = Graphomaton::Exporters::Svg::THEMES.dup
614
615
  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
- )
616
+ begin
617
+ Graphomaton::Theme.save_gallery_html(
618
+ output_path,
619
+ no_clobber: options[:no_clobber],
620
+ title: options[:title] || 'Graphomaton Theme Gallery',
621
+ themes: themes,
622
+ animated: options[:theme_gallery_animated]
623
+ )
624
+ rescue Errno::EEXIST
625
+ warn "Output file already exists: #{output_path}"
626
+ halt(EXIT_EXPORT)
627
+ rescue SystemCallError => e
628
+ report_exception(e)
629
+ halt(EXIT_EXPORT)
630
+ end
621
631
  halt(EXIT_SUCCESS)
622
632
  end
623
633
 
@@ -650,7 +660,18 @@ rescue JSON::ParserError, Psych::Exception, ArgumentError, SystemCallError => e
650
660
  end
651
661
 
652
662
  if command == :validate
653
- diagnostics = automaton.validation_diagnostics(profile: :all)
663
+ profile = (options[:profile] || :references).to_s.to_sym
664
+ profiles = {
665
+ references: :references,
666
+ fsm_semantics: %i[references fsm_semantics],
667
+ dfa: %i[references dfa],
668
+ all: :all
669
+ }
670
+ unless profiles.key?(profile)
671
+ warn "Unknown validation profile: #{profile}"
672
+ halt(EXIT_USAGE)
673
+ end
674
+ diagnostics = automaton.validation_diagnostics(profile: profiles.fetch(profile))
654
675
  emit_diagnostics(diagnostics, format: options[:diagnostics] || :text, stream: @stdout)
655
676
  has_errors = diagnostics.any? { |diagnostic| diagnostic.severity == :error }
656
677
  has_warnings = diagnostics.any? { |diagnostic| diagnostic.severity == :warning }
@@ -819,7 +840,8 @@ begin
819
840
  Graphomaton::AtomicFile.write(
820
841
  output_path,
821
842
  result.output,
822
- binary: Graphomaton::EXPORTERS.fetch(resolved_output_format).binary
843
+ binary: Graphomaton::EXPORTERS.fetch(resolved_output_format).binary,
844
+ no_clobber: options[:no_clobber]
823
845
  )
824
846
  end
825
847
  rescue Graphomaton::SecurityError => e
@@ -76,9 +76,9 @@ class Graphomaton
76
76
  graph.state_records.any? { |_, state| metadata_value(state.metadata, :url, :href) } ||
77
77
  graph.transition_records.any? { |transition| metadata_value(transition.metadata, :url, :href) }
78
78
  },
79
- tooltip: lambda { |graph|
80
- graph.state_records.any? { |_, state| metadata_value(state.metadata, :tooltip, :description) } ||
81
- graph.transition_records.any? { |transition| metadata_value(transition.metadata, :tooltip, :description) }
79
+ state_tooltip: ->(graph) { graph.state_records.any? { |_, state| metadata_value(state.metadata, :tooltip, :description) } },
80
+ transition_tooltip: lambda { |graph|
81
+ graph.transition_records.any? { |transition| metadata_value(transition.metadata, :tooltip, :description) }
82
82
  },
83
83
  group: ->(graph) { graph.state_records.any? { |_, state| metadata_value(state.metadata, :group, :cluster) } },
84
84
  parent: ->(graph) { graph.state_records.any? { |_, state| metadata_value(state.metadata, :parent) } },
@@ -92,6 +92,9 @@ class Graphomaton
92
92
  }.freeze
93
93
 
94
94
  def self.losses_for(graph, capabilities)
95
+ if capabilities.include?(:tooltip)
96
+ capabilities = capabilities + %i[state_tooltip transition_tooltip]
97
+ end
95
98
  FEATURE_DETECTORS.filter_map do |feature, detector|
96
99
  feature if detector.call(graph) && !capabilities.include?(feature)
97
100
  end
@@ -108,13 +108,14 @@ class Graphomaton
108
108
 
109
109
  def scaled_dimension(value, scale)
110
110
  scaled = value.to_f * scale
111
+ return scaled unless scaled.finite?
111
112
  return scaled.to_i if scaled == scaled.to_i
112
113
 
113
114
  scaled
114
115
  end
115
116
 
116
117
  def resolve_scale(scale)
117
- unless scale.is_a?(Numeric) && scale.finite? && scale.positive?
118
+ unless scale.is_a?(Numeric) && scale.real? && scale.to_f.finite? && scale.positive?
118
119
  raise ArgumentError, 'PNG scale must be a positive finite number'
119
120
  end
120
121
 
@@ -127,8 +128,11 @@ class Graphomaton
127
128
  view_box = root.attributes['viewBox'].to_s.split.map(&:to_f)
128
129
  logical_width = numeric_svg_dimension(root.attributes['width'], view_box[2])
129
130
  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
131
+ pixel_width = scaled_dimension(logical_width, scale)
132
+ pixel_height = scaled_dimension(logical_height, scale)
133
+ Graphomaton.validate_canvas_dimensions!(pixel_width, pixel_height)
134
+ root.attributes['width'] = pixel_width.to_s
135
+ root.attributes['height'] = pixel_height.to_s
132
136
  document.to_s
133
137
  end
134
138
 
@@ -312,6 +312,7 @@ class Graphomaton
312
312
  fit: Graphomaton::DEFAULT_FIT,
313
313
  title: nil, description: nil, svg_id: nil)
314
314
  source_automaton = @automaton
315
+ @state_records = nil
315
316
  @diagnostics = []
316
317
  @state_radius = resolve_state_radius(state_radius, auto_state_radius, min_state_radius, max_state_radius)
317
318
  @state_shape = resolve_state_shape(state_shape)
@@ -360,12 +361,14 @@ class Graphomaton
360
361
  @scc_groups = scc_groups
361
362
  @max_transition_label_width = finite_number!(max_transition_label_width, 'max_transition_label_width', nonnegative: true)
362
363
  @sort_labels = sort_labels
364
+ @auto_size = auto_size
363
365
  @label_tooltips = label_tooltips
364
366
  @html_tooltips = html_tooltips
365
367
  @font_family = safe_css_value(font_family, context: 'font_family')
366
368
  @state_font_weight = safe_css_value(state_font_weight, context: 'state_font_weight', allow_nil: true)
367
369
  @transition_font_weight = safe_css_value(transition_font_weight, context: 'transition_font_weight', allow_nil: true)
368
370
  @automaton = folded_automaton(@automaton) if fold_groups
371
+ @state_records = nil if fold_groups
369
372
  layout_padding = automatic_layout_padding
370
373
  @positions = @automaton.layout_positions(
371
374
  width,
@@ -388,6 +391,9 @@ class Graphomaton
388
391
  if auto_size
389
392
  width, height = auto_size_canvas(width, height)
390
393
  end
394
+ Graphomaton.validate_canvas_dimensions!(width, height)
395
+ @canvas_x = 0.0
396
+ @canvas_y = 0.0
391
397
  @canvas_width = width.to_f
392
398
  @canvas_height = height.to_f
393
399
  @label_boxes = state_collision_boxes + group_label_collision_boxes
@@ -411,6 +417,7 @@ class Graphomaton
411
417
  @svg_id = svg_id ? svg_id_component(svg_id) : default_svg_id(width, height)
412
418
  @arrowhead_id = "#{@svg_id}-arrowhead"
413
419
  @element_id_counts = Hash.new(0)
420
+ @element_ids = Set.new([@svg_id, @arrowhead_id, "#{@svg_id}-title", "#{@svg_id}-desc"])
414
421
 
415
422
  doc = REXML::Document.new
416
423
  svg = doc.add_element('svg', svg_root_attributes(width, height, responsive: responsive))
@@ -441,7 +448,14 @@ class Graphomaton
441
448
  output = export(*arguments, **options)
442
449
  render_diagnostics = (@diagnostics || []).dup
443
450
  render_diagnostics.concat(
444
- @automaton.layout_diagnostics_for(@positions, @canvas_width, @canvas_height, @state_radius)
451
+ @automaton.layout_diagnostics_for(
452
+ @positions,
453
+ @canvas_width,
454
+ @canvas_height,
455
+ @state_radius,
456
+ x: @canvas_x,
457
+ y: @canvas_y
458
+ )
445
459
  )
446
460
  RenderResult.new(
447
461
  output: output.dup.freeze,
@@ -588,7 +602,7 @@ class Graphomaton
588
602
  resolved_rank_spacing = finite_number!(rank_spacing, 'rank_spacing', nonnegative: true)
589
603
  return [resolved_node_spacing, resolved_rank_spacing] unless auto_density_spacing
590
604
 
591
- state_count = @automaton.state_records.size
605
+ state_count = state_records.size
592
606
  return [resolved_node_spacing, resolved_rank_spacing] if state_count <= 4
593
607
 
594
608
  multiplier = 1.0 + ([[state_count - 4, 16].min, 0].max * 0.06)
@@ -636,6 +650,11 @@ class Graphomaton
636
650
  view_y = bounds[:min_y] - margin
637
651
  view_width = (bounds[:max_x] - bounds[:min_x]) + (margin * 2)
638
652
  view_height = (bounds[:max_y] - bounds[:min_y]) + (margin * 2)
653
+ Graphomaton.validate_canvas_dimensions!(view_width, view_height)
654
+ @canvas_x = view_x
655
+ @canvas_y = view_y
656
+ @canvas_width = view_width
657
+ @canvas_height = view_height
639
658
  svg.attributes['viewBox'] = "#{view_x} #{view_y} #{view_width} #{view_height}"
640
659
  unless responsive
641
660
  svg.attributes['width'] = view_width.to_s
@@ -800,7 +819,7 @@ class Graphomaton
800
819
  end
801
820
 
802
821
  def generated_description
803
- state_count = @automaton.state_records.size
822
+ state_count = state_records.size
804
823
  transition_count = @automaton.transition_records.size
805
824
 
806
825
  "Automaton with #{state_count} states and #{transition_count} transitions."
@@ -828,7 +847,7 @@ class Graphomaton
828
847
  end
829
848
 
830
849
  def state_label_radius
831
- max_width_units = @automaton.state_records.map do |name, state|
850
+ max_width_units = state_records.map do |name, state|
832
851
  text_display_width_units(state_label(name, state))
833
852
  end.max || 0
834
853
  return DEFAULT_STATE_RADIUS if max_width_units <= 0
@@ -940,7 +959,7 @@ class Graphomaton
940
959
  def add_style(svg)
941
960
  style = svg.add_element('style')
942
961
  background = theme_css_value(:background, fallback: 'transparent')
943
- scope = "##{@svg_id}"
962
+ scope = %(svg[id="#{@svg_id}"])
944
963
  style.text = <<-CSS
945
964
  #{css_variables_css}
946
965
  #{scope} .diagram-background { fill: #{background}; }
@@ -1005,7 +1024,7 @@ class Graphomaton
1005
1024
  "#{indentation} --graphomaton-#{css_variable_name(key)}: #{value};"
1006
1025
  end.join("\n")
1007
1026
 
1008
- "#{indentation}##{svg_id} {\n#{declarations}\n#{indentation}}\n"
1027
+ %(#{indentation}svg[id="#{svg_id}"] {\n#{declarations}\n#{indentation}}\n)
1009
1028
  end
1010
1029
 
1011
1030
  def theme_css_value(key, fallback: nil)
@@ -1064,7 +1083,7 @@ class Graphomaton
1064
1083
 
1065
1084
  def default_svg_id(width, height)
1066
1085
  components = [
1067
- @automaton.state_records,
1086
+ state_records,
1068
1087
  @automaton.transition_records,
1069
1088
  @automaton.initial_state,
1070
1089
  @automaton.final_states,
@@ -1304,6 +1323,7 @@ class Graphomaton
1304
1323
  words.each do |word|
1305
1324
  if text_exceeds_width?(word, max_width)
1306
1325
  lines << current unless current.empty?
1326
+ current = +''
1307
1327
  split_words = split_long_word(word, max_width)
1308
1328
  split_words.each do |split_word|
1309
1329
  candidate = current.empty? ? split_word : "#{current} #{split_word}"
@@ -1384,7 +1404,7 @@ class Graphomaton
1384
1404
  if label_box_overlap?(collision_box) || label_box_overlaps_state?(collision_box)
1385
1405
  @diagnostics << layout_diagnostic('label-overlap-unresolved', 'Transition label overlap could not be resolved')
1386
1406
  end
1387
- if label_box_outside_canvas?(collision_box)
1407
+ if !@auto_size && label_box_outside_canvas?(collision_box)
1388
1408
  @diagnostics << layout_diagnostic('label-outside-canvas', 'Transition label extends outside the SVG canvas')
1389
1409
  end
1390
1410
  box
@@ -1577,7 +1597,7 @@ class Graphomaton
1577
1597
  delta_y = target[:y].to_f - center[:y].to_f
1578
1598
  return [center[:x].to_f, center[:y].to_f] if delta_x.zero? && delta_y.zero?
1579
1599
 
1580
- shape = state_shape(@automaton.state_records.fetch(name))
1600
+ shape = state_shape(state_records.fetch(name))
1581
1601
  scale = connection_scale(shape, delta_x, delta_y)
1582
1602
  [center[:x].to_f + (delta_x * scale), center[:y].to_f + (delta_y * scale)]
1583
1603
  end
@@ -2039,7 +2059,7 @@ class Graphomaton
2039
2059
 
2040
2060
  def add_initial_arrow(svg)
2041
2061
  init = state_position(@automaton.initial_state)
2042
- init ||= @automaton.state_records[@automaton.initial_state]
2062
+ init ||= state_records[@automaton.initial_state]
2043
2063
  return unless init
2044
2064
 
2045
2065
  return unless init[:x] && init[:y]
@@ -2102,7 +2122,7 @@ class Graphomaton
2102
2122
 
2103
2123
  def add_final_arrows(svg)
2104
2124
  @automaton.final_states.each do |state_name|
2105
- state = state_position(state_name) || @automaton.state_records[state_name]
2125
+ state = state_position(state_name) || state_records[state_name]
2106
2126
  next unless state && state[:x] && state[:y]
2107
2127
 
2108
2128
  x1, y1, x2, y2, label_x, label_y, anchor = final_arrow_points(state)
@@ -2184,7 +2204,7 @@ class Graphomaton
2184
2204
  end
2185
2205
 
2186
2206
  def grouped_state_positions
2187
- groups = @automaton.state_records.each_with_object({}) do |(name, state), grouped|
2207
+ groups = state_records.each_with_object({}) do |(name, state), grouped|
2188
2208
  group_name = state_group_name(state)
2189
2209
  next unless group_name
2190
2210
 
@@ -2244,11 +2264,11 @@ class Graphomaton
2244
2264
  end
2245
2265
 
2246
2266
  def svg_group_decorations?
2247
- @scc_groups || @automaton.state_records.any? { |_, state| state_group_name(state) }
2267
+ @scc_groups || state_records.any? { |_, state| state_group_name(state) }
2248
2268
  end
2249
2269
 
2250
2270
  def explicit_state_group?(state_name)
2251
- state = @automaton.state_records[state_name]
2271
+ state = state_records[state_name]
2252
2272
  state && state_group_name(state)
2253
2273
  end
2254
2274
 
@@ -2260,7 +2280,8 @@ class Graphomaton
2260
2280
  end
2261
2281
 
2262
2282
  def folded_automaton(source)
2263
- groups = source.states.each_with_object({}) do |(name, state), grouped|
2283
+ states = source.states
2284
+ groups = states.each_with_object({}) do |(name, state), grouped|
2264
2285
  group_name = state_group_name(state)
2265
2286
  next unless group_name
2266
2287
 
@@ -2272,20 +2293,23 @@ class Graphomaton
2272
2293
 
2273
2294
  folded = Graphomaton.new
2274
2295
  group_ids = {}
2296
+ reserved_group_ids = Set.new
2275
2297
  member_to_group = {}
2276
2298
 
2277
2299
  groups.each do |group_name, members|
2278
- group_id = folded_group_state_id(group_name, source.states, group_ids.values)
2300
+ group_id = folded_group_state_id(group_name, states, reserved_group_ids)
2279
2301
  group_ids[group_name] = group_id
2302
+ reserved_group_ids << group_id
2280
2303
  members.each { |member| member_to_group[member] = group_id }
2281
2304
  end
2282
2305
 
2283
- source.states.each do |name, state|
2306
+ added_groups = Set.new
2307
+ states.each do |name, state|
2284
2308
  group_name = state_group_name(state)
2285
2309
  if group_name && groups.key?(group_name)
2286
- next if folded.states.key?(group_ids[group_name])
2310
+ next unless added_groups.add?(group_name)
2287
2311
 
2288
- position = folded_group_position(groups[group_name], source.states)
2312
+ position = folded_group_position(groups[group_name], states)
2289
2313
  folded.add_state(
2290
2314
  group_ids[group_name],
2291
2315
  position[:x],
@@ -2302,15 +2326,19 @@ class Graphomaton
2302
2326
  label: state[:label],
2303
2327
  style: state[:style],
2304
2328
  metadata: state[:metadata],
2305
- shape: state[:shape]
2329
+ shape: state[:shape],
2330
+ kind: state[:kind]
2306
2331
  )
2307
2332
  end
2308
2333
  end
2309
2334
 
2310
- source.transitions.each do |transition|
2311
- from = member_to_group.fetch(transition[:from], transition[:from])
2312
- to = member_to_group.fetch(transition[:to], transition[:to])
2313
- next if from == to
2335
+ source.transition_records.each do |transition|
2336
+ from_group = member_to_group[transition.from]
2337
+ to_group = member_to_group[transition.to]
2338
+ next if from_group && from_group == to_group
2339
+
2340
+ from = from_group || transition.from
2341
+ to = to_group || transition.to
2314
2342
 
2315
2343
  folded.add_transition(
2316
2344
  from,
@@ -2429,7 +2457,7 @@ class Graphomaton
2429
2457
  end
2430
2458
 
2431
2459
  def add_states(svg)
2432
- @automaton.state_records.each do |name, state|
2460
+ state_records.each do |name, state|
2433
2461
  label = state_label(name, state)
2434
2462
  lines = state_label_lines(label)
2435
2463
  position = state_position(name) || state
@@ -2660,12 +2688,7 @@ class Graphomaton
2660
2688
  def safe_css_value(value, context:, allow_nil: false)
2661
2689
  return nil if value.nil? && allow_nil
2662
2690
 
2663
- css_value = value.to_s
2664
- if css_value.match?(/[\u0000-\u001f\u007f;{}]/) || css_value.match?(/url\s*\(/i)
2665
- raise Graphomaton::SecurityError, "Unsafe SVG #{context}: #{value.inspect}"
2666
- end
2667
-
2668
- css_value
2691
+ Graphomaton::Theme.safe_css_value(value, context: "SVG #{context}")
2669
2692
  end
2670
2693
 
2671
2694
  def state_group_attributes(name)
@@ -2681,7 +2704,7 @@ class Graphomaton
2681
2704
  'id' => unique_svg_id("state-#{svg_id_component(name)}"),
2682
2705
  'data-state' => name.to_s
2683
2706
  }
2684
- state = @automaton.state_records[name]
2707
+ state = state_records[name]
2685
2708
  folded_group = state_metadata_value(state, :folded_group) if state
2686
2709
  folded_states = state_metadata_value(state, :folded_states) if state
2687
2710
  attributes['data-folded-group'] = folded_group.to_s if folded_group
@@ -2834,10 +2857,16 @@ class Graphomaton
2834
2857
  end
2835
2858
 
2836
2859
  def unique_svg_id(base_id)
2837
- @element_id_counts[base_id] += 1
2838
- return base_id if @element_id_counts[base_id] == 1
2860
+ index = @element_id_counts[base_id] + 1
2861
+ candidate = index == 1 ? base_id : "#{base_id}-#{index}"
2862
+ while @element_ids.include?(candidate)
2863
+ index += 1
2864
+ candidate = "#{base_id}-#{index}"
2865
+ end
2839
2866
 
2840
- "#{base_id}-#{@element_id_counts[base_id]}"
2867
+ @element_id_counts[base_id] = index
2868
+ @element_ids << candidate
2869
+ candidate
2841
2870
  end
2842
2871
 
2843
2872
  def svg_id_component(value)
@@ -2849,6 +2878,10 @@ class Graphomaton
2849
2878
  @positions[state_name]
2850
2879
  end
2851
2880
 
2881
+ def state_records
2882
+ @state_records ||= @automaton.state_records
2883
+ end
2884
+
2852
2885
  def forward_direction?(x1, y1, x2, y2)
2853
2886
  case @direction
2854
2887
  when :lr
@@ -25,6 +25,7 @@ class Graphomaton
25
25
 
26
26
  def self.identifier!(value, context:)
27
27
  raise ArgumentError, "#{context} cannot be nil" if value.nil?
28
+ raise ArgumentError, "#{context} cannot be boolean" if value == true || value == false
28
29
 
29
30
  text!(value, context: context)
30
31
  value
@@ -64,16 +65,16 @@ class Graphomaton
64
65
  raise ArgumentError, 'max_metadata_depth must be a positive Integer' unless maximum.is_a?(Integer) && maximum.positive?
65
66
 
66
67
  stack = [[value, 1]]
67
- visited = {}
68
+ visited_depth = {}
68
69
  until stack.empty?
69
70
  current, depth = stack.pop
70
71
  text!(current, context: context, max_bytes: max_string_bytes) if current.is_a?(String)
71
72
  next unless current.is_a?(Hash) || current.is_a?(Array)
72
- next if visited[current.object_id]
73
+ next if visited_depth.fetch(current.object_id, 0) >= depth
73
74
 
74
75
  raise ArgumentError, "#{context} exceeds max_metadata_depth (#{maximum})" if depth > maximum
75
76
 
76
- visited[current.object_id] = true
77
+ visited_depth[current.object_id] = depth
77
78
  children = current.is_a?(Hash) ? current.flat_map { |key, item| [key, item] } : current
78
79
  children.each { |child| stack << [child, depth + 1] }
79
80
  end
@@ -37,7 +37,7 @@ class Graphomaton
37
37
  private_constant :ModelValue
38
38
 
39
39
  Label = Data.define(:kind, :value) do
40
- KINDS = %i[text symbols epsilon uml].freeze
40
+ KINDS = %i[text symbols epsilon uml alternatives].freeze
41
41
 
42
42
  def initialize(kind:, value: nil)
43
43
  resolved_kind = kind.to_sym
@@ -64,6 +64,11 @@ class Graphomaton
64
64
  raise ArgumentError, 'UML label requires an event' unless uml_value.key?(:event)
65
65
 
66
66
  uml_value
67
+ when :alternatives
68
+ alternatives = Array(value)
69
+ raise ArgumentError, 'Alternative label requires at least one label' if alternatives.empty?
70
+
71
+ alternatives
67
72
  end
68
73
  super(kind: resolved_kind, value: ModelValue.copy(normalized_value))
69
74
  end
@@ -84,6 +89,10 @@ class Graphomaton
84
89
  new(kind: :uml, value: { event: event, guard: guard, action: action }.compact)
85
90
  end
86
91
 
92
+ def self.alternatives(*values)
93
+ new(kind: :alternatives, value: values.flatten)
94
+ end
95
+
87
96
  def to_s
88
97
  case kind
89
98
  when :symbols
@@ -95,13 +104,16 @@ class Graphomaton
95
104
  guard = value[:guard] ? " [#{value[:guard]}]" : ''
96
105
  action = value[:action] ? " / #{value[:action]}" : ''
97
106
  "#{event}#{guard}#{action}"
107
+ when :alternatives
108
+ value.join(', ')
98
109
  else
99
110
  value.to_s
100
111
  end
101
112
  end
102
113
 
103
114
  def to_h
104
- { type: kind, value: value }.compact
115
+ serialized = kind == :alternatives ? value.map { |label| label.is_a?(Label) ? label.to_h : label } : value
116
+ { type: kind, value: serialized }.compact
105
117
  end
106
118
  end
107
119
 
@@ -110,13 +110,12 @@ class Graphomaton
110
110
  private_class_method :output_reader
111
111
 
112
112
  def self.terminate(wait_thread)
113
- return unless wait_thread.alive?
114
-
115
- signal_process(wait_thread.pid, 'TERM')
116
- return if wait_thread.join(0.25)
117
-
118
- signal_process(wait_thread.pid, 'KILL')
119
- wait_thread.join
113
+ pid = wait_thread.pid
114
+ parent_alive = wait_thread.alive?
115
+ signal_process(pid, 'TERM')
116
+ parent_alive ? wait_thread.join(0.25) : sleep(0.25)
117
+ signal_process(pid, 'KILL')
118
+ wait_thread.join if wait_thread.alive?
120
119
  end
121
120
  private_class_method :terminate
122
121
 
@@ -137,7 +136,7 @@ class Graphomaton
137
136
  private_class_method :signal_process
138
137
 
139
138
  def self.validate_limit(value, name)
140
- return if value.is_a?(Numeric) && value.finite? && value.positive?
139
+ return if value.is_a?(Numeric) && value.real? && value.to_f.finite? && value.positive?
141
140
 
142
141
  raise ArgumentError, "#{name} must be a positive finite number"
143
142
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Graphomaton
4
- VERSION = '1.1.0'
4
+ VERSION = '1.2.0'
5
5
  end
data/lib/graphomaton.rb CHANGED
@@ -90,7 +90,18 @@ class Graphomaton
90
90
  end
91
91
 
92
92
  def self.save_gallery_html(filename, **options)
93
- AtomicFile.write(filename, gallery_html(**options))
93
+ no_clobber = options.delete(:no_clobber) || false
94
+ AtomicFile.write(filename, gallery_html(**options), no_clobber: no_clobber)
95
+ end
96
+
97
+ def self.safe_css_value(value, context:)
98
+ string = value.to_s
99
+ if string.match?(/[\u0000-\u001f\u007f;{}\\]/) ||
100
+ string.match?(/\/\*|(?:url|src|image(?:-set)?|cross-fade|element|paint)\s*\(/i)
101
+ raise Graphomaton::SecurityError, "Unsafe #{context}: #{value.inspect}"
102
+ end
103
+
104
+ string
94
105
  end
95
106
 
96
107
  def self.theme_card(name, theme)
@@ -150,10 +161,7 @@ class Graphomaton
150
161
  private_class_method :escape_html
151
162
 
152
163
  def self.validate_value(key, value, context:)
153
- string = value.to_s
154
- if string.match?(/[\u0000-\u001f\u007f;{}]/) || string.match?(/url\s*\(/i)
155
- raise Graphomaton::SecurityError, "Unsafe #{context} value for #{key}: #{value.inspect}"
156
- end
164
+ safe_css_value(value, context: "#{context} value for #{key}")
157
165
 
158
166
  return unless key == :label_opacity
159
167
 
@@ -190,17 +198,18 @@ class Graphomaton
190
198
  puml: :plantuml
191
199
  }.freeze
192
200
  ALL_EXPORT_CAPABILITIES = %i[
193
- state_style transition_style url tooltip group parent pseudostate bundle line_style
201
+ state_style transition_style url state_tooltip transition_tooltip group parent pseudostate bundle line_style
194
202
  ].freeze
203
+ RASTER_EXPORT_CAPABILITIES = (ALL_EXPORT_CAPABILITIES - %i[url state_tooltip transition_tooltip]).freeze
195
204
  EXPORTERS = ExporterRegistry.new.tap do |registry|
196
205
  registry.register(:svg, extensions: %w[svg], capabilities: ALL_EXPORT_CAPABILITIES) { Exporters::Svg }
197
- registry.register(:png, extensions: %w[png], binary: true, capabilities: ALL_EXPORT_CAPABILITIES) { Exporters::Png }
198
- registry.register(:pdf, extensions: %w[pdf], binary: true, capabilities: ALL_EXPORT_CAPABILITIES) { Exporters::Pdf }
199
- registry.register(:webp, extensions: %w[webp], binary: true, capabilities: ALL_EXPORT_CAPABILITIES) { Exporters::Webp }
200
- registry.register(:html, extensions: %w[html], capabilities: %i[group parent pseudostate tooltip]) { Exporters::Mermaid }
201
- registry.register(:mermaid, aliases: %i[mmd], extensions: %w[mermaid mmd], capabilities: %i[group parent pseudostate tooltip]) { Exporters::Mermaid }
202
- registry.register(:dot, aliases: %i[gv], extensions: %w[dot gv], capabilities: %i[url tooltip group pseudostate bundle line_style]) { Exporters::Dot }
203
- registry.register(:plantuml, aliases: %i[puml], extensions: %w[plantuml puml], capabilities: %i[group parent pseudostate tooltip]) { Exporters::Plantuml }
206
+ registry.register(:png, extensions: %w[png], binary: true, capabilities: RASTER_EXPORT_CAPABILITIES) { Exporters::Png }
207
+ registry.register(:pdf, extensions: %w[pdf], binary: true, capabilities: RASTER_EXPORT_CAPABILITIES) { Exporters::Pdf }
208
+ registry.register(:webp, extensions: %w[webp], binary: true, capabilities: RASTER_EXPORT_CAPABILITIES) { Exporters::Webp }
209
+ registry.register(:html, extensions: %w[html], capabilities: %i[group parent pseudostate]) { Exporters::Mermaid }
210
+ registry.register(:mermaid, aliases: %i[mmd], extensions: %w[mermaid mmd], capabilities: %i[group parent pseudostate]) { Exporters::Mermaid }
211
+ registry.register(:dot, aliases: %i[gv], extensions: %w[dot gv], capabilities: %i[url state_tooltip transition_tooltip group pseudostate bundle line_style]) { Exporters::Dot }
212
+ registry.register(:plantuml, aliases: %i[puml], extensions: %w[plantuml puml], capabilities: %i[group parent pseudostate]) { Exporters::Plantuml }
204
213
  end
205
214
  DEFAULT_INITIAL_POSITION = :auto
206
215
  DEFAULT_FINAL_POSITION = :auto
@@ -212,12 +221,29 @@ class Graphomaton
212
221
  DEFAULT_MAX_LABEL_LENGTH = 64 * 1024
213
222
  DEFAULT_MAX_GROUP_DEPTH = 64
214
223
  DEFAULT_MAX_CANVAS_AREA = 100_000_000
224
+ DEFAULT_MAX_CANVAS_DIMENSION = 100_000
215
225
  DEFAULT_MAX_LAYOUT_ITERATIONS = 10_000
216
226
  FORCE_TREE_THRESHOLD = 128
217
227
  VALIDATION_MODES = %i[deferred strict].freeze
218
228
  VALIDATION_PROFILES = %i[references fsm_semantics dfa].freeze
219
229
  UNSET = Object.new.freeze
220
230
  EMPTY_TRANSITIONS = [].freeze
231
+
232
+ def self.validate_canvas_dimensions!(width, height)
233
+ { width: width, height: height }.each do |name, value|
234
+ unless value.is_a?(Numeric) && value.real? && value.to_f.finite? && value.positive?
235
+ raise ArgumentError, "#{name} must be a positive finite number"
236
+ end
237
+ end
238
+ if width.to_f * height.to_f > DEFAULT_MAX_CANVAS_AREA
239
+ raise ArgumentError, "canvas area exceeds max_canvas_area (#{DEFAULT_MAX_CANVAS_AREA})"
240
+ end
241
+ if width.to_f > DEFAULT_MAX_CANVAS_DIMENSION || height.to_f > DEFAULT_MAX_CANVAS_DIMENSION
242
+ raise ArgumentError, "canvas dimension exceeds max_canvas_dimension (#{DEFAULT_MAX_CANVAS_DIMENSION})"
243
+ end
244
+
245
+ true
246
+ end
221
247
  attr_reader :initial_state, :revision
222
248
 
223
249
  def self.png_available?(converter: Exporters::Png::DEFAULT_CONVERTER)
@@ -429,6 +455,9 @@ class Graphomaton
429
455
  guard: input_value(value, :guard),
430
456
  action: input_value(value, :action)
431
457
  )
458
+ when :alternatives
459
+ values = Array(input_value(label, :value))
460
+ Label.alternatives(*values.map { |value| structured_label_from_input(value) })
432
461
  else
433
462
  raise ArgumentError, "Unknown label type: #{type.inspect}"
434
463
  end
@@ -682,10 +711,13 @@ class Graphomaton
682
711
  shape: immutable_copy(attributes.fetch(:shape, state.shape)),
683
712
  kind: resolve_state_kind(attributes.fetch(:kind, state.kind))
684
713
  )
685
- return self if updated_state == state
714
+ coordinates_updated = attributes.key?(:x)
715
+ manual = !x.nil? && !y.nil?
716
+ manual_changed = coordinates_updated && @manual_states[name] != manual
717
+ return self if updated_state == state && !manual_changed
686
718
 
687
719
  @states[name] = updated_state
688
- @manual_states[name] = !x.nil? && !y.nil?
720
+ @manual_states[name] = manual if coordinates_updated
689
721
  graph_changed!
690
722
  self
691
723
  end
@@ -1058,12 +1090,16 @@ class Graphomaton
1058
1090
  layout_diagnostics_for(positions, width, height, state_radius).map(&:message)
1059
1091
  end
1060
1092
 
1061
- def layout_diagnostics_for(positions, width, height, state_radius)
1093
+ def layout_diagnostics_for(positions, width, height, state_radius, x: 0, y: 0)
1062
1094
  radius = state_radius.to_f
1095
+ left = x.to_f
1096
+ top = y.to_f
1097
+ right = left + width.to_f
1098
+ bottom = top + height.to_f
1063
1099
  positions.each_with_object([]) do |(name, position), diagnostics|
1064
- x = position[:x].to_f
1065
- y = position[:y].to_f
1066
- if x - radius < 0 || x + radius > width.to_f
1100
+ state_x = position[:x].to_f
1101
+ state_y = position[:y].to_f
1102
+ if state_x - radius < left || state_x + radius > right
1067
1103
  diagnostics << Diagnostic.new(
1068
1104
  code: 'state-clipped-horizontal',
1069
1105
  severity: :warning,
@@ -1072,7 +1108,7 @@ class Graphomaton
1072
1108
  hint: 'Increase the canvas width or use fit: :contain'
1073
1109
  )
1074
1110
  end
1075
- if y - radius < 0 || y + radius > height.to_f
1111
+ if state_y - radius < top || state_y + radius > bottom
1076
1112
  diagnostics << Diagnostic.new(
1077
1113
  code: 'state-clipped-vertical',
1078
1114
  severity: :warning,
@@ -1092,11 +1128,7 @@ class Graphomaton
1092
1128
  initial_position: DEFAULT_INITIAL_POSITION, final_position: DEFAULT_FINAL_POSITION,
1093
1129
  preserve_manual_positions: DEFAULT_PRESERVE_MANUAL_POSITIONS,
1094
1130
  fit: DEFAULT_FIT)
1095
- validate_finite_number!(width, 'width', positive: true)
1096
- validate_finite_number!(height, 'height', positive: true)
1097
- if width.to_f * height.to_f > DEFAULT_MAX_CANVAS_AREA
1098
- raise ArgumentError, "canvas area exceeds max_canvas_area (#{DEFAULT_MAX_CANVAS_AREA})"
1099
- end
1131
+ self.class.validate_canvas_dimensions!(width, height)
1100
1132
  validate_finite_number!(state_radius, 'state_radius', positive: true)
1101
1133
  validate_finite_number!(padding, 'padding', nonnegative: true)
1102
1134
  validate_finite_number!(node_spacing, 'node_spacing', nonnegative: true)
@@ -1222,14 +1254,11 @@ class Graphomaton
1222
1254
  end
1223
1255
 
1224
1256
  def ordered_state_names
1225
- ordered_states = []
1226
- ordered_states << @initial_state if @initial_state && @states[@initial_state]
1227
-
1228
- @states.each_key do |name|
1229
- ordered_states << name unless ordered_states.include?(name)
1230
- end
1257
+ states = @states.keys
1258
+ return states unless @states.key?(@initial_state)
1231
1259
 
1232
- ordered_states
1260
+ states.delete(@initial_state)
1261
+ states.unshift(@initial_state)
1233
1262
  end
1234
1263
 
1235
1264
  def auto_layout(width = 800, height = 600, layout: :linear, direction: :lr,
@@ -1994,9 +2023,10 @@ class Graphomaton
1994
2023
  io.write(output)
1995
2024
  end
1996
2025
 
1997
- def semantic_diagnostics(format)
2026
+ def semantic_diagnostics(format, **options)
1998
2027
  resolved = resolve_format(format)
1999
- capabilities = self.class.exporter_capabilities(resolved)
2028
+ capabilities = self.class.exporter_capabilities(resolved).dup
2029
+ capabilities << :state_tooltip if options[:notes] && %i[html mermaid plantuml].include?(resolved)
2000
2030
  ExporterCapabilities.losses_for(self, capabilities).map do |feature|
2001
2031
  Diagnostic.new(
2002
2032
  code: 'unsupported-export-feature',
@@ -2010,7 +2040,7 @@ class Graphomaton
2010
2040
 
2011
2041
  def render(format: :svg, width: 800, height: 600, strict_semantics: false, **options)
2012
2042
  resolved_format = resolve_format(format)
2013
- losses = semantic_diagnostics(resolved_format)
2043
+ losses = semantic_diagnostics(resolved_format, **options)
2014
2044
  if strict_semantics && losses.any?
2015
2045
  raise ExportError, losses.map(&:message).join("\n")
2016
2046
  end
@@ -2046,10 +2076,28 @@ class Graphomaton
2046
2076
 
2047
2077
  def render_result(format: :svg, width: 800, height: 600, strict_semantics: false, **options)
2048
2078
  resolved = resolve_format(format)
2049
- return Exporters::Svg.new(self).export_result(width, height, **options) if resolved == :svg
2050
- return Exporters::Png.new(self).export_result(width, height, **options) if resolved == :png
2051
- return Exporters::Pdf.new(self).export_result(width, height, **options) if resolved == :pdf
2052
- return Exporters::Webp.new(self).export_result(width, height, **options) if resolved == :webp
2079
+ losses = semantic_diagnostics(resolved, **options)
2080
+ if strict_semantics && losses.any?
2081
+ raise ExportError, losses.map(&:message).join("\n")
2082
+ end
2083
+
2084
+ exporter = case resolved
2085
+ when :svg then Exporters::Svg
2086
+ when :png then Exporters::Png
2087
+ when :pdf then Exporters::Pdf
2088
+ when :webp then Exporters::Webp
2089
+ end
2090
+ if exporter
2091
+ result = exporter.new(self).export_result(width, height, **options)
2092
+ return result if losses.empty?
2093
+
2094
+ return RenderResult.new(
2095
+ output: result.output,
2096
+ diagnostics: (losses + result.diagnostics).freeze,
2097
+ bounds: result.bounds,
2098
+ layout: result.layout
2099
+ )
2100
+ end
2053
2101
 
2054
2102
  output = render(
2055
2103
  format: resolved,
@@ -2060,7 +2108,7 @@ class Graphomaton
2060
2108
  )
2061
2109
  RenderResult.new(
2062
2110
  output: output,
2063
- diagnostics: semantic_diagnostics(resolved),
2111
+ diagnostics: losses,
2064
2112
  bounds: nil,
2065
2113
  layout: nil
2066
2114
  )
@@ -2533,6 +2581,11 @@ class Graphomaton
2533
2581
  if label.is_a?(Array)
2534
2582
  labels = label.map { |item| normalize_single_transition_label(item, epsilon_label: epsilon_label) }.uniq
2535
2583
  labels = labels.sort_by(&:to_s) if sort_labels
2584
+ if labels.any? { |item| item.is_a?(Label) && item.kind == :epsilon }
2585
+ return labels.first if labels.one?
2586
+
2587
+ return Label.alternatives(*labels)
2588
+ end
2536
2589
  return Label.symbols(*labels.map(&:to_s))
2537
2590
  end
2538
2591
 
@@ -2670,7 +2723,7 @@ class Graphomaton
2670
2723
  @states.each_key do |from|
2671
2724
  by_symbol = Hash.new { |hash, key| hash[key] = [] }
2672
2725
  @outgoing_by_state[from].each do |transition|
2673
- if transition.label.is_a?(Label) && transition.label.kind == :epsilon
2726
+ if epsilon_transition_label?(transition.label)
2674
2727
  diagnostics << diagnostic(
2675
2728
  'epsilon-transition-in-dfa',
2676
2729
  :error,
@@ -2695,11 +2748,22 @@ class Graphomaton
2695
2748
  end
2696
2749
 
2697
2750
  def transition_label_symbols(label)
2698
- return label.value if label.is_a?(Label) && label.kind == :symbols
2751
+ if label.is_a?(Label)
2752
+ return label.value if label.kind == :symbols
2753
+ return [] if label.kind == :epsilon
2754
+ return label.value.flat_map { |item| transition_label_symbols(item) } if label.kind == :alternatives
2755
+ end
2699
2756
 
2700
2757
  [label.to_s]
2701
2758
  end
2702
2759
 
2760
+ def epsilon_transition_label?(label)
2761
+ return false unless label.is_a?(Label)
2762
+ return true if label.kind == :epsilon
2763
+
2764
+ label.kind == :alternatives && label.value.any? { |item| epsilon_transition_label?(item) }
2765
+ end
2766
+
2703
2767
  def diagnostic(code, severity, path, message, hint = nil)
2704
2768
  Diagnostic.new(code: code, severity: severity, path: path.freeze, message: message, hint: hint)
2705
2769
  end
data/sig/graphomaton.rbs CHANGED
@@ -29,6 +29,7 @@ class Graphomaton
29
29
  def self.symbols: (*untyped values) -> Label
30
30
  def self.epsilon: (?untyped display) -> Label
31
31
  def self.uml: (event: untyped, ?guard: untyped, ?action: untyped) -> Label
32
+ def self.alternatives: (*untyped values) -> Label
32
33
  def to_s: () -> String
33
34
  def to_h: () -> Hash[Symbol, untyped]
34
35
  end
@@ -108,6 +109,7 @@ class Graphomaton
108
109
  def to_json: (*untyped) -> String
109
110
  def to_yaml: (**untyped) -> String
110
111
  def render: (?format: Symbol | String, ?width: Numeric, ?height: Numeric, ?strict_semantics: bool, **untyped) -> String
112
+ def semantic_diagnostics: (Symbol | String format, **untyped) -> Array[Diagnostic]
111
113
  def render_result: (?format: Symbol | String, ?width: Numeric, ?height: Numeric, ?strict_semantics: bool, **untyped) -> RenderResult
112
114
  def render_with: (RenderOptions options) -> String
113
115
  def reachable_states: () -> Array[untyped]
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphomaton
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-08 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: rexml
@@ -76,6 +77,7 @@ metadata:
76
77
  documentation_uri: https://rubydoc.info/gems/graphomaton
77
78
  bug_tracker_uri: https://github.com/ydah/graphomaton/issues
78
79
  rubygems_mfa_required: 'true'
80
+ post_install_message:
79
81
  rdoc_options: []
80
82
  require_paths:
81
83
  - lib
@@ -90,7 +92,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
90
92
  - !ruby/object:Gem::Version
91
93
  version: '0'
92
94
  requirements: []
93
- rubygems_version: 4.0.6
95
+ rubygems_version: 3.4.19
96
+ signing_key:
94
97
  specification_version: 4
95
98
  summary: Generate and analyze finite state machine diagrams in multiple formats.
96
99
  test_files: []