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 +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +3 -2
- data/docs/cli.md +4 -3
- data/docs/custom-exporters.md +3 -0
- data/docs/exporters.md +3 -1
- data/docs/input-schema.md +2 -1
- data/lib/graphomaton/atomic_file.rb +6 -2
- data/lib/graphomaton/cli/config.rb +2 -2
- data/lib/graphomaton/cli.rb +33 -11
- data/lib/graphomaton/exporter_registry.rb +6 -3
- data/lib/graphomaton/exporters/png.rb +7 -3
- data/lib/graphomaton/exporters/svg.rb +68 -35
- data/lib/graphomaton/input_policy.rb +4 -3
- data/lib/graphomaton/model.rb +14 -2
- data/lib/graphomaton/process_runner.rb +7 -8
- data/lib/graphomaton/version.rb +1 -1
- data/lib/graphomaton.rb +106 -42
- data/sig/graphomaton.rbs +2 -0
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 168a8c5641230bf2da705a71bbc3156757a0cc820047d47f18c1c69f3ab08f0a
|
|
4
|
+
data.tar.gz: 52152b9aded87cdae3bca6f47bddd5cded06c91f7c7c4ca8450c0e1b51401084
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
8
|
-
|
|
9
|
-
`--fail-on-warning` for CI.
|
|
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
|
|
data/docs/custom-exporters.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
data/lib/graphomaton/cli.rb
CHANGED
|
@@ -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
|
|
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 [--
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 = "
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 ||=
|
|
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) ||
|
|
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 =
|
|
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 ||
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
2310
|
+
next unless added_groups.add?(group_name)
|
|
2287
2311
|
|
|
2288
|
-
position = folded_group_position(groups[group_name],
|
|
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.
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
next if
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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]
|
|
2838
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/graphomaton/model.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
signal_process(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
data/lib/graphomaton/version.rb
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
198
|
-
registry.register(:pdf, extensions: %w[pdf], binary: true, capabilities:
|
|
199
|
-
registry.register(:webp, extensions: %w[webp], binary: true, capabilities:
|
|
200
|
-
registry.register(:html, extensions: %w[html], capabilities: %i[group parent pseudostate
|
|
201
|
-
registry.register(:mermaid, aliases: %i[mmd], extensions: %w[mermaid mmd], capabilities: %i[group parent pseudostate
|
|
202
|
-
registry.register(:dot, aliases: %i[gv], extensions: %w[dot gv], capabilities: %i[url
|
|
203
|
-
registry.register(:plantuml, aliases: %i[puml], extensions: %w[plantuml puml], capabilities: %i[group parent pseudostate
|
|
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
|
-
|
|
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] =
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
if
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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.
|
|
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: []
|