rsyntaxtree 1.10.0 → 1.11.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/.gitattributes +10 -0
- data/.gitignore +3 -0
- data/CHANGELOG.md +59 -0
- data/CITATION.cff +2 -2
- data/README.md +4 -1
- data/Rakefile +9 -2
- data/bin/rsyntaxtree +52 -24
- data/lib/rsyntaxtree/base_graph.rb +6 -1
- data/lib/rsyntaxtree/color_names.rb +42 -0
- data/lib/rsyntaxtree/element.rb +124 -8
- data/lib/rsyntaxtree/markup_parser.rb +31 -9
- data/lib/rsyntaxtree/notation_core.md +52 -0
- data/lib/rsyntaxtree/string_parser.rb +28 -7
- data/lib/rsyntaxtree/svg_graph.rb +39 -30
- data/lib/rsyntaxtree/tikz_generator.rb +6 -1
- data/lib/rsyntaxtree/version.rb +1 -1
- data/lib/rsyntaxtree.rb +207 -29
- metadata +4 -2
- data/syntree.svg +0 -41
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6afa75eb042fe177454d387fa097c74fb6b44fd317ae709f91495f2f9b8df269
|
|
4
|
+
data.tar.gz: 60e201c577b1cf02d12f6506b10e62b1b21e419d6d37955e4a69a338b28bfd90
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 27d3c6eaf4e9d892238f41c7cce05bea11a639351069e80e130af6ad809916e89794dd41bbaf32b407c8560e49b8dc45c243fe7724d4aa36a3a1ce727dc61321
|
|
7
|
+
data.tar.gz: c3744c0b738859a27699dc444f9d27d3169b5d8ee91bd04be2d9296deca560050f9f7cce8db69b7cb980342ef94a1dd2f29e627030b04f7de990cb4adf0850f9
|
data/.gitattributes
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Text files are stored with LF and checked out with LF, whatever the
|
|
2
|
+
# editor on the machine does. Two of the documentation pages had drifted
|
|
3
|
+
# to CRLF, which turned a three-line edit into a whole-file diff.
|
|
4
|
+
* text=auto eol=lf
|
|
5
|
+
|
|
6
|
+
*.png binary
|
|
7
|
+
*.jpg binary
|
|
8
|
+
*.gif binary
|
|
9
|
+
*.pdf binary
|
|
10
|
+
*.gem binary
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,64 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.11.0] - 2026-08
|
|
4
|
+
|
|
5
|
+
Every input the tool accepts now draws, and every input it refuses says why.
|
|
6
|
+
Line thickness follows the type size, so figures drawn at any font size keep
|
|
7
|
+
the same balance between text and rules — existing figures come out about a
|
|
8
|
+
fifth lighter than before.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `--validate` checks input without drawing or writing a file, reporting a
|
|
12
|
+
machine-readable diagnosis on stdout and the verdict in the exit code.
|
|
13
|
+
- `--notation` prints a short reference for the notation, which now ships
|
|
14
|
+
with the gem.
|
|
15
|
+
- Errors carry a code, the offending label and an offset inside it, a
|
|
16
|
+
one-line fix, and whether rewriting the input could help. The message text
|
|
17
|
+
is unchanged. Each cause is confirmed by applying its fix and parsing
|
|
18
|
+
again, so a named cause is one whose fix is known to work.
|
|
19
|
+
- A label may be nothing but a matrix: `[#(HEAD\tnoun#)]` had no spelling
|
|
20
|
+
before, since the enclosure rule took the `#` first.
|
|
21
|
+
- `-f tikz` on the command line, which the documentation had offered while
|
|
22
|
+
the CLI rejected it. TikZ output also carries the line width, which it
|
|
23
|
+
had been accepting and dropping.
|
|
24
|
+
- A gallery figure and a heading of its own for levelling the terminals with
|
|
25
|
+
`<>` joints.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- Line width is a fraction of the font size rather than an absolute number.
|
|
29
|
+
`1` is five per cent of the type size at any size, and the scale runs from
|
|
30
|
+
`0.5` to `3.0` in halves where it ran `1` to `5` and meant `2` to `6`.
|
|
31
|
+
Lines were two units wide whatever the type size, which read as heavily as
|
|
32
|
+
a serif stem at 16 point and heavier still below that, and nothing thinner
|
|
33
|
+
was available.
|
|
34
|
+
- On/off options accept every spelling of off. `mirror: "no"` reversed the
|
|
35
|
+
tree and `transparent: "0"` cut the background away, because anything but
|
|
36
|
+
`"off"` and `"false"` was read as on.
|
|
37
|
+
- Option values are checked against the values the option takes. A value
|
|
38
|
+
nobody defines was taken and read as something else — `direction:
|
|
39
|
+
"left-to-right"` laid the tree out top to bottom. The CLI has always
|
|
40
|
+
refused these; the library did not, which left the web interface and other
|
|
41
|
+
programmatic callers unguarded.
|
|
42
|
+
- Colour names are checked against the CSS colour names. A name nobody
|
|
43
|
+
defines passed validation and drew black.
|
|
44
|
+
- Penn Treebank input converts in the library, not only in the CLI. Other
|
|
45
|
+
callers got no conversion and no error: `(S (NP the dog))` drew as one
|
|
46
|
+
leaf containing that text.
|
|
47
|
+
- Font sizes go down to 6, which the web interface has always offered while
|
|
48
|
+
the documentation said 8.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
- Validation accepted input that drawing then refused: a movement path with
|
|
52
|
+
one end, a tree too wide for a raster surface, a colour spec that fails
|
|
53
|
+
only when the label is parsed. It now generates the drawing and discards
|
|
54
|
+
it, going as far as the requested format can still refuse.
|
|
55
|
+
- A malformed colour was reported as an unclosed enclosure, sending the
|
|
56
|
+
writer to fix a `#` that was never the problem.
|
|
57
|
+
- A left-to-right matrix drew its brackets on top of the attribute names.
|
|
58
|
+
- Text in front of a tree crashed symmetrization, and an empty argument was
|
|
59
|
+
read as a path to the current directory.
|
|
60
|
+
- Overline was documented as missing from PNG output. It has been there.
|
|
61
|
+
|
|
3
62
|
## [1.10.0] - 2026-08
|
|
4
63
|
|
|
5
64
|
A transitional release ahead of 2.0, which drops JPG/GIF output and the
|
data/CITATION.cff
CHANGED
|
@@ -11,8 +11,8 @@ repository-code: "https://github.com/yohasebe/rsyntaxtree"
|
|
|
11
11
|
# Concept DOI: always resolves to the latest archived version on Zenodo
|
|
12
12
|
doi: "10.5281/zenodo.21916150"
|
|
13
13
|
license: MIT
|
|
14
|
-
version: 1.
|
|
15
|
-
date-released: 2026-08-
|
|
14
|
+
version: 1.11.0
|
|
15
|
+
date-released: 2026-08-20
|
|
16
16
|
keywords:
|
|
17
17
|
- linguistics
|
|
18
18
|
- syntax tree
|
data/README.md
CHANGED
|
@@ -170,7 +170,8 @@ Usage:
|
|
|
170
170
|
| `-f, --format` | Output format: png, svg, pdf, lsif, tikz (jpg and gif are deprecated) | `png` |
|
|
171
171
|
| `-l, --leafstyle` | Leaf style: auto, triangle, bar, nothing | `auto` |
|
|
172
172
|
| `-n, --fontstyle` | Font style: sans, serif, cjk, mono | `sans` |
|
|
173
|
-
| `-s, --fontsize` | Font size:
|
|
173
|
+
| `-s, --fontsize` | Font size: 6-26 | `16` |
|
|
174
|
+
| `-i, --linewidth` | Line width relative to the font size: 0.5-3.0 (`1` = 5% of the font size) | `1.0` |
|
|
174
175
|
| `-c, --color` | Color mode: modern, traditional, gray (black text, grey lines), off | `modern` |
|
|
175
176
|
| `-p, --polyline` | Polyline connectors: on, off | `off` |
|
|
176
177
|
| `-d, --direction` | Tree layout direction: ttb, ltr | `ttb` |
|
|
@@ -178,6 +179,8 @@ Usage:
|
|
|
178
179
|
| `--hspacing` | Horizontal spacing factor, all layout modes (0.5-3.0) | `1.0` |
|
|
179
180
|
| `--hyphen` | How a hyphen reads: markup (`-underline-`) or literal | `markup` |
|
|
180
181
|
| `-m, --mirror` | Flip the tree horizontally (RTL convention): on, off | `off` |
|
|
182
|
+
| `--validate` | Check the input without drawing: a JSON diagnosis on stdout, the verdict in the exit code | |
|
|
183
|
+
| `--notation` | Print a short reference for the notation | |
|
|
181
184
|
|
|
182
185
|
Run `rsyntaxtree -h` for the full list of options.
|
|
183
186
|
|
data/Rakefile
CHANGED
|
@@ -32,7 +32,12 @@ end
|
|
|
32
32
|
|
|
33
33
|
Rake::Task["build"].enhance([:normalize_permissions])
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
# For trying something out. It does NOT reproduce the committed gallery:
|
|
36
|
+
# fontconfig resolves a family to whatever this machine has, so the same input
|
|
37
|
+
# measures differently here than in the image the figures were drawn with. Run
|
|
38
|
+
# over docs/assets it rewrites every PNG and about half the SVGs, none of it an
|
|
39
|
+
# intended change. Use docker_generate to update the gallery.
|
|
40
|
+
desc "Generate SVG and PNG example images locally (not for updating the gallery)"
|
|
36
41
|
task :generate do
|
|
37
42
|
require_relative "dev/generate_examples"
|
|
38
43
|
end
|
|
@@ -47,7 +52,9 @@ task :docker_build do
|
|
|
47
52
|
`docker build ./ -t rsyntaxtree_devel`
|
|
48
53
|
end
|
|
49
54
|
|
|
50
|
-
|
|
55
|
+
# The gallery is drawn here and nowhere else, so that a figure depends on the
|
|
56
|
+
# input and the options rather than on the fonts of whoever regenerated it.
|
|
57
|
+
desc "Generate SVG and PNG example images using Docker image (the gallery)"
|
|
51
58
|
task :docker_generate do
|
|
52
59
|
docpath = File.expand_path(File.join(__dir__, "docs"))
|
|
53
60
|
`docker build ./ -t rsyntaxtree_devel`
|
data/bin/rsyntaxtree
CHANGED
|
@@ -3,32 +3,37 @@
|
|
|
3
3
|
|
|
4
4
|
require 'optimist'
|
|
5
5
|
require 'yaml'
|
|
6
|
+
require 'json'
|
|
6
7
|
|
|
7
8
|
require_relative '../lib/rsyntaxtree'
|
|
8
9
|
require_relative '../lib/rsyntaxtree/utils'
|
|
9
10
|
require_relative '../lib/rsyntaxtree/format_converter'
|
|
10
11
|
|
|
12
|
+
# "png, jpg, gif, pdf, svg, lsif, or tikz" — built once from the library's
|
|
13
|
+
# own list so the three places that name the formats cannot drift apart.
|
|
14
|
+
FORMAT_LIST_MESSAGE = FORMATS.join(", ").sub(/, ([^,]+)\z/, ', or \1')
|
|
15
|
+
|
|
11
16
|
# Valid configuration keys and their validators
|
|
12
17
|
CONFIG_VALIDATORS = {
|
|
13
18
|
outdir: ->(v) { FileTest.directory?(v) ? nil : "must be an existing directory path" },
|
|
14
19
|
outfilename: ->(v) { v.is_a?(String) ? nil : "must be a string" },
|
|
15
|
-
format: ->(v) {
|
|
16
|
-
leafstyle: ->(v) {
|
|
17
|
-
fontstyle: ->(v) {
|
|
18
|
-
fontsize: ->(v) { v.is_a?(Integer) && v >=
|
|
19
|
-
linewidth: ->(v) { v.is_a?(
|
|
20
|
+
format: ->(v) { FORMATS.include?(v.to_s) ? nil : "must be #{FORMAT_LIST_MESSAGE}" },
|
|
21
|
+
leafstyle: ->(v) { OPTION_VALUES[:leafstyle].include?(v.to_s) ? nil : "must be auto, triangle, bar, or nothing" },
|
|
22
|
+
fontstyle: ->(v) { OPTION_VALUES[:fontstyle].include?(v.to_s) ? nil : "must be sans, serif, cjk, or mono" },
|
|
23
|
+
fontsize: ->(v) { v.is_a?(Integer) && v >= 6 && v <= 26 ? nil : "must be in the range of 6-26" },
|
|
24
|
+
linewidth: ->(v) { v.is_a?(Numeric) && v >= 0.5 && v <= 3.0 ? nil : "must be in the range of 0.5-3.0" },
|
|
20
25
|
vheight: ->(v) { v.is_a?(Numeric) && v >= 0.5 && v <= 5.0 ? nil : "must be in the range of 0.5-5.0" },
|
|
21
|
-
color: ->(v) {
|
|
26
|
+
color: ->(v) { OPTION_VALUES[:color].include?(v.to_s) ? nil : "must be modern, traditional, gray, or off" },
|
|
22
27
|
symmetrize: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
|
|
23
28
|
transparent: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
|
|
24
29
|
polyline: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
|
|
25
30
|
hide_default_connectors: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
|
|
26
31
|
mirror: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
|
|
27
|
-
tidy: ->(v) {
|
|
32
|
+
tidy: ->(v) { OPTION_VALUES[:tidy].include?(v.to_s) ? nil : "must be off, symmetric, low, medium, or high" },
|
|
28
33
|
hspacing: ->(v) { v.is_a?(Numeric) && v >= 0.5 && v <= 3.0 ? nil : "must be in the range of 0.5-3.0" },
|
|
29
34
|
tidy_spacing: ->(v) { v.is_a?(Numeric) && v >= 0.5 && v <= 3.0 ? nil : "must be in the range of 0.5-3.0" },
|
|
30
|
-
direction: ->(v) {
|
|
31
|
-
hyphen: ->(v) {
|
|
35
|
+
direction: ->(v) { OPTION_VALUES[:direction].include?(v.to_s) ? nil : "must be ttb or ltr" },
|
|
36
|
+
hyphen: ->(v) { OPTION_VALUES[:hyphen].include?(v.to_s) ? nil : "must be markup or literal" }
|
|
32
37
|
}.freeze
|
|
33
38
|
|
|
34
39
|
# Validate configuration and return errors/warnings
|
|
@@ -102,11 +107,11 @@ opts = Optimist.options do
|
|
|
102
107
|
|
|
103
108
|
opt :outdir, "Output directory", default: "./", short: :o
|
|
104
109
|
opt :outfilename, "Output file base name", default: "syntree", short: :u
|
|
105
|
-
opt :format, "Output format:
|
|
110
|
+
opt :format, "Output format: #{FORMAT_LIST_MESSAGE}", default: "png", short: :f
|
|
106
111
|
opt :leafstyle, "visual style of tree leaves: auto, triangle, bar, or nothing", default: "auto", short: :l
|
|
107
112
|
opt :fontstyle, "Font style: sans, serif, cjk, mono", default: "sans", short: :n
|
|
108
|
-
opt :fontsize, "Size:
|
|
109
|
-
opt :linewidth, "Size:
|
|
113
|
+
opt :fontsize, "Size: 6-26", default: 16, short: :s
|
|
114
|
+
opt :linewidth, "Size: 0.5-3.0", default: 1.0, short: :i
|
|
110
115
|
opt :vheight, "Connector Height: 0.5-5.0", default: 2.0, short: :v
|
|
111
116
|
opt :color, "Color text and bars: modern, traditional, gray, or off", default: "modern", short: :c
|
|
112
117
|
opt :symmetrize, "DEPRECATED alias of --tidy symmetric: on or off", default: "off", short: :y
|
|
@@ -119,25 +124,27 @@ opts = Optimist.options do
|
|
|
119
124
|
opt :tidy_spacing, "DEPRECATED alias of --hspacing: 0.5-3.0", default: 1.0, short: :none
|
|
120
125
|
opt :direction, "Tree layout direction: ttb (top-to-bottom) or ltr (left-to-right)", default: "ttb", short: :d
|
|
121
126
|
opt :hyphen, "How a hyphen in a label reads: markup (-underline-) or literal (a hyphen; \-underline\- then underlines)", default: "markup", short: :none
|
|
127
|
+
opt :validate, "Validate the input without drawing or writing a file: print a machine-readable diagnosis (JSON) to stdout and report the result in the exit code", short: :none
|
|
128
|
+
opt :notation, "Print the notation reference to stdout and exit", short: :none
|
|
122
129
|
opt :help, "This is a custom help message", short: :h
|
|
123
130
|
end
|
|
124
131
|
|
|
125
132
|
Optimist.die :outdir, "must be an exsting directory path" unless FileTest.directory?(opts[:outdir])
|
|
126
|
-
Optimist.die :direction, "must be ttb or ltr" unless
|
|
127
|
-
Optimist.die :hyphen, "must be markup or literal" unless
|
|
128
|
-
Optimist.die :format, "must be
|
|
129
|
-
Optimist.die :leafstyle, "must be auto, triangle, bar, or nothing" unless
|
|
130
|
-
Optimist.die :fontstyle, "must be sans, serif, cjk, or mono" unless
|
|
131
|
-
Optimist.die :fontsize, "must be in the range of
|
|
132
|
-
Optimist.die :linewidth, "must be in the range of
|
|
133
|
-
Optimist.die :color, "must be either modern, traditional, gray, or off" unless
|
|
133
|
+
Optimist.die :direction, "must be ttb or ltr" unless OPTION_VALUES[:direction].include?(opts[:direction])
|
|
134
|
+
Optimist.die :hyphen, "must be markup or literal" unless OPTION_VALUES[:hyphen].include?(opts[:hyphen])
|
|
135
|
+
Optimist.die :format, "must be #{FORMAT_LIST_MESSAGE}" unless FORMATS.include?(opts[:format])
|
|
136
|
+
Optimist.die :leafstyle, "must be auto, triangle, bar, or nothing" unless OPTION_VALUES[:leafstyle].include?(opts[:leafstyle])
|
|
137
|
+
Optimist.die :fontstyle, "must be sans, serif, cjk, or mono" unless OPTION_VALUES[:fontstyle].include?(opts[:fontstyle])
|
|
138
|
+
Optimist.die :fontsize, "must be in the range of 6-26" unless opts[:fontsize] >= 6 && opts[:fontsize] <= 26
|
|
139
|
+
Optimist.die :linewidth, "must be in the range of 0.5-3.0" unless opts[:linewidth] >= 0.5 && opts[:linewidth] <= 3.0
|
|
140
|
+
Optimist.die :color, "must be either modern, traditional, gray, or off" unless OPTION_VALUES[:color].include?(opts[:color])
|
|
134
141
|
Optimist.die :symmetrize, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:symmetrize]
|
|
135
142
|
Optimist.die :vheight, "must be in the range of 0.5-5.0" if opts[:vheight] < 0.5 || opts[:vheight] > 5.0
|
|
136
143
|
Optimist.die :transparent, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:transparent]
|
|
137
144
|
Optimist.die :polyline, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:polyline]
|
|
138
145
|
Optimist.die :hide_default_connectors, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:hide_default_connectors]
|
|
139
146
|
Optimist.die :mirror, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:mirror]
|
|
140
|
-
Optimist.die :tidy, "must be off, symmetric, low, medium, or high" unless
|
|
147
|
+
Optimist.die :tidy, "must be off, symmetric, low, medium, or high" unless OPTION_VALUES[:tidy].include?(opts[:tidy])
|
|
141
148
|
Optimist.die :hspacing, "must be in the range of 0.5-3.0" if opts[:hspacing] < 0.5 || opts[:hspacing] > 3.0
|
|
142
149
|
Optimist.die :tidy_spacing, "must be in the range of 0.5-3.0" if opts[:tidy_spacing] < 0.5 || opts[:tidy_spacing] > 3.0
|
|
143
150
|
|
|
@@ -164,7 +171,10 @@ if %w[jpg gif].include?(string_opts[:format])
|
|
|
164
171
|
warn "Warning: #{string_opts[:format].upcase} output is deprecated and will be removed in RSyntaxTree 2.0. Use PNG instead."
|
|
165
172
|
end
|
|
166
173
|
|
|
167
|
-
|
|
174
|
+
# An argument names a file only if it really is one: File.exist? on an
|
|
175
|
+
# expanded empty string answers for the current directory, and reading that
|
|
176
|
+
# as the tree ended the run with a Ruby error.
|
|
177
|
+
data = if ARGV[0] && !ARGV[0].empty? && File.file?(File.expand_path(ARGV[0]))
|
|
168
178
|
File.read ARGV[0]
|
|
169
179
|
elsif ARGV[0]
|
|
170
180
|
ARGV[0]
|
|
@@ -177,8 +187,26 @@ data = if ARGV[0] && File.exist?(File.expand_path(ARGV[0]))
|
|
|
177
187
|
# Auto-convert Penn TreeBank format to bracket notation
|
|
178
188
|
data = RSyntaxTree::FormatConverter.to_bracket(data) if data
|
|
179
189
|
|
|
190
|
+
if opts[:notation]
|
|
191
|
+
puts File.read(File.expand_path("../lib/rsyntaxtree/notation_core.md", __dir__))
|
|
192
|
+
exit 0
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Validate without drawing or writing anything: the diagnosis goes to stdout
|
|
196
|
+
# as JSON and the exit code carries the verdict.
|
|
197
|
+
if opts[:validate]
|
|
198
|
+
diagnosis = begin
|
|
199
|
+
RSyntaxTree::RSGenerator.check_data(data, string_opts)
|
|
200
|
+
{ "schema" => "rsyntaxtree.error/1", "ok" => true }
|
|
201
|
+
rescue RSTError => e
|
|
202
|
+
{ "schema" => "rsyntaxtree.error/1" }.merge(e.to_h)
|
|
203
|
+
end
|
|
204
|
+
puts JSON.pretty_generate(diagnosis)
|
|
205
|
+
exit(diagnosis["ok"] ? 0 : 1)
|
|
206
|
+
end
|
|
207
|
+
|
|
180
208
|
begin
|
|
181
|
-
RSyntaxTree::RSGenerator.check_data(data)
|
|
209
|
+
RSyntaxTree::RSGenerator.check_data(data, string_opts)
|
|
182
210
|
rescue RSTError => e
|
|
183
211
|
warn e
|
|
184
212
|
exit 1
|
|
@@ -189,7 +217,7 @@ begin
|
|
|
189
217
|
rsg = RSyntaxTree::RSGenerator.new(string_opts)
|
|
190
218
|
ext = string_opts[:format]
|
|
191
219
|
outfilename = string_opts[:outfilename] || "syntree"
|
|
192
|
-
file_ext = ext
|
|
220
|
+
file_ext = FORMAT_EXTENSIONS[ext] || ext
|
|
193
221
|
filepath = File.expand_path(string_opts[:outdir]) + "/#{outfilename}.#{file_ext}"
|
|
194
222
|
draw_method = "draw_#{ext}"
|
|
195
223
|
output = rsg.send(draw_method)
|
|
@@ -241,6 +241,11 @@ module RSyntaxTree
|
|
|
241
241
|
target = @element_list.get_id(id)
|
|
242
242
|
if target.children.empty?
|
|
243
243
|
parent = @element_list.get_id(target.parent)
|
|
244
|
+
# A childless element with no parent is a root standing alone —
|
|
245
|
+
# text in front of the tree makes one. It has no siblings to match
|
|
246
|
+
# widths with, so its own width is already the answer.
|
|
247
|
+
return target.width if parent.nil?
|
|
248
|
+
|
|
244
249
|
accum_array = []
|
|
245
250
|
parent.children.each do |c|
|
|
246
251
|
accum_array << @element_list.get_id(c).width
|
|
@@ -416,7 +421,7 @@ module RSyntaxTree
|
|
|
416
421
|
x0 = node.horizontal_indent
|
|
417
422
|
x1 = node.horizontal_indent + node.content_width
|
|
418
423
|
if [:brackets, :rectangle, :brectangle].include?(node.enclosure)
|
|
419
|
-
ext = @global[:h_gap_between_nodes] / 2 +
|
|
424
|
+
ext = @global[:h_gap_between_nodes] / 2 + @global[:stroke_bold]
|
|
420
425
|
x0 -= ext
|
|
421
426
|
x1 += ext
|
|
422
427
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#==========================
|
|
4
|
+
# color_names.rb
|
|
5
|
+
#==========================
|
|
6
|
+
#
|
|
7
|
+
# The color names a label may use: the CSS Color 4 named colors plus
|
|
8
|
+
# "transparent", which is what librsvg accepts when it paints. Validation
|
|
9
|
+
# checks a name against this list because an unknown name does not fail —
|
|
10
|
+
# it silently comes out black, which breaks the promise that what
|
|
11
|
+
# validates also draws. The list is a fixed web standard, so it is
|
|
12
|
+
# embedded rather than asked from an outside source that cannot answer
|
|
13
|
+
# (fontconfig has no color names, and librsvg never rejects one).
|
|
14
|
+
module RSyntaxTree
|
|
15
|
+
COLOR_NAMES = [
|
|
16
|
+
"aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige",
|
|
17
|
+
"bisque", "black", "blanchedalmond", "blue", "blueviolet", "brown",
|
|
18
|
+
"burlywood", "cadetblue", "chartreuse", "chocolate", "coral", "cornflowerblue",
|
|
19
|
+
"cornsilk", "crimson", "cyan", "darkblue", "darkcyan", "darkgoldenrod",
|
|
20
|
+
"darkgray", "darkgreen", "darkgrey", "darkkhaki", "darkmagenta", "darkolivegreen",
|
|
21
|
+
"darkorange", "darkorchid", "darkred", "darksalmon", "darkseagreen", "darkslateblue",
|
|
22
|
+
"darkslategray", "darkslategrey", "darkturquoise", "darkviolet", "deeppink", "deepskyblue",
|
|
23
|
+
"dimgray", "dimgrey", "dodgerblue", "firebrick", "floralwhite", "forestgreen",
|
|
24
|
+
"fuchsia", "gainsboro", "ghostwhite", "gold", "goldenrod", "gray",
|
|
25
|
+
"green", "greenyellow", "grey", "honeydew", "hotpink", "indianred",
|
|
26
|
+
"indigo", "ivory", "khaki", "lavender", "lavenderblush", "lawngreen",
|
|
27
|
+
"lemonchiffon", "lightblue", "lightcoral", "lightcyan", "lightgoldenrodyellow", "lightgray",
|
|
28
|
+
"lightgreen", "lightgrey", "lightpink", "lightsalmon", "lightseagreen", "lightskyblue",
|
|
29
|
+
"lightslategray", "lightslategrey", "lightsteelblue", "lightyellow", "lime", "limegreen",
|
|
30
|
+
"linen", "magenta", "maroon", "mediumaquamarine", "mediumblue", "mediumorchid",
|
|
31
|
+
"mediumpurple", "mediumseagreen", "mediumslateblue", "mediumspringgreen", "mediumturquoise", "mediumvioletred",
|
|
32
|
+
"midnightblue", "mintcream", "mistyrose", "moccasin", "navajowhite", "navy",
|
|
33
|
+
"oldlace", "olive", "olivedrab", "orange", "orangered", "orchid",
|
|
34
|
+
"palegoldenrod", "palegreen", "paleturquoise", "palevioletred", "papayawhip", "peachpuff",
|
|
35
|
+
"peru", "pink", "plum", "powderblue", "purple", "rebeccapurple",
|
|
36
|
+
"red", "rosybrown", "royalblue", "saddlebrown", "salmon", "sandybrown",
|
|
37
|
+
"seagreen", "seashell", "sienna", "silver", "skyblue", "slateblue",
|
|
38
|
+
"slategray", "slategrey", "snow", "springgreen", "steelblue", "tan",
|
|
39
|
+
"teal", "thistle", "tomato", "transparent", "turquoise", "violet",
|
|
40
|
+
"wheat", "white", "whitesmoke", "yellow", "yellowgreen",
|
|
41
|
+
].freeze
|
|
42
|
+
end
|
data/lib/rsyntaxtree/element.rb
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
require_relative "markup_parser"
|
|
11
11
|
require_relative "utils"
|
|
12
|
+
require_relative "color_names"
|
|
12
13
|
|
|
13
14
|
module RSyntaxTree
|
|
14
15
|
class Element
|
|
@@ -37,22 +38,23 @@ module RSyntaxTree
|
|
|
37
38
|
@fontsize = fontsize
|
|
38
39
|
@raw_content = content.sub(/\^?(?:\+-?>?<?\d+)+\^?\z/, '')
|
|
39
40
|
|
|
40
|
-
parsed = Markup.parse(
|
|
41
|
+
parsed = Markup.parse(prepare_markup(content))
|
|
41
42
|
|
|
42
43
|
if parsed[:status] == :success
|
|
43
44
|
results = parsed[:results]
|
|
44
45
|
else
|
|
45
46
|
error_text = +"Error: input text contains an invalid string"
|
|
46
47
|
error_text += "\n > " + content
|
|
47
|
-
raise RSTError,
|
|
48
|
+
raise RSTError.new(error_text, **markup_failure_details(content, parsed[:charpos]))
|
|
48
49
|
end
|
|
49
50
|
@content = results[:contents]
|
|
50
|
-
@paths = results[:paths]
|
|
51
51
|
@enclosure = results[:enclosure]
|
|
52
52
|
@triangle = results[:triangle]
|
|
53
53
|
@color = results[:color]
|
|
54
54
|
@region = results[:region]
|
|
55
55
|
@region_color = results[:region_color]
|
|
56
|
+
validate_color!(@color, content)
|
|
57
|
+
validate_color!(@region_color, content)
|
|
56
58
|
|
|
57
59
|
@contains_phrase = false
|
|
58
60
|
setup
|
|
@@ -81,13 +83,19 @@ module RSyntaxTree
|
|
|
81
83
|
# alone: a line of nothing but hyphens is the horizontal rule, and the dash
|
|
82
84
|
# in a path suffix (+-1, +->2) is what makes that path dashed. Swapping
|
|
83
85
|
# those turned a rule into the text "---" without a word of complaint.
|
|
84
|
-
|
|
86
|
+
# Split a label into lines with the trailing path markers detached, so
|
|
87
|
+
# hyphen handling can spare the two places a run of hyphens means
|
|
88
|
+
# something else: a `---` rule line of its own, and the `+-2` markers
|
|
89
|
+
# at the end. Shared by swap_hyphen_markup and escape_hyphens.
|
|
90
|
+
def self.hyphen_safe_lines(text)
|
|
85
91
|
path = text[/\^?(?:\+-?>?<?\d+)+\^?\z/]
|
|
86
92
|
body = path ? text[0...-path.length] : text
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
[body.split('\n', -1), path.to_s]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def swap_hyphen_markup(text)
|
|
97
|
+
lines, path = Element.hyphen_safe_lines(text)
|
|
98
|
+
lines.map { |line| /\A-{3,}\z/.match?(line) ? line : swap_hyphens(line) }.join('\n') + path
|
|
91
99
|
end
|
|
92
100
|
|
|
93
101
|
def swap_hyphens(text)
|
|
@@ -98,6 +106,114 @@ module RSyntaxTree
|
|
|
98
106
|
text.gsub('\\-', placeholder).gsub("-", '\\-').gsub(placeholder, "-")
|
|
99
107
|
end
|
|
100
108
|
|
|
109
|
+
# Escape the hyphens that open an underline. Shares its exemptions with
|
|
110
|
+
# swap_hyphen_markup through hyphen_safe_lines.
|
|
111
|
+
def self.escape_hyphens(text)
|
|
112
|
+
lines, path = hyphen_safe_lines(text)
|
|
113
|
+
lines.map { |line| /\A-{3,}\z/.match?(line) ? line : line.gsub(/(?<!\\)-/, '\\-') }.join('\n') + path
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# What the markup parser is actually handed: under hyphen: literal a
|
|
117
|
+
# hyphen and its escape swap roles before parsing.
|
|
118
|
+
def prepare_markup(text)
|
|
119
|
+
@global[:literal_hyphen] ? swap_hyphen_markup(text) : text
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# One candidate repair per way of getting the notation wrong, tried in
|
|
123
|
+
# order. Each is a whole edit of the label, not a pattern to recognise:
|
|
124
|
+
# the diagnosis below applies one and asks the parser whether the label
|
|
125
|
+
# now parses, so a cause is only ever reported when its fix is known to
|
|
126
|
+
# work. That keeps the list from drifting away from the grammar the way
|
|
127
|
+
# a set of hand-written patterns would — the grammar is the judge.
|
|
128
|
+
MARKUP_REPAIRS = [
|
|
129
|
+
# A malformed color spec ('@' then a bad name or a bad hex) leaves a
|
|
130
|
+
# bare '#' or '@' behind that the enclosure repair below would happily
|
|
131
|
+
# blame itself for. Tried first, so a color mistake is named as one.
|
|
132
|
+
[:invalid_color,
|
|
133
|
+
->(s) { s.sub(/\A%?@(?:#[0-9a-zA-Z]+|[a-zA-Z]+)?:/, "") },
|
|
134
|
+
"A color is @name: with a CSS color name, or @#rgb: / @#rrggbb: with 3 or 6 hex digits (e.g. @blue:VP, @#3af:VP)."],
|
|
135
|
+
[:angle_brackets,
|
|
136
|
+
->(s) { s.gsub(/(?<!\\)<([^<>]*[^<>\d][^<>]*)>/) { "〈#{$1}〉" } },
|
|
137
|
+
"'<' and '>' mark whitespace here, not a list. Write the angle bracket characters themselves: 〈NP〉, 'hand〈SUBJ,OBJ〉'."],
|
|
138
|
+
[:bare_hyphen,
|
|
139
|
+
->(s) { Element.escape_hyphens(s) },
|
|
140
|
+
"A hyphen opens an underline. Escape it (e.g. f\\-structure, V\\-bar) or set the hyphen option to literal."],
|
|
141
|
+
# Every other repair leaves a label it has nothing to do with exactly
|
|
142
|
+
# as it was, and an unchanged label is not tried. Appending always
|
|
143
|
+
# changes one, so this one asks first whether there is an unclosed
|
|
144
|
+
# matrix at all: without that, a label of "^" parses once "#)" is
|
|
145
|
+
# stuck on the end, and would be reported as a matrix left open.
|
|
146
|
+
[:unclosed_matrix,
|
|
147
|
+
->(s) { s.scan("#(").size > s.scan("#)").size ? s + "#)" : s },
|
|
148
|
+
"A matrix opened with '#(' is never closed with '#)'."],
|
|
149
|
+
# Neutralising every occurrence of one character, rather than adding a
|
|
150
|
+
# closing one, locates the culprit wherever it sits in the label — an
|
|
151
|
+
# opener left unclosed halfway down a matrix is not fixed by appending.
|
|
152
|
+
*{ "*" => "A '*' decoration (italic or bold) is never closed.",
|
|
153
|
+
"|" => "A '|' box is never closed.",
|
|
154
|
+
"_" => "A '_' subscript or superscript is never closed.",
|
|
155
|
+
"{" => "A '{...}' circle is never closed.",
|
|
156
|
+
"=" => "An '=' overline is never closed.",
|
|
157
|
+
"~" => "A '~' strikethrough is never closed.",
|
|
158
|
+
"#" => "A '#' enclosure is not one of #, ## or ###, or a matrix is left open.",
|
|
159
|
+
"<" => "'<' and '>' mark whitespace: <> is one space, <3> is three." }
|
|
160
|
+
.map { |ch, hint| [:unclosed_markup, ->(s) { s.gsub(/(?<!\\)#{Regexp.escape(ch)}/) { "\\#{ch}" } }, hint] },
|
|
161
|
+
[:stray_triangle,
|
|
162
|
+
->(s) { s.sub(/\A\^+/, "^") }, "Only one '^' may prefix a label."],
|
|
163
|
+
[:incomplete_path,
|
|
164
|
+
->(s) { s.sub(/(?<!\\)\+>?<?\z/, "") },
|
|
165
|
+
"A path marker needs a number: write +1, or +>1 for the arrowhead."]
|
|
166
|
+
].freeze
|
|
167
|
+
|
|
168
|
+
# Turn a Markup.parse failure into structured error attributes: a code
|
|
169
|
+
# for machines, the label and the offset inside it for people, a fix
|
|
170
|
+
# that has been checked to work, and whether rewriting could help.
|
|
171
|
+
def markup_failure_details(label, charpos)
|
|
172
|
+
details = { label: label, position: charpos }
|
|
173
|
+
|
|
174
|
+
# Nothing left to parse: the raw spaces around it took the whole label
|
|
175
|
+
# away, one of them starting the split and the next ending it.
|
|
176
|
+
if label.strip.empty?
|
|
177
|
+
return details.merge(code: :label_split,
|
|
178
|
+
hint: "Raw spaces left this label empty. Write a space inside a label as <> (e.g. 〈<>NP<>〉).",
|
|
179
|
+
retryable: true)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
MARKUP_REPAIRS.each do |code, repair, hint|
|
|
183
|
+
repaired = repair.call(label)
|
|
184
|
+
next if repaired == label
|
|
185
|
+
# Through the same preprocessing the real parse uses, or a label
|
|
186
|
+
# under hyphen: literal would be judged against a different string.
|
|
187
|
+
next unless Markup.parse(prepare_markup(repaired))[:status] == :success
|
|
188
|
+
|
|
189
|
+
return details.merge(code: code, hint: hint, retryable: true)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# No single repair worked. In practice this is where two mistakes in
|
|
193
|
+
# one label land — each of them one this could have named alone — so
|
|
194
|
+
# it is the most fixable bucket, not the least: still retryable, with
|
|
195
|
+
# a hint that admits no one cause was found rather than naming a wrong
|
|
196
|
+
# one. retryable: false is for what no rewriting reaches.
|
|
197
|
+
details.merge(code: :invalid_markup,
|
|
198
|
+
hint: "No single cause fits, which usually means more than one mistake in this label. Check that *, _, =, ~, |, { and #(...#) are paired, that hyphens are escaped, and that 〈 〉 are the angle bracket characters.",
|
|
199
|
+
retryable: true)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A hex color is already constrained by the grammar; a named color is
|
|
203
|
+
# checked against the names librsvg can paint, because an unknown name
|
|
204
|
+
# does not fail downstream — it silently comes out black.
|
|
205
|
+
def validate_color!(color, content)
|
|
206
|
+
return if color.nil? || color.start_with?("#")
|
|
207
|
+
return if COLOR_NAMES.include?(color.downcase)
|
|
208
|
+
|
|
209
|
+
raise RSTError.new(+"Error: input text contains an unknown color '#{color}'" \
|
|
210
|
+
"\n > #{content}",
|
|
211
|
+
code: :unknown_color,
|
|
212
|
+
label: content,
|
|
213
|
+
hint: "Use a CSS color name or a hex code: @blue:VP, @#3af:VP, @#33aaff:VP.",
|
|
214
|
+
retryable: true)
|
|
215
|
+
end
|
|
216
|
+
|
|
101
217
|
def setup
|
|
102
218
|
layout = measure_lines(@content)
|
|
103
219
|
@text_width = layout[:width]
|
|
@@ -79,8 +79,18 @@ class MarkupParser < Parslet::Parser
|
|
|
79
79
|
|
|
80
80
|
rule(:markup) { (matrix | tabstop | text | decoration | shape | bstroke) }
|
|
81
81
|
|
|
82
|
+
# A label that is one whole matrix. The enclosure rules in :lines would
|
|
83
|
+
# eat the '#' of '#(' before the matrix rule could see it, so a matrix
|
|
84
|
+
# could only live inside a label, never be one. Trying this first is
|
|
85
|
+
# safe: a label starting '#(' that contains the closing '#)' could never
|
|
86
|
+
# parse before (a bare '#' is not consumable outside a matrix), so the
|
|
87
|
+
# only inputs this newly reaches are ones that used to fail. A matrix
|
|
88
|
+
# followed by anything but a path or the end does not match here and
|
|
89
|
+
# falls through to the enclosure reading, exactly as before.
|
|
90
|
+
rule(:whole_label_matrix) { matrix >> (str('+') | cr | eof).present? }
|
|
91
|
+
|
|
82
92
|
rule(:line) { (cr.as(:extracr) | border | bborder | markup.repeat(1).as(:line) >> (cr | eof | str('+').present?)) }
|
|
83
|
-
rule(:lines) { triangle.maybe.as(:triangle) >> (brectangle | rectangle | brackets).maybe.as(:enclosure) >> region.maybe.as(:region) >> color_spec.maybe.as(:color) >> line.repeat(1) >> path.repeat(0).as(:paths) >> (cr | eof) }
|
|
93
|
+
rule(:lines) { triangle.maybe.as(:triangle) >> (whole_label_matrix.as(:whole_label_matrix) | ((brectangle | rectangle | brackets).maybe.as(:enclosure) >> region.maybe.as(:region) >> color_spec.maybe.as(:color) >> line.repeat(1))) >> path.repeat(0).as(:paths) >> (cr | eof) }
|
|
84
94
|
root :lines
|
|
85
95
|
end
|
|
86
96
|
|
|
@@ -177,9 +187,6 @@ module Markup
|
|
|
177
187
|
rule(circle: subtree(:text)) {
|
|
178
188
|
text[:decoration] << :circle; text
|
|
179
189
|
}
|
|
180
|
-
rule(math: subtree(:text)) {
|
|
181
|
-
text[:decoration] << :math; text
|
|
182
|
-
}
|
|
183
190
|
rule(border: simple(:border)) {
|
|
184
191
|
{ type: :border }
|
|
185
192
|
}
|
|
@@ -199,15 +206,28 @@ module Markup
|
|
|
199
206
|
}
|
|
200
207
|
end
|
|
201
208
|
|
|
209
|
+
# Largest charpos anywhere in a parse-failure cause tree: the position
|
|
210
|
+
# the parser actually reached before giving up.
|
|
211
|
+
def deepest_charpos(cause, best = 0)
|
|
212
|
+
pos = cause.respond_to?(:pos) && cause.pos ? cause.pos.charpos : 0
|
|
213
|
+
best = [best, pos].max
|
|
214
|
+
cause.children&.each { |child| best = deepest_charpos(child, best) }
|
|
215
|
+
best
|
|
216
|
+
end
|
|
217
|
+
|
|
202
218
|
def parse(txt)
|
|
203
219
|
begin
|
|
204
220
|
parsed = @parser.parse(txt)
|
|
205
|
-
rescue Parslet::ParseFailed
|
|
206
|
-
#
|
|
207
|
-
|
|
221
|
+
rescue Parslet::ParseFailed => e
|
|
222
|
+
# The cause is kept: the deepest node of the failure tree is where the
|
|
223
|
+
# parse actually got stuck, which is what structured errors report.
|
|
224
|
+
return { status: :error, text: txt, charpos: deepest_charpos(e.parse_failure_cause) }
|
|
208
225
|
end
|
|
209
226
|
|
|
210
227
|
applied = @evaluator.apply(parsed)
|
|
228
|
+
# A label holding a single named part (a whole-label matrix, nothing
|
|
229
|
+
# else) comes back as one hash, not a one-element array.
|
|
230
|
+
applied = [applied] unless applied.is_a?(Array)
|
|
211
231
|
|
|
212
232
|
results = { enclosure: :none, triangle: false, paths: [], contents: [], color: nil, region: false, region_color: nil }
|
|
213
233
|
applied.each do |h|
|
|
@@ -239,11 +259,13 @@ module Markup
|
|
|
239
259
|
results[:color] = color_value
|
|
240
260
|
end
|
|
241
261
|
results[:contents] << h if h[:type] == :text || h[:type] == :border || h[:type] == :bborder
|
|
262
|
+
# A label that is one whole matrix arrives under its own key in the
|
|
263
|
+
# top-level hash; the matrix element becomes the label's only line.
|
|
264
|
+
results[:contents] << { type: :text, elements: [h[:whole_label_matrix]] } if h[:whole_label_matrix]
|
|
242
265
|
end
|
|
243
266
|
{ status: :success, results: results }
|
|
244
267
|
end
|
|
245
268
|
|
|
246
|
-
module_function :parse
|
|
269
|
+
module_function :parse, :deepest_charpos
|
|
247
270
|
end
|
|
248
271
|
|
|
249
|
-
# pp results = Markup.parse('^#\_\#\+あり\-がとう**_X_**\\n----\\n933\\n__|Y|__+>3+2+1343+>5464')
|