rsyntaxtree 1.12.0 → 1.13.1

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: 160b79f9047cb7674fe1c980dcf9e1a4044ca2c68eb3c758eabc9389d73d829a
4
- data.tar.gz: 648c5297ccfbaefde5c2904cc5266ed56207487bc6ac64320041ac7e135e4814
3
+ metadata.gz: '0804e9119e0ebb089b4109ae05c9353180362bd6e86fddbf2e4da00db3b08e18'
4
+ data.tar.gz: '01867ffda61d7f81fd3d81a9f6ad44ba83eda4f7d89d1b3b05d53b196bc23fa1'
5
5
  SHA512:
6
- metadata.gz: 5465a152a7de5a453731cee9595f5d98636640fc8f97f7a21320e3ac66d14cf9876c10941518f936b586e0e6613944c2eb6e05175b6740a49aab544c97008e54
7
- data.tar.gz: 78c83cee893a040d10959508199f354d3c568aa2a65fcba7bb486ddce6839314f6fcbd7d094c759804a77df07df660811508949ab7ffb9a9a7e81559e0e85b89
6
+ metadata.gz: 990e4522fe0e0694d009eddebc2b3b2af98e5beda1f3e2daf9fbf1250199cef31b981215d1ae7db3b711d0030826e2445c7011f427223f8e081bffb8d76451ae
7
+ data.tar.gz: fe620d8c1fe64c289b18d853d6663ceaae5ea8a7486a5f0cf0ce578f8c0e9073e48251f0d7a933d0c7afa9ee7fce896dd6cbf00f3499c8d5314278e9cc0e0b57
data/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.13.1] - 2026-08
4
+
5
+ ### Fixed
6
+ - `^` at the head of a leaf draws the triangle it asks for. The mark may be
7
+ written on the node's label or on the leaf's own text — `[^NP cats]` and
8
+ `[NP ^cats]` — and the documentation, the notation reference and the gallery
9
+ all use both, but only the first was drawn: the second lost its caret to the
10
+ parser and then a bar was drawn under it. The two now produce the same figure
11
+ in every connector style, and LSIF records the edge as a triangle. Three
12
+ gallery figures change accordingly, each toward what it was written to show.
13
+ - An option given as an empty string is read as an option not given, rather
14
+ than as a value no list contains. An HTML form posts a field for every
15
+ control it carries, and a control with nothing selected posts the empty
16
+ string, so a form that has outlived one of its own controls sends that field
17
+ empty alongside the rest. Since option validation arrived in 1.11.0 that
18
+ failed the whole request: the web UI's Download buttons answered 500 for
19
+ every input, because the form still read a `format` select the page no longer
20
+ had. A value that is actually wrong is still rejected.
21
+
22
+ ## [1.13.0] - 2026-08
23
+
24
+ ### Added
25
+ - Derivations. A derivation puts the words first and the result last, and
26
+ joins what each step combines with one rule drawn across all of it rather
27
+ than with a line to each daughter. `derivation` draws the rules and
28
+ `direction: btt` turns the tree over; together they give the format
29
+ categorial grammar is written in, and `derivation` on its own marks the
30
+ spans of an ordinary tree. The name of each step rides in the label after a
31
+ column break and is set beside the end of its rule. A derivation runs down
32
+ the page and is drawn with its rules, so `direction: ltr` and hiding the
33
+ default connectors are refused rather than approximated.
34
+ - `direction: btt`, the layout turned over, leaves first.
35
+
36
+ ### Fixed
37
+ - Connectors follow the tree when it is turned over. They kept the ends they
38
+ have when the root is at the top, so every line ran from above the daughter,
39
+ through its label and the mother's, to below the mother.
40
+
3
41
  ## [1.12.0] - 2026-08
4
42
 
5
43
  Everything needed to write this notation is now available as text, built from
data/bin/rsyntaxtree CHANGED
@@ -32,7 +32,7 @@ CONFIG_VALIDATORS = {
32
32
  tidy: ->(v) { OPTION_VALUES[:tidy].include?(v.to_s) ? nil : "must be off, symmetric, low, medium, or high" },
33
33
  hspacing: ->(v) { v.is_a?(Numeric) && v >= 0.5 && v <= 3.0 ? nil : "must be in the range of 0.5-3.0" },
34
34
  tidy_spacing: ->(v) { v.is_a?(Numeric) && v >= 0.5 && v <= 3.0 ? nil : "must be in the range of 0.5-3.0" },
35
- direction: ->(v) { OPTION_VALUES[:direction].include?(v.to_s) ? nil : "must be ttb or ltr" },
35
+ direction: ->(v) { OPTION_VALUES[:direction].include?(v.to_s) ? nil : "must be one of: #{OPTION_VALUES[:direction].join(', ')}" },
36
36
  hyphen: ->(v) { OPTION_VALUES[:hyphen].include?(v.to_s) ? nil : "must be markup or literal" }
37
37
  }.freeze
38
38
 
@@ -122,7 +122,8 @@ opts = Optimist.options do
122
122
  opt :tidy, "tidy tree layout: off, symmetric (radical symmetrization), low (packed, strict leaf positions), medium (packed; leaves may overlap but never swap order), or high (packed; leaf order kept per row only)", default: "off", short: :none
123
123
  opt :hspacing, "Horizontal spacing factor (counterpart of vheight): 0.5-3.0", default: 1.0, short: :none
124
124
  opt :tidy_spacing, "DEPRECATED alias of --hspacing: 0.5-3.0", default: 1.0, short: :none
125
- opt :direction, "Tree layout direction: ttb (top-to-bottom) or ltr (left-to-right)", default: "ttb", short: :d
125
+ opt :direction, "Tree layout direction: ttb (top-to-bottom), ltr (left-to-right) or btt (bottom-to-top, the root last)", default: "ttb", short: :d
126
+ opt :derivation, "Join each node to its daughters with one rule drawn across them, as a derivation is written, rather than with a line to each: on, off", default: "off", short: :none
126
127
  opt :hyphen, "How a hyphen in a label reads: markup (-underline-) or literal (a hyphen; \-underline\- then underlines)", default: "markup", short: :none
127
128
  opt :validate, "Validate the input without drawing or writing a file: print a machine-readable diagnosis (JSON) to stdout and report the result in the exit code", short: :none
128
129
  opt :notation, "Print the notation reference to stdout and exit", short: :none
@@ -131,7 +132,8 @@ opts = Optimist.options do
131
132
  end
132
133
 
133
134
  Optimist.die :outdir, "must be an exsting directory path" unless FileTest.directory?(opts[:outdir])
134
- Optimist.die :direction, "must be ttb or ltr" unless OPTION_VALUES[:direction].include?(opts[:direction])
135
+ Optimist.die :direction, "must be one of: #{OPTION_VALUES[:direction].join(', ')}" unless OPTION_VALUES[:direction].include?(opts[:direction])
136
+ Optimist.die :derivation, "must be one of: #{OPTION_VALUES[:derivation].join(', ')}" unless OPTION_VALUES[:derivation].include?(opts[:derivation])
135
137
  Optimist.die :hyphen, "must be markup or literal" unless OPTION_VALUES[:hyphen].include?(opts[:hyphen])
136
138
  Optimist.die :format, "must be #{FORMAT_LIST_MESSAGE}" unless FORMATS.include?(opts[:format])
137
139
  Optimist.die :leafstyle, "must be auto, triangle, bar, or nothing" unless OPTION_VALUES[:leafstyle].include?(opts[:leafstyle])
@@ -67,6 +67,7 @@ module RSyntaxTree
67
67
  @fontset = params[:fontset]
68
68
  @fontsize = params[:fontsize]
69
69
  @mirror = params[:mirror] == true
70
+ @derivation = params[:derivation] == true
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
@@ -182,6 +183,17 @@ module RSyntaxTree
182
183
  accum_array.sum
183
184
  end
184
185
 
186
+ # The name of the step hangs past the right end of its rule, and the
187
+ # rule reaches the full width of what the step combines. With no room
188
+ # kept for it the name lands on the next subtree's rule and two steps
189
+ # read as one, so the node claims that much more width. The label is
190
+ # drawn from the left edge and does not move.
191
+ #
192
+ # Doubled: the children sit centred in the width their mother claims,
193
+ # so half of whatever is added lands on the left where nothing needs
194
+ # it, and only the other half reaches the side the name is on.
195
+ accum_width += rule_name_room(target) * 2
196
+
185
197
  if target.content_width > accum_width
186
198
  # Parent label is wider than children's total width.
187
199
  # Distribute the excess equally among children to prevent
@@ -209,7 +221,12 @@ module RSyntaxTree
209
221
  else
210
222
  parent = @element_list.get_id(target.parent)
211
223
 
212
- vertical_indent = if !target.triangle &&
224
+ # A leaf with nothing drawn over it is pulled up against its node; one
225
+ # with a triangle over it needs the room the triangle takes. Which is
226
+ # which is the same question the connector asks, so it is asked the same
227
+ # way — reading only the leaf's own mark left `[^NP cats]` a triangle
228
+ # squashed into a gap that had been closed.
229
+ vertical_indent = if !triangle_asked_for?(parent, target) &&
213
230
  (@leafstyle == "nothing" || @leafstyle == "none") &&
214
231
  ETYPE_LEAF == target.type && parent.children.size == 1
215
232
  if @direction == "ltr"
@@ -234,9 +251,91 @@ module RSyntaxTree
234
251
  end
235
252
  target.height = accum_array.max - target.vertical_indent
236
253
  accum_array.max
254
+ end.tap { level_derivation_rows if id == 1 && @derivation }
255
+ end
256
+
257
+ # In a derivation every premise is given at the start, so the words stand in
258
+ # one row and each step sits below everything it draws on. A node's row is
259
+ # therefore its distance from the words rather than its distance from the
260
+ # root: a step joining a category to a derivation three deep goes below
261
+ # both. Placed by depth from the root instead, the words come out in a
262
+ # staircase, each at the depth of its own branch.
263
+ #
264
+ # Run after the ordinary placement, so the row heights and the drop between
265
+ # them are the ones that pass settled on.
266
+ # Levelled twice, and the two are not the same pass. The first runs inside
267
+ # the layout, because tidy packs subtrees by their contours and a contour is
268
+ # read off the vertical positions: rows it has not seen levelled pack as
269
+ # though the figure were a tree, and two rules of one row come out
270
+ # overlapping. The second runs after the layout has been turned over, since
271
+ # turning it over sets each box against its own bottom edge, which leaves a
272
+ # tall label — a feature matrix, say — standing proud of its row. Counting
273
+ # rows from the end the words are at is what makes the second pass the same
274
+ # reckoning as the first, read the other way up.
275
+ def level_derivation_rows(flipped: false)
276
+ root_height = subtree_height(1)
277
+ rows = {}
278
+ tallest = Hash.new(0.0)
279
+ @element_list.get_elements.each do |e|
280
+ height = subtree_height(e.id)
281
+ row = flipped ? height : root_height - height
282
+ rows[e.id] = row
283
+ tallest[row] = [tallest[row], e.content_height].max
284
+ end
285
+
286
+ drop = @global[:height_connector].to_f
287
+ top = {}
288
+ y = 0.0
289
+ tallest.keys.sort.each do |row|
290
+ top[row] = y
291
+ y += tallest[row] + drop
292
+ end
293
+
294
+ @element_list.get_elements.each { |e| e.vertical_indent = top[rows[e.id]] }
295
+
296
+ # Which row each element ended up in, kept for the drawing: a rule belongs
297
+ # between two rows, not between two labels. Placed from the label instead,
298
+ # a step whose result is taller than its neighbours — a feature matrix
299
+ # among plain categories — had its rule drawn at a height of its own while
300
+ # the rules beside it stayed on the row.
301
+ @derivation_rows = rows
302
+ end
303
+
304
+ # How far a row reaches, read from the labels as they stand. Taken when the
305
+ # rows were levelled it would be the measured height, and the drawing works
306
+ # to a slightly different one.
307
+ def derivation_row_extent(row)
308
+ @derivation_extents ||= {}
309
+ @derivation_extents[row] ||= begin
310
+ members = @element_list.get_elements.select { |e| @derivation_rows[e.id] == row }
311
+ [members.map(&:vertical_indent).min,
312
+ members.map { |e| e.vertical_indent + e.content_height }.max]
237
313
  end
238
314
  end
239
315
 
316
+ # The rule for a step runs between the row its result sits in and the row
317
+ # holding the last of its premises — always the next row along, since a step
318
+ # is one taller than the tallest thing it draws on.
319
+ def derivation_rule_band(parent)
320
+ return nil unless @derivation_rows
321
+
322
+ row = @derivation_rows[parent.id]
323
+ neighbour = row + (@direction == "btt" ? -1 : 1)
324
+ return nil unless @derivation_rows.value?(neighbour)
325
+
326
+ here = derivation_row_extent(row)
327
+ there = derivation_row_extent(neighbour)
328
+ @direction == "btt" ? [there[1], here[0]] : [here[1], there[0]]
329
+ end
330
+
331
+ # Steps from +id+ down to the furthest word under it. A word is zero.
332
+ def subtree_height(id)
333
+ node = @element_list.get_id(id)
334
+ return 0 if node.children.empty?
335
+
336
+ 1 + node.children.map { |c| subtree_height(c) }.max
337
+ end
338
+
240
339
  def make_balance(id = 1)
241
340
  target = @element_list.get_id(id)
242
341
  if target.children.empty?
@@ -301,27 +400,53 @@ module RSyntaxTree
301
400
  end
302
401
  end
303
402
 
403
+ # Whether a `^` has asked for a triangle over this leaf. It may be written
404
+ # at the head of the node's label or at the head of the leaf's own text —
405
+ # `[^NP cats]` and `[NP ^cats]` — and both forms are in the gallery. Only
406
+ # the first was honoured here, and the second was dropped without a word:
407
+ # the caret vanished from the text and a bar was drawn, so an example named
408
+ # for its triangles had none. The rest of the layout already reads the
409
+ # leaf's own flag — a leaf marked this way keeps the gap a triangle needs
410
+ # even when connectors are off — which is what left the two sides of one
411
+ # rule disagreeing.
412
+ #
413
+ # Asked of a leaf only. A `^` on an internal node is that node's own mark,
414
+ # and it is honoured when that node is in turn the parent here.
415
+ def triangle_asked_for?(parent, child)
416
+ parent.triangle || (ETYPE_LEAF == child.type && child.triangle)
417
+ end
418
+
304
419
  def draw_connector(id = 1)
305
420
  parent = @element_list.get_id(id)
306
421
  children = parent.children.map { |c| @element_list.get_id(c) }
307
422
 
423
+ # A derivation joins its premises with one rule drawn across all of them,
424
+ # not with a line to each. The rule replaces the lines, so a tree is drawn
425
+ # this way throughout or not at all.
426
+ if @derivation && !children.empty?
427
+ rule_to_parent(parent, children)
428
+ parent.children.each { |c| draw_connector(c) }
429
+ return
430
+ end
431
+
308
432
  if children.size == 1
309
433
  child = children[0]
434
+ forced = triangle_asked_for?(parent, child)
310
435
  case @leafstyle
311
436
  when "auto"
312
- if parent.triangle || child.contains_phrase
437
+ if forced || child.contains_phrase
313
438
  triangle_to_parent(parent, child)
314
439
  else
315
440
  line_to_parent(parent, child)
316
441
  end
317
442
  when "bar"
318
- if parent.triangle
443
+ if forced
319
444
  triangle_to_parent(parent, child)
320
445
  else
321
446
  line_to_parent(parent, child)
322
447
  end
323
448
  when "nothing", "none"
324
- if parent.triangle
449
+ if forced
325
450
  triangle_to_parent(parent, child)
326
451
  elsif ETYPE_LEAF != child.type
327
452
  line_to_parent(parent, child)
@@ -345,6 +470,20 @@ module RSyntaxTree
345
470
  (children_indent << target_indent).min
346
471
  end
347
472
 
473
+ # Width to keep clear to the right of a step's rule for the name beside it,
474
+ # with a gap between the name and whatever follows. Zero unless the tree is
475
+ # drawn as a derivation and the step has a name.
476
+ def rule_name_room(element)
477
+ return 0 unless @derivation
478
+
479
+ name = element.rule_name
480
+ return 0 if name.nil? || name.empty?
481
+
482
+ size = (@fontsize * 0.8).round(2)
483
+ width = FontMetrics.get_metrics(name, @fontset[:family], size, :normal, :normal).width
484
+ width + @global[:width_half_x]
485
+ end
486
+
348
487
  def get_rightmost(id = 1)
349
488
  target = @element_list.get_id(id)
350
489
  target_right_end = target.horizontal_indent + target.content_width
@@ -363,6 +502,19 @@ module RSyntaxTree
363
502
  end
364
503
  end
365
504
 
505
+ # Flip the laid-out tree vertically: the root ends up at the bottom and the
506
+ # leaves at the top, which is how a derivation is written — the words at the
507
+ # top, each step below the material it combines. Done after the layout is
508
+ # final, like the horizontal flip, so connectors, triangles, paths and
509
+ # region shades follow from the coordinates without knowing about it.
510
+ def flip_vertical
511
+ elements = @element_list.get_elements
512
+ max_bottom = elements.map { |e| e.vertical_indent + e.content_height }.max
513
+ elements.each do |e|
514
+ e.vertical_indent = max_bottom - (e.vertical_indent + e.content_height)
515
+ end
516
+ end
517
+
366
518
  # Flip the laid-out tree horizontally (RTL linguistics convention: the
367
519
  # first word sits at the right edge). Connectors, triangles, polylines,
368
520
  # movement paths, and region shades all derive from element coordinates,
@@ -425,9 +577,23 @@ module RSyntaxTree
425
577
  x0 -= ext
426
578
  x1 += ext
427
579
  end
580
+ # A step's name is drawn past the right end of its rule, and the rule
581
+ # reaches the full width of what the step combines. Packing sees only the
582
+ # labels, so without this the neighbour is pulled up against the name and
583
+ # two steps read as one long rule.
584
+ x1 = [x1, subtree_right(node.id) + rule_name_room(node)].max if @derivation
428
585
  [node.vertical_indent, node.vertical_indent + node.content_height, x0, x1]
429
586
  end
430
587
 
588
+ # Right edge of the subtree rooted at +id+, from wherever the layout has
589
+ # put things so far.
590
+ def subtree_right(id)
591
+ node = @element_list.get_id(id)
592
+ right = node.horizontal_indent + node.content_width
593
+ node.children.each { |c| right = [right, subtree_right(c)].max }
594
+ right
595
+ end
596
+
431
597
  # Effective-rect list of the subtree rooted at +id+, as [y0, y1, x0, x1].
432
598
  # A region-shaded (%) node additionally contributes the shade's padded
433
599
  # bounding rect so that neighboring subtrees keep clear of the plane.
@@ -900,6 +1066,10 @@ module RSyntaxTree
900
1066
  finalize_ltr if @direction == "ltr"
901
1067
 
902
1068
  # RTL flip (mirror option): after the layout is final, before drawing
1069
+ # btt is the top-to-bottom layout turned over, so the layout runs as
1070
+ # usual and the flip comes after it.
1071
+ flip_vertical if @direction == "btt"
1072
+ level_derivation_rows(flipped: true) if @derivation && @direction == "btt"
903
1073
  mirror_layout if @mirror
904
1074
 
905
1075
  draw_elements
@@ -915,6 +1085,9 @@ module RSyntaxTree
915
1085
  max_x = r if r > max_x
916
1086
  max_y = b if b > max_y
917
1087
  end
1088
+ # A rule's name is set beside the rule, past the right edge of every box
1089
+ # the loop above measured.
1090
+ max_x = @rule_name_edge if @rule_name_edge.to_f > max_x
918
1091
  width = max_x + @global[:h_gap_between_nodes]
919
1092
  height = max_y
920
1093
  height = @height if @height > height
@@ -13,9 +13,14 @@ require_relative "color_names"
13
13
 
14
14
  module RSyntaxTree
15
15
  class Element
16
- 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
17
-
18
- def initialize(id, parent, content, level, fontset, fontsize, global)
16
+ attr_accessor :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
17
+
18
+ # names_a_rule says the content is a mother's label rather than a leaf's
19
+ # text. Only a mother has a step under it for a name to sit beside, and a
20
+ # leaf's `a\tb` is a row of two columns, so the name is read out of the one
21
+ # and left alone in the other.
22
+ def initialize(id, parent, content, level, fontset, fontsize, global,
23
+ names_a_rule = false)
19
24
  @global = global
20
25
  @type = ETYPE_LEAF
21
26
  @id = id # Unique element id
@@ -36,6 +41,20 @@ module RSyntaxTree
36
41
 
37
42
  @fontset = fontset
38
43
  @fontsize = fontsize
44
+ # In a derivation the label may carry the name of the rule that produced
45
+ # it, written after a column break: `S/NP\t\>B`. The name belongs beside
46
+ # the rule rather than beside the result, so it comes out of the label
47
+ # here, before the label is measured, and BaseGraph draws it at the end
48
+ # of the rule.
49
+ if global && global[:derivation] && names_a_rule
50
+ at = rule_name_break(content)
51
+ name = at && content[(at + 2)..].to_s.strip
52
+ if name && !name.empty?
53
+ @rule_name = name
54
+ content = content[0...at]
55
+ end
56
+ end
57
+
39
58
  @raw_content = content.sub(/\^?(?:\+-?>?<?\d+)+\^?\z/, '')
40
59
 
41
60
  parsed = Markup.parse(prepare_markup(content))
@@ -65,12 +84,21 @@ module RSyntaxTree
65
84
  # enclosure. Such nodes act as pass-through joints: connectors run
66
85
  # continuously through them, which lets a `<>` chain push a leaf down
67
86
  # to align with deeper leaves while the line stays unbroken.
87
+ #
88
+ # A label that is one whole feature matrix keeps its text inside the
89
+ # matrix and leaves none of it here, and the matrix is a decoration on the
90
+ # run rather than the element's own enclosure. Counted as empty, such a
91
+ # node was joined as a pass-through joint and the connectors were run to
92
+ # the middle of it — through the matrix and the rows written in it.
68
93
  def empty_label?
69
94
  return false if @enclosure && @enclosure != :none
70
95
 
71
96
  @content.all? do |c|
72
97
  c[:type] == :text &&
73
- c[:elements].all? { |e| e[:text].gsub(WHITESPACE_BLOCK, "").strip.empty? }
98
+ c[:elements].all? do |e|
99
+ !e[:decoration].to_a.include?(:matrix) &&
100
+ e[:text].gsub(WHITESPACE_BLOCK, "").strip.empty?
101
+ end
74
102
  end
75
103
  end
76
104
 
@@ -146,6 +174,16 @@ module RSyntaxTree
146
174
  [:unclosed_matrix,
147
175
  ->(s) { s.scan("#(").size > s.scan("#)").size ? s + "#)" : s },
148
176
  "A matrix opened with '#(' is never closed with '#)'."],
177
+ # A derivation writes the name of each step after a column break, and
178
+ # the combinators it is written with are made of the same characters as
179
+ # the whitespace marker. Read as an ordinary tree such a label is
180
+ # unclosed markup, and the advice below is about spaces — the opposite of
181
+ # what the writer needs. Asked here, before those, so that a derivation
182
+ # written without the option on is told so.
183
+ [:rule_name_without_derivation,
184
+ ->(s) { s.include?('\\n') ? s : s.sub(/(?<!\\)\\t.*\z/m, "") },
185
+ "This label names a rule after a column break, the way a derivation " \
186
+ "does. Turn derivation on to draw it as one, or escape the break as \\\\t."],
149
187
  # Neutralising every occurrence of one character, rather than adding a
150
188
  # closing one, locates the culprit wherever it sits in the label — an
151
189
  # opener left unclosed halfway down a matrix is not fixed by appending.
@@ -282,7 +320,8 @@ module RSyntaxTree
282
320
  # the padding and the second is what actually separates the
283
321
  # bracket from the descenders of the line above it.
284
322
  content[:top_room] = matrix_vertical_room * 2
285
- elements_height << e[:height]
323
+ elements_height << with_first_row_margin(e[:height], one_bvm_given)
324
+ one_bvm_given = true
286
325
  row_width += e[:width]
287
326
  next
288
327
  end
@@ -313,7 +352,12 @@ module RSyntaxTree
313
352
  standard_metrics = FontMetrics.get_metrics('X', font, fontsize, :normal, :normal)
314
353
 
315
354
  height = standard_metrics.height
316
- line_height = height
355
+ # The rhythm a row advances by belongs to the label, not to the size
356
+ # a run inside it happens to be set at. Taken from the run, a label
357
+ # that is nothing but a subscript measured a subscript tall and came
358
+ # out shorter than the text drawn in it, so its daughters were
359
+ # placed up into it.
360
+ line_height = @global[:single_x_metrics].height
317
361
  if /\A[<>]+\z/ =~ text
318
362
  width = standard_metrics.width * text.size / 2
319
363
  elsif text.contains_emoji?
@@ -356,7 +400,7 @@ module RSyntaxTree
356
400
  # ENCLOSURE_SIZE only for content that will not fit.
357
401
  band = FontMetrics.get_metrics("Xg", font, fontsize, :normal, :normal)
358
402
  descender = band.ink_height - band.ink_above
359
- centre = standard_metrics.ink_above / 2.0
403
+ centre = FontMetrics.visual_centre(font, fontsize)
360
404
 
361
405
  ink = FontMetrics.get_metrics(text, font, fontsize, style, weight)
362
406
  ink_height = ink.ink_height.to_f
@@ -399,14 +443,8 @@ module RSyntaxTree
399
443
  # the height of the nodes above it, so a node measured short of the
400
444
  # rhythm pulls its own children up and off the row its cousins sit
401
445
  # on.
402
- measured = [height, line_height].max
403
-
404
- if one_bvm_given
405
- elements_height << measured
406
- else
407
- one_bvm_given = true
408
- elements_height << measured + @global[:box_vertical_margin]
409
- end
446
+ elements_height << with_first_row_margin([height, line_height].max, one_bvm_given)
447
+ one_bvm_given = true
410
448
 
411
449
  e[:width] = width
412
450
  row_width += width
@@ -431,6 +469,48 @@ module RSyntaxTree
431
469
  @global[:width_half_x] * MATRIX_BRACKET_ROOM
432
470
  end
433
471
 
472
+ # Where the break that names the rule is, or nil if the label does not name
473
+ # one. It is the break at the top level of the label: a feature matrix is
474
+ # written with breaks of its own and those belong to its columns, so a step
475
+ # whose category is a matrix can still name the rule that produced it.
476
+ #
477
+ # Exactly one, and no line break outside a matrix: a rule has one name, and
478
+ # a label written over several lines is a matrix rather than a step. A
479
+ # break the writer escaped is theirs and is not a break here.
480
+ def rule_name_break(content)
481
+ depth = 0
482
+ breaks = []
483
+ lined = false
484
+ index = 0
485
+ while index < content.length
486
+ case content[index, 2]
487
+ when '\\t' then breaks << index if depth.zero?
488
+ when '\\n' then lined = true if depth.zero?
489
+ when "#(" then depth += 1
490
+ when "#)" then depth -= 1
491
+ else
492
+ # A backslash takes the character after it, whatever that character
493
+ # is. Only the two breaks above are read here; every other escape is
494
+ # the notation's, and `\#` is a hash that opens no matrix — counted as
495
+ # one it left the depth wrong and the break that names the rule was
496
+ # looked for in the wrong place.
497
+ index += content[index] == "\\" ? 2 : 1
498
+ next
499
+ end
500
+ index += 2
501
+ end
502
+ breaks.first if !lined && breaks.size == 1
503
+ end
504
+
505
+ # The first row of a label carries a margin above it that holds the text
506
+ # clear of the connector coming down to the node. Every row is entered
507
+ # through here so that the one holding a feature matrix is given it too: it
508
+ # used to be measured without, and a matrix node came out shorter than the
509
+ # rows drawn in it, which sat its daughters up inside the matrix.
510
+ def with_first_row_margin(height, already_given)
511
+ already_given ? height : height + @global[:box_vertical_margin]
512
+ end
513
+
434
514
  # Vertical room a nested matrix keeps between itself and the rows above and
435
515
  # below it.
436
516
  def matrix_vertical_room
@@ -69,23 +69,35 @@ module RSyntaxTree
69
69
  parent = @element_list.get_id(id)
70
70
  children = parent.children.map { |c| @element_list.get_id(c) }
71
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
82
+
72
83
  if children.size == 1
73
84
  child = children[0]
85
+ forced = triangle_asked_for?(parent, child)
74
86
  case @leafstyle
75
87
  when "auto"
76
- if parent.triangle || child.contains_phrase
88
+ if forced || child.contains_phrase
77
89
  triangle_to_parent(parent, child)
78
90
  else
79
91
  line_to_parent(parent, child)
80
92
  end
81
93
  when "bar"
82
- if parent.triangle
94
+ if forced
83
95
  triangle_to_parent(parent, child)
84
96
  else
85
97
  line_to_parent(parent, child)
86
98
  end
87
99
  when "nothing", "none"
88
- if parent.triangle
100
+ if forced
89
101
  triangle_to_parent(parent, child)
90
102
  elsif ETYPE_LEAF != child.type
91
103
  line_to_parent(parent, child)
@@ -275,6 +287,7 @@ module RSyntaxTree
275
287
  font_size: (@params[:fontsize] / FONT_SCALING).round,
276
288
  color: @params[:color],
277
289
  connector: @params[:leafstyle],
290
+ derivation: @params[:derivation] == true,
278
291
  connector_height: @params[:vheight],
279
292
  horizontal_spacing: @params[:hspacing] || 1.0,
280
293
  line_width: @params[:linewidth],