rsyntaxtree 1.8.2 → 1.9.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 +68 -0
- data/README.md +2 -1
- data/Rakefile +11 -0
- data/bin/rsyntaxtree +7 -4
- data/lib/rsyntaxtree/base_graph.rb +168 -1
- data/lib/rsyntaxtree/element.rb +198 -11
- data/lib/rsyntaxtree/lsif_graph.rb +5 -0
- data/lib/rsyntaxtree/markup_parser.rb +28 -2
- data/lib/rsyntaxtree/string_parser.rb +1 -1
- data/lib/rsyntaxtree/svg_graph.rb +283 -137
- data/lib/rsyntaxtree/tikz_generator.rb +16 -4
- data/lib/rsyntaxtree/utils.rb +9 -2
- data/lib/rsyntaxtree/version.rb +1 -1
- data/lib/rsyntaxtree.rb +25 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ef69a566b76dec78e1e804475c604162173582605c852900b5cd5c5cf3f2e5eb
|
|
4
|
+
data.tar.gz: 3ee903c09bab59e55e1dd81544768482ddd53c3f0eaf6081364f4bb0c446cfed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f9057ca7c228e28351e95fb5b2573818736a738ed5ae2990db33291b884d1aa0d4e8158949b390b84f5151bf0117850521e0b25d1051fd7f5fe080d4559ecdfb
|
|
7
|
+
data.tar.gz: dc727f0091f56ced11c29a25739f8ee73fe912fc8673e8c073a4f3fd7a9de37e4344c392f4e7691773c7abcd7c72f71209f991c1d450e9ddd96e230dbebf4461
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,73 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.9.0] - 2026-08
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `hyphen: literal`, which trades the two readings of a hyphen: a bare one is
|
|
7
|
+
a hyphen and `\-underlined\-` underlines. Feature names in HPSG and its
|
|
8
|
+
relatives are full of hyphens, and escaping each one is a poor trade for a
|
|
9
|
+
rule that work never uses.
|
|
10
|
+
- A matrix nested in a label, written between `#(` and `#)`. The value of an
|
|
11
|
+
attribute can be another attribute-value matrix, to any depth, which is what
|
|
12
|
+
a feature path such as SYNSEM | LOCAL | CATEGORY | HEAD needs and what HPSG,
|
|
13
|
+
SBCG and LFG are written in. The nested matrix draws its own brackets and
|
|
14
|
+
lays out its own columns, and the rows after it clear its full height.
|
|
15
|
+
- `\t` in a label cuts the line into cells. Every line is cut at the same
|
|
16
|
+
points and each column is drawn at the width of its widest cell, so the
|
|
17
|
+
parts line up down the label. Together with the bracket enclosure and the
|
|
18
|
+
horizontal rule this gives attribute-value matrices — the feature structures
|
|
19
|
+
of HPSG, SBCG and LFG — without spacing each row by hand, which is how the
|
|
20
|
+
gallery's HPSG example used to do it.
|
|
21
|
+
- `color: gray`, a scheme that keeps node and leaf labels black and draws the
|
|
22
|
+
connectors, triangles and movement paths in grey. It is for diagrams whose
|
|
23
|
+
links outnumber their labels — an ontology, a network of constructions —
|
|
24
|
+
where a page of black lines buries the text. `grey` is accepted too.
|
|
25
|
+
- Gallery examples for two more frameworks: LFG (an annotated c-structure and
|
|
26
|
+
the f-structure it maps to, after Kaplan & Bresnan 1982 and Bresnan 2001)
|
|
27
|
+
and DRT (a discourse representation structure, after Kamp 1981).
|
|
28
|
+
- The example gallery scales each figure by its own width rather than by one
|
|
29
|
+
factor for all of them, so a small tree and a wide one are read at a similar
|
|
30
|
+
apparent size; a figure now fits its row instead of scrolling, loads lazily,
|
|
31
|
+
and carries an anchor of its own.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- Every box and circle in a figure is drawn at one size and on one centre
|
|
35
|
+
line. The size used to come from the line's height, which left a box
|
|
36
|
+
standing a head taller than the numeral inside it, and each shape was
|
|
37
|
+
centred on its own glyph, so the box around `s` sat lower than the one
|
|
38
|
+
around `G`. A shape is now drawn at a fixed fraction of the font size,
|
|
39
|
+
centred on a capital, and grows only for content that will not fit. Every
|
|
40
|
+
figure with a boxed or circled label is redrawn.
|
|
41
|
+
- The SVG's declared width and height agree with its viewBox. They did not,
|
|
42
|
+
so every figure was scaled down by a few percent and letterboxed inside
|
|
43
|
+
its own canvas.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
- TikZ export dropped everything inside a nested matrix: a feature structure
|
|
47
|
+
came out as its outermost attribute names and nothing else. The export still
|
|
48
|
+
cannot draw the brackets, but it keeps what they hold. The documented list of
|
|
49
|
+
what TikZ does not carry now names column alignment, nested matrices and the
|
|
50
|
+
grey line scheme.
|
|
51
|
+
- LSIF records which of the two readings of a hyphen the input was parsed
|
|
52
|
+
under. It records the input verbatim, and the same string means different
|
|
53
|
+
things under the two, so a reader could not re-parse it.
|
|
54
|
+
- A line-type connection with only one end raised a NoMethodError from inside
|
|
55
|
+
the drawing instead of being reported as the input error it is.
|
|
56
|
+
- A line-type connection between two nodes was anchored a full inter-node gap
|
|
57
|
+
outside each of them, so the link fell short of both boxes where the gap was
|
|
58
|
+
wide and reached inside them where it was narrow. It now runs between the
|
|
59
|
+
boxes' own edges, a quarter of a gap short of each. The double arrowhead
|
|
60
|
+
keeps the shape it had but is sized to the link, instead of one fixed size
|
|
61
|
+
that spilled over both boxes on a short link. The same anchors are used in
|
|
62
|
+
left-to-right layout, where a link between siblings had been drawn diagonally
|
|
63
|
+
from the movement-path anchors and now runs straight between the facing
|
|
64
|
+
edges.
|
|
65
|
+
- Two nodes joined by a line-type connection are laid out far enough apart for
|
|
66
|
+
a full-size arrowhead between them. Where the layout had packed them closer
|
|
67
|
+
than the arrow is wide — the two leaves at the foot of the quicksort figure —
|
|
68
|
+
the arrow was drawn small to fit; now the pair is spread and the arrow keeps
|
|
69
|
+
its size. Only pairs that carry a link move, and only when they need to.
|
|
70
|
+
|
|
3
71
|
## [1.8.2] - 2026-08
|
|
4
72
|
|
|
5
73
|
### Changed
|
data/README.md
CHANGED
|
@@ -171,11 +171,12 @@ Usage:
|
|
|
171
171
|
| `-l, --leafstyle` | Leaf style: auto, triangle, bar, nothing | `auto` |
|
|
172
172
|
| `-n, --fontstyle` | Font style: sans, serif, cjk, mono | `sans` |
|
|
173
173
|
| `-s, --fontsize` | Font size: 8-26 | `16` |
|
|
174
|
-
| `-c, --color` | Color mode: modern, traditional, off | `modern` |
|
|
174
|
+
| `-c, --color` | Color mode: modern, traditional, gray (black text, grey lines), off | `modern` |
|
|
175
175
|
| `-p, --polyline` | Polyline connectors: on, off | `off` |
|
|
176
176
|
| `-d, --direction` | Tree layout direction: ttb, ltr | `ttb` |
|
|
177
177
|
| `--tidy` | Layout scale: off, symmetric, low, medium, high | `off` |
|
|
178
178
|
| `--hspacing` | Horizontal spacing factor, all layout modes (0.5-3.0) | `1.0` |
|
|
179
|
+
| `--hyphen` | How a hyphen reads: markup (`-underline-`) or literal | `markup` |
|
|
179
180
|
| `-m, --mirror` | Flip the tree horizontally (RTL convention): on, off | `off` |
|
|
180
181
|
|
|
181
182
|
Run `rsyntaxtree -h` for the full list of options.
|
data/Rakefile
CHANGED
|
@@ -37,6 +37,11 @@ task :generate do
|
|
|
37
37
|
require_relative "dev/generate_examples"
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
desc "Record the pixel size of each gallery figure for the examples page"
|
|
41
|
+
task :figure_sizes do
|
|
42
|
+
ruby "dev/stamp_figure_sizes.rb"
|
|
43
|
+
end
|
|
44
|
+
|
|
40
45
|
desc "Docker image Build"
|
|
41
46
|
task :docker_build do
|
|
42
47
|
`docker build ./ -t rsyntaxtree_devel`
|
|
@@ -50,6 +55,12 @@ task :docker_generate do
|
|
|
50
55
|
`cat #{docpath}/generate_examples.log`
|
|
51
56
|
end
|
|
52
57
|
|
|
58
|
+
# The examples page scales every figure by the size recorded here, so the
|
|
59
|
+
# record has to be refreshed whenever the figures are.
|
|
60
|
+
["generate", "docker_generate"].each do |name|
|
|
61
|
+
Rake::Task[name].enhance { Rake::Task["figure_sizes"].invoke }
|
|
62
|
+
end
|
|
63
|
+
|
|
53
64
|
# Add new task for macOS environment configuration
|
|
54
65
|
|
|
55
66
|
desc "Configure Bundler build options for macOS"
|
data/bin/rsyntaxtree
CHANGED
|
@@ -18,7 +18,7 @@ CONFIG_VALIDATORS = {
|
|
|
18
18
|
fontsize: ->(v) { v.is_a?(Integer) && v >= 8 && v <= 26 ? nil : "must be in the range of 8-26" },
|
|
19
19
|
linewidth: ->(v) { v.is_a?(Integer) && v >= 1 && v <= 5 ? nil : "must be in the range of 1-5" },
|
|
20
20
|
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) { /\A(modern|traditional|on|off|true|false)\z/ =~ v.to_s ? nil : "must be modern, traditional, or off" },
|
|
21
|
+
color: ->(v) { /\A(modern|traditional|gray|grey|on|off|true|false)\z/ =~ v.to_s ? nil : "must be modern, traditional, gray, or off" },
|
|
22
22
|
symmetrize: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
|
|
23
23
|
transparent: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
|
|
24
24
|
polyline: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
|
|
@@ -27,7 +27,8 @@ CONFIG_VALIDATORS = {
|
|
|
27
27
|
tidy: ->(v) { /\A(off|symmetric|low|medium|high|on|compact|true|false)\z/ =~ v.to_s ? nil : "must be off, symmetric, low, medium, or high" },
|
|
28
28
|
hspacing: ->(v) { v.is_a?(Numeric) && v >= 0.5 && v <= 3.0 ? nil : "must be in the range of 0.5-3.0" },
|
|
29
29
|
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) { /\A(ttb|ltr)\z/ =~ v.to_s ? nil : "must be ttb or ltr" }
|
|
30
|
+
direction: ->(v) { /\A(ttb|ltr)\z/ =~ v.to_s ? nil : "must be ttb or ltr" },
|
|
31
|
+
hyphen: ->(v) { /\A(markup|literal)\z/ =~ v.to_s ? nil : "must be markup or literal" }
|
|
31
32
|
}.freeze
|
|
32
33
|
|
|
33
34
|
# Validate configuration and return errors/warnings
|
|
@@ -107,7 +108,7 @@ opts = Optimist.options do
|
|
|
107
108
|
opt :fontsize, "Size: 8-26", default: 16, short: :s
|
|
108
109
|
opt :linewidth, "Size: 1-5", default: 1, short: :i
|
|
109
110
|
opt :vheight, "Connector Height: 0.5-5.0", default: 2.0, short: :v
|
|
110
|
-
opt :color, "Color text and bars: modern, traditional, or off", default: "modern", short: :c
|
|
111
|
+
opt :color, "Color text and bars: modern, traditional, gray, or off", default: "modern", short: :c
|
|
111
112
|
opt :symmetrize, "DEPRECATED alias of --tidy symmetric: on or off", default: "off", short: :y
|
|
112
113
|
opt :transparent, "Make background transparent: on or off", default: "off", short: :r
|
|
113
114
|
opt :polyline, "draw polyline connectors: on or off", default: "off", short: :p
|
|
@@ -117,17 +118,19 @@ opts = Optimist.options do
|
|
|
117
118
|
opt :hspacing, "Horizontal spacing factor (counterpart of vheight): 0.5-3.0", default: 1.0, short: :none
|
|
118
119
|
opt :tidy_spacing, "DEPRECATED alias of --hspacing: 0.5-3.0", default: 1.0, short: :none
|
|
119
120
|
opt :direction, "Tree layout direction: ttb (top-to-bottom) or ltr (left-to-right)", default: "ttb", short: :d
|
|
121
|
+
opt :hyphen, "How a hyphen in a label reads: markup (-underline-) or literal (a hyphen; \-underline\- then underlines)", default: "markup", short: :none
|
|
120
122
|
opt :help, "This is a custom help message", short: :h
|
|
121
123
|
end
|
|
122
124
|
|
|
123
125
|
Optimist.die :outdir, "must be an exsting directory path" unless FileTest.directory?(opts[:outdir])
|
|
124
126
|
Optimist.die :direction, "must be ttb or ltr" unless /\A(ttb|ltr)\z/ =~ opts[:direction]
|
|
127
|
+
Optimist.die :hyphen, "must be markup or literal" unless /\A(markup|literal)\z/ =~ opts[:hyphen]
|
|
125
128
|
Optimist.die :format, "must be png, pdf, svg, or lsif" unless /\A(png|jpg|gif|pdf|svg|lsif)\z/ =~ opts[:format]
|
|
126
129
|
Optimist.die :leafstyle, "must be auto, triangle, bar, or nothing" unless /\A(auto|triangle|bar|nothing)\z/ =~ opts[:leafstyle]
|
|
127
130
|
Optimist.die :fontstyle, "must be sans, serif, cjk, or mono" unless /\A(sans|serif|cjk|mono)\z/ =~ opts[:fontstyle]
|
|
128
131
|
Optimist.die :fontsize, "must be in the range of 8-26" unless opts[:fontsize] >= 8 && opts[:fontsize] <= 26
|
|
129
132
|
Optimist.die :linewidth, "must be in the range of 1-5" unless opts[:linewidth] >= 1 && opts[:linewidth] <= 5
|
|
130
|
-
Optimist.die :color, "must be either modern, traditional, or off" unless /\A(modern|traditional|on|off|true|false)\z/ =~ opts[:color]
|
|
133
|
+
Optimist.die :color, "must be either modern, traditional, gray, or off" unless /\A(modern|traditional|gray|grey|on|off|true|false)\z/ =~ opts[:color]
|
|
131
134
|
Optimist.die :symmetrize, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:symmetrize]
|
|
132
135
|
Optimist.die :vheight, "must be in the range of 0.5-5.0" if opts[:vheight] < 0.5 || opts[:vheight] > 5.0
|
|
133
136
|
Optimist.die :transparent, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:transparent]
|
|
@@ -35,6 +35,17 @@ module RSyntaxTree
|
|
|
35
35
|
@col_path = "purple"
|
|
36
36
|
@col_extra = "purple"
|
|
37
37
|
@col_emph = "red"
|
|
38
|
+
# Text stays black; the lines that hold the diagram together are drawn
|
|
39
|
+
# lighter. A figure whose links outnumber its labels — an ontology, a
|
|
40
|
+
# construction network — reads as a black thicket in the monochrome
|
|
41
|
+
# scheme, and grey scaffolding lets the labels come forward again.
|
|
42
|
+
when "gray"
|
|
43
|
+
@col_node = "black"
|
|
44
|
+
@col_leaf = "black"
|
|
45
|
+
@col_path = "#666666"
|
|
46
|
+
@col_extra = "#666666"
|
|
47
|
+
@col_emph = "black"
|
|
48
|
+
@col_connector = "#666666"
|
|
38
49
|
else
|
|
39
50
|
@col_node = "black"
|
|
40
51
|
@col_leaf = "black"
|
|
@@ -44,11 +55,12 @@ module RSyntaxTree
|
|
|
44
55
|
|
|
45
56
|
@col_bg = "none"
|
|
46
57
|
@col_fg = "black"
|
|
58
|
+
@col_connector ||= "black"
|
|
47
59
|
|
|
48
60
|
@col_line = if params[:hide_default_connectors] == true
|
|
49
61
|
"none"
|
|
50
62
|
else
|
|
51
|
-
|
|
63
|
+
@col_connector
|
|
52
64
|
end
|
|
53
65
|
|
|
54
66
|
@leafstyle = params[:leafstyle]
|
|
@@ -595,6 +607,152 @@ module RSyntaxTree
|
|
|
595
607
|
@element_list.get_elements.each { |e| e.horizontal_indent += offset }
|
|
596
608
|
end
|
|
597
609
|
|
|
610
|
+
# --- Line-type connections: room for the arrow ---
|
|
611
|
+
|
|
612
|
+
# The clearance a horizontally linked pair of boxes needs, in units of
|
|
613
|
+
# h_gap_between_nodes. SvgGraph#bothways_arrows draws the two heads at a
|
|
614
|
+
# span of 3 gaps whenever the link is long enough, shrinking only when it
|
|
615
|
+
# is not (span = link length × 0.8 then). The full span therefore wants a
|
|
616
|
+
# link of 3 / 0.8 gaps, and the link itself stops a 0.25-gap margin short
|
|
617
|
+
# of each box. Layout side of the same contract: spread the pair so the
|
|
618
|
+
# full-size arrow fits, and let the clamp cover what cannot be spread.
|
|
619
|
+
LINK_ARROW_CLEARANCE = 3.0 / 0.8 + 0.25 * 2
|
|
620
|
+
|
|
621
|
+
# Pairs of elements joined by a line-type connection (path entries of the
|
|
622
|
+
# -N / ->N / -<N form), mirroring the pool in SvgGraph#draw_paths.
|
|
623
|
+
def link_connection_pairs
|
|
624
|
+
pool = {}
|
|
625
|
+
@element_list.get_elements.each do |e|
|
|
626
|
+
e.path.each do |tr|
|
|
627
|
+
next unless /\A-(>|<)?(\d+)\z/ =~ tr
|
|
628
|
+
|
|
629
|
+
(pool[$2] ||= []) << e
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
pool.values.select { |ends| ends.size == 2 }
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Ids of the strict ancestors of +element+ (not including the element
|
|
636
|
+
# itself): the nodes that must stay put while a linked pair is spread,
|
|
637
|
+
# so they can be re-centred over their children afterwards.
|
|
638
|
+
def ancestor_ids(element)
|
|
639
|
+
ids = []
|
|
640
|
+
current = element
|
|
641
|
+
until current.parent.to_i.zero?
|
|
642
|
+
current = @element_list.get_id(current.parent)
|
|
643
|
+
ids << current.id
|
|
644
|
+
end
|
|
645
|
+
ids
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
# For every parent with an odd number of children, the offset of the
|
|
649
|
+
# middle child's centre from the centre of the children's span. In a
|
|
650
|
+
# balanced tree that offset is zero and the parent's edge to the middle
|
|
651
|
+
# child is vertical. The spread below moves the right side of a linked
|
|
652
|
+
# pair rightward, so re-centring the parents afterwards puts the span
|
|
653
|
+
# centre — and the parent — off the middle child. Recorded before the
|
|
654
|
+
# spread so the pass can hand the alignment back; see
|
|
655
|
+
# restore_middle_child_alignment.
|
|
656
|
+
def middle_child_alignment
|
|
657
|
+
@element_list.get_elements
|
|
658
|
+
.reject { |e| e.children.empty? || e.children.size.even? }
|
|
659
|
+
.to_h do |p|
|
|
660
|
+
kids = p.children.map { |c| @element_list.get_id(c) }
|
|
661
|
+
centers = kids.map { |k| k.horizontal_indent + k.content_width / 2.0 }
|
|
662
|
+
mid = kids[kids.size / 2]
|
|
663
|
+
[p.id, (mid.horizontal_indent + mid.content_width / 2.0) - (centers.min + centers.max) / 2.0]
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
# Undo whatever the spread did to the middle-child alignment of every
|
|
668
|
+
# odd-child parent, deepest first. The drift is cancelled by shifting
|
|
669
|
+
# the whole side the drift points away from: when the span centre ran
|
|
670
|
+
# right of the middle child, everything right of the middle child's
|
|
671
|
+
# subtree moves right by twice the drift (moving the span centre back
|
|
672
|
+
# by exactly the drift). The side moves rigidly, so no gap inside it
|
|
673
|
+
# changes, and the gap across the middle only widens — no overlaps.
|
|
674
|
+
def restore_middle_child_alignment(before)
|
|
675
|
+
parents = @element_list.get_elements
|
|
676
|
+
.reject { |e| e.children.empty? || e.children.size.even? }
|
|
677
|
+
parents.sort_by { |p| -p.level }.each do |p|
|
|
678
|
+
kids = p.children.map { |c| @element_list.get_id(c) }
|
|
679
|
+
mid = kids[kids.size / 2]
|
|
680
|
+
centers = kids.map { |k| k.horizontal_indent + k.content_width / 2.0 }
|
|
681
|
+
dx = (mid.horizontal_indent + mid.content_width / 2.0) - (centers.min + centers.max) / 2.0
|
|
682
|
+
drift = dx - before[p.id]
|
|
683
|
+
next if drift.abs < 0.01
|
|
684
|
+
|
|
685
|
+
if drift.positive?
|
|
686
|
+
split = get_rightmost(mid.id)
|
|
687
|
+
@element_list.get_elements.each do |e|
|
|
688
|
+
e.horizontal_indent += drift * 2 if e.horizontal_indent >= split - 0.001
|
|
689
|
+
end
|
|
690
|
+
else
|
|
691
|
+
split = get_leftmost(mid.id)
|
|
692
|
+
@element_list.get_elements.each do |e|
|
|
693
|
+
e.horizontal_indent += drift * 2 if e.horizontal_indent + e.content_width <= split + 0.001
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
node_centering
|
|
697
|
+
end
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
# Push linked pairs apart until the gap between their boxes holds a
|
|
701
|
+
# full-size arrow. Runs after the tidy passes: a compression pass would
|
|
702
|
+
# simply undo any room reserved in the width calculation, while shifting
|
|
703
|
+
# the right-hand side rightward here can never create an overlap, so
|
|
704
|
+
# tidy's guarantee survives. Only links drawn horizontally need room —
|
|
705
|
+
# the two y ranges must overlap, the same test draw_paths applies. The
|
|
706
|
+
# pair's own ancestors stay put and are re-centred over their children
|
|
707
|
+
# afterwards; everything else right of the split moves by the deficit.
|
|
708
|
+
# LTR is excluded: its sibling links are vertical, and sizing them to the
|
|
709
|
+
# same span would stretch every stacked pair far beyond what the compact
|
|
710
|
+
# stacking justifies — the clamp covers those.
|
|
711
|
+
def spread_linked_pairs
|
|
712
|
+
return if @direction == "ltr"
|
|
713
|
+
|
|
714
|
+
pairs = link_connection_pairs
|
|
715
|
+
return if pairs.empty?
|
|
716
|
+
|
|
717
|
+
hct = @global[:height_connector_to_text]
|
|
718
|
+
required = @global[:h_gap_between_nodes] * LINK_ARROW_CLEARANCE
|
|
719
|
+
alignment_before = middle_child_alignment
|
|
720
|
+
spread = false
|
|
721
|
+
pairs.map { |a, b| [a, b].sort_by(&:horizontal_indent) }
|
|
722
|
+
.sort_by { |a, _b| a.horizontal_indent }
|
|
723
|
+
.each do |a, b|
|
|
724
|
+
a_top = a.vertical_indent + hct / 2
|
|
725
|
+
b_top = b.vertical_indent + hct / 2
|
|
726
|
+
a_bottom = a.vertical_indent + a.content_height + hct
|
|
727
|
+
b_bottom = b.vertical_indent + b.content_height + hct
|
|
728
|
+
next if a_top > b_bottom || b_top > a_bottom
|
|
729
|
+
|
|
730
|
+
gap = b.horizontal_indent - (a.horizontal_indent + a.content_width)
|
|
731
|
+
deficit = required - gap
|
|
732
|
+
next if deficit <= 0.01
|
|
733
|
+
|
|
734
|
+
ancestors = ancestor_ids(a) + ancestor_ids(b)
|
|
735
|
+
split = a.horizontal_indent + a.content_width
|
|
736
|
+
@element_list.get_elements.each do |e|
|
|
737
|
+
e.horizontal_indent += deficit if e.horizontal_indent >= split - 0.001 && !ancestors.include?(e.id)
|
|
738
|
+
end
|
|
739
|
+
spread = true
|
|
740
|
+
end
|
|
741
|
+
# A figure whose links already had their room is left exactly as tidy
|
|
742
|
+
# left it: re-centring it would move nothing but the last decimal.
|
|
743
|
+
return unless spread
|
|
744
|
+
|
|
745
|
+
node_centering
|
|
746
|
+
# The spread pushed the right side of each linked pair rightward, which
|
|
747
|
+
# pulls an odd-child parent's span centre off its middle child (the
|
|
748
|
+
# middle edge slants). Give the alignment back before settling.
|
|
749
|
+
restore_middle_child_alignment(alignment_before)
|
|
750
|
+
normalize_horizontal
|
|
751
|
+
# The dynamic connector height couples level spacing to the horizontal
|
|
752
|
+
# spread, so let it settle against the widened layout.
|
|
753
|
+
calculate_height
|
|
754
|
+
end
|
|
755
|
+
|
|
598
756
|
# Snapshot / restore of everything the tidy passes mutate.
|
|
599
757
|
def layout_snapshot
|
|
600
758
|
@element_list.get_elements.map { |e| [e.horizontal_indent, e.vertical_indent, e.height] }
|
|
@@ -724,6 +882,15 @@ module RSyntaxTree
|
|
|
724
882
|
restore_layout(snapshot) if layout_overlaps?
|
|
725
883
|
end
|
|
726
884
|
|
|
885
|
+
# Give line-linked pairs room for a full-size arrow (no-op without
|
|
886
|
+
# links). Guarded the same way tidy is: if spreading ever produced an
|
|
887
|
+
# overlap, roll back and let the arrow clamp instead.
|
|
888
|
+
if @element_list.elements.size > 1
|
|
889
|
+
snapshot = layout_snapshot
|
|
890
|
+
spread_linked_pairs
|
|
891
|
+
restore_layout(snapshot) if layout_overlaps?
|
|
892
|
+
end
|
|
893
|
+
|
|
727
894
|
# Phase 2: swap axes and restore content dimensions for LTR
|
|
728
895
|
finalize_ltr if @direction == "ltr"
|
|
729
896
|
|
data/lib/rsyntaxtree/element.rb
CHANGED
|
@@ -12,7 +12,7 @@ require_relative "utils"
|
|
|
12
12
|
|
|
13
13
|
module RSyntaxTree
|
|
14
14
|
class Element
|
|
15
|
-
attr_accessor :id, :parent, :type, :level, :width, :height, :content, :content_width, :content_height, :horizontal_indent, :vertical_indent, :triangle, :enclosure, :children, :font, :fontsize, :contains_phrase, :path, :color, :raw_content, :region, :region_color
|
|
15
|
+
attr_accessor :id, :parent, :type, :level, :width, :height, :content, :content_width, :text_width, :content_height, :horizontal_indent, :vertical_indent, :triangle, :enclosure, :children, :font, :fontsize, :contains_phrase, :path, :color, :raw_content, :region, :region_color
|
|
16
16
|
|
|
17
17
|
def initialize(id, parent, content, level, fontset, fontsize, global)
|
|
18
18
|
@global = global
|
|
@@ -37,7 +37,7 @@ module RSyntaxTree
|
|
|
37
37
|
@fontsize = fontsize
|
|
38
38
|
@raw_content = content.sub(/\^?(?:\+-?>?<?\d+)+\^?\z/, '')
|
|
39
39
|
|
|
40
|
-
parsed = Markup.parse(content)
|
|
40
|
+
parsed = Markup.parse(@global[:literal_hyphen] ? swap_hyphen_markup(content) : content)
|
|
41
41
|
|
|
42
42
|
if parsed[:status] == :success
|
|
43
43
|
results = parsed[:results]
|
|
@@ -72,11 +72,63 @@ module RSyntaxTree
|
|
|
72
72
|
end
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
# With hyphen: literal, the two readings of '-' trade places: a bare one is
|
|
76
|
+
# a hyphen and an escaped one opens and closes an underline. Feature names
|
|
77
|
+
# in HPSG and its relatives are full of hyphens — HEAD-DTR, RELIED-ON — and
|
|
78
|
+
# escaping every one of them is a poor trade for a rule nobody there uses.
|
|
79
|
+
# Swapping the two before parsing leaves the grammar untouched.
|
|
80
|
+
# Two uses of the hyphen are structure rather than markup, and are left
|
|
81
|
+
# alone: a line of nothing but hyphens is the horizontal rule, and the dash
|
|
82
|
+
# in a path suffix (+-1, +->2) is what makes that path dashed. Swapping
|
|
83
|
+
# those turned a rule into the text "---" without a word of complaint.
|
|
84
|
+
def swap_hyphen_markup(text)
|
|
85
|
+
path = text[/\^?(?:\+-?>?<?\d+)+\^?\z/]
|
|
86
|
+
body = path ? text[0...-path.length] : text
|
|
87
|
+
swapped = body.split('\n', -1).map do |line|
|
|
88
|
+
/\A-{3,}\z/.match?(line) ? line : swap_hyphens(line)
|
|
89
|
+
end.join('\n')
|
|
90
|
+
swapped + path.to_s
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def swap_hyphens(text)
|
|
94
|
+
# A character no label can contain, so the two swaps cannot see each
|
|
95
|
+
# other's output. Written as an escape: a literal NUL in the source
|
|
96
|
+
# makes git and grep treat this file as binary.
|
|
97
|
+
placeholder = "\u0000"
|
|
98
|
+
text.gsub('\\-', placeholder).gsub("-", '\\-').gsub(placeholder, "-")
|
|
99
|
+
end
|
|
100
|
+
|
|
75
101
|
def setup
|
|
102
|
+
layout = measure_lines(@content)
|
|
103
|
+
@text_width = layout[:width]
|
|
104
|
+
@content_width = layout[:width] + label_enclosure_room * 2
|
|
105
|
+
@content_height = layout[:height]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Room on each side of a label for its own bracket or rectangle. It is part
|
|
109
|
+
# of the width the tree lays the node out at, so that whatever attaches to
|
|
110
|
+
# the node — a connector, a movement arrow, the neighbour beside it —
|
|
111
|
+
# meets the line actually drawn around it. A matrix nested in the label
|
|
112
|
+
# keeps the same room, so the outermost pair of brackets in a feature
|
|
113
|
+
# structure stands as far from its contents as every pair within.
|
|
114
|
+
def label_enclosure_room
|
|
115
|
+
return 0 unless [:brackets, :rectangle, :brectangle].include?(@enclosure)
|
|
116
|
+
|
|
117
|
+
@global[:width_half_x] * MATRIX_BRACKET_ROOM
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Measures a list of label lines, filling in the width and height of every
|
|
121
|
+
# element and aligning the columns that \t marks. A nested matrix runs
|
|
122
|
+
# through here again, which is what lets a feature structure hold another.
|
|
123
|
+
# `nested` is set for a matrix inside a label. The first row of a label
|
|
124
|
+
# carries an extra margin that holds the text clear of the connector above
|
|
125
|
+
# it; a nested block sits inside that margin already, so counting it again
|
|
126
|
+
# would leave a bracket half a line taller than the rows it encloses.
|
|
127
|
+
def measure_lines(lines, nested: false)
|
|
76
128
|
total_width = 0
|
|
77
129
|
total_height = 0
|
|
78
|
-
one_bvm_given =
|
|
79
|
-
|
|
130
|
+
one_bvm_given = nested
|
|
131
|
+
lines.each do |content|
|
|
80
132
|
content_width = 0
|
|
81
133
|
case content[:type]
|
|
82
134
|
when :border, :bborder
|
|
@@ -86,7 +138,39 @@ module RSyntaxTree
|
|
|
86
138
|
when :text
|
|
87
139
|
row_width = 0
|
|
88
140
|
elements_height = []
|
|
141
|
+
# A line of nothing but shapes is spaced by the shapes themselves, so
|
|
142
|
+
# a grid of boxes closes up instead of showing a seam between its
|
|
143
|
+
# rows. A shape sharing a line with text cannot be: the line keeps the
|
|
144
|
+
# text's rhythm, and a box is nearly as tall as a line, so two of them
|
|
145
|
+
# on consecutive rows would come out edge to edge.
|
|
146
|
+
row_holds_text = content[:elements].any? do |e|
|
|
147
|
+
(e[:decoration] & [:box, :circle, :bar]).empty? && !e[:text].to_s.strip.empty?
|
|
148
|
+
end
|
|
89
149
|
content[:elements].each do |e|
|
|
150
|
+
# A nested matrix is measured by the same code one level down, and
|
|
151
|
+
# reports the size of the block it will occupy in this row: its own
|
|
152
|
+
# content plus the brackets drawn around it.
|
|
153
|
+
if e[:decoration].include?(:matrix)
|
|
154
|
+
inner = measure_lines(e[:matrix], nested: true)
|
|
155
|
+
e[:matrix_width] = inner[:width]
|
|
156
|
+
e[:matrix_height] = inner[:height]
|
|
157
|
+
e[:width] = inner[:width] + matrix_bracket_room * 2
|
|
158
|
+
# Two separate allowances. The block is padded inside its own
|
|
159
|
+
# brackets, above and below, and that padding is part of the row.
|
|
160
|
+
# The gap that keeps the block clear of the rows either side is
|
|
161
|
+
# not: it widens the space the row is entered on, so the bracket
|
|
162
|
+
# is not simply drawn over the line before it.
|
|
163
|
+
e[:height] = inner[:height] + matrix_vertical_room * 2
|
|
164
|
+
# Twice the padding: the bracket is drawn that far above the
|
|
165
|
+
# baseline of its first row, so the first helping only pays for
|
|
166
|
+
# the padding and the second is what actually separates the
|
|
167
|
+
# bracket from the descenders of the line above it.
|
|
168
|
+
content[:top_room] = matrix_vertical_room * 2
|
|
169
|
+
elements_height << e[:height]
|
|
170
|
+
row_width += e[:width]
|
|
171
|
+
next
|
|
172
|
+
end
|
|
173
|
+
|
|
90
174
|
text = e[:text]
|
|
91
175
|
# Handle escaped square brackets
|
|
92
176
|
text = text.gsub('\\[', '[')
|
|
@@ -113,6 +197,7 @@ module RSyntaxTree
|
|
|
113
197
|
standard_metrics = FontMetrics.get_metrics('X', font, fontsize, :normal, :normal)
|
|
114
198
|
|
|
115
199
|
height = standard_metrics.height
|
|
200
|
+
line_height = height
|
|
116
201
|
if /\A[<>]+\z/ =~ text
|
|
117
202
|
width = standard_metrics.width * text.size / 2
|
|
118
203
|
elsif text.contains_emoji?
|
|
@@ -138,11 +223,48 @@ module RSyntaxTree
|
|
|
138
223
|
width = metrics.width
|
|
139
224
|
end
|
|
140
225
|
|
|
141
|
-
# The following is unchanged
|
|
142
226
|
if e[:decoration].include?(:box) || e[:decoration].include?(:circle) || e[:decoration].include?(:bar)
|
|
227
|
+
# One size and one height for every enclosure in a figure, so a
|
|
228
|
+
# hatched circle, an empty box and a lettered tag line up and
|
|
229
|
+
# share a diameter. The line's rhythm used to set the size, which
|
|
230
|
+
# left a box standing a head taller than the numeral inside;
|
|
231
|
+
# centring each shape on its own glyph instead made the box
|
|
232
|
+
# around 's' sit lower than the one around 'G'.
|
|
233
|
+
#
|
|
234
|
+
# The shape is centred on a capital — the half-way point of the
|
|
235
|
+
# letters it will usually hold — and drawn at ENCLOSURE_SIZE,
|
|
236
|
+
# which is wide enough that a descender still clears the bottom.
|
|
237
|
+
# Centring on the whole cap-to-descender band instead would sit
|
|
238
|
+
# the shape low around the digits and capitals that fill most
|
|
239
|
+
# tags, since those never reach below the baseline. It grows past
|
|
240
|
+
# ENCLOSURE_SIZE only for content that will not fit.
|
|
241
|
+
band = FontMetrics.get_metrics("Xg", font, fontsize, :normal, :normal)
|
|
242
|
+
descender = band.ink_height - band.ink_above
|
|
243
|
+
centre = standard_metrics.ink_above / 2.0
|
|
244
|
+
|
|
245
|
+
ink = FontMetrics.get_metrics(text, font, fontsize, style, weight)
|
|
246
|
+
ink_height = ink.ink_height.to_f
|
|
247
|
+
# Deep enough for a descender, so the one letter in a hundred that
|
|
248
|
+
# has one does not get a taller box than its neighbours.
|
|
249
|
+
half = [fontsize * ENCLOSURE_SIZE / 2.0, centre + descender].max
|
|
250
|
+
|
|
251
|
+
if ink_height.positive?
|
|
252
|
+
reach = [ink.ink_above - centre, centre - (ink.ink_above - ink_height)].max
|
|
253
|
+
half = reach + fontsize * ENCLOSURE_PADDING if reach > half
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
e[:enc_height] = half * 2
|
|
257
|
+
e[:enc_above] = centre + half
|
|
258
|
+
|
|
259
|
+
height = if row_holds_text
|
|
260
|
+
[height, e[:enc_height] + fontsize * ENCLOSURE_PADDING * 2].max
|
|
261
|
+
else
|
|
262
|
+
e[:enc_height]
|
|
263
|
+
end
|
|
264
|
+
|
|
143
265
|
e[:content_width] = width
|
|
144
266
|
width += if e[:text].size == 1
|
|
145
|
-
|
|
267
|
+
[e[:enc_height] - width, 0].max
|
|
146
268
|
else
|
|
147
269
|
@global[:width_half_x]
|
|
148
270
|
end
|
|
@@ -155,28 +277,93 @@ module RSyntaxTree
|
|
|
155
277
|
|
|
156
278
|
e[:height] = height
|
|
157
279
|
|
|
280
|
+
# What the label measures is not what its rows advance by. A line
|
|
281
|
+
# of nothing but shapes advances by the shapes, so a grid closes
|
|
282
|
+
# up, but it still measures a full line: the tree places a level by
|
|
283
|
+
# the height of the nodes above it, so a node measured short of the
|
|
284
|
+
# rhythm pulls its own children up and off the row its cousins sit
|
|
285
|
+
# on.
|
|
286
|
+
measured = [height, line_height].max
|
|
287
|
+
|
|
158
288
|
if one_bvm_given
|
|
159
|
-
elements_height <<
|
|
289
|
+
elements_height << measured
|
|
160
290
|
else
|
|
161
291
|
one_bvm_given = true
|
|
162
|
-
elements_height <<
|
|
292
|
+
elements_height << measured + @global[:box_vertical_margin]
|
|
163
293
|
end
|
|
164
294
|
|
|
165
295
|
e[:width] = width
|
|
166
296
|
row_width += width
|
|
167
297
|
end
|
|
168
298
|
|
|
169
|
-
|
|
299
|
+
content[:height] = elements_height.max
|
|
300
|
+
total_height += elements_height.max + content[:top_room].to_f
|
|
170
301
|
content_width += row_width
|
|
171
302
|
end
|
|
172
303
|
total_width = content_width if total_width < content_width
|
|
173
304
|
end
|
|
174
|
-
|
|
175
|
-
@content_height = total_height
|
|
305
|
+
{ width: align_columns(lines, total_width), height: total_height }
|
|
176
306
|
end
|
|
177
307
|
|
|
178
308
|
def add_child(child_id)
|
|
179
309
|
@children << child_id
|
|
180
310
|
end
|
|
311
|
+
|
|
312
|
+
# Horizontal room a nested matrix needs on each side for its bracket and
|
|
313
|
+
# the air around it.
|
|
314
|
+
def matrix_bracket_room
|
|
315
|
+
@global[:width_half_x] * MATRIX_BRACKET_ROOM
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Vertical room a nested matrix keeps between itself and the rows above and
|
|
319
|
+
# below it.
|
|
320
|
+
def matrix_vertical_room
|
|
321
|
+
@global[:single_x_metrics].height * MATRIX_VERTICAL_ROOM
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
private
|
|
325
|
+
|
|
326
|
+
# Lines cut at \t are laid out in columns: every column is as wide as its
|
|
327
|
+
# widest cell, so attributes line up down the left and their values down
|
|
328
|
+
# the right, which is what an attribute-value matrix is. The separator
|
|
329
|
+
# element carries the padding that takes its cell out to the column width,
|
|
330
|
+
# and the widest line decides the label's width.
|
|
331
|
+
#
|
|
332
|
+
# Returns the label width, unchanged when no line has a separator.
|
|
333
|
+
def align_columns(lines, fallback_width)
|
|
334
|
+
rows = lines.select { |c| c[:type] == :text }
|
|
335
|
+
.map { |c| split_into_cells(c[:elements]) }
|
|
336
|
+
return fallback_width unless rows.any? { |cells| cells.size > 1 }
|
|
337
|
+
|
|
338
|
+
columns = rows.map(&:size).max
|
|
339
|
+
widths = Array.new(columns) do |i|
|
|
340
|
+
rows.filter_map { |cells| cells[i]&.sum { |e| e[:width] } }.max || 0
|
|
341
|
+
end
|
|
342
|
+
gutter = @global[:width_half_x]
|
|
343
|
+
|
|
344
|
+
rows.each do |cells|
|
|
345
|
+
cells.each_with_index do |cell, i|
|
|
346
|
+
separator = cell.find { |e| e[:decoration].include?(:tabstop) }
|
|
347
|
+
next unless separator
|
|
348
|
+
|
|
349
|
+
used = cell.sum { |e| e[:width] }
|
|
350
|
+
separator[:width] = widths[i] - used + gutter
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
widths.sum + gutter * (columns - 1)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Each cell keeps the separator that closes it, so the padding has
|
|
358
|
+
# something to sit on.
|
|
359
|
+
def split_into_cells(elements)
|
|
360
|
+
cells = [[]]
|
|
361
|
+
elements.each do |e|
|
|
362
|
+
cells.last << e
|
|
363
|
+
cells << [] if e[:decoration].include?(:tabstop)
|
|
364
|
+
end
|
|
365
|
+
cells.pop if cells.last.empty?
|
|
366
|
+
cells
|
|
367
|
+
end
|
|
181
368
|
end
|
|
182
369
|
end
|
|
@@ -284,6 +284,11 @@ module RSyntaxTree
|
|
|
284
284
|
symmetrize: @params[:symmetrize],
|
|
285
285
|
mirror: @params[:mirror] == true,
|
|
286
286
|
direction: @params[:direction] || "ttb",
|
|
287
|
+
# Which of the two readings of a hyphen the input was parsed
|
|
288
|
+
# under. The input is recorded verbatim above, and the same
|
|
289
|
+
# string means different things under the two, so a reader
|
|
290
|
+
# cannot re-parse it without knowing this.
|
|
291
|
+
hyphen: @params[:hyphen] || "markup",
|
|
287
292
|
polyline: @params[:polyline],
|
|
288
293
|
hide_default_connectors: @params[:hide_default_connectors],
|
|
289
294
|
transparent: @params[:transparent]
|