rsyntaxtree 1.3.1 → 1.4.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/.github/FUNDING.yml +6 -0
- data/.gitignore +1 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +34 -0
- data/Gemfile +6 -0
- data/LICENSE +21 -0
- data/README.md +132 -50
- data/Rakefile +42 -8
- data/bin/rsyntaxtree +107 -19
- data/docs/_examples/016.md +1 -1
- data/docs/_examples/053.md +5 -12
- data/docs/_examples/054.md +22 -0
- data/docs/_examples/055.md +24 -0
- data/docs/_examples/056.md +27 -0
- data/docs/assets/img/054.png +0 -0
- data/docs/assets/img/055.png +0 -0
- data/docs/assets/img/056.png +0 -0
- data/docs/assets/svg/054.svg +50 -0
- data/docs/assets/svg/055.svg +53 -0
- data/docs/assets/svg/056.svg +73 -0
- data/docs/documentation.md +89 -0
- data/docs/documentation_ja.md +90 -1
- data/lib/rsyntaxtree/base_graph.rb +5 -5
- data/lib/rsyntaxtree/element.rb +3 -2
- data/lib/rsyntaxtree/elementlist.rb +3 -5
- data/lib/rsyntaxtree/format_converter.rb +65 -0
- data/lib/rsyntaxtree/markup_parser.rb +13 -2
- data/lib/rsyntaxtree/string_parser.rb +1 -1
- data/lib/rsyntaxtree/svg_graph.rb +10 -3
- data/lib/rsyntaxtree/tikz_generator.rb +131 -0
- data/lib/rsyntaxtree/utils.rb +4 -2
- data/lib/rsyntaxtree/version.rb +1 -1
- data/lib/rsyntaxtree.rb +43 -22
- data/rsyntaxtree.gemspec +2 -0
- data/syntree.svg +41 -0
- data/test/cli_test.rb +262 -0
- data/test/format_converter_test.rb +129 -0
- data/test/node_styling_test.rb +239 -0
- data/test/tikz_test.rb +89 -0
- metadata +51 -6
|
@@ -13,6 +13,11 @@ class MarkupParser < Parslet::Parser
|
|
|
13
13
|
rule(:brackets) { str('#') }
|
|
14
14
|
rule(:triangle) { str('^') }
|
|
15
15
|
|
|
16
|
+
# Color specification: @colorname: or @#hexcode:
|
|
17
|
+
rule(:color_name) { match('[a-zA-Z]').repeat(1) }
|
|
18
|
+
rule(:color_hex) { str('#') >> match('[0-9a-fA-F]').repeat(3, 6) }
|
|
19
|
+
rule(:color_spec) { str('@') >> (color_hex | color_name).as(:color_value) >> str(':') }
|
|
20
|
+
|
|
16
21
|
rule(:path) { (str('+') >> str('-').maybe >> (str('>') | str('<')).maybe >> match('\d').repeat(1)).as(:path) }
|
|
17
22
|
# rule(:escaped) { str('\\') >> match('[#<>{}\\^+*_=~\|\n\-]').as(:chr) }
|
|
18
23
|
rule(:escaped) { str('\\') >> match('[#<>{}\\\\^+*_=~\\|\\n\\-\\[\\]]').as(:chr) }
|
|
@@ -51,7 +56,7 @@ class MarkupParser < Parslet::Parser
|
|
|
51
56
|
rule(:markup) { (text | decoration | shape | bstroke) }
|
|
52
57
|
|
|
53
58
|
rule(:line) { (cr.as(:extracr) | border | bborder | markup.repeat(1).as(:line) >> (cr | eof | str('+').present?)) }
|
|
54
|
-
rule(:lines) { triangle.maybe.as(:triangle) >> (brectangle | rectangle | brackets).maybe.as(:enclosure) >> line.repeat(1) >> path.repeat(0).as(:paths) >> (cr | eof) }
|
|
59
|
+
rule(:lines) { triangle.maybe.as(:triangle) >> (brectangle | rectangle | brackets).maybe.as(:enclosure) >> color_spec.maybe.as(:color) >> line.repeat(1) >> path.repeat(0).as(:paths) >> (cr | eof) }
|
|
55
60
|
root :lines
|
|
56
61
|
end
|
|
57
62
|
|
|
@@ -171,7 +176,7 @@ module Markup
|
|
|
171
176
|
|
|
172
177
|
applied = @evaluator.apply(parsed)
|
|
173
178
|
|
|
174
|
-
results = { enclosure: :none, triangle: false, paths: [], contents: [] }
|
|
179
|
+
results = { enclosure: :none, triangle: false, paths: [], contents: [], color: nil }
|
|
175
180
|
applied.each do |h|
|
|
176
181
|
if h[:enclosure]
|
|
177
182
|
results[:enclosure] = case h[:enclosure].to_s
|
|
@@ -187,6 +192,12 @@ module Markup
|
|
|
187
192
|
end
|
|
188
193
|
results[:triangle] = h[:triangle].to_s == '^' if h[:triangle]
|
|
189
194
|
results[:paths] = h[:paths] if h[:paths]
|
|
195
|
+
# Handle color specification
|
|
196
|
+
if h[:color] && h[:color][:color_value]
|
|
197
|
+
color_value = h[:color][:color_value].to_s
|
|
198
|
+
# Prepend # if it's a hex color without it (parser captures just the hex part after #)
|
|
199
|
+
results[:color] = color_value
|
|
200
|
+
end
|
|
190
201
|
results[:contents] << h if h[:type] == :text || h[:type] == :border || h[:type] == :bborder
|
|
191
202
|
end
|
|
192
203
|
{ status: :success, results: results }
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
#
|
|
7
7
|
# Parses a phrase into leafs and nodes and store the result in an element list
|
|
8
8
|
# (see element_list.rb)
|
|
9
|
-
# Copyright (c) 2007-
|
|
9
|
+
# Copyright (c) 2007-2026 Yoichiro Hasebe <yohasebe@gmail.com>
|
|
10
10
|
|
|
11
11
|
require_relative 'elementlist'
|
|
12
12
|
require_relative 'element'
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
#==========================
|
|
6
6
|
#
|
|
7
7
|
# Parses an element list into an SVG tree.
|
|
8
|
-
# Copyright (c) 2007-
|
|
8
|
+
# Copyright (c) 2007-2026 Yoichiro Hasebe <yohasebe@gmail.com>
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
# No tempfile usage in this file
|
|
11
11
|
require_relative 'base_graph'
|
|
12
12
|
require_relative 'utils'
|
|
13
13
|
|
|
@@ -149,7 +149,10 @@ module RSyntaxTree
|
|
|
149
149
|
right = left + element.content_width
|
|
150
150
|
txt_pos = left + (right - left) / 2
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
# Use element's custom color if specified, otherwise use default based on type
|
|
153
|
+
col = if element.color
|
|
154
|
+
element.color
|
|
155
|
+
elsif element.type == ETYPE_LEAF
|
|
153
156
|
@col_leaf
|
|
154
157
|
else
|
|
155
158
|
@col_node
|
|
@@ -444,7 +447,11 @@ module RSyntaxTree
|
|
|
444
447
|
|
|
445
448
|
path_flags.uniq.each do |k|
|
|
446
449
|
targets = path_pool_target[k]
|
|
450
|
+
next if targets.nil? || targets.empty?
|
|
451
|
+
|
|
447
452
|
fst = targets.shift
|
|
453
|
+
next if fst.nil?
|
|
454
|
+
|
|
448
455
|
targets.each do |t|
|
|
449
456
|
paths << { x1: fst[0], y1: fst[1], x2: t[0], y2: t[1], arrow: :double }
|
|
450
457
|
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#==========================
|
|
4
|
+
# tikz_generator.rb
|
|
5
|
+
#==========================
|
|
6
|
+
#
|
|
7
|
+
# Generates TikZ/forest LaTeX code from parsed tree elements
|
|
8
|
+
# Copyright (c) 2007-2026 Yoichiro Hasebe <yohasebe@gmail.com>
|
|
9
|
+
|
|
10
|
+
module RSyntaxTree
|
|
11
|
+
class TikZGenerator
|
|
12
|
+
LATEX_ESCAPE_MAP = {
|
|
13
|
+
'&' => '\\&',
|
|
14
|
+
'%' => '\\%',
|
|
15
|
+
'$' => '\\$',
|
|
16
|
+
'#' => '\\#',
|
|
17
|
+
'_' => '\\_',
|
|
18
|
+
'{' => '\\{',
|
|
19
|
+
'}' => '\\}',
|
|
20
|
+
'~' => '\\textasciitilde{}',
|
|
21
|
+
'^' => '\\textasciicircum{}'
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(element_list, params)
|
|
25
|
+
@element_list = element_list
|
|
26
|
+
@params = params
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Generate TikZ forest code
|
|
30
|
+
# @param standalone [Boolean] whether to include LaTeX preamble
|
|
31
|
+
# @param font [String, nil] font name for XeLaTeX/fontspec (enables fontspec when specified)
|
|
32
|
+
# @return [String] TikZ/forest code
|
|
33
|
+
def generate(standalone: false, font: nil)
|
|
34
|
+
tree_code = generate_tree(1)
|
|
35
|
+
|
|
36
|
+
if standalone
|
|
37
|
+
generate_standalone(tree_code, font: font)
|
|
38
|
+
else
|
|
39
|
+
generate_forest_only(tree_code)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def generate_standalone(tree_code, font: nil)
|
|
46
|
+
if font
|
|
47
|
+
<<~LATEX
|
|
48
|
+
\\documentclass[border=10pt]{standalone}
|
|
49
|
+
\\usepackage{forest}
|
|
50
|
+
\\usepackage{fontspec}
|
|
51
|
+
\\setmainfont{#{font}}
|
|
52
|
+
|
|
53
|
+
\\begin{document}
|
|
54
|
+
#{generate_forest_only(tree_code)}
|
|
55
|
+
\\end{document}
|
|
56
|
+
LATEX
|
|
57
|
+
else
|
|
58
|
+
<<~LATEX
|
|
59
|
+
\\documentclass[border=10pt]{standalone}
|
|
60
|
+
\\usepackage{forest}
|
|
61
|
+
|
|
62
|
+
\\begin{document}
|
|
63
|
+
#{generate_forest_only(tree_code)}
|
|
64
|
+
\\end{document}
|
|
65
|
+
LATEX
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def generate_forest_only(tree_code)
|
|
70
|
+
<<~LATEX
|
|
71
|
+
\\begin{forest}
|
|
72
|
+
for tree={
|
|
73
|
+
parent anchor=south,
|
|
74
|
+
child anchor=north,
|
|
75
|
+
align=center,
|
|
76
|
+
base=top
|
|
77
|
+
}
|
|
78
|
+
#{tree_code}
|
|
79
|
+
\\end{forest}
|
|
80
|
+
LATEX
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Recursively generate tree structure
|
|
84
|
+
def generate_tree(element_id, indent = 0)
|
|
85
|
+
element = @element_list.get_id(element_id)
|
|
86
|
+
return "" unless element
|
|
87
|
+
|
|
88
|
+
label = extract_label(element)
|
|
89
|
+
escaped_label = escape_latex(label)
|
|
90
|
+
|
|
91
|
+
children = element.children
|
|
92
|
+
indent_str = " " * indent
|
|
93
|
+
|
|
94
|
+
if children.empty?
|
|
95
|
+
"#{indent_str}[#{escaped_label}]"
|
|
96
|
+
else
|
|
97
|
+
child_code = children.map { |child_id| generate_tree(child_id, indent + 1) }.join("\n")
|
|
98
|
+
"#{indent_str}[#{escaped_label}\n#{child_code}\n#{indent_str}]"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Extract plain text label from element content
|
|
103
|
+
def extract_label(element)
|
|
104
|
+
content = element.content
|
|
105
|
+
return "" if content.nil? || content.empty?
|
|
106
|
+
|
|
107
|
+
# content is an array of hashes with :type and :elements
|
|
108
|
+
texts = []
|
|
109
|
+
content.each do |line|
|
|
110
|
+
next unless line[:type] == :text
|
|
111
|
+
|
|
112
|
+
line[:elements].each do |el|
|
|
113
|
+
text = el[:text].to_s
|
|
114
|
+
# Remove RSyntaxTree-specific whitespace block
|
|
115
|
+
text = text.gsub("■", " ")
|
|
116
|
+
texts << text unless text.strip.empty?
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
texts.join(" ")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Escape LaTeX special characters
|
|
123
|
+
def escape_latex(text)
|
|
124
|
+
result = text.dup
|
|
125
|
+
LATEX_ESCAPE_MAP.each do |char, escaped|
|
|
126
|
+
result.gsub!(char, escaped)
|
|
127
|
+
end
|
|
128
|
+
result
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
data/lib/rsyntaxtree/utils.rb
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
#==========================
|
|
6
6
|
#
|
|
7
7
|
# Image utility functions to inspect text font metrics
|
|
8
|
-
# Copyright (c) 2007-
|
|
8
|
+
# Copyright (c) 2007-2026 Yoichiro Hasebe <yohasebe@gmail.com>
|
|
9
9
|
|
|
10
10
|
require 'rmagick'
|
|
11
11
|
|
|
@@ -53,7 +53,9 @@ module FontMetrics
|
|
|
53
53
|
gca.interline_spacing = 0
|
|
54
54
|
gca.interword_spacing = 0
|
|
55
55
|
end
|
|
56
|
-
gc.get_multiline_type_metrics(background, text)
|
|
56
|
+
metrics = gc.get_multiline_type_metrics(background, text)
|
|
57
|
+
background.destroy! # Explicitly destroy the image
|
|
58
|
+
metrics
|
|
57
59
|
end
|
|
58
60
|
module_function :get_metrics
|
|
59
61
|
end
|
data/lib/rsyntaxtree/version.rb
CHANGED
data/lib/rsyntaxtree.rb
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
#
|
|
7
7
|
# Facade of rsyntaxtree library. When loaded by a driver script, it does all
|
|
8
8
|
# the necessary 'require' to use the library.
|
|
9
|
-
# Copyright (c) 2007-
|
|
9
|
+
# Copyright (c) 2007-2026 Yoichiro Hasebe <yohasebe@gmail.com>
|
|
10
10
|
|
|
11
11
|
FONT_DIR = File.expand_path(File.join(__dir__, "/../fonts"))
|
|
12
12
|
ETYPE_NODE = 1
|
|
@@ -24,7 +24,7 @@ DEFAULT_OPTS = {
|
|
|
24
24
|
linewidth: 1,
|
|
25
25
|
vheight: 2.0,
|
|
26
26
|
color: "modern",
|
|
27
|
-
symmetrize: "
|
|
27
|
+
symmetrize: "off",
|
|
28
28
|
transparent: "off",
|
|
29
29
|
polyline: "off",
|
|
30
30
|
hide_default_connectors: "off"
|
|
@@ -41,6 +41,7 @@ require_relative 'rsyntaxtree/utils'
|
|
|
41
41
|
require_relative 'rsyntaxtree/element'
|
|
42
42
|
require_relative 'rsyntaxtree/elementlist'
|
|
43
43
|
require_relative 'rsyntaxtree/svg_graph'
|
|
44
|
+
require_relative 'rsyntaxtree/tikz_generator'
|
|
44
45
|
require_relative 'rsyntaxtree/version'
|
|
45
46
|
require_relative 'rsyntaxtree/string_parser'
|
|
46
47
|
|
|
@@ -152,6 +153,9 @@ module RSyntaxTree
|
|
|
152
153
|
end
|
|
153
154
|
|
|
154
155
|
def draw_png(binary = false)
|
|
156
|
+
surface = nil
|
|
157
|
+
context = nil
|
|
158
|
+
b = nil
|
|
155
159
|
svg = draw_svg
|
|
156
160
|
rsvg = RSVG::Handle.new_from_data(svg)
|
|
157
161
|
dim = rsvg.dimensions
|
|
@@ -160,16 +164,19 @@ module RSyntaxTree
|
|
|
160
164
|
context.render_rsvg_handle(rsvg)
|
|
161
165
|
b = StringIO.new
|
|
162
166
|
surface.write_to_png(b)
|
|
163
|
-
|
|
164
|
-
b
|
|
165
|
-
else
|
|
166
|
-
b.string
|
|
167
|
-
end
|
|
167
|
+
binary ? b : b.string
|
|
168
168
|
rescue Cairo::InvalidSize
|
|
169
169
|
raise RSTError, +"Error: the result syntree is too big"
|
|
170
|
+
ensure
|
|
171
|
+
b&.close unless binary
|
|
172
|
+
surface&.finish
|
|
173
|
+
context&.destroy
|
|
170
174
|
end
|
|
171
175
|
|
|
172
176
|
def draw_pdf(binary = false)
|
|
177
|
+
surface = nil
|
|
178
|
+
context = nil
|
|
179
|
+
b = nil
|
|
173
180
|
b = StringIO.new
|
|
174
181
|
svg = draw_svg
|
|
175
182
|
rsvg = RSVG::Handle.new_from_data(svg)
|
|
@@ -178,13 +185,12 @@ module RSyntaxTree
|
|
|
178
185
|
context = Cairo::Context.new(surface)
|
|
179
186
|
context.render_rsvg_handle(rsvg)
|
|
180
187
|
surface.finish
|
|
181
|
-
|
|
182
|
-
b
|
|
183
|
-
else
|
|
184
|
-
b.string
|
|
185
|
-
end
|
|
188
|
+
binary ? b : b.string
|
|
186
189
|
rescue Cairo::InvalidSize
|
|
187
190
|
raise RSTError, +"Error: the result syntree is too big"
|
|
191
|
+
ensure
|
|
192
|
+
b&.close unless binary
|
|
193
|
+
context&.destroy
|
|
188
194
|
end
|
|
189
195
|
|
|
190
196
|
def draw_svg
|
|
@@ -194,16 +200,31 @@ module RSyntaxTree
|
|
|
194
200
|
graph.svg_data
|
|
195
201
|
end
|
|
196
202
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
image
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
203
|
+
def draw_jpg
|
|
204
|
+
png_data = draw_png
|
|
205
|
+
images = Magick::Image.from_blob(png_data)
|
|
206
|
+
image = images.first
|
|
207
|
+
image.format = 'JPEG'
|
|
208
|
+
blob = image.to_blob
|
|
209
|
+
images.each(&:destroy!)
|
|
210
|
+
blob
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def draw_gif
|
|
214
|
+
png_data = draw_png
|
|
215
|
+
images = Magick::Image.from_blob(png_data)
|
|
216
|
+
image = images.first
|
|
217
|
+
image.format = 'GIF'
|
|
218
|
+
blob = image.to_blob
|
|
219
|
+
images.each(&:destroy!)
|
|
220
|
+
blob
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def draw_tikz(standalone: false, font: nil)
|
|
224
|
+
sp = StringParser.new(@params[:data].gsub('&', '&'), @params[:fontset], @params[:fontsize], @global)
|
|
225
|
+
sp.parse
|
|
226
|
+
generator = TikZGenerator.new(sp.get_elementlist, @params)
|
|
227
|
+
generator.generate(standalone: standalone, font: font)
|
|
207
228
|
end
|
|
208
229
|
end
|
|
209
230
|
end
|
data/rsyntaxtree.gemspec
CHANGED
|
@@ -22,6 +22,8 @@ Gem::Specification.new do |s|
|
|
|
22
22
|
s.add_runtime_dependency "rmagick", ">= 4.3"
|
|
23
23
|
s.add_runtime_dependency "rsvg2"
|
|
24
24
|
|
|
25
|
+
s.add_development_dependency "minitest"
|
|
25
26
|
s.add_development_dependency "nokogiri"
|
|
27
|
+
s.add_development_dependency "rake"
|
|
26
28
|
s.add_development_dependency "yaml"
|
|
27
29
|
end
|
data/syntree.svg
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<?xml version="1.0" standalone="no"?>
|
|
2
|
+
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
3
|
+
<svg width="118.60000000000001" height="434.25" viewBox="-15.200000000000001, 0, 133.8, 445.5" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
|
4
|
+
<defs>
|
|
5
|
+
<marker id="arrow" markerUnits="userSpaceOnUse" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="15.200000000000001" markerHeight="15.200000000000001" orient="auto">
|
|
6
|
+
<path d="M 0 0 L 10 5 L 0 10" fill="#CC79A7"/>
|
|
7
|
+
</marker>
|
|
8
|
+
<marker id="arrowBackward" markerUnits="userSpaceOnUse" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="15.200000000000001" markerHeight="15.200000000000001" orient="auto">
|
|
9
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill="#CC79A7"/>
|
|
10
|
+
</marker>
|
|
11
|
+
<marker id="arrowForward" markerUnits="userSpaceOnUse" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="15.200000000000001" markerHeight="15.200000000000001" orient="auto">
|
|
12
|
+
<path d="M 10 0 L 0 5 L 10 10 z" fill="#CC79A7"/>
|
|
13
|
+
</marker>
|
|
14
|
+
<marker id="arrowBothways" markerUnits="userSpaceOnUse" viewBox="0 0 30 10" refX="15" refY="5" markerWidth="45.6" markerHeight="15.200000000000001" orient="auto">
|
|
15
|
+
<path d="M 0 5 L 10 0 L 10 5 L 20 5 L 20 0 L 30 5 L 20 10 L 20 5 L 10 5 L 10 10 z" fill="#CC79A7"/>
|
|
16
|
+
</marker>
|
|
17
|
+
<pattern id="hatchBlack" x="10" y="10" width="10" height="10" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
|
18
|
+
<line x1="0" y="0" x2="0" y2="10" stroke="black" stroke-width="4"></line>
|
|
19
|
+
</pattern>
|
|
20
|
+
<pattern id="hatchForNode" x="10" y="10" width="10" height="10" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
|
21
|
+
<line x1="0" y="0" x2="0" y2="10" stroke="#0072B2" stroke-width="4"></line>
|
|
22
|
+
</pattern>
|
|
23
|
+
<pattern id="hatchForLeaf" x="10" y="10" width="10" height="10" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
|
24
|
+
<line x1="0" y="0" x2="0" y2="10" stroke="#009E73" stroke-width="4"></line>
|
|
25
|
+
</pattern>
|
|
26
|
+
</defs>
|
|
27
|
+
<rect x="-15.200000000000001" y="0" width="133.8" height="445.5" stroke="none" fill="white" />"
|
|
28
|
+
<text white-space='pre' alignment-baseline='text-top' style='fill: #0072B2; storoke-width: 0; font-size: 32px;' x='42.7' y='67.5'><tspan x='42.7' y='67.5' style="" text-decoration="" font-family="'Noto Sans', 'Noto Sans JP', OpenMoji, 'OpenMoji Color', 'OpenMoji Black', sans-serif">S</tspan>
|
|
29
|
+
</text>
|
|
30
|
+
<text white-space='pre' alignment-baseline='text-top' style='fill: red; storoke-width: 0; font-size: 32px;' x='30.200000000000003' y='238.5'><tspan x='30.200000000000003' y='238.5' style="" text-decoration="" font-family="'Noto Sans', 'Noto Sans JP', OpenMoji, 'OpenMoji Color', 'OpenMoji Black', sans-serif">NP</tspan>
|
|
31
|
+
</text>
|
|
32
|
+
<text white-space='pre' alignment-baseline='text-top' style='fill: #009E73; storoke-width: 0; font-size: 32px;' x='15.200000000000003' y='409.5'><tspan x='15.200000000000003' y='409.5' style="" text-decoration="" font-family="'Noto Sans', 'Noto Sans JP', OpenMoji, 'OpenMoji Color', 'OpenMoji Black', sans-serif">hello</tspan>
|
|
33
|
+
</text>
|
|
34
|
+
<line style='fill: none; stroke:black; stroke-width:2; stroke-linejoin:round; stroke-linecap:round;' x1='51.7' y1='182.25' x2='51.7' y2='95.625' />
|
|
35
|
+
<line style='fill: none; stroke:black; stroke-width:2; stroke-linejoin:round; stroke-linecap:round;' x1='51.7' y1='353.25' x2='51.7' y2='266.625' />
|
|
36
|
+
<polyline style='stroke:red; stroke-width:2; fill:none; stroke-linejoin:round; stroke-linecap:round;'
|
|
37
|
+
points='30.200000000000003,187.875 22.6,187.875 22.6,261.0 30.200000000000003,261.0' />
|
|
38
|
+
|
|
39
|
+
<polyline style='stroke:red; stroke-width:2; fill:none; stroke-linejoin:round; stroke-linecap:round;'
|
|
40
|
+
points='73.20000000000002,187.875 80.80000000000001,187.875 80.80000000000001,261.0 73.20000000000002,261.0' />
|
|
41
|
+
</svg>
|
data/test/cli_test.rb
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require "minitest/pride"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
require "open3"
|
|
7
|
+
|
|
8
|
+
class CLITest < Minitest::Test
|
|
9
|
+
BIN_PATH = File.expand_path("../bin/rsyntaxtree", __dir__)
|
|
10
|
+
RUBY = "ruby"
|
|
11
|
+
|
|
12
|
+
def setup
|
|
13
|
+
@tmpdir = Dir.mktmpdir
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def teardown
|
|
17
|
+
FileUtils.remove_entry(@tmpdir) if @tmpdir && Dir.exist?(@tmpdir)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Helper to run CLI and capture output (defined at bottom as private)
|
|
21
|
+
|
|
22
|
+
# ===================
|
|
23
|
+
# Feature 1: stdin support
|
|
24
|
+
# ===================
|
|
25
|
+
|
|
26
|
+
def test_stdin_input_generates_output
|
|
27
|
+
tree_data = "[S [NP test] [VP works]]"
|
|
28
|
+
outfile = File.join(@tmpdir, "syntree.svg")
|
|
29
|
+
|
|
30
|
+
result = run_cli("-f", "svg", "-o", @tmpdir, stdin_data: tree_data)
|
|
31
|
+
|
|
32
|
+
assert result[:status].success?, "CLI should succeed with stdin input: #{result[:stderr]}"
|
|
33
|
+
assert File.exist?(outfile), "Output file should be created"
|
|
34
|
+
assert File.size(outfile) > 0, "Output file should not be empty"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def test_stdin_input_with_pipe_simulation
|
|
38
|
+
tree_data = "[S [NP hello] [VP world]]"
|
|
39
|
+
outfile = File.join(@tmpdir, "syntree.png")
|
|
40
|
+
|
|
41
|
+
result = run_cli("-f", "png", "-o", @tmpdir, stdin_data: tree_data)
|
|
42
|
+
|
|
43
|
+
assert result[:status].success?, "CLI should succeed with piped input"
|
|
44
|
+
assert File.exist?(outfile), "PNG output file should be created"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def test_argument_takes_precedence_over_stdin
|
|
48
|
+
tree_data_stdin = "[S [NP stdin] [VP data]]"
|
|
49
|
+
tree_data_arg = "[S [NP arg] [VP data]]"
|
|
50
|
+
outfile = File.join(@tmpdir, "syntree.svg")
|
|
51
|
+
|
|
52
|
+
# When both arg and stdin provided, arg should win
|
|
53
|
+
result = run_cli("-f", "svg", "-o", @tmpdir, tree_data_arg, stdin_data: tree_data_stdin)
|
|
54
|
+
|
|
55
|
+
assert result[:status].success?, "CLI should succeed"
|
|
56
|
+
assert File.exist?(outfile), "Output file should be created"
|
|
57
|
+
content = File.read(outfile)
|
|
58
|
+
assert content.include?("arg"), "Output should contain data from argument, not stdin"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_file_input_still_works
|
|
62
|
+
tree_file = File.join(@tmpdir, "input.txt")
|
|
63
|
+
File.write(tree_file, "[S [NP file] [VP input]]")
|
|
64
|
+
outfile = File.join(@tmpdir, "syntree.svg")
|
|
65
|
+
|
|
66
|
+
result = run_cli("-f", "svg", "-o", @tmpdir, tree_file)
|
|
67
|
+
|
|
68
|
+
assert result[:status].success?, "CLI should succeed with file input"
|
|
69
|
+
assert File.exist?(outfile), "Output file should be created"
|
|
70
|
+
content = File.read(outfile)
|
|
71
|
+
assert content.include?("file"), "Output should contain data from file"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# ===================
|
|
75
|
+
# Feature 2: Config file support
|
|
76
|
+
# ===================
|
|
77
|
+
|
|
78
|
+
def test_config_file_invalid_format_shows_error
|
|
79
|
+
config_file = File.join(@tmpdir, ".rsyntaxtreerc")
|
|
80
|
+
File.write(config_file, <<~YAML)
|
|
81
|
+
format: invalid_format
|
|
82
|
+
YAML
|
|
83
|
+
|
|
84
|
+
tree_data = "[S [NP test] [VP config]]"
|
|
85
|
+
result = run_cli("-o", @tmpdir, tree_data, chdir: @tmpdir)
|
|
86
|
+
|
|
87
|
+
refute result[:status].success?, "CLI should fail with invalid format in config"
|
|
88
|
+
assert result[:stderr].include?("format") || result[:stdout].include?("format"),
|
|
89
|
+
"Error should mention 'format'"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def test_config_file_invalid_fontsize_shows_error
|
|
93
|
+
config_file = File.join(@tmpdir, ".rsyntaxtreerc")
|
|
94
|
+
File.write(config_file, <<~YAML)
|
|
95
|
+
fontsize: 999
|
|
96
|
+
YAML
|
|
97
|
+
|
|
98
|
+
tree_data = "[S [NP test] [VP config]]"
|
|
99
|
+
result = run_cli("-o", @tmpdir, tree_data, chdir: @tmpdir)
|
|
100
|
+
|
|
101
|
+
refute result[:status].success?, "CLI should fail with invalid fontsize in config"
|
|
102
|
+
assert result[:stderr].include?("fontsize") || result[:stdout].include?("fontsize"),
|
|
103
|
+
"Error should mention 'fontsize'"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def test_config_file_invalid_color_shows_error
|
|
107
|
+
config_file = File.join(@tmpdir, ".rsyntaxtreerc")
|
|
108
|
+
File.write(config_file, <<~YAML)
|
|
109
|
+
color: rainbow
|
|
110
|
+
YAML
|
|
111
|
+
|
|
112
|
+
tree_data = "[S [NP test] [VP config]]"
|
|
113
|
+
result = run_cli("-o", @tmpdir, tree_data, chdir: @tmpdir)
|
|
114
|
+
|
|
115
|
+
refute result[:status].success?, "CLI should fail with invalid color in config"
|
|
116
|
+
assert result[:stderr].include?("color") || result[:stdout].include?("color"),
|
|
117
|
+
"Error should mention 'color'"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def test_config_file_unknown_key_shows_warning
|
|
121
|
+
config_file = File.join(@tmpdir, ".rsyntaxtreerc")
|
|
122
|
+
File.write(config_file, <<~YAML)
|
|
123
|
+
format: svg
|
|
124
|
+
unknown_option: value
|
|
125
|
+
YAML
|
|
126
|
+
|
|
127
|
+
tree_data = "[S [NP test] [VP config]]"
|
|
128
|
+
outfile = File.join(@tmpdir, "syntree.svg")
|
|
129
|
+
result = run_cli("-o", @tmpdir, tree_data, chdir: @tmpdir)
|
|
130
|
+
|
|
131
|
+
# Should succeed but warn about unknown key
|
|
132
|
+
assert result[:status].success?, "CLI should succeed with unknown key"
|
|
133
|
+
assert File.exist?(outfile), "Output should be created"
|
|
134
|
+
assert result[:stderr].include?("unknown_option") || result[:stdout].include?("unknown_option"),
|
|
135
|
+
"Should warn about unknown option"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def test_config_file_shows_path_in_error
|
|
139
|
+
config_file = File.join(@tmpdir, ".rsyntaxtreerc")
|
|
140
|
+
File.write(config_file, <<~YAML)
|
|
141
|
+
format: invalid
|
|
142
|
+
YAML
|
|
143
|
+
|
|
144
|
+
tree_data = "[S [NP test] [VP config]]"
|
|
145
|
+
result = run_cli("-o", @tmpdir, tree_data, chdir: @tmpdir)
|
|
146
|
+
|
|
147
|
+
refute result[:status].success?, "CLI should fail"
|
|
148
|
+
# Error message should include the config file path
|
|
149
|
+
assert result[:stderr].include?(".rsyntaxtreerc") || result[:stdout].include?(".rsyntaxtreerc"),
|
|
150
|
+
"Error should mention config file path"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def test_config_file_in_current_dir
|
|
154
|
+
# Create config file in tmpdir
|
|
155
|
+
config_file = File.join(@tmpdir, ".rsyntaxtreerc")
|
|
156
|
+
File.write(config_file, <<~YAML)
|
|
157
|
+
format: svg
|
|
158
|
+
color: off
|
|
159
|
+
fontsize: 20
|
|
160
|
+
YAML
|
|
161
|
+
|
|
162
|
+
tree_data = "[S [NP config] [VP test]]"
|
|
163
|
+
outfile = File.join(@tmpdir, "syntree.svg")
|
|
164
|
+
|
|
165
|
+
# Run from tmpdir with config
|
|
166
|
+
result = run_cli("-o", @tmpdir, tree_data, chdir: @tmpdir)
|
|
167
|
+
|
|
168
|
+
assert result[:status].success?, "CLI should succeed with config file: #{result[:stderr]}"
|
|
169
|
+
assert File.exist?(outfile), "Should output SVG as specified in config"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def test_cli_args_override_config
|
|
173
|
+
config_file = File.join(@tmpdir, ".rsyntaxtreerc")
|
|
174
|
+
File.write(config_file, <<~YAML)
|
|
175
|
+
format: svg
|
|
176
|
+
YAML
|
|
177
|
+
|
|
178
|
+
tree_data = "[S [NP override] [VP test]]"
|
|
179
|
+
outfile_png = File.join(@tmpdir, "syntree.png")
|
|
180
|
+
|
|
181
|
+
# CLI arg -f png should override config's svg
|
|
182
|
+
result = run_cli("-f", "png", "-o", @tmpdir, tree_data, chdir: @tmpdir)
|
|
183
|
+
|
|
184
|
+
assert result[:status].success?, "CLI should succeed"
|
|
185
|
+
assert File.exist?(outfile_png), "Should output PNG (CLI override)"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def test_config_file_with_custom_filename
|
|
189
|
+
config_file = File.join(@tmpdir, ".rsyntaxtreerc")
|
|
190
|
+
File.write(config_file, <<~YAML)
|
|
191
|
+
format: svg
|
|
192
|
+
outfilename: custom_tree
|
|
193
|
+
YAML
|
|
194
|
+
|
|
195
|
+
tree_data = "[S [NP custom] [VP name]]"
|
|
196
|
+
outfile = File.join(@tmpdir, "custom_tree.svg")
|
|
197
|
+
|
|
198
|
+
result = run_cli("-o", @tmpdir, tree_data, chdir: @tmpdir)
|
|
199
|
+
|
|
200
|
+
assert result[:status].success?, "CLI should succeed"
|
|
201
|
+
assert File.exist?(outfile), "Should use custom filename from config"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# ===================
|
|
205
|
+
# Feature 3: Penn TreeBank format
|
|
206
|
+
# ===================
|
|
207
|
+
|
|
208
|
+
def test_penn_treebank_input
|
|
209
|
+
tree_data = "(S (NP hello) (VP world))"
|
|
210
|
+
outfile = File.join(@tmpdir, "syntree.svg")
|
|
211
|
+
|
|
212
|
+
result = run_cli("-f", "svg", "-o", @tmpdir, tree_data)
|
|
213
|
+
|
|
214
|
+
assert result[:status].success?, "CLI should succeed with Penn TreeBank input: #{result[:stderr]}"
|
|
215
|
+
assert File.exist?(outfile), "Output file should be created"
|
|
216
|
+
content = File.read(outfile)
|
|
217
|
+
assert content.include?("hello"), "Output should contain tree content"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def test_penn_treebank_nested
|
|
221
|
+
tree_data = "(S (NP (Det the) (N dog)) (VP (V runs)))"
|
|
222
|
+
outfile = File.join(@tmpdir, "syntree.svg")
|
|
223
|
+
|
|
224
|
+
result = run_cli("-f", "svg", "-o", @tmpdir, tree_data)
|
|
225
|
+
|
|
226
|
+
assert result[:status].success?, "CLI should succeed with nested Penn TreeBank"
|
|
227
|
+
assert File.exist?(outfile), "Output file should be created"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def test_penn_treebank_from_file
|
|
231
|
+
tree_file = File.join(@tmpdir, "tree.penn")
|
|
232
|
+
File.write(tree_file, "(S (NP test) (VP file))")
|
|
233
|
+
outfile = File.join(@tmpdir, "syntree.svg")
|
|
234
|
+
|
|
235
|
+
result = run_cli("-f", "svg", "-o", @tmpdir, tree_file)
|
|
236
|
+
|
|
237
|
+
assert result[:status].success?, "CLI should succeed with Penn file input"
|
|
238
|
+
assert File.exist?(outfile), "Output file should be created"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def test_penn_treebank_from_stdin
|
|
242
|
+
tree_data = "(S (NP stdin) (VP penn))"
|
|
243
|
+
outfile = File.join(@tmpdir, "syntree.svg")
|
|
244
|
+
|
|
245
|
+
result = run_cli("-f", "svg", "-o", @tmpdir, stdin_data: tree_data)
|
|
246
|
+
|
|
247
|
+
assert result[:status].success?, "CLI should succeed with Penn from stdin"
|
|
248
|
+
assert File.exist?(outfile), "Output file should be created"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
private
|
|
252
|
+
|
|
253
|
+
# Updated helper to support chdir
|
|
254
|
+
def run_cli(*args, stdin_data: nil, chdir: nil)
|
|
255
|
+
cmd = [RUBY, BIN_PATH] + args
|
|
256
|
+
opts = {}
|
|
257
|
+
opts[:stdin_data] = stdin_data if stdin_data
|
|
258
|
+
opts[:chdir] = chdir if chdir
|
|
259
|
+
stdout, stderr, status = Open3.capture3(*cmd, **opts)
|
|
260
|
+
{ stdout: stdout, stderr: stderr, status: status }
|
|
261
|
+
end
|
|
262
|
+
end
|