rsyntaxtree 1.12.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: 160b79f9047cb7674fe1c980dcf9e1a4044ca2c68eb3c758eabc9389d73d829a
4
- data.tar.gz: 648c5297ccfbaefde5c2904cc5266ed56207487bc6ac64320041ac7e135e4814
3
+ metadata.gz: 32cc6cec6f19c5fe62dc9a6de16493d6db35680f72e40b6bbd36f0b1e98add96
4
+ data.tar.gz: b6f928fd6919bb289765fac684ae3e98f7988103985deebcb6f73e7f8836e939
5
5
  SHA512:
6
- metadata.gz: 5465a152a7de5a453731cee9595f5d98636640fc8f97f7a21320e3ac66d14cf9876c10941518f936b586e0e6613944c2eb6e05175b6740a49aab544c97008e54
7
- data.tar.gz: 78c83cee893a040d10959508199f354d3c568aa2a65fcba7bb486ddce6839314f6fcbd7d094c759804a77df07df660811508949ab7ffb9a9a7e81559e0e85b89
6
+ metadata.gz: 50d8b1e91e3c71286406bf45e5e5cdeb2d8170f98a513a6662f0b593a7e077b7af2650057e6d9670469ed937b6ddc7c0073868df6833721c2ba3c81a8f691c46
7
+ data.tar.gz: 92df8d83ddbabcdd3cbeaa30f26868da4a13c168d5f97600475f552a197f7ebba70a80cc6ab272f98e2a68c1b8bd9d9a9094d3b01235ec556790f4a944337fee
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
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
+
3
22
  ## [1.12.0] - 2026-08
4
23
 
5
24
  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
@@ -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
 
@@ -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,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],
@@ -276,73 +276,24 @@ Source: Radford 2004
276
276
  ]
277
277
  ```
278
278
 
279
- ## 011 — CCG sample
279
+ ## 011 — CCG derivation: forward and backward application
280
280
 
281
281
  Category: Combinatory Categorial Grammar
282
- Settings: color=none fontstyle=noto-serif leafstyle=nothing tidy=low
282
+ Settings: color=none derivation=on direction=btt fontstyle=noto-serif leafstyle=nothing tidy=low vheight=0.7
283
+ Source: Steedman 2000
283
284
 
284
285
  ```
285
- [S\
286
- \<
287
- [NP\
288
- \>
289
- [NP/N\
290
- ---\
291
- *the*
292
- ]
293
- [N\
294
- ---\
295
- *dog*
296
- ]
297
- ]
298
- [S\\NP\
299
- \>
300
- [(S\\NP)\\NP\
301
- ---\
302
- *bit*
303
- ]
304
- [NP\
305
- ---\
306
- *John*
307
- ]
308
- ]
309
- ]
286
+ [S\t< [NP\t> [NP/N the] [N dog]] [S\\NP\t> [(S\\NP)/NP bit] [NP John]]]
310
287
  ```
311
288
 
312
- ## 012 — CCG sample
289
+ ## 012 — CCG derivation: a relative clause
313
290
 
314
291
  Category: Combinatory Categorial Grammar
315
- Settings: color=none fontstyle=noto-serif leafstyle=nothing tidy=low vheight=1.5
292
+ Settings: color=none derivation=on direction=btt fontstyle=noto-serif leafstyle=nothing tidy=low vheight=0.7
293
+ Source: Steedman 2000
316
294
 
317
295
  ```
318
- [S\
319
- \>
320
- [S/NP\
321
- B_\>_
322
- [S/(S\\NP)\
323
- T_\>_
324
- [NP\
325
- \>
326
- [NP/N\
327
- ---\
328
- *the*
329
- ]
330
- [N\
331
- ---\
332
- *dog*
333
- ]
334
- ]
335
- ]
336
- [(S\\NP)/NP\
337
- ---\
338
- *bit*
339
- ]
340
- ]
341
- [NP\
342
- ---\
343
- *John*
344
- ]
345
- ]
296
+ [NP\t> [NP/N the] [N\t< [N man] [N\\N\t> [(N\\N)/(S/NP) that] [S/NP\t>B [S/(S\\NP)\t>T [NP Mary]] [(S\\NP)/NP saw]]]]]
346
297
  ```
347
298
 
348
299
  ## 013 — HPSG sample
@@ -41,7 +41,6 @@ module RSyntaxTree
41
41
  end
42
42
 
43
43
  @data = string # Store it for later...
44
- fontset[:family] = fontset[:family_cjk] if @data.contains_cjk?
45
44
  @elist = ElementList.new # Initialize internal element list
46
45
  @pos = 0 # Position in the sentence
47
46
  @id = 1 # ID for the next element
@@ -149,7 +148,13 @@ module RSyntaxTree
149
148
  else
150
149
  token += ch
151
150
  end
152
- when /[nt{}<>^+*_=~|%-]/
151
+ # The characters a backslash may take, which is the grammar's own list
152
+ # (Markup's `escaped` rule) plus the two letters that name a break. A
153
+ # backslash before anything else is dropped here, and '#' was missing:
154
+ # `\#` arrived at the grammar as a bare '#', which opens an enclosure,
155
+ # so a label written with a hash in it lost the hash and everything
156
+ # after it went inside brackets instead.
157
+ when /[nt{}<>^+*_=~|%\-#]/
153
158
  if escape
154
159
  token += '\\' + ch
155
160
  escape = false
@@ -202,7 +207,7 @@ module RSyntaxTree
202
207
  parts[1] = token_r[spaceat, tl - spaceat].join
203
208
 
204
209
  element = begin
205
- Element.new(@id, parent, parts[0], @level, @fontset, @fontsize, @global)
210
+ Element.new(@id, parent, parts[0], @level, @fontset, @fontsize, @global, true)
206
211
  rescue RSTError => e
207
212
  # The first raw space splits a token into the node's label
208
213
  # and its children (that split is the notation's core rule,
@@ -227,7 +232,7 @@ module RSyntaxTree
227
232
  @id += 1
228
233
  else
229
234
  joined = token_r.join
230
- element = Element.new(@id, parent, joined, @level, @fontset, @fontsize, @global)
235
+ element = Element.new(@id, parent, joined, @level, @fontset, @fontsize, @global, true)
231
236
  @id += 1
232
237
  newparent = element.id
233
238
  end
@@ -469,7 +469,7 @@ module RSyntaxTree
469
469
  style += "font-style: italic; " if e[:decoration].include?(:italic) || e[:decoration].include?(:bolditalic)
470
470
  style += "\""
471
471
 
472
- fontstyle = FontFamily.for_svg(@fontstyle, cjk_priority: e[:cjk])
472
+ fontstyle = FontFamily.for_svg(@fontstyle)
473
473
 
474
474
  if e[:decoration].include?(:box) || e[:decoration].include?(:circle) || e[:decoration].include?(:bar)
475
475
  # Measured around the enclosed marks in Element#setup; the line
@@ -878,6 +878,105 @@ module RSyntaxTree
878
878
  # (see Element#empty_label?) is a pass-through joint: the line runs to
879
879
  # its center instead of stopping at its (invisible) text box, so the
880
880
  # segments on both sides connect without a gap.
881
+ # One rule across all the premises, in place of a line to each. The rule
882
+ # runs the width of what it combines — the span from the leftmost child's
883
+ # left edge to the rightmost child's right edge — because that span is what
884
+ # the step applies to, and reading it off the children is what keeps it
885
+ # right when the layout moves.
886
+ #
887
+ # Drawn at the parent's edge facing the children, so it sits between the
888
+ # material above it and the result below in a derivation, and between a
889
+ # node and its daughters in an ordinary tree.
890
+ def rule_to_parent(parent, children)
891
+ return if children.empty?
892
+
893
+ # The whole of what the step combines, not the labels of its premises:
894
+ # a premise stands over a derivation of its own, and the rule is drawn
895
+ # across all of it. Taken from the subtree extents, which is what the
896
+ # layout already computes for the same purpose.
897
+ left = children.map { |c| get_leftmost(c.id) }.min
898
+ right = children.map { |c| get_rightmost(c.id) }.max
899
+ # The parent may be wider than everything it combines, which happens when
900
+ # a category is longer than the words under it. The rule covers both, so
901
+ # that the label it belongs to is never wider than its own rule.
902
+ left = [left, parent.horizontal_indent].min
903
+ right = [right, parent.horizontal_indent + parent.content_width].max
904
+
905
+ hctt = @global[:height_connector_to_text]
906
+ above, below = derivation_rule_band(parent)
907
+ y = if above
908
+ (above + hctt + below) / 2.0
909
+ else
910
+ child_edge = children.map { |c| c.vertical_indent + c.content_height }.max
911
+ parent_top = parent.vertical_indent
912
+ if parent_top >= child_edge
913
+ # The parent sits below: the rule goes between the two.
914
+ (child_edge + hctt + parent_top) / 2.0
915
+ else
916
+ # The parent sits above (an ordinary top-to-bottom tree).
917
+ (parent.vertical_indent + parent.content_height + hctt +
918
+ children.map(&:vertical_indent).min) / 2.0
919
+ end
920
+ end
921
+
922
+ @tree_data += @line_styles.sub(/X1/, left.to_s).sub(/Y1/, y.to_s)
923
+ .sub(/X2/, right.to_s).sub(/Y2/, y.to_s)
924
+
925
+ draw_rule_name(parent, right, y)
926
+ end
927
+
928
+ # The name of the rule, set beside the end of its own line and smaller than
929
+ # the categories, which is where a derivation puts it and how it keeps the
930
+ # name from reading as part of either row.
931
+ def draw_rule_name(parent, right, y)
932
+ name = parent.rule_name
933
+ return if name.nil? || name.empty?
934
+
935
+ size = (@fontsize * 0.8).round(2)
936
+ # Level with the rule, by the same reckoning an enclosure uses to sit
937
+ # level with what it encloses.
938
+ baseline = y + FontMetrics.visual_centre(@fontset[:family], size)
939
+ x = right + @global[:width_half_x]
940
+ # The name is the one thing drawn outside every element's own box, so the
941
+ # canvas has to be told where it ends or the last step's name is cut off
942
+ # at the edge of the image.
943
+ edge = x + FontMetrics.get_metrics(name, @fontset[:family], size, :normal, :normal).width
944
+ @rule_name_edge = edge if edge > @rule_name_edge.to_f
945
+ @tree_data += @text_styles.sub(/COLOR/, @col_line)
946
+ .sub(/fontsize/, size.to_s + "px;")
947
+ .sub(/X_VALUE/, x.to_s)
948
+ .sub(/Y_VALUE/, baseline.round(2).to_s)
949
+ .sub(/CONTENT/) { CGI.escapeHTML(name) }
950
+ end
951
+
952
+ # Where a connector meets each box in a vertical layout: the underside of
953
+ # whichever sits higher and the top of whichever sits lower. Turned over,
954
+ # the parent is the lower of the two, and keeping the top-to-bottom edges
955
+ # sent the line back up through both labels. An empty label has no text to
956
+ # clear, so the line runs to its middle.
957
+ def connector_edges(child, parent)
958
+ hctt = @global[:height_connector_to_text]
959
+ child_above = child.vertical_indent <= parent.vertical_indent
960
+
961
+ child_y = if child.empty_label?
962
+ child.vertical_indent + child.content_height / 2
963
+ elsif child_above
964
+ child.vertical_indent + child.content_height + hctt
965
+ else
966
+ child.vertical_indent + hctt / 2
967
+ end
968
+
969
+ parent_y = if parent.empty_label?
970
+ parent.vertical_indent + parent.content_height / 2
971
+ elsif child_above
972
+ parent.vertical_indent + hctt / 2
973
+ else
974
+ parent.vertical_indent + parent.content_height + hctt
975
+ end
976
+
977
+ [child_y, parent_y]
978
+ end
979
+
881
980
  def line_to_parent(parent, child)
882
981
  return if child.horizontal_indent.zero?
883
982
 
@@ -915,10 +1014,8 @@ module RSyntaxTree
915
1014
  # TTB: parent's bottom → child's top
916
1015
  if @polyline
917
1016
  chi_x = child.horizontal_indent + child.content_width / 2
918
- chi_y = child.empty_label? ? child.vertical_indent + child.content_height / 2 : child.vertical_indent + @global[:height_connector_to_text] / 2
919
-
920
1017
  par_x = parent.horizontal_indent + parent.content_width / 2
921
- par_y = parent.empty_label? ? parent.vertical_indent + parent.content_height / 2 : parent.vertical_indent + parent.content_height + @global[:height_connector_to_text]
1018
+ chi_y, par_y = connector_edges(child, parent)
922
1019
 
923
1020
  mid_x1 = chi_x
924
1021
  mid_y1 = par_y + (chi_y - par_y) / 2
@@ -936,9 +1033,8 @@ module RSyntaxTree
936
1033
  .sub(/PARY/, par_y.to_s)
937
1034
  else
938
1035
  x1 = child.horizontal_indent + child.content_width / 2
939
- y1 = child.empty_label? ? child.vertical_indent + child.content_height / 2 : child.vertical_indent + @global[:height_connector_to_text] / 2
940
1036
  x2 = parent.horizontal_indent + parent.content_width / 2
941
- y2 = parent.empty_label? ? parent.vertical_indent + parent.content_height / 2 : parent.vertical_indent + parent.content_height + @global[:height_connector_to_text]
1037
+ y1, y2 = connector_edges(child, parent)
942
1038
 
943
1039
  line_data = @line_styles.sub(/X1/, x1.to_s)
944
1040
  line_data = line_data.sub(/Y1/, y1.to_s)
@@ -24,6 +24,22 @@ module RSyntaxTree
24
24
  def initialize(element_list, params)
25
25
  @element_list = element_list
26
26
  @params = params
27
+
28
+ # forest draws a tree: a parent joined to each daughter by an edge of
29
+ # its own, the root at the top. A derivation is neither — one rule
30
+ # across all the daughters at once, the root last — so the code this
31
+ # backend would emit is a correct tree of the same structure, which is
32
+ # a different figure rather than this one with something missing. What
33
+ # it drops elsewhere (colour, enclosures, decoration) leaves a figure
34
+ # that still says what the tree is. These two do not.
35
+ if params[:derivation] == true || params[:direction] == "btt"
36
+ what = params[:derivation] == true ? "a derivation" : "a tree drawn bottom to top"
37
+ raise RSTError.new(+"Error: TikZ output cannot draw #{what}",
38
+ code: :invalid_option,
39
+ hint: "forest joins each daughter with its own edge and puts the root " \
40
+ "at the top. Use png, svg or pdf for this figure.",
41
+ retryable: false)
42
+ end
27
43
  end
28
44
 
29
45
  # Generate TikZ forest code
@@ -61,30 +61,29 @@ FONT_FAMILIES = {
61
61
  }.freeze
62
62
 
63
63
  module FontFamily
64
- # cjk_priority: put the JP font first (mirrors the e[:cjk] branch in
65
- # SVGGraph#draw_element). No-op for the :cjk style, which has no variant.
66
- def list(style, cjk_priority: false)
67
- families = FONT_FAMILIES.fetch(style.to_sym).dup
68
- families[0], families[1] = families[1], families[0] if cjk_priority && style.to_sym != :cjk
69
- families
64
+ # One order, used by the measuring and by the drawing alike. There was a
65
+ # second, with the CJK face moved to the front, which measurement switched to
66
+ # as soon as any character outside ASCII appeared anywhere in the figure while
67
+ # the drawing kept to this one — so Latin text was measured in a face it was
68
+ # not set in. Fontconfig reaches the CJK faces through this order anyway, the
69
+ # scripts that need them measuring the same either way, so the second order
70
+ # bought nothing and is gone.
71
+ def list(style)
72
+ FONT_FAMILIES.fetch(style.to_sym)
70
73
  end
71
74
 
72
- def for_svg(style, cjk_priority: false)
73
- list(style, cjk_priority: cjk_priority).map { |name| name.include?(" ") ? "'#{name}'" : name }.join(", ")
75
+ def for_svg(style)
76
+ list(style).map { |name| name.include?(" ") ? "'#{name}'" : name }.join(", ")
74
77
  end
75
78
 
76
- def for_pango(style, cjk_priority: false)
77
- list(style, cjk_priority: cjk_priority).join(", ")
79
+ def for_pango(style)
80
+ list(style).join(", ")
78
81
  end
79
82
 
80
83
  module_function :list, :for_svg, :for_pango
81
84
  end
82
85
 
83
86
  class String
84
- def contains_cjk?
85
- !!(gsub(WHITESPACE_BLOCK, "") =~ /\p{Han}|\p{Katakana}|\p{Hiragana}|\p{Hangul}|[^\x01-\x7E]/)
86
- end
87
-
88
87
  def contains_emoji?
89
88
  !!(gsub(WHITESPACE_BLOCK, "").gsub(/\d/, "") =~ /\p{Emoji}/)
90
89
  end
@@ -127,6 +126,19 @@ module FontMetrics
127
126
  # 1.25x, so mixed-language documents had inconsistent spacing).
128
127
  LINE_HEIGHT_FACTOR = 1.4
129
128
 
129
+ # How far below the baseline the visual centre of a line of text sits.
130
+ #
131
+ # Text is placed by its baseline, so anything meant to sit level with it — a
132
+ # box drawn around it, a rule beside it — has to know where the middle of the
133
+ # marks is. The answer is half the height of a capital, not half of whatever
134
+ # is written: centring on the marks themselves puts the middle of an `s`
135
+ # lower than the middle of a `G`, and centring on the whole cap-to-descender
136
+ # band sits low around digits and capitals, which reach nothing below the
137
+ # baseline and are most of what a label holds.
138
+ def visual_centre(font, fontsize)
139
+ get_metrics("X", font, fontsize, :normal, :normal).ink_above / 2.0
140
+ end
141
+
130
142
  # Measures text with Pango, the same engine (and the same font fallback
131
143
  # resolution) that librsvg uses to render the SVG output, so widths stay
132
144
  # consistent with the drawn output for any script. `font` is a
@@ -152,5 +164,5 @@ module FontMetrics
152
164
  @pango_layout ||= Pango::Layout.new(Pango::CairoFontMap.default.create_context)
153
165
  end
154
166
 
155
- module_function :get_metrics, :pango_layout
167
+ module_function :get_metrics, :pango_layout, :visual_centre
156
168
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RSyntaxTree
4
- VERSION = "1.12.0"
4
+ VERSION = "1.13.0"
5
5
  end
data/lib/rsyntaxtree.rb CHANGED
@@ -58,8 +58,9 @@ OPTION_VALUES = {
58
58
  fontstyle: ["sans", "serif", "cjk", "mono", "noto-sans", "noto-serif", "noto-sans-mono", "cjk zenhei"],
59
59
  color: %w[modern traditional gray grey off none on true false],
60
60
  tidy: %w[off symmetric low medium high on compact true false],
61
- direction: %w[ttb ltr],
62
- hyphen: %w[markup literal]
61
+ direction: %w[ttb ltr btt],
62
+ hyphen: %w[markup literal],
63
+ derivation: %w[on off true false yes no 0 1 none]
63
64
  }.freeze
64
65
 
65
66
  NUMERIC_RANGES = {
@@ -86,7 +87,8 @@ DEFAULT_OPTS = {
86
87
  tidy: "off",
87
88
  hspacing: 1.0,
88
89
  direction: "ttb",
89
- hyphen: "markup"
90
+ hyphen: "markup",
91
+ derivation: "off"
90
92
  }.freeze
91
93
 
92
94
  # A parse or generation failure. `message` is the human-readable text the
@@ -202,7 +204,7 @@ module RSyntaxTree
202
204
  else
203
205
  "off"
204
206
  end
205
- when :tidy_nest, :symmetrize, :transparent, :polyline, :hide_default_connectors, :mirror
207
+ when :tidy_nest, :symmetrize, :transparent, :polyline, :hide_default_connectors, :mirror, :derivation
206
208
  new_params[key] = switched_on?(value)
207
209
  when :color
208
210
  new_params[key] = case value.to_s
@@ -234,7 +236,6 @@ module RSyntaxTree
234
236
  else :sans
235
237
  end
236
238
  fontset[:family] = FontFamily.for_pango(style)
237
- fontset[:family_cjk] = FontFamily.for_pango(style, cjk_priority: style != :cjk)
238
239
  new_params[:fontstyle] = style.to_s
239
240
  else
240
241
  new_params[key] = value
@@ -256,12 +257,43 @@ module RSyntaxTree
256
257
  # fall back to the merged default style otherwise
257
258
  if fontset[:family].nil?
258
259
  fontset[:family] = FontFamily.for_pango(@params[:fontstyle])
259
- fontset[:family_cjk] = FontFamily.for_pango(@params[:fontstyle], cjk_priority: true)
260
260
  end
261
261
  @params[:fontset] = fontset
262
262
  single_x_metrics = FontMetrics.get_metrics("X", fontset[:family], @params[:fontsize], :normal, :normal)
263
263
  @global = {}
264
+ # A derivation is written down the page: the rule that joins the
265
+ # premises is a horizontal line across them, and the result sits under
266
+ # it. Laid out left to right there is nothing for that line to span, and
267
+ # the drawing came out with rules struck through the categories. There
268
+ # is no left-to-right convention for a derivation to fall back on, so
269
+ # the combination is refused rather than approximated.
270
+ if @params[:derivation] == true && @params[:direction] == "ltr"
271
+ raise RSTError.new(+"Error: a derivation cannot be drawn left to right",
272
+ code: :invalid_option,
273
+ hint: "A derivation runs down the page. Use direction ttb or btt, " \
274
+ "or turn derivation off.",
275
+ retryable: false)
276
+ end
277
+
278
+ # Hiding the default connectors draws them in the background colour
279
+ # rather than skipping them. A derivation's rules are drawn as connectors
280
+ # but they are the figure itself, not a default the drawing adds, so
281
+ # hiding them leaves rows of categories floating with nothing joining
282
+ # them. Refused for the same reason as left to right.
283
+ if @params[:derivation] == true && @params[:hide_default_connectors] == true
284
+ raise RSTError.new(+"Error: a derivation's rules cannot be hidden",
285
+ code: :invalid_option,
286
+ hint: "The rules are what a derivation is drawn with, not a " \
287
+ "connector added to it. Turn off hide default connectors, " \
288
+ "or turn derivation off.",
289
+ retryable: false)
290
+ end
291
+
264
292
  @global[:single_x_metrics] = single_x_metrics
293
+ # A derivation labels each step with the rule it applied, written at the
294
+ # right end of the line. Elements need to know, because the name is taken
295
+ # out of the label before it is measured.
296
+ @global[:derivation] = @params[:derivation] == true
265
297
  @global[:height_connector_to_text] = single_x_metrics.height / 2.0
266
298
  @global[:single_line_height] = single_x_metrics.height * 2.0
267
299
  @global[:width_half_x] = single_x_metrics.width / 2.0
data/rsyntaxtree.gemspec CHANGED
@@ -12,10 +12,18 @@ Gem::Specification.new do |s|
12
12
  s.description = "Syntax tree generator made with Ruby"
13
13
  s.licenses = ["MIT"]
14
14
  s.required_ruby_version = ">= 3.2.0"
15
- # Exclude the documentation site (gallery assets), development tools and
16
- # tests from the installed gem; they live in the repository.
15
+ # What the gem carries is what someone installing it needs: the library, the
16
+ # command, the notation reference the command prints, and the files that say
17
+ # what this is, how to cite it and under what licence. The rest of the
18
+ # repository is for working on it — the documentation site and its gallery,
19
+ # the scripts that build them, the tests, the CI and container setup, the
20
+ # bundle and the rake tasks — and whoever wants those clones the repository.
21
+ development = ["docs/", "dev/", "img/", "test/", ".github/"]
22
+ development_files = [".gitattributes", ".gitignore", ".ruby-version",
23
+ "Dockerfile", "Gemfile", "Rakefile"]
17
24
  s.files = `git ls-files`.split("\n").reject do |path|
18
- path.start_with?("docs/", "dev/", "img/", "test/")
25
+ development.any? { |dir| path.start_with?(dir) } ||
26
+ development_files.include?(path)
19
27
  end
20
28
  s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
21
29
  s.require_paths = ["lib"]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rsyntaxtree
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.12.0
4
+ version: 1.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yoichiro Hasebe
@@ -143,18 +143,10 @@ executables:
143
143
  extensions: []
144
144
  extra_rdoc_files: []
145
145
  files:
146
- - ".gitattributes"
147
- - ".github/FUNDING.yml"
148
- - ".github/workflows/test.yml"
149
- - ".gitignore"
150
- - ".ruby-version"
151
146
  - CHANGELOG.md
152
147
  - CITATION.cff
153
- - Dockerfile
154
- - Gemfile
155
148
  - LICENSE
156
149
  - README.md
157
- - Rakefile
158
150
  - bin/rsyntaxtree
159
151
  - lib/rsyntaxtree.rb
160
152
  - lib/rsyntaxtree/base_graph.rb
data/.gitattributes DELETED
@@ -1,10 +0,0 @@
1
- # Text files are stored with LF and checked out with LF, whatever the
2
- # editor on the machine does. Two of the documentation pages had drifted
3
- # to CRLF, which turned a three-line edit into a whole-file diff.
4
- * text=auto eol=lf
5
-
6
- *.png binary
7
- *.jpg binary
8
- *.gif binary
9
- *.pdf binary
10
- *.gem binary
data/.github/FUNDING.yml DELETED
@@ -1,6 +0,0 @@
1
- # These are supported funding model platforms
2
-
3
- github: [yohasebe]
4
- ko_fi: yohasebe
5
- buy_me_a_coffee: yohasebe
6
- # custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
@@ -1,35 +0,0 @@
1
- name: Test
2
-
3
- on:
4
- push:
5
- branches: [master]
6
- pull_request:
7
- branches: [master]
8
- workflow_dispatch:
9
-
10
- jobs:
11
- test:
12
- runs-on: ubuntu-latest
13
- strategy:
14
- matrix:
15
- ruby-version: ["3.2", "3.4"]
16
- steps:
17
- - uses: actions/checkout@v4
18
-
19
- - name: Install system dependencies
20
- run: |
21
- sudo apt-get update
22
- sudo apt-get install -y \
23
- libpango1.0-dev librsvg2-dev libmagickwand-dev \
24
- libgirepository1.0-dev gobject-introspection \
25
- fonts-noto-core fonts-noto-cjk
26
- sudo fc-cache -f
27
-
28
- - name: Set up Ruby ${{ matrix.ruby-version }}
29
- uses: ruby/setup-ruby@v1
30
- with:
31
- ruby-version: ${{ matrix.ruby-version }}
32
- bundler-cache: true
33
-
34
- - name: Run tests
35
- run: bundle exec rake test
data/.gitignore DELETED
@@ -1,25 +0,0 @@
1
- pkg/
2
- tmp/
3
- doc/
4
- .DS_Store
5
- Gemfile.lock
6
-
7
- *.bak
8
- *.~
9
- *.log
10
- *.gem
11
- log/
12
- logs/
13
-
14
- bin/rsync_code
15
- bin/linktree.rb
16
- tags
17
- .tags
18
- .rubocop.yml
19
- .rubocop_todo.yml
20
- .solargraph.yml
21
-
22
- CLAUDE.md
23
-
24
- # git filter-branch leaves this behind; it is machinery, not source.
25
- .git-rewrite/
data/.ruby-version DELETED
@@ -1 +0,0 @@
1
- 4.0.1
data/Dockerfile DELETED
@@ -1,41 +0,0 @@
1
- FROM ruby:3.3-alpine
2
- ENV WORKSPACE=/rsyntaxtree
3
- WORKDIR $WORKSPACE
4
-
5
- RUN apk update && \
6
- apk upgrade && \
7
- apk add --no-cache linux-headers libxml2-dev make gcc libc-dev bash && \
8
- apk add --no-cache librsvg librsvg-dev pango-dev imagemagick imagemagick-dev xz-dev libbz2 && \
9
- apk add --no-cache gobject-introspection gobject-introspection-dev && \
10
- apk add --no-cache -t .build-packages --no-cache build-base curl-dev wget gcompat && \
11
- apk add --no-cache font-noto font-noto-cjk font-noto-cjk-extra \
12
- font-noto-arabic font-noto-naskh-arabic font-noto-hebrew \
13
- font-noto-devanagari font-noto-thai font-noto-khmer \
14
- font-dejavu
15
-
16
- # Noto Sans Math (pulled in by font-noto) claims the Arabic block but carries no
17
- # joining rules, and fontconfig ranks it above Noto Sans Arabic — Arabic then
18
- # renders as isolated letterforms. Keep the font, since trees use its
19
- # mathematical alphanumerics (the little v of vP), but drop Arabic from it.
20
- COPY dev/fontconfig/99-noto-math-no-arabic.conf /etc/fonts/conf.d/99-noto-math-no-arabic.conf
21
-
22
- # Emoji: the monochrome Noto Emoji, not Alpine's font-noto-emoji (which is the
23
- # colour NotoColorEmoji). Cairo does not rasterise its bitmap glyphs in this
24
- # pipeline, so emoji would silently fall back to whatever outline font happens
25
- # to cover them. Pinned to a google/fonts commit so the gallery stays stable.
26
- ARG NOTO_EMOJI_SHA=b979dba422e445492b0eb9951ac52ee0b4d648c3
27
- ARG NOTO_EMOJI_SHA256=de6c18832938afc99caf132b39d6a30a19bac7f2e812e28db2535b4608d27551
28
- RUN mkdir -p /usr/share/fonts/noto-emoji && \
29
- wget -q -O /usr/share/fonts/noto-emoji/NotoEmoji-Regular.ttf \
30
- "https://raw.githubusercontent.com/google/fonts/${NOTO_EMOJI_SHA}/ofl/notoemoji/NotoEmoji%5Bwght%5D.ttf" && \
31
- echo "${NOTO_EMOJI_SHA256} /usr/share/fonts/noto-emoji/NotoEmoji-Regular.ttf" | sha256sum -c -
32
-
33
-
34
- ADD Gemfile $WORKSPACE
35
- ADD rsyntaxtree.gemspec $WORKSPACE
36
- RUN bundle install -j4
37
-
38
- RUN fc-cache -fv
39
-
40
- ADD . $WORKSPACE
41
- CMD ["/bin/bash"]
data/Gemfile DELETED
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- source "https://rubygems.org"
4
-
5
- gem "optimist"
6
- gem "pango"
7
- gem "parslet"
8
- gem "rmagick"
9
- gem "rsvg2"
10
-
11
- group :development, :test do
12
- gem "minitest"
13
- gem "nokogiri"
14
- gem "rake"
15
- end
data/Rakefile DELETED
@@ -1,111 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/gem_tasks"
4
- require "rake/testtask"
5
- require "yaml"
6
- require_relative 'lib/rsyntaxtree'
7
- require_relative 'lib/rsyntaxtree/utils'
8
-
9
- # task default: "test"
10
-
11
- Rake::TestTask.new do |task|
12
- task.pattern = "test/*_test.rb"
13
- task.warning = false
14
- end
15
-
16
- # Gem packaging preserves on-disk file modes. Owner-only permissions (which a
17
- # Dropbox-synced checkout tends to produce) ship files that are unreadable, and
18
- # a CLI that is unexecutable, for anyone but the owner after `sudo gem install`.
19
- # Normalize every tracked file before the gem is built: a file is executable
20
- # only if it is a command (bin/, exe/) or a script with a shebang; everything
21
- # else is data and gets 0644. Deciding by path/content rather than by the
22
- # current mode also drops stray executable bits picked up from the filesystem.
23
- task :normalize_permissions do
24
- `git ls-files -z`.split("\x0").each do |f|
25
- next unless File.file?(f)
26
-
27
- executable = f.start_with?("bin/", "exe/") ||
28
- File.open(f, "rb") { |io| io.read(2) } == "#!"
29
- File.chmod(executable ? 0o755 : 0o644, f)
30
- end
31
- end
32
-
33
- Rake::Task["build"].enhance([:normalize_permissions])
34
-
35
- # For trying something out. It does NOT reproduce the committed gallery:
36
- # fontconfig resolves a family to whatever this machine has, so the same input
37
- # measures differently here than in the image the figures were drawn with. Run
38
- # over docs/assets it rewrites every PNG and about half the SVGs, none of it an
39
- # intended change. Use docker_generate to update the gallery.
40
- desc "Generate SVG and PNG example images locally (not for updating the gallery)"
41
- task :generate do
42
- require_relative "dev/generate_examples"
43
- end
44
-
45
- # What a language model is given: the reference, the manual and every example,
46
- # built from the files those already live in. Run it after changing any of
47
- # them; llm_payload_test.rb fails if the built files are out of date.
48
- desc "Build the notation payload for language models (gem and site)"
49
- task :llm_payload do
50
- ruby "dev/generate_llm_payload.rb"
51
- end
52
-
53
- desc "Record the pixel size of each gallery figure for the examples page"
54
- task :figure_sizes do
55
- ruby "dev/stamp_figure_sizes.rb"
56
- end
57
-
58
- desc "Docker image Build"
59
- task :docker_build do
60
- `docker build ./ -t rsyntaxtree_devel`
61
- end
62
-
63
- # The gallery is drawn here and nowhere else, so that a figure depends on the
64
- # input and the options rather than on the fonts of whoever regenerated it.
65
- desc "Generate SVG and PNG example images using Docker image (the gallery)"
66
- task :docker_generate do
67
- docpath = File.expand_path(File.join(__dir__, "docs"))
68
- `docker build ./ -t rsyntaxtree_devel`
69
- `docker run --rm -v #{docpath}:/rsyntaxtree/hostdocs rsyntaxtree_devel ruby /rsyntaxtree/dev/generate_examples.rb /rsyntaxtree/hostdocs`
70
- `cat #{docpath}/generate_examples.log`
71
- end
72
-
73
- # The examples page scales every figure by the size recorded here, so the
74
- # record has to be refreshed whenever the figures are.
75
- ["generate", "docker_generate"].each do |name|
76
- Rake::Task[name].enhance { Rake::Task["figure_sizes"].invoke }
77
- end
78
-
79
- # Add new task for macOS environment configuration
80
-
81
- desc "Configure Bundler build options for macOS"
82
- task :setup_macos do
83
- require "rbconfig"
84
- host_os = RbConfig::CONFIG['host_os']
85
-
86
- # Check if the host OS is macOS (Darwin)
87
-
88
- if host_os =~ /darwin/
89
- puts "macOS detected. Setting up build options for native extensions..."
90
-
91
- gems_with_options = {
92
- "gobject-introspection" => '--with-ldflags=-Wl,-undefined,dynamic_lookup',
93
- "cairo-gobject" => '--with-ldflags=-Wl,-undefined,dynamic_lookup',
94
- "gio2" => '--with-ldflags=-Wl,-undefined,dynamic_lookup'
95
- }
96
-
97
- # Configure each gem with the necessary ldflags option using Bundler config command
98
-
99
- gems_with_options.each do |gem_name, flags|
100
- command = "bundle config build.#{gem_name} \"#{flags}\""
101
- puts "Executing: #{command}"
102
- unless system(command)
103
- abort("Failed to execute command: #{command}")
104
- end
105
- end
106
-
107
- puts "macOS setup complete. Please run 'bundle install' after this."
108
- else
109
- puts "This setup task is intended for macOS environments. Current OS: #{host_os}"
110
- end
111
- end