rsyntaxtree 1.8.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b9513e8d667af90de0a12e427eae11d0089f6a969ed1a47714ca30401001821
4
- data.tar.gz: '0458b65b7f9ad1f495d344963b41b801b7c26562b0256abe48885756a1481bef'
3
+ metadata.gz: ef69a566b76dec78e1e804475c604162173582605c852900b5cd5c5cf3f2e5eb
4
+ data.tar.gz: 3ee903c09bab59e55e1dd81544768482ddd53c3f0eaf6081364f4bb0c446cfed
5
5
  SHA512:
6
- metadata.gz: d1738b00df2a2dbe5cafba5d52b19c44149472912c2859c8183384582770be0b54c0c4e96688fe4c0a808969c96096504956b7ce7dffc76bd3c682ee28124cf6
7
- data.tar.gz: a8363b3b3e6b11f345603f020e36766275f740ab0f80af4ecb4be3f9160dd7860d4a26c4cdd2788a8b02e27d4c99d1147bd9843200cdd41cf48f87b6ce373e34
6
+ metadata.gz: f9057ca7c228e28351e95fb5b2573818736a738ed5ae2990db33291b884d1aa0d4e8158949b390b84f5151bf0117850521e0b25d1051fd7f5fe080d4559ecdfb
7
+ data.tar.gz: dc727f0091f56ced11c29a25739f8ee73fe912fc8673e8c073a4f3fd7a9de37e4344c392f4e7691773c7abcd7c72f71209f991c1d450e9ddd96e230dbebf4461
data/CHANGELOG.md CHANGED
@@ -1,5 +1,101 @@
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
+
71
+ ## [1.8.2] - 2026-08
72
+
73
+ ### Changed
74
+ - `tidy: high` now compresses as far as its name promises. The level-balance
75
+ floor added in 1.8.0, which keeps a pair of siblings from being tucked
76
+ narrower than the level below it, applied to `medium` and `high` alike and
77
+ bound first in nearly every tree: across the 75 gallery examples the two
78
+ modes produced identical figures 70 times. The floor now applies to `medium`
79
+ only, leaving `high` free to trade even branch angles for width — 29 of the
80
+ 75 examples now differ, by up to 19%. `off`, `low` and `medium` are
81
+ unchanged; `high`, like `symmetric` at the other end of the scale, is for
82
+ figures that ask for it.
83
+ - Mathematical alphanumerics (U+1D400–, such as the little *v* of *v*P) are
84
+ named in the family chains. Neither Noto Sans nor Noto Serif covers the
85
+ block, so the glyphs came from whatever the machine offered: Noto Sans Math
86
+ on Alpine, DejaVu Serif on Debian/Ubuntu, STIX Two Math on macOS. The serif
87
+ style now asks for a serif source first, so a serif tree no longer shows a
88
+ sans *v*. The Docker images install `font-dejavu` for it.
89
+
90
+ ### Fixed
91
+ - The CLI printed parse errors to stdout and exited 0, so a script generating
92
+ figures in bulk could not tell a rejected input from a drawn one. Errors go
93
+ to stderr and the exit status is 1.
94
+ - Dropped `Noto Sans Mono SemiCondensed` from the mono chain. It is a width
95
+ style of the variable Noto Sans Mono rather than a family of its own, and
96
+ resolved nowhere on macOS, Debian/Ubuntu or Alpine; the chain fell through
97
+ to `Noto Sans Mono`, which it now names directly.
98
+
3
99
  ## [1.8.1] - 2026-08
4
100
 
5
101
  ### Fixed
@@ -7,11 +103,13 @@
7
103
  Scripts outside Latin and CJK were left to the system's generic font
8
104
  fallback, which is not the same font on every machine: on Alpine the Arabic
9
105
  block was claimed by Noto Sans Math, which has the glyphs but no joining
10
- rules, while on Debian the same text fell to DejaVu Sans. The family chains
11
- now name the scripts the gallery covers — Arabic (`Noto Sans Arabic`, with
12
- `Noto Naskh Arabic` for the serif style), Hebrew, Devanagari, Thai and
13
- Khmer — so the same input renders the same way everywhere. Gallery example
14
- 067 (Arabic) was affected and has been regenerated.
106
+ rules, while on Debian/Ubuntu the same text fell to DejaVu Sans. The family
107
+ chains now name the scripts the gallery covers — Arabic (`Noto Sans Arabic`,
108
+ with `Noto Naskh Arabic` for the serif style), Hebrew, Devanagari, Thai and
109
+ Khmer — so machines that have those fonts installed produce the same shapes.
110
+ Mathematical alphanumerics (U+1D400–) are not named yet and still vary by
111
+ environment. Gallery example 067 (Arabic) was affected and has been
112
+ regenerated.
15
113
  - Emoji were measured with one font and drawn with another where a colour
16
114
  emoji font was installed: Pango selects `Noto Color Emoji`, but the
17
115
  librsvg/Cairo pipeline does not rasterise its bitmap glyphs, so the drawing
data/CITATION.cff CHANGED
@@ -11,7 +11,7 @@ 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.8.1
14
+ version: 1.8.2
15
15
  date-released: 2026-08-15
16
16
  keywords:
17
17
  - linguistics
data/Dockerfile CHANGED
@@ -10,7 +10,8 @@ RUN apk update && \
10
10
  apk add --no-cache -t .build-packages --no-cache build-base curl-dev wget gcompat && \
11
11
  apk add --no-cache font-noto font-noto-cjk font-noto-cjk-extra \
12
12
  font-noto-arabic font-noto-naskh-arabic font-noto-hebrew \
13
- font-noto-devanagari font-noto-thai font-noto-khmer
13
+ font-noto-devanagari font-noto-thai font-noto-khmer \
14
+ font-dejavu
14
15
 
15
16
  # Noto Sans Math (pulled in by font-noto) claims the Arabic block but carries no
16
17
  # joining rules, and fontconfig ranks it above Noto Sans Arabic — Arabic then
data/README.md CHANGED
@@ -112,20 +112,19 @@ See [RSyntaxTree Example Gallery](https://yohasebe.github.io/rsyntaxtree/example
112
112
 
113
113
  ## System Fonts
114
114
 
115
- RSyntaxTree resolves fonts by family name through fontconfig (measurement via Pango, rendering via librsvg), so the fonts must be installed on the system that generates the images. Recommended:
115
+ RSyntaxTree resolves fonts by family name through fontconfig (measurement via Pango, rendering via librsvg), so the fonts must be installed on the machine that generates the images. As of v1.8.0 the gem no longer bundles font files; they were not used at runtime.
116
116
 
117
- - Debian/Ubuntu: `apt install fonts-noto-core fonts-noto-cjk fonts-noto-color-emoji`
118
- - Alpine: `apk add font-noto font-noto-cjk font-noto-cjk-extra font-noto-emoji` (the `-extra` package carries Noto Serif CJK)
119
- - macOS: install [Noto Sans/Serif](https://fonts.google.com/noto) (and Noto Sans/Serif JP for CJK); otherwise the system falls back to available fonts
117
+ - Debian/Ubuntu: `apt install fonts-noto-core fonts-noto-cjk`
118
+ - Alpine: `apk add font-noto font-noto-cjk font-noto-cjk-extra` (the `-extra` package carries Noto Serif CJK)
119
+ - macOS: `brew install --cask font-noto-sans font-noto-serif font-noto-sans-mono font-noto-sans-jp font-noto-serif-jp font-noto-sans-cjk font-noto-serif-cjk font-noto-sans-mono-cjk-jp` (macOS itself ships Noto only for a few rare scripts)
120
120
 
121
- Any script supported by an installed font renders correctly — the family chains fall back from Noto Sans/Serif to the JP and CJK variants, so Japanese, Chinese (simplified and traditional) and Korean are covered by the Noto CJK package. As of v1.8.0 the gem no longer bundles font files (they were not used at runtime).
121
+ Arabic, Hebrew, Devanagari, Thai and Khmer need their own packages, and emoji need the **monochrome** [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) colour emoji fonts are not drawn by this pipeline:
122
122
 
123
- Scripts outside Latin and CJK need their own Noto package, or they render as tofu boxes. Since v1.8.0 the gem names these families explicitly (`Noto Sans Arabic`, `Noto Naskh Arabic`, `Noto Sans/Serif Hebrew`, `Devanagari`, `Thai`, `Khmer`) instead of leaving them to the system fallback, so install them to get the intended shapes:
124
-
125
- - Debian/Ubuntu: `apt install fonts-noto-core` already covers them; on minimal images add `fonts-noto` for the full set
123
+ - Debian/Ubuntu: `fonts-noto-core` covers the scripts; on minimal images add `fonts-noto` for the full set
126
124
  - Alpine: `apk add font-noto-arabic font-noto-naskh-arabic font-noto-hebrew font-noto-devanagari font-noto-thai font-noto-khmer`
125
+ - macOS: the same families are available as `font-noto-*` casks
127
126
 
128
- Emoji need the **monochrome** [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji); colour emoji fonts (`NotoColorEmoji`, shipped by Alpine's `font-noto-emoji` and Debian's `fonts-noto-color-emoji`) are measured but not drawn by the librsvg/Cairo pipeline, so emoji then fall back to whatever outline font happens to cover them.
127
+ See [Fonts](https://yohasebe.github.io/rsyntaxtree/documentation#install-fonts-for-svg) in the documentation for which family is used for what, how to substitute your own, and the macOS caveats.
129
128
 
130
129
  ## Installation
131
130
 
@@ -172,11 +171,12 @@ Usage:
172
171
  | `-l, --leafstyle` | Leaf style: auto, triangle, bar, nothing | `auto` |
173
172
  | `-n, --fontstyle` | Font style: sans, serif, cjk, mono | `sans` |
174
173
  | `-s, --fontsize` | Font size: 8-26 | `16` |
175
- | `-c, --color` | Color mode: modern, traditional, off | `modern` |
174
+ | `-c, --color` | Color mode: modern, traditional, gray (black text, grey lines), off | `modern` |
176
175
  | `-p, --polyline` | Polyline connectors: on, off | `off` |
177
176
  | `-d, --direction` | Tree layout direction: ttb, ltr | `ttb` |
178
177
  | `--tidy` | Layout scale: off, symmetric, low, medium, high | `off` |
179
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` |
180
180
  | `-m, --mirror` | Flip the tree horizontally (RTL convention): on, off | `off` |
181
181
 
182
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]
@@ -173,8 +176,8 @@ data = RSyntaxTree::FormatConverter.to_bracket(data) if data
173
176
  begin
174
177
  RSyntaxTree::RSGenerator.check_data(data)
175
178
  rescue RSTError => e
176
- puts e
177
- exit
179
+ warn e
180
+ exit 1
178
181
  end
179
182
 
180
183
  begin
@@ -188,10 +191,10 @@ begin
188
191
  output = rsg.send(draw_method)
189
192
  File.binwrite(filepath, output)
190
193
  rescue RSTError => e
191
- puts e
192
- exit
194
+ warn e
195
+ exit 1
193
196
  rescue StandardError => e
194
- p e
195
- puts "Error: something unexpected occurred"
196
- exit
197
+ warn "Error: something unexpected occurred"
198
+ warn "#{e.class}: #{e.message}"
199
+ exit 1
197
200
  end
@@ -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
- "black"
63
+ @col_connector
52
64
  end
53
65
 
54
66
  @leafstyle = params[:leafstyle]
@@ -376,7 +388,8 @@ module RSyntaxTree
376
388
  # being compressed narrower than the level right below it (an inversion
377
389
  # that reads as a needle-thin top over flat lower branches); branch
378
390
  # angles then stay comparable between adjacent levels. 1.0 = a pair is
379
- # never tighter than the widest pair among its own children.
391
+ # never tighter than the widest pair among its own children. Applied in
392
+ # the :ordered nest mode only; see tidy_compress.
380
393
  TIDY_LEVEL_BALANCE = 1.0
381
394
 
382
395
  # Base multiplier for the minimum clearance between adjacent subtrees.
@@ -554,16 +567,21 @@ module RSyntaxTree
554
567
  end
555
568
  end
556
569
 
557
- # Level-balance floor (nest mode only): keep the pair at least a
570
+ # Level-balance floor (:ordered only): keep the pair at least a
558
571
  # fraction of the widest child-level spread found inside its own
559
572
  # two subtrees. Nesting can tuck an upper pair far narrower than
560
573
  # the level right below it — an inversion that reads as a
561
- # needle-thin top over flat lower branches. Without nesting the
562
- # leaf-span guard already prevents such inversions, and applying
563
- # the floor there would only forfeit legitimate compression.
574
+ # needle-thin top over flat lower branches.
575
+ #
576
+ # :none does not need it, since the leaf-span guard already
577
+ # prevents such inversions. :free deliberately goes without: it is
578
+ # the end of the scale where density outranks even branch angles,
579
+ # and with the floor in place it compressed no further than
580
+ # :ordered on all but a handful of trees.
581
+ #
564
582
  # Compression is a negative delta (the right subtree moves left),
565
583
  # so the floor bounds delta from below.
566
- if @tidy_nest != :none && delta.negative?
584
+ if @tidy_nest == :ordered && delta.negative?
567
585
  dist = (right_child.horizontal_indent + right_child.content_width / 2.0) -
568
586
  (left_child.horizontal_indent + left_child.content_width / 2.0)
569
587
  below = [left_child, right_child].map { |c| child_spread(c) }.compact.max
@@ -589,6 +607,152 @@ module RSyntaxTree
589
607
  @element_list.get_elements.each { |e| e.horizontal_indent += offset }
590
608
  end
591
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
+
592
756
  # Snapshot / restore of everything the tidy passes mutate.
593
757
  def layout_snapshot
594
758
  @element_list.get_elements.map { |e| [e.horizontal_indent, e.vertical_indent, e.height] }
@@ -718,6 +882,15 @@ module RSyntaxTree
718
882
  restore_layout(snapshot) if layout_overlaps?
719
883
  end
720
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
+
721
894
  # Phase 2: swap axes and restore content dimensions for LTR
722
895
  finalize_ltr if @direction == "ltr"
723
896