rsyntaxtree 1.11.0 → 1.13.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: 6afa75eb042fe177454d387fa097c74fb6b44fd317ae709f91495f2f9b8df269
4
- data.tar.gz: 60e201c577b1cf02d12f6506b10e62b1b21e419d6d37955e4a69a338b28bfd90
3
+ metadata.gz: 32cc6cec6f19c5fe62dc9a6de16493d6db35680f72e40b6bbd36f0b1e98add96
4
+ data.tar.gz: b6f928fd6919bb289765fac684ae3e98f7988103985deebcb6f73e7f8836e939
5
5
  SHA512:
6
- metadata.gz: 27d3c6eaf4e9d892238f41c7cce05bea11a639351069e80e130af6ad809916e89794dd41bbaf32b407c8560e49b8dc45c243fe7724d4aa36a3a1ce727dc61321
7
- data.tar.gz: c3744c0b738859a27699dc444f9d27d3169b5d8ee91bd04be2d9296deca560050f9f7cce8db69b7cb980342ef94a1dd2f29e627030b04f7de990cb4adf0850f9
6
+ metadata.gz: 50d8b1e91e3c71286406bf45e5e5cdeb2d8170f98a513a6662f0b593a7e077b7af2650057e6d9670469ed937b6ddc7c0073868df6833721c2ba3c81a8f691c46
7
+ data.tar.gz: 92df8d83ddbabcdd3cbeaa30f26868da4a13c168d5f97600475f552a197f7ebba70a80cc6ab272f98e2a68c1b8bd9d9a9094d3b01235ec556790f4a944337fee
data/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.13.0] - 2026-08
4
+
5
+ ### Added
6
+ - Derivations. A derivation puts the words first and the result last, and
7
+ joins what each step combines with one rule drawn across all of it rather
8
+ than with a line to each daughter. `derivation` draws the rules and
9
+ `direction: btt` turns the tree over; together they give the format
10
+ categorial grammar is written in, and `derivation` on its own marks the
11
+ spans of an ordinary tree. The name of each step rides in the label after a
12
+ column break and is set beside the end of its rule. A derivation runs down
13
+ the page and is drawn with its rules, so `direction: ltr` and hiding the
14
+ default connectors are refused rather than approximated.
15
+ - `direction: btt`, the layout turned over, leaves first.
16
+
17
+ ### Fixed
18
+ - Connectors follow the tree when it is turned over. They kept the ends they
19
+ have when the root is at the top, so every line ran from above the daughter,
20
+ through its label and the mother's, to below the mother.
21
+
22
+ ## [1.12.0] - 2026-08
23
+
24
+ Everything needed to write this notation is now available as text, built from
25
+ the files the tool itself reads, and a refusal says where that text is.
26
+
27
+ ### Added
28
+ - `--examples` prints every published example with the options it was drawn
29
+ with. The gallery examples are checked against the parser that draws them,
30
+ so each one is known to be accepted.
31
+ - The site carries the same material as plain text for a reader that can fetch
32
+ a URL but cannot run a command: `llms.txt` and `llms-full.txt`, the second
33
+ holding the reference, the manual and every example in one file. Both are
34
+ generated from their sources, and a test fails if they fall behind.
35
+ - A refusal names where the notation is written down, once, alongside the
36
+ cause and the fix. A hint repairs the mistake in front of it and says
37
+ nothing about the rest, which is not enough for a caller that was guessing.
38
+
39
+ ### Changed
40
+ - The angle bracket the reference recommends is U+27E8 and U+27E9, which it
41
+ had always named while writing U+3008 and U+3009 in its own examples. The
42
+ East Asian pair draws a full em wide, wider than a capital, and had spread
43
+ through the gallery and into the repair that corrects ASCII brackets. Both
44
+ pairs still parse; documents written before this are unaffected.
45
+ - Two feature-structure figures are redrawn at the narrower bracket.
46
+
47
+ ### Fixed
48
+ - `--notation` and `--examples` answer before the input is read. Reading first
49
+ meant waiting on a pipe that never closes when stdin is not a terminal,
50
+ which is how a script reaches them.
51
+
3
52
  ## [1.11.0] - 2026-08
4
53
 
5
54
  Every input the tool accepts now draws, and every input it refuses says why.
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,15 +122,18 @@ 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
130
+ opt :examples, "Print every gallery example, with the options each needs, to stdout and exit", short: :none
129
131
  opt :help, "This is a custom help message", short: :h
130
132
  end
131
133
 
132
134
  Optimist.die :outdir, "must be an exsting directory path" unless FileTest.directory?(opts[:outdir])
133
- 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])
134
137
  Optimist.die :hyphen, "must be markup or literal" unless OPTION_VALUES[:hyphen].include?(opts[:hyphen])
135
138
  Optimist.die :format, "must be #{FORMAT_LIST_MESSAGE}" unless FORMATS.include?(opts[:format])
136
139
  Optimist.die :leafstyle, "must be auto, triangle, bar, or nothing" unless OPTION_VALUES[:leafstyle].include?(opts[:leafstyle])
@@ -171,6 +174,21 @@ if %w[jpg gif].include?(string_opts[:format])
171
174
  warn "Warning: #{string_opts[:format].upcase} output is deprecated and will be removed in RSyntaxTree 2.0. Use PNG instead."
172
175
  end
173
176
 
177
+ # These print and exit, so they answer before the input is read. Reading
178
+ # first meant waiting on a pipe that never closes when stdin is not a
179
+ # terminal, which is how they are reached from a script.
180
+ if opts[:notation]
181
+ puts File.read(File.expand_path("../lib/rsyntaxtree/notation_core.md", __dir__))
182
+ exit 0
183
+ end
184
+
185
+ # The examples are the reference's counterpart: the rules in brief there, and
186
+ # here every published tree with the options it was drawn with.
187
+ if opts[:examples]
188
+ puts File.read(File.expand_path("../lib/rsyntaxtree/notation_examples.md", __dir__))
189
+ exit 0
190
+ end
191
+
174
192
  # An argument names a file only if it really is one: File.exist? on an
175
193
  # expanded empty string answers for the current directory, and reading that
176
194
  # as the tree ended the run with a Ruby error.
@@ -187,11 +205,6 @@ data = if ARGV[0] && !ARGV[0].empty? && File.file?(File.expand_path(ARGV[0]))
187
205
  # Auto-convert Penn TreeBank format to bracket notation
188
206
  data = RSyntaxTree::FormatConverter.to_bracket(data) if data
189
207
 
190
- if opts[:notation]
191
- puts File.read(File.expand_path("../lib/rsyntaxtree/notation_core.md", __dir__))
192
- exit 0
193
- end
194
-
195
208
  # Validate without drawing or writing anything: the diagnosis goes to stdout
196
209
  # as JSON and the exit code carries the verdict.
197
210
  if opts[:validate]
@@ -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
@@ -234,9 +246,91 @@ module RSyntaxTree
234
246
  end
235
247
  target.height = accum_array.max - target.vertical_indent
236
248
  accum_array.max
249
+ end.tap { level_derivation_rows if id == 1 && @derivation }
250
+ end
251
+
252
+ # In a derivation every premise is given at the start, so the words stand in
253
+ # one row and each step sits below everything it draws on. A node's row is
254
+ # therefore its distance from the words rather than its distance from the
255
+ # root: a step joining a category to a derivation three deep goes below
256
+ # both. Placed by depth from the root instead, the words come out in a
257
+ # staircase, each at the depth of its own branch.
258
+ #
259
+ # Run after the ordinary placement, so the row heights and the drop between
260
+ # them are the ones that pass settled on.
261
+ # Levelled twice, and the two are not the same pass. The first runs inside
262
+ # the layout, because tidy packs subtrees by their contours and a contour is
263
+ # read off the vertical positions: rows it has not seen levelled pack as
264
+ # though the figure were a tree, and two rules of one row come out
265
+ # overlapping. The second runs after the layout has been turned over, since
266
+ # turning it over sets each box against its own bottom edge, which leaves a
267
+ # tall label — a feature matrix, say — standing proud of its row. Counting
268
+ # rows from the end the words are at is what makes the second pass the same
269
+ # reckoning as the first, read the other way up.
270
+ def level_derivation_rows(flipped: false)
271
+ root_height = subtree_height(1)
272
+ rows = {}
273
+ tallest = Hash.new(0.0)
274
+ @element_list.get_elements.each do |e|
275
+ height = subtree_height(e.id)
276
+ row = flipped ? height : root_height - height
277
+ rows[e.id] = row
278
+ tallest[row] = [tallest[row], e.content_height].max
279
+ end
280
+
281
+ drop = @global[:height_connector].to_f
282
+ top = {}
283
+ y = 0.0
284
+ tallest.keys.sort.each do |row|
285
+ top[row] = y
286
+ y += tallest[row] + drop
287
+ end
288
+
289
+ @element_list.get_elements.each { |e| e.vertical_indent = top[rows[e.id]] }
290
+
291
+ # Which row each element ended up in, kept for the drawing: a rule belongs
292
+ # between two rows, not between two labels. Placed from the label instead,
293
+ # a step whose result is taller than its neighbours — a feature matrix
294
+ # among plain categories — had its rule drawn at a height of its own while
295
+ # the rules beside it stayed on the row.
296
+ @derivation_rows = rows
297
+ end
298
+
299
+ # How far a row reaches, read from the labels as they stand. Taken when the
300
+ # rows were levelled it would be the measured height, and the drawing works
301
+ # to a slightly different one.
302
+ def derivation_row_extent(row)
303
+ @derivation_extents ||= {}
304
+ @derivation_extents[row] ||= begin
305
+ members = @element_list.get_elements.select { |e| @derivation_rows[e.id] == row }
306
+ [members.map(&:vertical_indent).min,
307
+ members.map { |e| e.vertical_indent + e.content_height }.max]
237
308
  end
238
309
  end
239
310
 
311
+ # The rule for a step runs between the row its result sits in and the row
312
+ # holding the last of its premises — always the next row along, since a step
313
+ # is one taller than the tallest thing it draws on.
314
+ def derivation_rule_band(parent)
315
+ return nil unless @derivation_rows
316
+
317
+ row = @derivation_rows[parent.id]
318
+ neighbour = row + (@direction == "btt" ? -1 : 1)
319
+ return nil unless @derivation_rows.value?(neighbour)
320
+
321
+ here = derivation_row_extent(row)
322
+ there = derivation_row_extent(neighbour)
323
+ @direction == "btt" ? [there[1], here[0]] : [here[1], there[0]]
324
+ end
325
+
326
+ # Steps from +id+ down to the furthest word under it. A word is zero.
327
+ def subtree_height(id)
328
+ node = @element_list.get_id(id)
329
+ return 0 if node.children.empty?
330
+
331
+ 1 + node.children.map { |c| subtree_height(c) }.max
332
+ end
333
+
240
334
  def make_balance(id = 1)
241
335
  target = @element_list.get_id(id)
242
336
  if target.children.empty?
@@ -305,6 +399,15 @@ module RSyntaxTree
305
399
  parent = @element_list.get_id(id)
306
400
  children = parent.children.map { |c| @element_list.get_id(c) }
307
401
 
402
+ # A derivation joins its premises with one rule drawn across all of them,
403
+ # not with a line to each. The rule replaces the lines, so a tree is drawn
404
+ # this way throughout or not at all.
405
+ if @derivation && !children.empty?
406
+ rule_to_parent(parent, children)
407
+ parent.children.each { |c| draw_connector(c) }
408
+ return
409
+ end
410
+
308
411
  if children.size == 1
309
412
  child = children[0]
310
413
  case @leafstyle
@@ -345,6 +448,20 @@ module RSyntaxTree
345
448
  (children_indent << target_indent).min
346
449
  end
347
450
 
451
+ # Width to keep clear to the right of a step's rule for the name beside it,
452
+ # with a gap between the name and whatever follows. Zero unless the tree is
453
+ # drawn as a derivation and the step has a name.
454
+ def rule_name_room(element)
455
+ return 0 unless @derivation
456
+
457
+ name = element.rule_name
458
+ return 0 if name.nil? || name.empty?
459
+
460
+ size = (@fontsize * 0.8).round(2)
461
+ width = FontMetrics.get_metrics(name, @fontset[:family], size, :normal, :normal).width
462
+ width + @global[:width_half_x]
463
+ end
464
+
348
465
  def get_rightmost(id = 1)
349
466
  target = @element_list.get_id(id)
350
467
  target_right_end = target.horizontal_indent + target.content_width
@@ -363,6 +480,19 @@ module RSyntaxTree
363
480
  end
364
481
  end
365
482
 
483
+ # Flip the laid-out tree vertically: the root ends up at the bottom and the
484
+ # leaves at the top, which is how a derivation is written — the words at the
485
+ # top, each step below the material it combines. Done after the layout is
486
+ # final, like the horizontal flip, so connectors, triangles, paths and
487
+ # region shades follow from the coordinates without knowing about it.
488
+ def flip_vertical
489
+ elements = @element_list.get_elements
490
+ max_bottom = elements.map { |e| e.vertical_indent + e.content_height }.max
491
+ elements.each do |e|
492
+ e.vertical_indent = max_bottom - (e.vertical_indent + e.content_height)
493
+ end
494
+ end
495
+
366
496
  # Flip the laid-out tree horizontally (RTL linguistics convention: the
367
497
  # first word sits at the right edge). Connectors, triangles, polylines,
368
498
  # movement paths, and region shades all derive from element coordinates,
@@ -425,9 +555,23 @@ module RSyntaxTree
425
555
  x0 -= ext
426
556
  x1 += ext
427
557
  end
558
+ # A step's name is drawn past the right end of its rule, and the rule
559
+ # reaches the full width of what the step combines. Packing sees only the
560
+ # labels, so without this the neighbour is pulled up against the name and
561
+ # two steps read as one long rule.
562
+ x1 = [x1, subtree_right(node.id) + rule_name_room(node)].max if @derivation
428
563
  [node.vertical_indent, node.vertical_indent + node.content_height, x0, x1]
429
564
  end
430
565
 
566
+ # Right edge of the subtree rooted at +id+, from wherever the layout has
567
+ # put things so far.
568
+ def subtree_right(id)
569
+ node = @element_list.get_id(id)
570
+ right = node.horizontal_indent + node.content_width
571
+ node.children.each { |c| right = [right, subtree_right(c)].max }
572
+ right
573
+ end
574
+
431
575
  # Effective-rect list of the subtree rooted at +id+, as [y0, y1, x0, x1].
432
576
  # A region-shaded (%) node additionally contributes the shade's padded
433
577
  # bounding rect so that neighboring subtrees keep clear of the plane.
@@ -900,6 +1044,10 @@ module RSyntaxTree
900
1044
  finalize_ltr if @direction == "ltr"
901
1045
 
902
1046
  # RTL flip (mirror option): after the layout is final, before drawing
1047
+ # btt is the top-to-bottom layout turned over, so the layout runs as
1048
+ # usual and the flip comes after it.
1049
+ flip_vertical if @direction == "btt"
1050
+ level_derivation_rows(flipped: true) if @derivation && @direction == "btt"
903
1051
  mirror_layout if @mirror
904
1052
 
905
1053
  draw_elements
@@ -915,6 +1063,9 @@ module RSyntaxTree
915
1063
  max_x = r if r > max_x
916
1064
  max_y = b if b > max_y
917
1065
  end
1066
+ # A rule's name is set beside the rule, past the right edge of every box
1067
+ # the loop above measured.
1068
+ max_x = @rule_name_edge if @rule_name_edge.to_f > max_x
918
1069
  width = max_x + @global[:h_gap_between_nodes]
919
1070
  height = max_y
920
1071
  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
 
@@ -133,8 +161,8 @@ module RSyntaxTree
133
161
  ->(s) { s.sub(/\A%?@(?:#[0-9a-zA-Z]+|[a-zA-Z]+)?:/, "") },
134
162
  "A color is @name: with a CSS color name, or @#rgb: / @#rrggbb: with 3 or 6 hex digits (e.g. @blue:VP, @#3af:VP)."],
135
163
  [:angle_brackets,
136
- ->(s) { s.gsub(/(?<!\\)<([^<>]*[^<>\d][^<>]*)>/) { "〈#{$1}" } },
137
- "'<' and '>' mark whitespace here, not a list. Write the angle bracket characters themselves: NP〉, 'handSUBJ,OBJ'."],
164
+ ->(s) { s.gsub(/(?<!\\)<([^<>]*[^<>\d][^<>]*)>/) { "⟨#{$1}" } },
165
+ "'<' and '>' mark whitespace here, not a list. Write the angle bracket characters themselves: NP⟩, 'handSUBJ,OBJ'."],
138
166
  [:bare_hyphen,
139
167
  ->(s) { Element.escape_hyphens(s) },
140
168
  "A hyphen opens an underline. Escape it (e.g. f\\-structure, V\\-bar) or set the hyphen option to literal."],
@@ -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.
@@ -175,7 +213,7 @@ module RSyntaxTree
175
213
  # away, one of them starting the split and the next ending it.
176
214
  if label.strip.empty?
177
215
  return details.merge(code: :label_split,
178
- hint: "Raw spaces left this label empty. Write a space inside a label as <> (e.g. 〈<>NP<>〉).",
216
+ hint: "Raw spaces left this label empty. Write a space inside a label as <> (e.g. ⟨<>NP<>⟩).",
179
217
  retryable: true)
180
218
  end
181
219
 
@@ -195,7 +233,7 @@ module RSyntaxTree
195
233
  # a hint that admits no one cause was found rather than naming a wrong
196
234
  # one. retryable: false is for what no rewriting reaches.
197
235
  details.merge(code: :invalid_markup,
198
- hint: "No single cause fits, which usually means more than one mistake in this label. Check that *, _, =, ~, |, { and #(...#) are paired, that hyphens are escaped, and that are the angle bracket characters.",
236
+ hint: "No single cause fits, which usually means more than one mistake in this label. Check that *, _, =, ~, |, { and #(...#) are paired, that hyphens are escaped, and that are the angle bracket characters.",
199
237
  retryable: true)
200
238
  end
201
239
 
@@ -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,6 +69,17 @@ 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]
74
85
  case @leafstyle
@@ -275,6 +286,7 @@ module RSyntaxTree
275
286
  font_size: (@params[:fontsize] / FONT_SCALING).round,
276
287
  color: @params[:color],
277
288
  connector: @params[:leafstyle],
289
+ derivation: @params[:derivation] == true,
278
290
  connector_height: @params[:vheight],
279
291
  horizontal_spacing: @params[:hspacing] || 1.0,
280
292
  line_width: @params[:linewidth],
@@ -16,10 +16,10 @@ Within a label:
16
16
 
17
17
  Four traps, because these characters already mean something:
18
18
  - `<` and `>` are never angle brackets in this notation: `<>` is one space,
19
- `<3>` is three. Wherever linguistics uses angle brackets — a list NP〉,
20
- an argument structure like 'handSUBJ,OBJ', anything — write the
21
- characters and themselves (U+27E8 and U+27E9):
22
- SPR\t〈<>NP<>〉 PRED\t'handSUBJ,OBJ'
19
+ `<3>` is three. Wherever linguistics uses angle brackets — a list NP⟩,
20
+ an argument structure like 'handSUBJ,OBJ', anything — write the
21
+ characters and themselves (U+27E8 and U+27E9):
22
+ SPR\t⟨<>NP<>⟩ PRED\t'handSUBJ,OBJ'
23
23
  - `-` opens and closes an underline, so a bare hyphen in ANY word is an
24
24
  error: V-bar, f-structure, HEAD-DTR. Write `V'`, or escape the hyphen:
25
25
  `V\-bar`, `f\-structure`, `HEAD\-DTR`.
@@ -35,14 +35,14 @@ An attribute-value matrix is columns plus an enclosure:
35
35
 
36
36
  [#*word*\
37
37
  HEAD\t*verb*\
38
- SPR\t〈<>NP<>〉\
39
- COMPS\t〈<>〉
38
+ SPR\t⟨<>NP<>⟩\
39
+ COMPS\t⟨<>⟩
40
40
  ]
41
41
 
42
42
  A value can be another matrix. Nest it with `#( ... #)`, never with square
43
43
  brackets — those are read as tree structure:
44
44
 
45
- [#PRED\t'handSUBJ,OBJ'\
45
+ [#PRED\t'handSUBJ,OBJ'\
46
46
  TENSE\tpast\
47
47
  SUBJ\t#(PRED\t'David'#)
48
48
  ]