rsyntaxtree 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -83,6 +83,15 @@ module RSyntaxTree
83
83
  # the deepest leaf) keep a margin instead of touching the image edge. A
84
84
  # margin is added around the region extents before unioning with the tree
85
85
  # bounds. x2/y2 are the viewBox width/height, so right = x1 + x2.
86
+ # Bottom to top, a movement path runs above the words, past the top of
87
+ # the tree; the canvas grows upward to hold it.
88
+ if @direction == "btt" && @path_bounds
89
+ top = [y1, @path_bounds[:min_y] - @global[:height_connector_to_text]].min
90
+ y2 += y1 - top
91
+ @height += y1 - top
92
+ y1 = top
93
+ end
94
+
86
95
  if @region_bounds
87
96
  rb = @region_bounds
88
97
  m = @global[:h_gap_between_nodes]
@@ -413,6 +422,52 @@ module RSyntaxTree
413
422
  "#{dasharray}#{markers}/>"
414
423
  end
415
424
 
425
+ # How far the labels standing between a path's two ends reach toward its
426
+ # rail: the rail runs past the two ends, and has to run past every label
427
+ # across its span too, or it is drawn through a word that stands farther
428
+ # out than either end. Measured to the same edge the ends are anchored at.
429
+ def label_reach(from, to)
430
+ lo, hi = [from, to].minmax
431
+ hctt = @global[:height_connector_to_text]
432
+ @element_list.get_elements.map do |e|
433
+ if @direction == "ltr"
434
+ next if e.vertical_indent > hi || e.vertical_indent + e.content_height < lo
435
+
436
+ e.horizontal_indent + e.content_width + hctt
437
+ else
438
+ next if e.horizontal_indent > hi || e.horizontal_indent + e.content_width < lo
439
+
440
+ @direction == "btt" ? e.vertical_indent - hctt : e.vertical_indent + e.content_height + hctt
441
+ end
442
+ end.compact.send(@direction == "btt" ? :min : :max) || from
443
+ end
444
+
445
+ RAIL_SLACK = 1e-6
446
+
447
+ # A rail that would run along one already drawn, over part of the same
448
+ # span, is moved outward past it: two paths whose rails coincide read as
449
+ # one line, and nothing tells which ends it joins.
450
+ #
451
+ # Each move goes outward and past the rail in the way, so no rail can be in
452
+ # the way twice and there are at most as many moves as rails. The distance
453
+ # is compared with a little slack: a rail moved exactly one spacing past
454
+ # another can measure a hair under it, and would be moved again forever.
455
+ def clear_of_rails(at, span, spacing, sign)
456
+ @rails ||= []
457
+ lo, hi = span
458
+ close = spacing - RAIL_SLACK
459
+ @rails.size.times do
460
+ blocking = @rails.find do |r|
461
+ r[:lo] <= hi + spacing && lo <= r[:hi] + spacing && (r[:at] - at).abs < close
462
+ end
463
+ break unless blocking
464
+
465
+ at = blocking[:at] + (sign * spacing)
466
+ end
467
+ @rails << { lo: lo, hi: hi, at: at }
468
+ at
469
+ end
470
+
416
471
  # Where the movement paths have been, as a box. The canvas learns of a
417
472
  # rail through @width and @height, which only say how far right and down
418
473
  # anything got; the plane behind a sheared figure has to enclose the rail
@@ -430,6 +485,13 @@ module RSyntaxTree
430
485
  def draw_a_path(s_x, s_y, t_x, t_y, target_arrow = :none)
431
486
  spacing = @global[:h_gap_between_nodes] * 1.25
432
487
  min_bulge = @global[:height_connector_to_text]
488
+ # An end that carries an arrowhead needs a run long enough for the head
489
+ # and a stem before the turn. The rail clears the farther end by the
490
+ # bulge alone, so an arrowhead on that end — every arrowhead, once the
491
+ # terminals stand on one row — was pressed against the corner.
492
+ arrow_run = @global[:h_gap_between_nodes] + (min_bulge * 2)
493
+ source_arrow = target_arrow == :double
494
+ target_arrow_drawn = target_arrow != :none
433
495
  # Small enough that the turn reads as an eased corner rather than as an
434
496
  # arc, and taken from a layout measure so it holds at every font size.
435
497
  corner_radius = min_bulge / 2.0
@@ -447,7 +509,7 @@ module RSyntaxTree
447
509
  if @direction == "ltr"
448
510
  # LTR: route to the RIGHT of the tree (⊃ shape / reversed C)
449
511
  # Source → right → vertical → left → Target
450
- xmax = [s_x, t_x].max
512
+ xmax = [s_x, t_x, label_reach(s_y, t_y)].max
451
513
  bulge = [min_bulge, (s_y - t_y).abs * 0.3 + min_bulge].min
452
514
  # Ensure paths don't overlap: extend beyond previous paths
453
515
  new_x = if xmax < @width
@@ -455,6 +517,8 @@ module RSyntaxTree
455
517
  else
456
518
  xmax + bulge
457
519
  end
520
+ new_x = [new_x, s_x + arrow_run].max if source_arrow
521
+ new_x = [new_x, t_x + arrow_run].max if target_arrow_drawn
458
522
 
459
523
  new_s_y = s_y + s_offset
460
524
  new_t_y = t_y + t_offset
@@ -468,16 +532,22 @@ module RSyntaxTree
468
532
  end_arrow: target_arrow != :none)
469
533
  @width = new_x if new_x > @width
470
534
  else
471
- # TTB: route BELOW the tree (U shape)
535
+ # TTB: route BELOW the tree (U shape), on the side the words are on;
536
+ # BTT turns the tree over, words at the top, and the path with it.
472
537
  # Source → down → horizontal → up → Target
473
- ymax = [s_y, t_y].max
538
+ outward = @direction == "btt" ? :min : :max
539
+ sign = @direction == "btt" ? -1 : 1
540
+ edge = [s_y, t_y, label_reach(s_x, t_x)].send(outward)
474
541
  # Proportional bulge: based on distance between endpoints,
475
542
  # not the full tree height
476
543
  bulge = [min_bulge, (s_x - t_x).abs * 0.3 + min_bulge].min
477
- new_y = ymax + bulge
544
+ new_y = edge + (sign * bulge)
545
+ new_y = [new_y, s_y + (sign * arrow_run)].send(outward) if source_arrow
546
+ new_y = [new_y, t_y + (sign * arrow_run)].send(outward) if target_arrow_drawn
478
547
 
479
548
  new_s_x = s_x - s_offset
480
549
  new_t_x = t_x - t_offset
550
+ new_y = clear_of_rails(new_y, [new_s_x, new_t_x].minmax, spacing, sign)
481
551
 
482
552
  note_path_extent([[new_s_x, s_y], [new_s_x, new_y],
483
553
  [new_t_x, new_y], [new_t_x, t_y]])
@@ -511,7 +581,7 @@ module RSyntaxTree
511
581
  # enclosure needs, so the text starts that far inside the node's left
512
582
  # edge and the enclosure is drawn on the edge itself.
513
583
  enclosure_room = element.label_enclosure_room
514
- text_x = txt_pos - element.content_width / 2 + enclosure_room
584
+ text_x = txt_pos - element.content_width / 2.0 + enclosure_room
515
585
  text_y = top + @global[:single_line_height] - @global[:height_connector_to_text]
516
586
  text_data = text_data.sub(/X_VALUE/, text_x.to_s)
517
587
  text_data = text_data.sub(/Y_VALUE/, text_y.to_s)
@@ -869,9 +939,9 @@ module RSyntaxTree
869
939
  lgeom = {
870
940
  left: element.horizontal_indent,
871
941
  right: element.horizontal_indent + element.content_width,
872
- top: y_center - element.content_height / 2,
873
- bottom: y_center + element.content_height / 2,
874
- cx: element.horizontal_indent + element.content_width / 2,
942
+ top: y_center - element.content_height / 2.0,
943
+ bottom: y_center + element.content_height / 2.0,
944
+ cx: element.horizontal_indent + element.content_width / 2.0,
875
945
  cy: y_center
876
946
  }
877
947
  else
@@ -881,12 +951,15 @@ module RSyntaxTree
881
951
  # them into the boxes in narrow ones. Only the line pool uses
882
952
  # x0/x2; movement paths read x1, which is unchanged.
883
953
  x0 = element.horizontal_indent
884
- x1 = element.horizontal_indent + element.content_width / 2
954
+ x1 = element.horizontal_indent + element.content_width / 2.0
885
955
  x2 = element.horizontal_indent + element.content_width
886
956
  y0 = element.vertical_indent + @global[:height_connector_to_text] / 2
887
957
  y1 = element.vertical_indent + element.content_height + @global[:height_connector_to_text]
888
958
  lgeom = nil
889
959
  end
960
+ # A movement path leaves a label on the side the words are on: below
961
+ # it top to bottom, above it bottom to top.
962
+ path_y = @direction == "btt" ? element.vertical_indent - @global[:height_connector_to_text] : y1
890
963
  et = element.path
891
964
  et.each do |tr|
892
965
  if /\A-(>|<)?(\d+)\z/ =~ tr
@@ -901,20 +974,20 @@ module RSyntaxTree
901
974
  elsif /\A(?:>|<)(\d+)\z/ =~ tr
902
975
  tr = $1
903
976
  if path_pool_target[tr]
904
- path_pool_target[tr] << [x1, y1]
977
+ path_pool_target[tr] << [x1, path_y]
905
978
  else
906
- path_pool_target[tr] = [[x1, y1]]
979
+ path_pool_target[tr] = [[x1, path_y]]
907
980
  end
908
981
  path_flags << tr
909
982
  elsif path_pool_source[tr]
910
983
  if path_pool_other[tr]
911
- path_pool_other[tr] << [x1, y1]
984
+ path_pool_other[tr] << [x1, path_y]
912
985
  else
913
- path_pool_other[tr] = [[x1, y1]]
986
+ path_pool_other[tr] = [[x1, path_y]]
914
987
  end
915
988
  path_flags << tr
916
989
  else
917
- path_pool_source[tr] = [x1, y1]
990
+ path_pool_source[tr] = [x1, path_y]
918
991
  path_flags << tr
919
992
  end
920
993
  raise RSTError.new(+"Error: input text contains a path having more than two ends:\n > #{tr}", code: :path_multiple_ends, hint: "A path or line takes exactly two ends; check the numbers.", retryable: true) if path_flags.tally.any? { |_k, v| v > 2 } || line_flags.tally.any? { |_k, v| v > 2 }
@@ -956,6 +1029,10 @@ module RSyntaxTree
956
1029
  end
957
1030
  end
958
1031
 
1032
+ # Narrowest first, so a path that spans another runs outside it rather
1033
+ # than across its ends. Left to right, every rail already clears the
1034
+ # ones drawn before it, in the order written.
1035
+ paths = paths.each_with_index.sort_by { |t, i| [(t[:x1] - t[:x2]).abs, i] }.map(&:first) unless @direction == "ltr"
959
1036
  paths.each do |t|
960
1037
  draw_a_path(t[:x1], t[:y1], t[:x2], t[:y2], t[:arrow])
961
1038
  end
@@ -1203,9 +1280,35 @@ module RSyntaxTree
1203
1280
  row ? row[:top] : element.ink_top
1204
1281
  end
1205
1282
 
1283
+ # The lower endpoint clears the element's own ink, not the row's. A row
1284
+ # keeps its labels top-aligned, so a tall one — a feature matrix, a
1285
+ # two-line label — reaches far below its neighbours; taking the row's
1286
+ # union here dragged every short label's downward connector down to that
1287
+ # depth, opening a gap under the label with no line in it and flattening
1288
+ # the branch of a polyline. The upper endpoint still takes the row (see
1289
+ # ink_over): the edges arriving at a row of leaves are what must line up,
1290
+ # which was the point of the band, and that is unchanged.
1206
1291
  def ink_under(element)
1207
- row = element.enclosure == :none && row_ink[element.vertical_indent.round(1)]
1208
- row ? row[:bottom] : element.ink_bottom
1292
+ element.ink_bottom
1293
+ end
1294
+
1295
+ # Where a polyline's rail sits: halfway between the parent's end and the
1296
+ # row its children stand in, so every child of one node turns off the same
1297
+ # rail. A plain child's own end is not the measure: a joint's line runs to
1298
+ # its middle, so as to pass straight through it, and a label's lower edge
1299
+ # is its own ink, which differs from letter to letter. An enclosed child is
1300
+ # met at the box drawn round it, as ink_over meets it, so nil sends it to
1301
+ # its own end.
1302
+ def rail_edge_y(child, parent)
1303
+ m = @global[:single_x_metrics].height * @global[:vmargin]
1304
+ row = child.enclosure == :none && row_ink[child.vertical_indent.round(1)]
1305
+ return nil unless m && row
1306
+
1307
+ child.vertical_indent <= parent.vertical_indent ? row[:bottom] + m : row[:top] - m
1308
+ end
1309
+
1310
+ def rail_edge_x(child)
1311
+ child.horizontal_indent - @global[:height_connector_to_text]
1209
1312
  end
1210
1313
 
1211
1314
  def connector_edges(child, parent)
@@ -1225,7 +1328,7 @@ module RSyntaxTree
1225
1328
  m = @global[:single_x_metrics].height * @global[:vmargin]
1226
1329
 
1227
1330
  child_y = if child.empty_label?
1228
- child.vertical_indent + child.content_height / 2
1331
+ child.vertical_indent + child.content_height / 2.0
1229
1332
  elsif child_above
1230
1333
  if m && child.ink_bottom
1231
1334
  ink_under(child) + m
@@ -1239,7 +1342,7 @@ module RSyntaxTree
1239
1342
  end
1240
1343
 
1241
1344
  parent_y = if parent.empty_label?
1242
- parent.vertical_indent + parent.content_height / 2
1345
+ parent.vertical_indent + parent.content_height / 2.0
1243
1346
  elsif child_above
1244
1347
  if m && parent.ink_top
1245
1348
  ink_over(parent) - m
@@ -1280,11 +1383,11 @@ module RSyntaxTree
1280
1383
  hctt = @global[:height_connector_to_text]
1281
1384
  y1 = child.vertical_indent + (child.content_height + hctt * 1.5) / 2
1282
1385
  y2 = parent.vertical_indent + (parent.content_height + hctt * 1.5) / 2
1283
- x1 = child.empty_label? ? child.horizontal_indent + child.content_width / 2 : child.horizontal_indent - hctt
1284
- x2 = parent.empty_label? ? parent.horizontal_indent + parent.content_width / 2 : parent.horizontal_indent + parent.content_width + hctt
1386
+ x1 = child.empty_label? ? child.horizontal_indent + child.content_width / 2.0 : child.horizontal_indent - hctt
1387
+ x2 = parent.empty_label? ? parent.horizontal_indent + parent.content_width / 2.0 : parent.horizontal_indent + parent.content_width + hctt
1285
1388
 
1286
1389
  if @polyline
1287
- mid_x1 = x2 + (x1 - x2) / 2
1390
+ mid_x1 = x2 + (rail_edge_x(child) - x2) / 2
1288
1391
  mid_y1 = y1
1289
1392
  mid_x2 = mid_x1
1290
1393
  mid_y2 = y2
@@ -1305,12 +1408,12 @@ module RSyntaxTree
1305
1408
  else
1306
1409
  # TTB: parent's bottom → child's top
1307
1410
  if @polyline
1308
- chi_x = child.horizontal_indent + child.content_width / 2
1309
- par_x = parent.horizontal_indent + parent.content_width / 2
1411
+ chi_x = child.horizontal_indent + child.content_width / 2.0
1412
+ par_x = parent.horizontal_indent + parent.content_width / 2.0
1310
1413
  chi_y, par_y = connector_edges(child, parent)
1311
1414
 
1312
1415
  mid_x1 = chi_x
1313
- mid_y1 = par_y + (chi_y - par_y) / 2
1416
+ mid_y1 = par_y + ((rail_edge_y(child, parent) || chi_y) - par_y) / 2
1314
1417
 
1315
1418
  mid_x2 = par_x
1316
1419
  mid_y2 = mid_y1
@@ -1324,8 +1427,8 @@ module RSyntaxTree
1324
1427
  .sub(/PARX/, par_x.to_s)
1325
1428
  .sub(/PARY/, par_y.to_s)
1326
1429
  else
1327
- x1 = child.horizontal_indent + child.content_width / 2
1328
- x2 = parent.horizontal_indent + parent.content_width / 2
1430
+ x1 = child.horizontal_indent + child.content_width / 2.0
1431
+ x2 = parent.horizontal_indent + parent.content_width / 2.0
1329
1432
  y1, y2 = connector_edges(child, parent)
1330
1433
 
1331
1434
  line_data = @line_styles.sub(/X1/, x1.to_s)
@@ -1337,7 +1440,7 @@ module RSyntaxTree
1337
1440
  end
1338
1441
 
1339
1442
  # Draw a triangle between child/parent elements
1340
- def triangle_to_parent(parent, child)
1443
+ def triangle_to_parent(parent, child, _joints = [])
1341
1444
  return if child.horizontal_indent.zero?
1342
1445
 
1343
1446
  if @direction == "ltr"
@@ -1366,7 +1469,7 @@ module RSyntaxTree
1366
1469
  # bottom-to-top examples are derivations, which draw rules instead.
1367
1470
  x1 = child.horizontal_indent
1368
1471
  x2 = child.horizontal_indent + child.content_width
1369
- x3 = parent.horizontal_indent + parent.content_width / 2
1472
+ x3 = parent.horizontal_indent + parent.content_width / 2.0
1370
1473
  child_above = child.vertical_indent <= parent.vertical_indent
1371
1474
  m = child.ink_top && parent.ink_bottom ? @global[:single_x_metrics].height * @global[:vmargin] : nil
1372
1475
  if child_above
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RSyntaxTree
4
- VERSION = "2.4.0"
4
+ VERSION = "2.5.0"
5
5
  end
data/lib/rsyntaxtree.rb CHANGED
@@ -61,7 +61,8 @@ OPTION_VALUES = {
61
61
  tidy: %w[off symmetric low medium high on compact true false],
62
62
  direction: %w[ttb ltr btt],
63
63
  hyphen: %w[markup literal],
64
- derivation: %w[on off true false yes no 0 1 none]
64
+ derivation: %w[on off true false yes no 0 1 none],
65
+ leaf_align: %w[off words subtrees]
65
66
  }.freeze
66
67
 
67
68
  NUMERIC_RANGES = {
@@ -96,6 +97,7 @@ DEFAULT_OPTS = {
96
97
  transparent: "off",
97
98
  polyline: "off",
98
99
  hide_default_connectors: "off",
100
+ leaf_align: "off",
99
101
  mirror: "off",
100
102
  tidy: "off",
101
103
  hspacing: 1.0,
@@ -306,6 +308,8 @@ module RSyntaxTree
306
308
  end
307
309
  when :transparent, :polyline, :hide_default_connectors, :mirror, :derivation
308
310
  new_params[key] = switched_on?(value)
311
+ when :leaf_align
312
+ new_params[key] = value.to_s
309
313
  when :color
310
314
  new_params[key] = case value.to_s
311
315
  when "modern", "on", "true"
@@ -693,16 +697,12 @@ module RSyntaxTree
693
697
  end
694
698
 
695
699
  def draw_svg
696
- sp = StringParser.new(@params[:data].gsub('&', '&amp;'), @params[:fontset], @params[:fontsize], @global)
697
- sp.parse
698
- graph = SVGGraph.new(sp.get_elementlist, @params, @global)
700
+ graph = SVGGraph.new(parsed_element_list, @params, @global)
699
701
  graph.svg_data
700
702
  end
701
703
 
702
704
  def draw_json
703
- sp = StringParser.new(@params[:data].gsub('&', '&amp;'), @params[:fontset], @params[:fontsize], @global)
704
- sp.parse
705
- graph = JSONGraph.new(sp.get_elementlist, @params, @global)
705
+ graph = JSONGraph.new(parsed_element_list, @params, @global)
706
706
  graph.json_data
707
707
  end
708
708
 
@@ -712,10 +712,20 @@ module RSyntaxTree
712
712
  end
713
713
 
714
714
  def draw_tikz(standalone: false, font: nil)
715
+ generator = TikZGenerator.new(parsed_element_list, @params)
716
+ generator.generate(standalone: standalone, font: font)
717
+ end
718
+
719
+ private
720
+
721
+ def parsed_element_list
715
722
  sp = StringParser.new(@params[:data].gsub('&', '&amp;'), @params[:fontset], @params[:fontsize], @global)
716
723
  sp.parse
717
- generator = TikZGenerator.new(sp.get_elementlist, @params)
718
- generator.generate(standalone: standalone, font: font)
724
+ list = sp.get_elementlist
725
+ if @params[:leaf_align] != "off" && @params[:derivation] != true
726
+ list.align_leaves(@params[:leaf_align], @params[:fontset], @params[:fontsize], @global)
727
+ end
728
+ list
719
729
  end
720
730
  end
721
731
  end
data/rsyntaxtree.gemspec CHANGED
@@ -19,8 +19,8 @@ Gem::Specification.new do |s|
19
19
  # the scripts that build them, the tests, the CI and container setup, the
20
20
  # bundle and the rake tasks — and whoever wants those clones the repository.
21
21
  development = ["docs/", "dev/", "img/", "test/", ".github/"]
22
- development_files = [".gitattributes", ".gitignore", ".ruby-version",
23
- "Dockerfile", "Gemfile", "Rakefile"]
22
+ development_files = [".dockerignore", ".gitattributes", ".gitignore",
23
+ ".ruby-version", "Dockerfile", "Gemfile", "Rakefile"]
24
24
  s.files = `git ls-files`.split("\n").reject do |path|
25
25
  development.any? { |dir| path.start_with?(dir) } ||
26
26
  development_files.include?(path)
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: 2.4.0
4
+ version: 2.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yoichiro Hasebe
@@ -115,7 +115,6 @@ executables:
115
115
  extensions: []
116
116
  extra_rdoc_files: []
117
117
  files:
118
- - ".dockerignore"
119
118
  - CHANGELOG.md
120
119
  - CITATION.cff
121
120
  - CODE_OF_CONDUCT.md
data/.dockerignore DELETED
@@ -1,5 +0,0 @@
1
- # The image needs the working tree, not the repository: without this, ADD .
2
- # copies .git into every image — hundreds of megabytes of history, including
3
- # refs that exist only on the build machine.
4
- .git
5
- pkg