graphomaton 0.1.1 → 1.0.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: 767a4132a2339a7d5114db3c9436a33ed5ae513f7b1bdc36b11c7f278628e80a
4
- data.tar.gz: 7dddb7e3ce4893071f12b66bc2b9c23e7a0b668edecb3419116518dafa92b61e
3
+ metadata.gz: 01b9006b89b4b5e93348f4dd47e9c8173ed036a8cffb357a165f68d1048e8be6
4
+ data.tar.gz: 2b8c1975ce405f8ffa6a2f550431281cc82605f536b76efa72d6e092e05902bc
5
5
  SHA512:
6
- metadata.gz: d2f57235675bbe4644662f6cd09d98cb927e36ede8383467fd29552be88574367aee332b6eeef8185f347cc7d40552e2d48fb4a3be309da9e03061d05e761967
7
- data.tar.gz: d61e423a81da6b3567833b9f0859bafd3a150f6b9f0a1b43e35a111570fadd68fcb1863dd38cf0789826de591365e02c6cbf2266127554cc7c6117931722609e
6
+ metadata.gz: 538893d73e2cdbcfe697bcf194ab259c7c796d6efdb9cd100b810ed7aa5363144819880d6d813ec76dc9350d455bdb232608b7b0ec39c00efa664cba54bcc2bd
7
+ data.tar.gz: 41e703f607023ceebd0aba66965084f3466cd60b414a3ba14670cc0f4e6d82391e18cd64d47911d80efa28dbb4f05378ea693de3ebb0461f84a465e9721de9ca
data/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.0.0 (2025-12-23)
6
+
7
+ - Add support multiple style outputs, including .dot, mermaid, and .plantuml formats.
8
+ - Improve output format for SVG files.
9
+
10
+ ## 0.1.1 (2025-08-26)
11
+
12
+ Fix gemspec dependency declaration for 'rexml'.
13
+
5
14
  ## 0.1.0 (2025-08-26)
6
15
 
7
16
  - Initial release
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # Graphomaton [![Gem Version](https://badge.fury.io/rb/graphomaton.svg?icon=si%3Arubygems)](https://badge.fury.io/rb/graphomaton)
1
+ # Graphomaton [![Gem Version](https://badge.fury.io/rb/graphomaton.svg?icon=si%3Arubygems)](https://badge.fury.io/rb/graphomaton) [![CI](https://github.com/ydah/graphomaton/actions/workflows/ci.yml/badge.svg)](https://github.com/ydah/graphomaton/actions/workflows/ci.yml)
2
2
 
3
- A tiny Ruby library for generating finite state machine (automaton) diagrams as SVG.
3
+ A tiny Ruby library for generating finite state machine (automaton) diagrams in multiple formats: SVG, HTML (Mermaid.js), GraphViz (DOT), and PlantUML.
4
4
 
5
5
  ![Image](https://github.com/user-attachments/assets/6907869c-1077-4a73-8394-4117f25adc17)
6
6
 
@@ -49,8 +49,53 @@ automaton.add_transition('q1', 'q0', 'a')
49
49
  automaton.add_transition('q2', 'q0', 'b')
50
50
  automaton.add_transition('q2', 'q1', 'a')
51
51
 
52
- # Save as SVG
53
- automaton.save_svg('output.svg')
52
+ # Save in different formats
53
+ automaton.save_svg('output.svg') # SVG format
54
+ automaton.save_html('output.html') # HTML with Mermaid.js (requires internet)
55
+ automaton.save_dot('output.dot') # GraphViz DOT format
56
+ automaton.save_plantuml('output.puml') # PlantUML format
57
+ ```
58
+
59
+ ### Output Formats
60
+
61
+ Graphomaton supports multiple output formats:
62
+
63
+ #### 1. SVG (Native)
64
+ ```ruby
65
+ automaton.save_svg('diagram.svg', width = 800, height = 600)
66
+ ```
67
+ Generates a standalone SVG file with custom rendering.
68
+
69
+ #### 2. HTML (Mermaid.js)
70
+ ```ruby
71
+ automaton.save_html('diagram.html')
72
+ ```
73
+ Generates an HTML file with embedded Mermaid.js state diagram. The diagram is rendered in the browser using Mermaid.js from CDN.
74
+
75
+ **Note:** Requires internet connection to load Mermaid.js from CDN. Does not work in offline environments.
76
+
77
+ #### 3. GraphViz (DOT)
78
+ ```ruby
79
+ automaton.save_dot('diagram.dot')
80
+ ```
81
+ Generates a DOT file that can be converted to images using GraphViz:
82
+ ```bash
83
+ dot -Tpng diagram.dot -o diagram.png
84
+ dot -Tsvg diagram.dot -o diagram.svg
85
+ dot -Tpdf diagram.dot -o diagram.pdf
86
+ ```
87
+
88
+ #### 4. PlantUML
89
+ ```ruby
90
+ automaton.save_plantuml('diagram.puml')
91
+ ```
92
+ Generates a PlantUML file that can be converted to images using PlantUML server or JAR:
93
+ ```bash
94
+ # Using PlantUML JAR
95
+ java -jar plantuml.jar diagram.puml
96
+
97
+ # Using online server
98
+ curl -X POST --data-binary @diagram.puml https://www.plantuml.com/plantuml/png > diagram.png
54
99
  ```
55
100
 
56
101
  ## Contributing
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Graphomaton
4
+ module Exporters
5
+ class Dot
6
+ def initialize(automaton)
7
+ @automaton = automaton
8
+ end
9
+
10
+ def export
11
+ lines = ['digraph finite_state_machine {']
12
+ lines << ' rankdir=LR;'
13
+ lines << ' node [shape = circle];'
14
+ lines << ''
15
+
16
+ if @automaton.initial_state
17
+ lines << ' __start__ [shape=point];'
18
+ lines << " __start__ -> \"#{escape_label(@automaton.initial_state)}\";"
19
+ lines << ''
20
+ end
21
+
22
+ unless @automaton.final_states.empty?
23
+ final_states_str = @automaton.final_states.map { |s| "\"#{escape_label(s)}\"" }.join(' ')
24
+ lines << " node [shape = doublecircle]; #{final_states_str};"
25
+ lines << ' node [shape = circle];'
26
+ lines << ''
27
+ end
28
+
29
+ @automaton.transitions.each do |trans|
30
+ from = escape_label(trans[:from])
31
+ to = escape_label(trans[:to])
32
+ label = escape_label(trans[:label])
33
+ lines << " \"#{from}\" -> \"#{to}\" [label=\"#{label}\"];"
34
+ end
35
+
36
+ lines << '}'
37
+ lines.join("\n")
38
+ end
39
+
40
+ private
41
+
42
+ def escape_label(label)
43
+ label.to_s.gsub('"', '\\"')
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Graphomaton
4
+ module Exporters
5
+ class Mermaid
6
+ def initialize(automaton)
7
+ @automaton = automaton
8
+ end
9
+
10
+ def export
11
+ lines = ['stateDiagram-v2']
12
+
13
+ lines << " [*] --> #{sanitize_state_name(@automaton.initial_state)}" if @automaton.initial_state
14
+
15
+ @automaton.transitions.each do |trans|
16
+ from = sanitize_state_name(trans[:from])
17
+ to = sanitize_state_name(trans[:to])
18
+ label = trans[:label]
19
+ lines << " #{from} --> #{to} : #{label}"
20
+ end
21
+
22
+ @automaton.final_states.each do |state|
23
+ lines << " #{sanitize_state_name(state)} --> [*]"
24
+ end
25
+
26
+ lines.join("\n")
27
+ end
28
+
29
+ def export_html
30
+ mermaid_code = export
31
+ <<~HTML
32
+ <!DOCTYPE html>
33
+ <html lang="ja">
34
+ <head>
35
+ <meta charset="UTF-8">
36
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
37
+ <title>状態図 - Graphomaton</title>
38
+ <script type="module">
39
+ import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
40
+ mermaid.initialize({ startOnLoad: true, theme: 'default' });
41
+ </script>
42
+ <style>
43
+ body {
44
+ font-family: Arial, sans-serif;
45
+ max-width: 1200px;
46
+ margin: 0 auto;
47
+ padding: 20px;
48
+ }
49
+ .mermaid {
50
+ text-align: center;
51
+ background: white;
52
+ border: 1px solid #ddd;
53
+ border-radius: 8px;
54
+ padding: 20px;
55
+ margin: 20px 0;
56
+ }
57
+ h1 {
58
+ color: #333;
59
+ text-align: center;
60
+ }
61
+ .info {
62
+ background: #f5f5f5;
63
+ padding: 10px;
64
+ border-radius: 4px;
65
+ margin-bottom: 20px;
66
+ }
67
+ </style>
68
+ </head>
69
+ <body>
70
+ <h1>状態図</h1>
71
+ <div class="info">
72
+ <p><strong>注意:</strong> この図はMermaid.jsを使用してブラウザ上でレンダリングされます。オフライン環境では動作しません。</p>
73
+ </div>
74
+ <div class="mermaid">
75
+ #{mermaid_code}
76
+ </div>
77
+ </body>
78
+ </html>
79
+ HTML
80
+ end
81
+
82
+ private
83
+
84
+ def sanitize_state_name(name)
85
+ sanitized = name.to_s.gsub(/[\s-]/, '_')
86
+ if sanitized =~ /[^\x00-\x7F]/
87
+ "\"#{sanitized}\""
88
+ else
89
+ sanitized
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Graphomaton
4
+ module Exporters
5
+ class Plantuml
6
+ def initialize(automaton)
7
+ @automaton = automaton
8
+ end
9
+
10
+ def export
11
+ lines = ['@startuml']
12
+ lines << 'hide empty description'
13
+ lines << ''
14
+
15
+ if @automaton.initial_state
16
+ lines << "[*] --> #{sanitize_state_name(@automaton.initial_state)}"
17
+ end
18
+
19
+ @automaton.transitions.each do |trans|
20
+ from = sanitize_state_name(trans[:from])
21
+ to = sanitize_state_name(trans[:to])
22
+ label = trans[:label]
23
+ lines << "#{from} --> #{to} : #{label}"
24
+ end
25
+
26
+ @automaton.final_states.each do |state|
27
+ lines << "#{sanitize_state_name(state)} --> [*]"
28
+ end
29
+
30
+ lines << ''
31
+ lines << '@enduml'
32
+ lines.join("\n")
33
+ end
34
+
35
+ private
36
+
37
+ def sanitize_state_name(name)
38
+ sanitized = name.to_s.gsub(/[\s-]/, '_')
39
+ if sanitized =~ /[^\x00-\x7F]/
40
+ "\"#{sanitized}\""
41
+ else
42
+ sanitized
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rexml/document'
4
+
5
+ class Graphomaton
6
+ module Exporters
7
+ class Svg
8
+ STATE_RADIUS = 40
9
+ STATE_INNER_RADIUS = 32
10
+
11
+ def initialize(automaton)
12
+ @automaton = automaton
13
+ end
14
+
15
+ def export(width = 800, height = 600)
16
+ @automaton.auto_layout(width, height)
17
+
18
+ doc = REXML::Document.new
19
+ svg = doc.add_element('svg', {
20
+ 'xmlns' => 'http://www.w3.org/2000/svg',
21
+ 'width' => width.to_s,
22
+ 'height' => height.to_s,
23
+ 'viewBox' => "0 0 #{width} #{height}"
24
+ })
25
+
26
+ add_defs(svg)
27
+ add_style(svg)
28
+ add_transitions(svg)
29
+ add_initial_arrow(svg) if @automaton.initial_state
30
+ add_states(svg)
31
+
32
+ doc.to_s
33
+ end
34
+
35
+ private
36
+
37
+ def calculate_text_width(text)
38
+ ascii_chars = text.chars.count { |c| c.ascii_only? }
39
+ non_ascii_chars = text.length - ascii_chars
40
+
41
+ width = (ascii_chars * 8) + (non_ascii_chars * 16) + 20
42
+ [width, 60].max
43
+ end
44
+
45
+ def calculate_state_font_size(name)
46
+ ascii_chars = name.chars.count { |c| c.ascii_only? }
47
+ non_ascii_chars = name.length - ascii_chars
48
+
49
+ estimated_width = (ascii_chars * 0.55) + (non_ascii_chars * 0.9)
50
+ available_width = STATE_RADIUS * 1.7
51
+
52
+ base_size = 20
53
+ calculated_size = if estimated_width * base_size > available_width
54
+ (available_width / estimated_width).floor
55
+ else
56
+ base_size
57
+ end
58
+
59
+ [calculated_size, 12].max
60
+ end
61
+
62
+ def add_defs(svg)
63
+ defs = svg.add_element('defs')
64
+ marker = defs.add_element('marker', {
65
+ 'id' => 'arrowhead',
66
+ 'markerWidth' => '10',
67
+ 'markerHeight' => '10',
68
+ 'refX' => '9',
69
+ 'refY' => '3',
70
+ 'orient' => 'auto'
71
+ })
72
+ marker.add_element('polygon', {
73
+ 'points' => '0 0, 10 3, 0 6',
74
+ 'fill' => '#333'
75
+ })
76
+ end
77
+
78
+ def add_style(svg)
79
+ style = svg.add_element('style')
80
+ style.text = <<-CSS
81
+ .state-circle { fill: white; stroke: #333; stroke-width: 2; }
82
+ .final-state { stroke-width: 4; }
83
+ .state-text { font-family: Arial, sans-serif; text-anchor: middle; }
84
+ .transition-line { stroke: #333; stroke-width: 1.5; fill: none; marker-end: url(#arrowhead); }
85
+ .transition-label { font-family: Arial, sans-serif; font-size: 14px; fill: #666; }
86
+ .initial-arrow { stroke: #333; stroke-width: 2; fill: none; marker-end: url(#arrowhead); }
87
+ .label-bg { fill: white; opacity: 0.9; }
88
+ CSS
89
+ end
90
+
91
+ def add_transitions(svg)
92
+ processed_pairs = {}
93
+ from_state_indices = {}
94
+
95
+ @automaton.transitions.each_with_index do |trans, _idx|
96
+ from_state = @automaton.states[trans[:from]]
97
+ to_state = @automaton.states[trans[:to]]
98
+
99
+ if from_state == to_state
100
+ add_self_loop(svg, from_state, trans)
101
+ else
102
+ from_state_indices[trans[:from]] = 0 unless from_state_indices[trans[:from]]
103
+ from_state_index = from_state_indices[trans[:from]]
104
+ from_state_indices[trans[:from]] += 1
105
+
106
+ add_transition(svg, from_state, to_state, trans, processed_pairs, from_state_index)
107
+ end
108
+ end
109
+ end
110
+
111
+ def add_self_loop(svg, state, trans)
112
+ cx = state[:x]
113
+ cy = state[:y]
114
+
115
+ loop_height = 80
116
+ loop_width = 45
117
+
118
+ start_angle = -135 * Math::PI / 180
119
+ end_angle = -45 * Math::PI / 180
120
+ radius = STATE_RADIUS
121
+
122
+ start_x = cx + (radius * Math.cos(start_angle))
123
+ start_y = cy + (radius * Math.sin(start_angle))
124
+ end_x = cx + (radius * Math.cos(end_angle))
125
+ end_y = cy + (radius * Math.sin(end_angle))
126
+
127
+ control1_x = cx - loop_width
128
+ control1_y = cy - loop_height
129
+ control2_x = cx + loop_width
130
+ control2_y = cy - loop_height
131
+
132
+ path_d = "M #{start_x} #{start_y} C #{control1_x} #{control1_y}, #{control2_x} #{control2_y}, #{end_x} #{end_y}"
133
+
134
+ svg.add_element('path', {
135
+ 'class' => 'transition-line',
136
+ 'd' => path_d
137
+ })
138
+
139
+ text_width = calculate_text_width(trans[:label])
140
+ svg.add_element('rect', {
141
+ 'class' => 'label-bg',
142
+ 'x' => (cx - (text_width / 2)).to_s,
143
+ 'y' => (cy - loop_height - 5).to_s,
144
+ 'width' => text_width.to_s,
145
+ 'height' => '20',
146
+ 'rx' => '3'
147
+ })
148
+
149
+ label = svg.add_element('text', {
150
+ 'class' => 'transition-label',
151
+ 'x' => cx.to_s,
152
+ 'y' => (cy - loop_height + 10).to_s,
153
+ 'text-anchor' => 'middle'
154
+ })
155
+ label.text = trans[:label]
156
+ end
157
+
158
+ def add_transition(svg, from_state, to_state, trans, processed_pairs, from_state_index)
159
+ x1 = from_state[:x]
160
+ y1 = from_state[:y]
161
+ x2 = to_state[:x]
162
+ y2 = to_state[:y]
163
+
164
+ pair_key = [trans[:from].to_s, trans[:to].to_s].sort.join('-')
165
+ processed_pairs[pair_key] = 0 unless processed_pairs[pair_key]
166
+
167
+ pair_index = processed_pairs[pair_key]
168
+ processed_pairs[pair_key] += 1
169
+
170
+ parallel_count = @automaton.count_parallel_transitions(trans[:from], trans[:to])
171
+
172
+ dx = x2 - x1
173
+ dy = y2 - y1
174
+ dist = Math.sqrt((dx**2) + (dy**2))
175
+
176
+ radius = STATE_RADIUS
177
+ start_x = x1 + ((dx / dist) * radius)
178
+ start_y = y1 + ((dy / dist) * radius)
179
+ end_x = x2 - ((dx / dist) * radius)
180
+ end_y = y2 - ((dy / dist) * radius)
181
+
182
+ state_names = @automaton.states.keys
183
+ from_index = state_names.index(trans[:from])
184
+ to_index = state_names.index(trans[:to])
185
+
186
+ is_adjacent = (to_index - from_index).abs == 1
187
+ states_between = (to_index - from_index).abs - 1
188
+
189
+ if is_adjacent && x1 < x2
190
+ add_straight_line(svg, start_x, start_y, end_x, end_y, trans)
191
+ else
192
+ add_curved_line(svg, start_x, start_y, end_x, end_y, x1, x2, trans, parallel_count, pair_index, states_between, from_state_index)
193
+ end
194
+ end
195
+
196
+ def add_straight_line(svg, start_x, start_y, end_x, end_y, trans)
197
+ svg.add_element('line', {
198
+ 'class' => 'transition-line',
199
+ 'x1' => start_x.to_s,
200
+ 'y1' => start_y.to_s,
201
+ 'x2' => end_x.to_s,
202
+ 'y2' => end_y.to_s
203
+ })
204
+
205
+ label_x = (start_x + end_x) / 2
206
+ label_y = ((start_y + end_y) / 2) - 10
207
+
208
+ add_label(svg, label_x, label_y, trans[:label])
209
+ end
210
+
211
+ def add_curved_line(svg, start_x, start_y, end_x, end_y, x1, x2, trans, parallel_count, pair_index, states_between, from_state_index)
212
+ mid_x = (start_x + end_x) / 2
213
+ mid_y = (start_y + end_y) / 2
214
+
215
+ base_offset = if states_between > 0
216
+ (STATE_RADIUS * 1.5) + (states_between * 30) + (from_state_index * 120)
217
+ else
218
+ STATE_RADIUS * 2
219
+ end
220
+
221
+ curve_offset = if parallel_count > 1
222
+ if trans[:from] < trans[:to]
223
+ -(base_offset + (50 * pair_index))
224
+ else
225
+ base_offset + (50 * pair_index)
226
+ end
227
+ elsif x1 < x2
228
+ -base_offset
229
+ else
230
+ base_offset
231
+ end
232
+
233
+ control_x = if (x2 - x1).abs < 10
234
+ mid_x + (50 * (pair_index.even? ? 1 : -1))
235
+ else
236
+ mid_x
237
+ end
238
+ control_y = mid_y + curve_offset
239
+
240
+ path_d = "M #{start_x} #{start_y} Q #{control_x} #{control_y}, #{end_x} #{end_y}"
241
+
242
+ svg.add_element('path', {
243
+ 'class' => 'transition-line',
244
+ 'd' => path_d
245
+ })
246
+
247
+ t = 0.5
248
+ label_x = ((1 - t) * (1 - t) * start_x) + (2 * (1 - t) * t * control_x) + (t * t * end_x)
249
+ label_y = ((1 - t) * (1 - t) * start_y) + (2 * (1 - t) * t * control_y) + (t * t * end_y)
250
+
251
+ add_label(svg, label_x, label_y, trans[:label])
252
+ end
253
+
254
+ def add_label(svg, x, y, text)
255
+ text_width = calculate_text_width(text)
256
+ svg.add_element('rect', {
257
+ 'class' => 'label-bg',
258
+ 'x' => (x - (text_width / 2)).to_s,
259
+ 'y' => (y - 10).to_s,
260
+ 'width' => text_width.to_s,
261
+ 'height' => '20',
262
+ 'rx' => '3'
263
+ })
264
+
265
+ label = svg.add_element('text', {
266
+ 'class' => 'transition-label',
267
+ 'x' => x.to_s,
268
+ 'y' => (y + 5).to_s,
269
+ 'text-anchor' => 'middle'
270
+ })
271
+ label.text = text
272
+ end
273
+
274
+ def add_initial_arrow(svg)
275
+ init = @automaton.states[@automaton.initial_state]
276
+ return unless init
277
+
278
+ svg.add_element('line', {
279
+ 'class' => 'initial-arrow',
280
+ 'x1' => (init[:x] - 60).to_s,
281
+ 'y1' => init[:y].to_s,
282
+ 'x2' => (init[:x] - 30).to_s,
283
+ 'y2' => init[:y].to_s
284
+ })
285
+
286
+ start_label = svg.add_element('text', {
287
+ 'class' => 'transition-label',
288
+ 'x' => (init[:x] - 70).to_s,
289
+ 'y' => (init[:y] - 10).to_s,
290
+ 'text-anchor' => 'end'
291
+ })
292
+ start_label.text = 'start'
293
+ end
294
+
295
+ def add_states(svg)
296
+ @automaton.states.each do |name, state|
297
+ circle_class = 'state-circle'
298
+ circle_class += ' final-state' if @automaton.final_states.include?(name)
299
+
300
+ svg.add_element('circle', {
301
+ 'class' => circle_class,
302
+ 'cx' => state[:x].to_s,
303
+ 'cy' => state[:y].to_s,
304
+ 'r' => STATE_RADIUS.to_s
305
+ })
306
+
307
+ if @automaton.final_states.include?(name)
308
+ svg.add_element('circle', {
309
+ 'class' => 'state-circle',
310
+ 'cx' => state[:x].to_s,
311
+ 'cy' => state[:y].to_s,
312
+ 'r' => STATE_INNER_RADIUS.to_s
313
+ })
314
+ end
315
+
316
+ font_size = calculate_state_font_size(name.to_s)
317
+ text = svg.add_element('text', {
318
+ 'class' => 'state-text',
319
+ 'x' => state[:x].to_s,
320
+ 'y' => (state[:y] + (font_size * 0.35)).to_s,
321
+ 'font-size' => font_size.to_s
322
+ })
323
+ text.text = name.to_s
324
+ end
325
+ end
326
+ end
327
+ end
328
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'exporters/svg'
4
+ require_relative 'exporters/mermaid'
5
+ require_relative 'exporters/dot'
6
+ require_relative 'exporters/plantuml'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Graphomaton
4
- VERSION = '0.1.1'
4
+ VERSION = '1.0.0'
5
5
  end