rsyntaxtree 2.4.0 → 2.5.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.
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.11.0
15
- date-released: 2026-08-20
14
+ version: 2.5.0
15
+ date-released: 2026-11-04
16
16
  keywords:
17
17
  - linguistics
18
18
  - syntax tree
data/README.md CHANGED
@@ -144,7 +144,7 @@ older setup still fails to build `gobject-introspection`, `cairo-gobject` or
144
144
  `gio2`, install those three first with
145
145
  `-- --with-ldflags="-Wl,-undefined,dynamic_lookup"` appended, then install
146
146
  RSyntaxTree; or skip the build entirely with the
147
- [Docker image](https://hub.docker.com/r/yohasebe/rsyntaxtree) or the
147
+ [Docker image](https://github.com/yohasebe/rsyntaxtree_web) or the
148
148
  [web interface](https://yohasebe.com/rsyntaxtree).
149
149
 
150
150
  ## Usage
@@ -172,6 +172,7 @@ Usage:
172
172
  | `-c, --color` | Color mode: modern, traditional, gray (black text, grey lines), off | `modern` |
173
173
  | `-p, --polyline` | Polyline connectors: on, off | `off` |
174
174
  | `--hide-default-connectors` | Draw no parent-to-child lines, leaving only the ones written with `+-n`: on, off | `off` |
175
+ | `--leaf-align` | Line the leaves up on one row: off, words (lower the words), subtrees (lower whole subtrees) | `off` |
175
176
  | `-d, --direction` | Tree layout direction: ttb, ltr, btt | `ttb` |
176
177
  | `--tidy` | Layout scale: off, symmetric, low, medium, high | `off` |
177
178
  | `--hspacing` | How far apart sisters sit, all layout modes (0.5-3.0) | `1.0` |
data/bin/rsyntaxtree CHANGED
@@ -27,6 +27,7 @@ CONFIG_VALIDATORS = {
27
27
  transparent: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
28
28
  polyline: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
29
29
  hide_default_connectors: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
30
+ leaf_align: ->(v) { OPTION_VALUES[:leaf_align].include?(v.to_s) ? nil : "must be off, words, or subtrees" },
30
31
  mirror: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
31
32
  shear: ->(v) { v.is_a?(Numeric) && v >= -45 && v <= 45 ? nil : "must be in the range of -45-45" },
32
33
  vmargin: ->(v) { v.is_a?(Numeric) && v >= 0.1 && v <= 1.0 ? nil : "must be in the range of 0.1-1.0" },
@@ -118,6 +119,7 @@ opts = Optimist.options do
118
119
  opt :transparent, "Make background transparent: on or off", default: "off", short: :r
119
120
  opt :polyline, "draw polyline connectors: on or off", default: "off", short: :p
120
121
  opt :hide_default_connectors, "make default connectors transparent: on or off", default: "off", short: :none
122
+ opt :leaf_align, "line the leaves up on one row: off, words (lower the words), or subtrees (lower whole subtrees)", default: "off", short: :none
121
123
  opt :mirror, "flip the tree horizontally (RTL linguistics convention: first word at the right edge): on or off", default: "off"
122
124
  opt :shear, "Tilt the drawn figure, in degrees: -45 to 45, positive leaning the top to the right", default: 0.0, short: :none
123
125
  opt :shear_plane, "The plane behind a sheared figure: on, off, a colour name, or a hex colour", default: "on", short: :none
@@ -157,6 +159,7 @@ Optimist.die :vheight, "must be in the range of 0.5-5.0" if opts[:vheight] < 0.5
157
159
  Optimist.die :transparent, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:transparent]
158
160
  Optimist.die :polyline, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:polyline]
159
161
  Optimist.die :hide_default_connectors, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:hide_default_connectors]
162
+ Optimist.die :leaf_align, "must be off, words, or subtrees" unless OPTION_VALUES[:leaf_align].include?(opts[:leaf_align])
160
163
  Optimist.die :mirror, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:mirror]
161
164
  Optimist.die :tidy, "must be off, symmetric, low, medium, or high" unless OPTION_VALUES[:tidy].include?(opts[:tidy])
162
165
  Optimist.die :hspacing, "must be in the range of 0.5-3.0" if opts[:hspacing] < 0.5 || opts[:hspacing] > 3.0
@@ -67,6 +67,7 @@ module RSyntaxTree
67
67
  @fontsize = params[:fontsize]
68
68
  @mirror = params[:mirror] == true
69
69
  @derivation = params[:derivation] == true
70
+ @leaf_align = !params[:leaf_align].nil? && params[:leaf_align].to_s != "off"
70
71
  # tidy is one layout scale: "symmetric" (radical symmetrization) |
71
72
  # "off" | "low" (packing, strict leaf positions) | "medium" (packing
72
73
  # with cross-row tucking as long as no two leaves swap left-right
@@ -247,7 +248,43 @@ module RSyntaxTree
247
248
  end
248
249
  target.height = accum_array.max - target.vertical_indent
249
250
  accum_array.max
250
- end.tap { level_derivation_rows if id == 1 && @derivation }
251
+ end.tap do
252
+ level_derivation_rows if id == 1 && @derivation
253
+ level_leaf_rows if id == 1 && @leaf_align && !@derivation
254
+ end
255
+ end
256
+
257
+ # Leaf alignment gives every leaf the same depth, but equal depth is not
258
+ # an equal row: each label is set below its own parent, so a taller parent
259
+ # (a label of two lines) or a shorter drop (a leaf pulled up under
260
+ # leafstyle nothing) leaves the words of one depth at different heights.
261
+ # Drawn left to right it is worse, a level being as wide as the label
262
+ # standing in it. Every depth is therefore given one row: it starts below
263
+ # the tallest label of the row above, by the largest drop any of its
264
+ # elements had, so each connector keeps at least the room it was given.
265
+ # Worked in the swapped coordinates the left-to-right layout runs in,
266
+ # where a column is a row.
267
+ def level_leaf_rows
268
+ elements = @element_list.get_elements
269
+ drop = Hash.new(0.0)
270
+ elements.each do |e|
271
+ next unless e.parent.to_i.positive?
272
+
273
+ parent = @element_list.get_id(e.parent)
274
+ drop[e.level] = [drop[e.level], e.vertical_indent - parent.vertical_indent - parent.content_height].max
275
+ end
276
+ tallest = Hash.new(0.0)
277
+ elements.each { |e| tallest[e.level] = [tallest[e.level], e.content_height].max }
278
+
279
+ top = {}
280
+ y = 0.0
281
+ tallest.keys.sort.each do |level|
282
+ y += drop[level]
283
+ top[level] = y
284
+ y += tallest[level]
285
+ end
286
+
287
+ elements.each { |e| e.vertical_indent = top[e.level] }
251
288
  end
252
289
 
253
290
  # In a derivation every premise is given at the start, so the words stand in
@@ -412,7 +449,9 @@ module RSyntaxTree
412
449
  parent.triangle || (ETYPE_LEAF == child.type && child.triangle)
413
450
  end
414
451
 
415
- def draw_connector(id = 1)
452
+ # `through` carries the connector chosen above a run of joints leaf
453
+ # alignment inserted, down that run to the child it reaches.
454
+ def draw_connector(id = 1, through = nil)
416
455
  parent = @element_list.get_id(id)
417
456
  children = parent.children.map { |c| @element_list.get_id(c) }
418
457
 
@@ -425,40 +464,65 @@ module RSyntaxTree
425
464
  return
426
465
  end
427
466
 
428
- if children.size == 1
429
- child = children[0]
430
- forced = triangle_asked_for?(parent, child)
431
- case @leafstyle
432
- when "auto"
433
- if forced || child.contains_phrase
434
- triangle_to_parent(parent, child)
435
- else
436
- line_to_parent(parent, child)
437
- end
438
- when "bar"
439
- if forced
440
- triangle_to_parent(parent, child)
441
- else
442
- line_to_parent(parent, child)
443
- end
444
- when "nothing", "none"
445
- if forced
446
- triangle_to_parent(parent, child)
447
- elsif ETYPE_LEAF != child.type
448
- line_to_parent(parent, child)
449
- end
450
- end
451
- else
452
- children.each do |child|
453
- line_to_parent(parent, child)
467
+ if through
468
+ child = children.first
469
+ line_to_parent(parent, child) if through == :line
470
+ draw_connector(child.id, (through if child.inserted_joint))
471
+ return
472
+ end
473
+
474
+ chosen = children.map do |child|
475
+ joints, target = past_inserted_joints(child)
476
+ connector = connector_for(parent, target, children.size == 1)
477
+ case connector
478
+ when :triangle then triangle_to_parent(parent, target, joints)
479
+ when :line then line_to_parent(parent, child)
480
+ else no_connector(parent, target, joints)
454
481
  end
482
+ [child, connector]
455
483
  end
456
484
 
457
- parent.children.each do |c|
458
- draw_connector(c)
485
+ chosen.each { |child, connector| draw_connector(child.id, (connector if child.inserted_joint)) }
486
+ end
487
+
488
+ # Leaf alignment puts joints between a node and the child it reaches. They
489
+ # are there to place the child, not to change how it is joined, so the
490
+ # connector is chosen for the node and the child past them and carried
491
+ # through them: a triangle runs from the node down to the words, a line
492
+ # passes through each joint, and where no line is drawn none is drawn at
493
+ # all. Joints written into the input are nodes like any other.
494
+ def past_inserted_joints(child)
495
+ joints = []
496
+ while child.inserted_joint
497
+ joints << child
498
+ child = @element_list.get_id(child.children.first)
459
499
  end
500
+ [joints, child]
460
501
  end
461
502
 
503
+ def connector_for(parent, child, only_child)
504
+ return :line unless only_child
505
+
506
+ forced = triangle_asked_for?(parent, child)
507
+ case @leafstyle
508
+ when "auto"
509
+ forced || child.contains_phrase ? :triangle : :line
510
+ when "bar"
511
+ forced ? :triangle : :line
512
+ when "nothing", "none"
513
+ if forced
514
+ :triangle
515
+ elsif ETYPE_LEAF != child.type
516
+ :line
517
+ else
518
+ :none
519
+ end
520
+ end
521
+ end
522
+
523
+ # A child joined to its parent by nothing at all: no mark to draw.
524
+ def no_connector(_parent, _child, _joints = []); end
525
+
462
526
  def get_leftmost(id = 1)
463
527
  target = @element_list.get_id(id)
464
528
  target_indent = target.horizontal_indent
@@ -13,7 +13,7 @@ require_relative "color_names"
13
13
 
14
14
  module RSyntaxTree
15
15
  class Element
16
- attr_accessor :ink_top, :ink_bottom, :rule_name, :label_with_rule_name, :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
+ attr_accessor :ink_top, :ink_bottom, :rule_name, :label_with_rule_name, :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, :inserted_joint
17
17
 
18
18
  # names_a_rule says the content is a mother's label rather than a leaf's
19
19
  # text. Only a mother has a step under it for a name to sit beside, and a
@@ -99,5 +99,100 @@ module RSyntaxTree
99
99
  end
100
100
  maxlevel + 1
101
101
  end
102
+
103
+ # Add invisible `<>` joints to put every leaf at the deepest leaf level.
104
+ def align_leaves(mode, fontset, fontsize, global)
105
+ leaves = @elements.select { |e| e.type == ETYPE_LEAF && e.children.empty? }
106
+ return if leaves.size < 2
107
+
108
+ next_id = @elements.map(&:id).max + 1
109
+
110
+ if mode == "subtrees"
111
+ root = @elements.find { |element| element.parent.zero? }
112
+ add_high_joints(root, next_id, fontset, fontsize, global) if root
113
+ else
114
+ deepest = leaves.map(&:level).max
115
+
116
+ leaves.each do |leaf|
117
+ joints = deepest - leaf.level
118
+ next unless joints.positive?
119
+
120
+ next_id = insert_empty_joints(leaf, joints, next_id, fontset, fontsize, global)
121
+ end
122
+ end
123
+
124
+ renumber_preorder
125
+ end
126
+
127
+ private
128
+
129
+ def add_high_joints(parent, next_id, fontset, fontsize, global)
130
+ children = parent.children.map { |id| get_id(id) }
131
+ heights = children.map { |child| subtree_height(child) }
132
+ deepest = heights.max
133
+
134
+ children.zip(heights).each do |child, height|
135
+ joints = deepest - height
136
+ if joints.positive?
137
+ next_id = insert_empty_joints(child, joints, next_id, fontset, fontsize, global)
138
+ shift_subtree_levels(child, joints)
139
+ end
140
+ next_id = add_high_joints(child, next_id, fontset, fontsize, global) unless child.children.empty?
141
+ end
142
+ next_id
143
+ end
144
+
145
+ def subtree_height(element)
146
+ return 0 if element.children.empty?
147
+
148
+ 1 + element.children.map { |id| subtree_height(get_id(id)) }.max
149
+ end
150
+
151
+ def insert_empty_joints(child, count, next_id, fontset, fontsize, global)
152
+ parent = get_id(child.parent)
153
+ return next_id unless parent
154
+
155
+ count.times do
156
+ joint = Element.new(next_id, parent.id, WHITESPACE_BLOCK, parent.level + 1, fontset, fontsize, global)
157
+ joint.inserted_joint = true
158
+ next_id += 1
159
+ @elements << joint
160
+ insert_between(parent, child, joint)
161
+ parent = joint
162
+ end
163
+ next_id
164
+ end
165
+
166
+ def shift_subtree_levels(element, count)
167
+ element.level += count
168
+ element.children.each { |id| shift_subtree_levels(get_id(id), count) }
169
+ end
170
+
171
+ def insert_between(parent, child, joint)
172
+ parent.children[parent.children.index(child.id)] = joint.id
173
+ joint.add_child(child.id)
174
+ joint.type = ETYPE_NODE
175
+ child.parent = joint.id
176
+ end
177
+
178
+ def renumber_preorder
179
+ root = @elements.find { |element| element.parent.zero? }
180
+ return unless root
181
+
182
+ ordered = []
183
+ visit = lambda do |element|
184
+ ordered << element
185
+ element.children.each { |id| visit.call(get_id(id)) }
186
+ end
187
+ visit.call(root)
188
+
189
+ ids = ordered.each_with_index.to_h { |element, index| [element.id, index + 1] }
190
+ ordered.each do |element|
191
+ element.parent = ids.fetch(element.parent, 0)
192
+ element.children = element.children.map { |id| ids.fetch(id) }
193
+ element.id = ids.fetch(element.id)
194
+ end
195
+ @elements = ordered
196
+ end
102
197
  end
103
198
  end
@@ -60,58 +60,32 @@ module RSyntaxTree
60
60
  @edges << { from: parent.id, to: child.id, type: "dominance", connector: "line" }
61
61
  end
62
62
 
63
- def triangle_to_parent(parent, child)
64
- @edges << { from: parent.id, to: child.id, type: "dominance", connector: "triangle" }
63
+ # A triangle spans from the parent to its child, through any joints leaf
64
+ # alignment put between them; each edge it crosses is recorded as part of
65
+ # it, so the joints keep their place in the tree.
66
+ def triangle_to_parent(parent, child, joints = [])
67
+ chain_edges(parent, joints, child, "triangle")
65
68
  end
66
69
 
67
- # Override draw_connector to capture all edges including "none" connector
68
- def draw_connector(id = 1)
69
- parent = @element_list.get_id(id)
70
- children = parent.children.map { |c| @element_list.get_id(c) }
71
-
72
- # A derivation joins a node to its daughters with one rule across all of
73
- # them. Recorded as what it is: this format states that it carries how
74
- # the figure was drawn, so calling these lines would be a false record.
75
- if @derivation && !children.empty?
76
- children.each do |child|
77
- @edges << { from: parent.id, to: child.id, type: "dominance", connector: "rule" }
78
- end
79
- parent.children.each { |c| draw_connector(c) }
80
- return
81
- end
70
+ # Recorded rather than left out: this format carries how the figure was
71
+ # drawn, and a child with no mark under it is part of that.
72
+ def no_connector(parent, child, joints = [])
73
+ chain_edges(parent, joints, child, "none")
74
+ end
82
75
 
83
- if children.size == 1
84
- child = children[0]
85
- forced = triangle_asked_for?(parent, child)
86
- case @leafstyle
87
- when "auto"
88
- if forced || child.contains_phrase
89
- triangle_to_parent(parent, child)
90
- else
91
- line_to_parent(parent, child)
92
- end
93
- when "bar"
94
- if forced
95
- triangle_to_parent(parent, child)
96
- else
97
- line_to_parent(parent, child)
98
- end
99
- when "nothing", "none"
100
- if forced
101
- triangle_to_parent(parent, child)
102
- elsif ETYPE_LEAF != child.type
103
- line_to_parent(parent, child)
104
- else
105
- @edges << { from: parent.id, to: child.id, type: "dominance", connector: "none" }
106
- end
107
- end
108
- else
109
- children.each do |child|
110
- line_to_parent(parent, child)
111
- end
76
+ # A derivation joins a node to its daughters with one rule across all of
77
+ # them. Recorded as what it is: this format states that it carries how
78
+ # the figure was drawn, so calling these lines would be a false record.
79
+ def rule_to_parent(parent, children)
80
+ children.each do |child|
81
+ @edges << { from: parent.id, to: child.id, type: "dominance", connector: "rule" }
112
82
  end
83
+ end
113
84
 
114
- parent.children.each { |c| draw_connector(c) }
85
+ def chain_edges(parent, joints, child, connector)
86
+ ([parent] + joints).zip(joints + [child]).each do |from, to|
87
+ @edges << { from: from.id, to: to.id, type: "dominance", connector: connector }
88
+ end
115
89
  end
116
90
 
117
91
  def draw_paths
@@ -331,6 +305,7 @@ module RSyntaxTree
331
305
  # before 2.0 carry a separate symmetrize flag beside it; the
332
306
  # scale is what it meant, and it is all that is written now.
333
307
  tidy: @params[:tidy] || "off",
308
+ leaf_align: @params[:leaf_align],
334
309
  mirror: @params[:mirror] == true,
335
310
  direction: @params[:direction] || "ttb",
336
311
  # Degrees, positive leaning the top right. The geometry below
@@ -107,6 +107,7 @@ Options (the command line spells them --like-this):
107
107
  | vheight | how far apart levels sit, 0.5–5.0 |
108
108
  | polyline | draw each connector as two right angles rather than a slanted line |
109
109
  | hide_default_connectors | draw no parent-to-child lines, leaving only the ones written with +-n — how a lattice or a network is drawn |
110
+ | leaf_align | line the leaves up on one row: off, words (lower the words) or subtrees (lower whole subtrees) |
110
111
  | transparent | leave the background clear instead of painting it white |
111
112
  | derivation | one rule across the daughters, categorial-grammar style |
112
113
  | hyphen | markup (hyphens underline) or literal (hyphens are hyphens) |