rsyntaxtree 1.8.2 → 1.10.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: 232efc991d608eb2af9ff19d6816d9f22f9bc4a1006506276507c1b8705f34ec
4
- data.tar.gz: da8e1ab08b470d76d47809b89ae4fd5b7bf0ef7519847ef3bc7e2955814f5521
3
+ metadata.gz: 38f3abe8841c2097201c6120f900a0d9eb99fb8bea9c6dc8388ac38a8e0df2c7
4
+ data.tar.gz: d22ebd93a2a2ed135e4afcc21797f4f057a3b5d340fdfd265a7f5490ccc642fb
5
5
  SHA512:
6
- metadata.gz: 5af80d2af48070f3733c21f19cb543da6a2f443b4e4e2fb4fa2f5a0760584cedcfac5e3b86067623f5f24ba63cfe3267e69692f5ddbc44b3c9fdf35f1de34ace
7
- data.tar.gz: 3d391279ae3b669f87f6c62cfa6b2f44cb607a6f5c9d0adbfd37e147cd9a9d2a8c4f998a3e508c423886a4adfe56610e5041c838ffcd40d47f958e96b45d9d49
6
+ metadata.gz: b3c9dc57831246bdd189e92998ba8325606635669e4e63839509d84db3e21911d0eb928f615b2d2d0f5d9ed6e711c2fed3785d3f1453988deef333a5e513ec48
7
+ data.tar.gz: eea07050d7df010b52615c475d4681749420c7b16687169c9e5ae3de5fbc6683daf939b9c17795442ed2589ad08dfddd745ad41b6d169f5238f521eaab638d3b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,96 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.10.0] - 2026-08
4
+
5
+ A transitional release ahead of 2.0, which drops JPG/GIF output and the
6
+ RMagick dependency.
7
+
8
+ ### Deprecated
9
+ - JPG and GIF output: the CLI prints a warning to stderr when either is
10
+ requested, and the documentation marks both as deprecated. JPEG blurs
11
+ line art and GIF has no use case here — use PNG. Both formats are
12
+ removed in 2.0.
13
+
14
+ ### Changed
15
+ - The library no longer loads RMagick at require time. SVG, PNG, PDF,
16
+ TikZ and LSIF work without ImageMagick installed; only a JPG or GIF
17
+ request needs RMagick, and a request without it reports the missing
18
+ dependency as a regular input error instead of a bare `LoadError`.
19
+
20
+ ### Fixed
21
+ - `RSTError` mutated the message it was given, so raising one with a plain
22
+ string literal crashed inside the error class with a `FrozenError`. Every
23
+ file here carries `frozen_string_literal`, which made the working form the
24
+ unobvious one.
25
+
26
+ ## [1.9.0] - 2026-08
27
+
28
+ ### Added
29
+ - `hyphen: literal`, which trades the two readings of a hyphen: a bare one is
30
+ a hyphen and `\-underlined\-` underlines. Feature names in HPSG and its
31
+ relatives are full of hyphens, and escaping each one is a poor trade for a
32
+ rule that work never uses.
33
+ - A matrix nested in a label, written between `#(` and `#)`. The value of an
34
+ attribute can be another attribute-value matrix, to any depth, which is what
35
+ a feature path such as SYNSEM | LOCAL | CATEGORY | HEAD needs and what HPSG,
36
+ SBCG and LFG are written in. The nested matrix draws its own brackets and
37
+ lays out its own columns, and the rows after it clear its full height.
38
+ - `\t` in a label cuts the line into cells. Every line is cut at the same
39
+ points and each column is drawn at the width of its widest cell, so the
40
+ parts line up down the label. Together with the bracket enclosure and the
41
+ horizontal rule this gives attribute-value matrices — the feature structures
42
+ of HPSG, SBCG and LFG — without spacing each row by hand, which is how the
43
+ gallery's HPSG example used to do it.
44
+ - `color: gray`, a scheme that keeps node and leaf labels black and draws the
45
+ connectors, triangles and movement paths in grey. It is for diagrams whose
46
+ links outnumber their labels — an ontology, a network of constructions —
47
+ where a page of black lines buries the text. `grey` is accepted too.
48
+ - Gallery examples for two more frameworks: LFG (an annotated c-structure and
49
+ the f-structure it maps to, after Kaplan & Bresnan 1982 and Bresnan 2001)
50
+ and DRT (a discourse representation structure, after Kamp 1981).
51
+ - The example gallery scales each figure by its own width rather than by one
52
+ factor for all of them, so a small tree and a wide one are read at a similar
53
+ apparent size; a figure now fits its row instead of scrolling, loads lazily,
54
+ and carries an anchor of its own.
55
+
56
+ ### Changed
57
+ - Every box and circle in a figure is drawn at one size and on one centre
58
+ line. The size used to come from the line's height, which left a box
59
+ standing a head taller than the numeral inside it, and each shape was
60
+ centred on its own glyph, so the box around `s` sat lower than the one
61
+ around `G`. A shape is now drawn at a fixed fraction of the font size,
62
+ centred on a capital, and grows only for content that will not fit. Every
63
+ figure with a boxed or circled label is redrawn.
64
+ - The SVG's declared width and height agree with its viewBox. They did not,
65
+ so every figure was scaled down by a few percent and letterboxed inside
66
+ its own canvas.
67
+
68
+ ### Fixed
69
+ - TikZ export dropped everything inside a nested matrix: a feature structure
70
+ came out as its outermost attribute names and nothing else. The export still
71
+ cannot draw the brackets, but it keeps what they hold. The documented list of
72
+ what TikZ does not carry now names column alignment, nested matrices and the
73
+ grey line scheme.
74
+ - LSIF records which of the two readings of a hyphen the input was parsed
75
+ under. It records the input verbatim, and the same string means different
76
+ things under the two, so a reader could not re-parse it.
77
+ - A line-type connection with only one end raised a NoMethodError from inside
78
+ the drawing instead of being reported as the input error it is.
79
+ - A line-type connection between two nodes was anchored a full inter-node gap
80
+ outside each of them, so the link fell short of both boxes where the gap was
81
+ wide and reached inside them where it was narrow. It now runs between the
82
+ boxes' own edges, a quarter of a gap short of each. The double arrowhead
83
+ keeps the shape it had but is sized to the link, instead of one fixed size
84
+ that spilled over both boxes on a short link. The same anchors are used in
85
+ left-to-right layout, where a link between siblings had been drawn diagonally
86
+ from the movement-path anchors and now runs straight between the facing
87
+ edges.
88
+ - Two nodes joined by a line-type connection are laid out far enough apart for
89
+ a full-size arrowhead between them. Where the layout had packed them closer
90
+ than the arrow is wide — the two leaves at the foot of the quicksort figure —
91
+ the arrow was drawn small to fit; now the pair is spread and the arrow keeps
92
+ its size. Only pairs that carry a link move, and only when they need to.
93
+
3
94
  ## [1.8.2] - 2026-08
4
95
 
5
96
  ### Changed
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.8.2
15
- date-released: 2026-08-15
14
+ version: 1.10.0
15
+ date-released: 2026-08-19
16
16
  keywords:
17
17
  - linguistics
18
18
  - syntax tree
data/README.md CHANGED
@@ -66,7 +66,7 @@ Connect nodes with lines or arrows:
66
66
 
67
67
  ### Multiple Output Formats
68
68
 
69
- Generate trees in PNG, SVG, PDF, JPG, GIF, or LSIF (JSON) format.
69
+ Generate trees in PNG, SVG, PDF, or LSIF (JSON) format. (JPG and GIF are deprecated and will be removed in 2.0.)
70
70
 
71
71
  ## Web Interface
72
72
 
@@ -167,15 +167,16 @@ Usage:
167
167
  | Option | Description | Default |
168
168
  |--------|-------------|---------|
169
169
  | `-o, --outdir` | Output directory | `./` |
170
- | `-f, --format` | Output format: png, gif, jpg, pdf, svg, lsif | `png` |
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
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]
@@ -157,6 +160,10 @@ opts.each do |key, value|
157
160
  end
158
161
  end
159
162
 
163
+ if %w[jpg gif].include?(string_opts[:format])
164
+ warn "Warning: #{string_opts[:format].upcase} output is deprecated and will be removed in RSyntaxTree 2.0. Use PNG instead."
165
+ end
166
+
160
167
  data = if ARGV[0] && File.exist?(File.expand_path(ARGV[0]))
161
168
  File.read ARGV[0]
162
169
  elsif ARGV[0]
@@ -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]
@@ -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