rsyntaxtree 2.4.1 → 2.6.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.
@@ -57,7 +57,6 @@ module RSyntaxTree
57
57
  @polygon_styles = "<polygon style='fill: none; stroke: #{@col_connector}; stroke-width:#{@global[:stroke_normal]}; stroke-linejoin:round;stroke-linecap:round;' points='X1 Y1 X2 Y2 X3 Y3' />\n"
58
58
  @text_styles = "<text white-space='pre' alignment-baseline='text-top' style='fill: COLOR; stroke-width: 0; font-size: fontsize' x='X_VALUE' y='Y_VALUE'>CONTENT</text>\n"
59
59
  @tree_data = String.new
60
- @visited_x = {}
61
60
  @visited_y = {}
62
61
  @global = global
63
62
  end
@@ -83,6 +82,15 @@ module RSyntaxTree
83
82
  # the deepest leaf) keep a margin instead of touching the image edge. A
84
83
  # margin is added around the region extents before unioning with the tree
85
84
  # bounds. x2/y2 are the viewBox width/height, so right = x1 + x2.
85
+ # Bottom to top, a movement path runs above the words, past the top of
86
+ # the tree; the canvas grows upward to hold it.
87
+ if @direction == "btt" && @path_bounds
88
+ top = [y1, @path_bounds[:min_y] - @global[:height_connector_to_text]].min
89
+ y2 += y1 - top
90
+ @height += y1 - top
91
+ y1 = top
92
+ end
93
+
86
94
  if @region_bounds
87
95
  rb = @region_bounds
88
96
  m = @global[:h_gap_between_nodes]
@@ -378,6 +386,12 @@ module RSyntaxTree
378
386
  d << " L#{at.call(points.last)}"
379
387
  end
380
388
 
389
+ # The dash every dashed line in the figure is drawn with.
390
+ def dash_array
391
+ dash = @fontsize / 4.0
392
+ "stroke-dasharray='#{dash} #{dash}'"
393
+ end
394
+
381
395
  # One stroke rather than three lines meeting at right angles. Drawn this way
382
396
  # the dash pattern runs round each turn instead of restarting at it, and an
383
397
  # arrowhead is the end of the stroke rather than a marker on whichever line
@@ -393,8 +407,7 @@ module RSyntaxTree
393
407
  radius = [radius, leg.call(points[-2], points[-1]) - arrow_length].min if end_arrow
394
408
  radius = [radius, 0].max
395
409
 
396
- dash = @fontsize / 4.0
397
- dasharray = dashed ? "stroke-dasharray='#{dash} #{dash}' " : ""
410
+ dasharray = dashed ? "#{dash_array} " : ""
398
411
  markers = +""
399
412
  markers << "marker-start='url(#arrowStart)' " if start_arrow
400
413
  markers << "marker-end='url(#arrow)' " if end_arrow
@@ -413,6 +426,59 @@ module RSyntaxTree
413
426
  "#{dasharray}#{markers}/>"
414
427
  end
415
428
 
429
+ # How far the labels standing between a path's two ends reach toward its
430
+ # rail: the rail runs past the two ends, and has to run past every label
431
+ # across its span too, or it is drawn through a word that stands farther
432
+ # out than either end. Measured to the same edge the ends are anchored at.
433
+ def label_reach(from, to)
434
+ lo, hi = [from, to].minmax
435
+ hctt = @global[:height_connector_to_text]
436
+ @element_list.get_elements.map do |e|
437
+ if @direction == "ltr"
438
+ next if e.vertical_indent > hi || e.vertical_indent + e.content_height < lo
439
+
440
+ e.horizontal_indent + e.content_width + hctt
441
+ else
442
+ next if e.horizontal_indent > hi || e.horizontal_indent + e.content_width < lo
443
+
444
+ @direction == "btt" ? e.vertical_indent - hctt : e.vertical_indent + e.content_height + hctt
445
+ end
446
+ end.compact.send(@direction == "btt" ? :min : :max) || from
447
+ end
448
+
449
+ RAIL_SLACK = 1e-6
450
+
451
+ # How far apart two rails stand, and two path ends on one label.
452
+ def rail_spacing
453
+ @global[:h_gap_between_nodes] * 1.25
454
+ end
455
+
456
+ # A rail that would run along one already drawn, over part of the same
457
+ # span or less than one spacing short of it, is moved outward past it: two
458
+ # paths whose rails coincide read as one line, and nothing tells which
459
+ # ends it joins. Rails a spacing or more apart end to end stay on one
460
+ # level, as two paths meeting at a label do once their ends are spread.
461
+ #
462
+ # Each move goes outward and past the rail in the way, so no rail can be in
463
+ # the way twice and there are at most as many moves as rails. Distances
464
+ # are compared with a little slack: a rail moved exactly one spacing past
465
+ # another can measure a hair under it, and would be moved again forever.
466
+ def clear_of_rails(at, span, spacing, sign)
467
+ @rails ||= []
468
+ lo, hi = span
469
+ close = spacing - RAIL_SLACK
470
+ @rails.size.times do
471
+ blocking = @rails.find do |r|
472
+ r[:lo] < hi + close && lo < r[:hi] + close && (r[:at] - at).abs < close
473
+ end
474
+ break unless blocking
475
+
476
+ at = blocking[:at] + (sign * spacing)
477
+ end
478
+ @rails << { lo: lo, hi: hi, at: at }
479
+ at
480
+ end
481
+
416
482
  # Where the movement paths have been, as a box. The canvas learns of a
417
483
  # rail through @width and @height, which only say how far right and down
418
484
  # anything got; the plane behind a sheared figure has to enclose the rail
@@ -427,27 +493,56 @@ module RSyntaxTree
427
493
  b[:max_y] = [b[:max_y], ys.max].max
428
494
  end
429
495
 
430
- def draw_a_path(s_x, s_y, t_x, t_y, target_arrow = :none)
431
- spacing = @global[:h_gap_between_nodes] * 1.25
496
+ # Path ends that stand in one line across the paths' run (on one label,
497
+ # or on labels straight above one another) are spread apart, each to the
498
+ # side its path heads for: a path going left leaves from the left and one
499
+ # going right from the right, so neither crosses the other's rail. Among
500
+ # ends heading the same way, the one that goes farther takes the outer
501
+ # place, since its rail runs outside. Left to right, the same holds with
502
+ # up and down in place of left and right. Neighbouring ends stand one
503
+ # rail spacing apart, the same distance that keeps two rails apart, or
504
+ # closer where that would take them past the narrowest label they share:
505
+ # an end stays on its label, and so off its neighbours and on the canvas.
506
+ def spread_shared_ends(paths)
507
+ along = @direction == "ltr" ? 1 : 0
508
+ ends = Hash.new { |h, k| h[k] = [] }
509
+ paths.each do |t|
510
+ s = [t[:x1], t[:y1]]
511
+ e = [t[:x2], t[:y2]]
512
+ ends[s[along].round] << [t, :s_offset, s[along], e[along], t[:across1]]
513
+ ends[e[along].round] << [t, :t_offset, e[along], s[along], t[:across2]]
514
+ end
515
+ ends.each_value do |shared|
516
+ gaps = shared.size - 1
517
+ step = gaps.zero? ? 0 : [rail_spacing, shared.map(&:last).min / gaps.to_f].min
518
+ shared.each_with_index
519
+ .sort_by { |(_, _, at, other, _), i| [other <=> at, -other, i] }
520
+ .each_with_index do |((t, key, _, _, _), _), place|
521
+ t[key] = (place - (gaps / 2.0)) * step
522
+ end
523
+ end
524
+ end
525
+
526
+ def draw_a_path(s_x, s_y, t_x, t_y, target_arrow = :none, s_offset: 0, t_offset: 0)
527
+ spacing = rail_spacing
432
528
  min_bulge = @global[:height_connector_to_text]
529
+ # An end that carries an arrowhead needs a run long enough for the head
530
+ # and a stem before the turn. The rail clears the farther end by the
531
+ # bulge alone, so an arrowhead on that end — every arrowhead, once the
532
+ # terminals stand on one row — was pressed against the corner.
533
+ arrow_run = @global[:h_gap_between_nodes] + (min_bulge * 2)
534
+ source_arrow = target_arrow == :double
535
+ target_arrow_drawn = target_arrow != :none
433
536
  # Small enough that the turn reads as an eased corner rather than as an
434
537
  # arc, and taken from a layout measure so it holds at every font size.
435
538
  corner_radius = min_bulge / 2.0
436
539
 
437
- # Centered offset for multiple lines at the same endpoint
438
- s_key = "#{s_x.round}"
439
- t_key = "#{t_x.round}"
440
- @visited_x[s_key] = (@visited_x[s_key] || 0) + 1
441
- @visited_x[t_key] = (@visited_x[t_key] || 0) + 1
442
- s_offset = ((@visited_x[s_key] - 1) - (@visited_x[s_key] - 1) / 2.0) * spacing
443
- t_offset = ((@visited_x[t_key] - 1) - (@visited_x[t_key] - 1) / 2.0) * spacing
444
-
445
540
  dashed = true if target_arrow == :none
446
541
 
447
542
  if @direction == "ltr"
448
543
  # LTR: route to the RIGHT of the tree (⊃ shape / reversed C)
449
544
  # Source → right → vertical → left → Target
450
- xmax = [s_x, t_x].max
545
+ xmax = [s_x, t_x, label_reach(s_y, t_y)].max
451
546
  bulge = [min_bulge, (s_y - t_y).abs * 0.3 + min_bulge].min
452
547
  # Ensure paths don't overlap: extend beyond previous paths
453
548
  new_x = if xmax < @width
@@ -455,6 +550,8 @@ module RSyntaxTree
455
550
  else
456
551
  xmax + bulge
457
552
  end
553
+ new_x = [new_x, s_x + arrow_run].max if source_arrow
554
+ new_x = [new_x, t_x + arrow_run].max if target_arrow_drawn
458
555
 
459
556
  new_s_y = s_y + s_offset
460
557
  new_t_y = t_y + t_offset
@@ -468,16 +565,22 @@ module RSyntaxTree
468
565
  end_arrow: target_arrow != :none)
469
566
  @width = new_x if new_x > @width
470
567
  else
471
- # TTB: route BELOW the tree (U shape)
568
+ # TTB: route BELOW the tree (U shape), on the side the words are on;
569
+ # BTT turns the tree over, words at the top, and the path with it.
472
570
  # Source → down → horizontal → up → Target
473
- ymax = [s_y, t_y].max
571
+ outward = @direction == "btt" ? :min : :max
572
+ sign = @direction == "btt" ? -1 : 1
573
+ edge = [s_y, t_y, label_reach(s_x, t_x)].send(outward)
474
574
  # Proportional bulge: based on distance between endpoints,
475
575
  # not the full tree height
476
576
  bulge = [min_bulge, (s_x - t_x).abs * 0.3 + min_bulge].min
477
- new_y = ymax + bulge
577
+ new_y = edge + (sign * bulge)
578
+ new_y = [new_y, s_y + (sign * arrow_run)].send(outward) if source_arrow
579
+ new_y = [new_y, t_y + (sign * arrow_run)].send(outward) if target_arrow_drawn
478
580
 
479
- new_s_x = s_x - s_offset
480
- new_t_x = t_x - t_offset
581
+ new_s_x = s_x + s_offset
582
+ new_t_x = t_x + t_offset
583
+ new_y = clear_of_rails(new_y, [new_s_x, new_t_x].minmax, spacing, sign)
481
584
 
482
585
  note_path_extent([[new_s_x, s_y], [new_s_x, new_y],
483
586
  [new_t_x, new_y], [new_t_x, t_y]])
@@ -511,7 +614,7 @@ module RSyntaxTree
511
614
  # enclosure needs, so the text starts that far inside the node's left
512
615
  # edge and the enclosure is drawn on the edge itself.
513
616
  enclosure_room = element.label_enclosure_room
514
- text_x = txt_pos - element.content_width / 2 + enclosure_room
617
+ text_x = txt_pos - element.content_width / 2.0 + enclosure_room
515
618
  text_y = top + @global[:single_line_height] - @global[:height_connector_to_text]
516
619
  text_data = text_data.sub(/X_VALUE/, text_x.to_s)
517
620
  text_data = text_data.sub(/Y_VALUE/, text_y.to_s)
@@ -869,9 +972,9 @@ module RSyntaxTree
869
972
  lgeom = {
870
973
  left: element.horizontal_indent,
871
974
  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,
975
+ top: y_center - element.content_height / 2.0,
976
+ bottom: y_center + element.content_height / 2.0,
977
+ cx: element.horizontal_indent + element.content_width / 2.0,
875
978
  cy: y_center
876
979
  }
877
980
  else
@@ -881,12 +984,23 @@ module RSyntaxTree
881
984
  # them into the boxes in narrow ones. Only the line pool uses
882
985
  # x0/x2; movement paths read x1, which is unchanged.
883
986
  x0 = element.horizontal_indent
884
- x1 = element.horizontal_indent + element.content_width / 2
987
+ x1 = element.horizontal_indent + element.content_width / 2.0
885
988
  x2 = element.horizontal_indent + element.content_width
886
989
  y0 = element.vertical_indent + @global[:height_connector_to_text] / 2
887
990
  y1 = element.vertical_indent + element.content_height + @global[:height_connector_to_text]
888
991
  lgeom = nil
889
992
  end
993
+ # A movement path leaves a label on the side the words are on: below
994
+ # it top to bottom, above it bottom to top.
995
+ path_y = @direction == "btt" ? element.vertical_indent - @global[:height_connector_to_text] : y1
996
+ # How much of the label there is across the paths' run, for the ends
997
+ # that share it to be spread over: an even amount either side of the
998
+ # anchor, which left to right sits below the middle of the label.
999
+ across = if @direction == "ltr"
1000
+ [element.content_height - (@global[:height_connector_to_text] * 1.5), 0].max
1001
+ else
1002
+ element.content_width
1003
+ end
890
1004
  et = element.path
891
1005
  et.each do |tr|
892
1006
  if /\A-(>|<)?(\d+)\z/ =~ tr
@@ -901,20 +1015,20 @@ module RSyntaxTree
901
1015
  elsif /\A(?:>|<)(\d+)\z/ =~ tr
902
1016
  tr = $1
903
1017
  if path_pool_target[tr]
904
- path_pool_target[tr] << [x1, y1]
1018
+ path_pool_target[tr] << [x1, path_y, across]
905
1019
  else
906
- path_pool_target[tr] = [[x1, y1]]
1020
+ path_pool_target[tr] = [[x1, path_y, across]]
907
1021
  end
908
1022
  path_flags << tr
909
1023
  elsif path_pool_source[tr]
910
1024
  if path_pool_other[tr]
911
- path_pool_other[tr] << [x1, y1]
1025
+ path_pool_other[tr] << [x1, path_y, across]
912
1026
  else
913
- path_pool_other[tr] = [[x1, y1]]
1027
+ path_pool_other[tr] = [[x1, path_y, across]]
914
1028
  end
915
1029
  path_flags << tr
916
1030
  else
917
- path_pool_source[tr] = [x1, y1]
1031
+ path_pool_source[tr] = [x1, path_y, across]
918
1032
  path_flags << tr
919
1033
  end
920
1034
  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 }
@@ -935,11 +1049,11 @@ module RSyntaxTree
935
1049
  path_flags.delete(k)
936
1050
  if (targets = path_pool_target[k])
937
1051
  targets.each do |t|
938
- paths << { x1: v[0], y1: v[1], x2: t[0], y2: t[1], arrow: :single }
1052
+ paths << { x1: v[0], y1: v[1], x2: t[0], y2: t[1], across1: v[2], across2: t[2], arrow: :single }
939
1053
  end
940
1054
  elsif (others = path_pool_other[k])
941
1055
  others.each do |t|
942
- paths << { x1: v[0], y1: v[1], x2: t[0], y2: t[1], arrow: :none }
1056
+ paths << { x1: v[0], y1: v[1], x2: t[0], y2: t[1], across1: v[2], across2: t[2], arrow: :none }
943
1057
  end
944
1058
  end
945
1059
  end
@@ -952,12 +1066,17 @@ module RSyntaxTree
952
1066
  next if fst.nil?
953
1067
 
954
1068
  targets.each do |t|
955
- paths << { x1: fst[0], y1: fst[1], x2: t[0], y2: t[1], arrow: :double }
1069
+ paths << { x1: fst[0], y1: fst[1], x2: t[0], y2: t[1], across1: fst[2], across2: t[2], arrow: :double }
956
1070
  end
957
1071
  end
958
1072
 
1073
+ # Narrowest first, so a path that spans another runs outside it rather
1074
+ # than across its ends. Left to right, every rail already clears the
1075
+ # ones drawn before it, in the order written.
1076
+ paths = paths.each_with_index.sort_by { |t, i| [(t[:x1] - t[:x2]).abs, i] }.map(&:first) unless @direction == "ltr"
1077
+ spread_shared_ends(paths)
959
1078
  paths.each do |t|
960
- draw_a_path(t[:x1], t[:y1], t[:x2], t[:y2], t[:arrow])
1079
+ draw_a_path(t[:x1], t[:y1], t[:x2], t[:y2], t[:arrow], s_offset: t[:s_offset], t_offset: t[:t_offset])
961
1080
  end
962
1081
 
963
1082
  line_pool.each do |_k, v|
@@ -1025,8 +1144,7 @@ module RSyntaxTree
1025
1144
  else
1026
1145
  ""
1027
1146
  end
1028
- dash = @fontsize / 4.0
1029
- dasharray = dashed ? "stroke-dasharray='#{dash} #{dash}'" : ""
1147
+ dasharray = dashed ? dash_array : ""
1030
1148
  swidth = bline ? @global[:stroke_bold] : @global[:stroke_normal]
1031
1149
 
1032
1150
  if s_arrow && t_arrow
@@ -1091,8 +1209,7 @@ module RSyntaxTree
1091
1209
  else
1092
1210
  ""
1093
1211
  end
1094
- dash = @fontsize / 4.0
1095
- dasharray = dashed ? "stroke-dasharray='#{dash} #{dash}'" : ""
1212
+ dasharray = dashed ? dash_array : ""
1096
1213
  swidth = bline ? @global[:stroke_bold] : @global[:stroke_normal]
1097
1214
 
1098
1215
  "<line x1='#{x1}' y1='#{y1}' x2='#{x2}' y2='#{y2}' style='fill: none; stroke: #{col}; stroke-width:#{swidth}; stroke-linecap:round;' #{dasharray} #{string}/>"
@@ -1215,6 +1332,25 @@ module RSyntaxTree
1215
1332
  element.ink_bottom
1216
1333
  end
1217
1334
 
1335
+ # Where a polyline's rail sits: halfway between the parent's end and the
1336
+ # row its children stand in, so every child of one node turns off the same
1337
+ # rail. A plain child's own end is not the measure: a joint's line runs to
1338
+ # its middle, so as to pass straight through it, and a label's lower edge
1339
+ # is its own ink, which differs from letter to letter. An enclosed child is
1340
+ # met at the box drawn round it, as ink_over meets it, so nil sends it to
1341
+ # its own end.
1342
+ def rail_edge_y(child, parent)
1343
+ m = @global[:single_x_metrics].height * @global[:vmargin]
1344
+ row = child.enclosure == :none && row_ink[child.vertical_indent.round(1)]
1345
+ return nil unless m && row
1346
+
1347
+ child.vertical_indent <= parent.vertical_indent ? row[:bottom] + m : row[:top] - m
1348
+ end
1349
+
1350
+ def rail_edge_x(child)
1351
+ child.horizontal_indent - @global[:height_connector_to_text]
1352
+ end
1353
+
1218
1354
  def connector_edges(child, parent)
1219
1355
  hctt = @global[:height_connector_to_text]
1220
1356
  child_above = child.vertical_indent <= parent.vertical_indent
@@ -1232,7 +1368,7 @@ module RSyntaxTree
1232
1368
  m = @global[:single_x_metrics].height * @global[:vmargin]
1233
1369
 
1234
1370
  child_y = if child.empty_label?
1235
- child.vertical_indent + child.content_height / 2
1371
+ child.vertical_indent + child.content_height / 2.0
1236
1372
  elsif child_above
1237
1373
  if m && child.ink_bottom
1238
1374
  ink_under(child) + m
@@ -1246,7 +1382,7 @@ module RSyntaxTree
1246
1382
  end
1247
1383
 
1248
1384
  parent_y = if parent.empty_label?
1249
- parent.vertical_indent + parent.content_height / 2
1385
+ parent.vertical_indent + parent.content_height / 2.0
1250
1386
  elsif child_above
1251
1387
  if m && parent.ink_top
1252
1388
  ink_over(parent) - m
@@ -1276,8 +1412,12 @@ module RSyntaxTree
1276
1412
  [child_y, parent_y]
1277
1413
  end
1278
1414
 
1279
- def line_to_parent(parent, child)
1280
- return if child.horizontal_indent.zero?
1415
+ # Dashed from +dash_from+ on, when given: a line through the joints leaf
1416
+ # alignment inserted is drawn one piece at a time, and each piece picks up
1417
+ # the dash where the one above it left off, so the run reads as one line.
1418
+ # Returns how long the line drawn is.
1419
+ def line_to_parent(parent, child, dash_from: nil)
1420
+ return 0 if child.horizontal_indent.zero?
1281
1421
 
1282
1422
  if @direction == "ltr"
1283
1423
  # LTR: parent's right side → child's left side
@@ -1287,64 +1427,67 @@ module RSyntaxTree
1287
1427
  hctt = @global[:height_connector_to_text]
1288
1428
  y1 = child.vertical_indent + (child.content_height + hctt * 1.5) / 2
1289
1429
  y2 = parent.vertical_indent + (parent.content_height + hctt * 1.5) / 2
1290
- x1 = child.empty_label? ? child.horizontal_indent + child.content_width / 2 : child.horizontal_indent - hctt
1291
- x2 = parent.empty_label? ? parent.horizontal_indent + parent.content_width / 2 : parent.horizontal_indent + parent.content_width + hctt
1430
+ x1 = child.empty_label? ? child.horizontal_indent + child.content_width / 2.0 : child.horizontal_indent - hctt
1431
+ x2 = parent.empty_label? ? parent.horizontal_indent + parent.content_width / 2.0 : parent.horizontal_indent + parent.content_width + hctt
1292
1432
 
1293
1433
  if @polyline
1294
- mid_x1 = x2 + (x1 - x2) / 2
1434
+ mid_x1 = x2 + (rail_edge_x(child) - x2) / 2
1295
1435
  mid_y1 = y1
1296
1436
  mid_x2 = mid_x1
1297
1437
  mid_y2 = y2
1298
- @tree_data += @polyline_styles.sub(/CHIX/, x1.to_s)
1299
- .sub(/CHIY/, y1.to_s)
1300
- .sub(/MIDX1/, mid_x1.to_s)
1301
- .sub(/MIDY1/, mid_y1.to_s)
1302
- .sub(/MIDX2/, mid_x2.to_s)
1303
- .sub(/MIDY2/, mid_y2.to_s)
1304
- .sub(/PARX/, x2.to_s)
1305
- .sub(/PARY/, y2.to_s)
1438
+ points = [[x1, y1], [mid_x1, mid_y1], [mid_x2, mid_y2], [x2, y2]]
1306
1439
  else
1307
- line_data = @line_styles.sub(/X1/, x1.to_s)
1308
- line_data = line_data.sub(/Y1/, y1.to_s)
1309
- line_data = line_data.sub(/X2/, x2.to_s)
1310
- @tree_data += line_data.sub(/Y2/, y2.to_s)
1440
+ points = [[x1, y1], [x2, y2]]
1311
1441
  end
1312
1442
  else
1313
1443
  # TTB: parent's bottom → child's top
1314
1444
  if @polyline
1315
- chi_x = child.horizontal_indent + child.content_width / 2
1316
- par_x = parent.horizontal_indent + parent.content_width / 2
1445
+ chi_x = child.horizontal_indent + child.content_width / 2.0
1446
+ par_x = parent.horizontal_indent + parent.content_width / 2.0
1317
1447
  chi_y, par_y = connector_edges(child, parent)
1318
1448
 
1319
1449
  mid_x1 = chi_x
1320
- mid_y1 = par_y + (chi_y - par_y) / 2
1450
+ mid_y1 = par_y + ((rail_edge_y(child, parent) || chi_y) - par_y) / 2
1321
1451
 
1322
1452
  mid_x2 = par_x
1323
1453
  mid_y2 = mid_y1
1324
1454
 
1325
- @tree_data += @polyline_styles.sub(/CHIX/, chi_x.to_s)
1326
- .sub(/CHIY/, chi_y.to_s)
1327
- .sub(/MIDX1/, mid_x1.to_s)
1328
- .sub(/MIDY1/, mid_y1.to_s)
1329
- .sub(/MIDX2/, mid_x2.to_s)
1330
- .sub(/MIDY2/, mid_y2.to_s)
1331
- .sub(/PARX/, par_x.to_s)
1332
- .sub(/PARY/, par_y.to_s)
1455
+ points = [[chi_x, chi_y], [mid_x1, mid_y1], [mid_x2, mid_y2], [par_x, par_y]]
1333
1456
  else
1334
- x1 = child.horizontal_indent + child.content_width / 2
1335
- x2 = parent.horizontal_indent + parent.content_width / 2
1457
+ x1 = child.horizontal_indent + child.content_width / 2.0
1458
+ x2 = parent.horizontal_indent + parent.content_width / 2.0
1336
1459
  y1, y2 = connector_edges(child, parent)
1337
-
1338
- line_data = @line_styles.sub(/X1/, x1.to_s)
1339
- line_data = line_data.sub(/Y1/, y1.to_s)
1340
- line_data = line_data.sub(/X2/, x2.to_s)
1341
- @tree_data += line_data.sub(/Y2/, y2.to_s)
1460
+ points = [[x1, y1], [x2, y2]]
1342
1461
  end
1343
1462
  end
1463
+ emit_connector(points, dash_from)
1464
+ end
1465
+
1466
+ # +points+ run from the child to the parent. A dashed line is laid the
1467
+ # other way, from the parent down, so that its dash carries on into the
1468
+ # piece below it.
1469
+ def emit_connector(points, dash_from)
1470
+ dash = ""
1471
+ if dash_from
1472
+ points = points.reverse
1473
+ dash = " #{dash_array} stroke-dashoffset='#{dash_from.round(3)}'"
1474
+ end
1475
+ if points.size == 2
1476
+ (x1, y1), (x2, y2) = points
1477
+ @tree_data += @line_styles.sub(/X1/, x1.to_s).sub(/Y1/, y1.to_s)
1478
+ .sub(/X2/, x2.to_s).sub(/Y2/, y2.to_s).sub(" />", "#{dash} />")
1479
+ else
1480
+ (chix, chiy), (mx1, my1), (mx2, my2), (parx, pary) = points
1481
+ @tree_data += @polyline_styles.sub(/CHIX/, chix.to_s).sub(/CHIY/, chiy.to_s)
1482
+ .sub(/MIDX1/, mx1.to_s).sub(/MIDY1/, my1.to_s)
1483
+ .sub(/MIDX2/, mx2.to_s).sub(/MIDY2/, my2.to_s)
1484
+ .sub(/PARX/, parx.to_s).sub(/PARY/, pary.to_s).sub(" />", "#{dash} />")
1485
+ end
1486
+ points.each_cons(2).sum { |(ax, ay), (bx, by)| Math.hypot(bx - ax, by - ay) }
1344
1487
  end
1345
1488
 
1346
1489
  # Draw a triangle between child/parent elements
1347
- def triangle_to_parent(parent, child)
1490
+ def triangle_to_parent(parent, child, _joints = [])
1348
1491
  return if child.horizontal_indent.zero?
1349
1492
 
1350
1493
  if @direction == "ltr"
@@ -1373,7 +1516,7 @@ module RSyntaxTree
1373
1516
  # bottom-to-top examples are derivations, which draw rules instead.
1374
1517
  x1 = child.horizontal_indent
1375
1518
  x2 = child.horizontal_indent + child.content_width
1376
- x3 = parent.horizontal_indent + parent.content_width / 2
1519
+ x3 = parent.horizontal_indent + parent.content_width / 2.0
1377
1520
  child_above = child.vertical_indent <= parent.vertical_indent
1378
1521
  m = child.ink_top && parent.ink_bottom ? @global[:single_x_metrics].height * @global[:vmargin] : nil
1379
1522
  if child_above
@@ -189,7 +189,7 @@ module RSyntaxTree
189
189
 
190
190
  label = extract_label(element)
191
191
  escaped_label = escape_latex(label)
192
- head = "#{escaped_label}#{region_option(element)}"
192
+ head = "#{escaped_label}#{leaf_edge_option(element)}#{region_option(element)}"
193
193
 
194
194
  children = element.children
195
195
  indent_str = " " * indent
@@ -202,6 +202,17 @@ module RSyntaxTree
202
202
  end
203
203
  end
204
204
 
205
+ # The edge down to a word is dashed when leafstyle asks for it, every
206
+ # piece of it: leaf alignment runs it through joints, and forest gives
207
+ # each joint the edge from the node above. Added to the edge style the
208
+ # tree sets, not in place of it, so the width holds.
209
+ def leaf_edge_option(element)
210
+ return "" unless @params[:leafstyle] == "dashed"
211
+
212
+ element = @element_list.get_id(element.children.first) while element.inserted_joint
213
+ element.type == ETYPE_LEAF ? ", edge+=dashed" : ""
214
+ end
215
+
205
216
  # forest node option that paints a semi-transparent plane behind the whole
206
217
  # subtree governed by +element+ (region shade). Uses forest's "fit to=tree"
207
218
  # on the background layer so the plane sits below the tree. Empty string
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RSyntaxTree
4
- VERSION = "2.4.1"
4
+ VERSION = "2.6.0"
5
5
  end
data/lib/rsyntaxtree.rb CHANGED
@@ -55,13 +55,14 @@ OPTION_VALUES = {
55
55
  # an alias until 3.0. It collided with a code-intelligence format of the
56
56
  # same initials, and claimed an interchange nobody else had implemented.
57
57
  format: FORMATS + ["lsif"],
58
- leafstyle: %w[auto triangle bar nothing],
58
+ leafstyle: %w[auto triangle bar dashed nothing],
59
59
  fontstyle: ["sans", "serif", "cjk", "mono", "noto-sans", "noto-serif", "noto-sans-mono", "cjk zenhei"],
60
60
  color: %w[modern traditional gray grey off none on true false],
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.1
4
+ version: 2.6.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