rsyntaxtree 1.13.2 → 2.0.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.
@@ -24,6 +24,10 @@ module RSyntaxTree
24
24
  # a darker shade of the same color — keeping the region clearly bounded on
25
25
  # a white page without a per-color "darker shade" lookup.
26
26
  REGION_STROKE_OPACITY = 0.55
27
+ # The plane a sheared figure lies on. Lighter than a region shade: a
28
+ # region marks part of a figure out from the rest, and the plane is
29
+ # behind all of it — at the region's own opacity the whole page darkens.
30
+ SHEAR_PLANE_FILL_OPACITY = 0.12
27
31
 
28
32
  attr_accessor :width, :height
29
33
 
@@ -41,11 +45,17 @@ module RSyntaxTree
41
45
  @fontstyle = params[:fontstyle]
42
46
  @polyline = params[:polyline] == true
43
47
  @direction = params[:direction] || "ttb"
48
+ # The angle arrives in degrees, positive leaning the top to the right.
49
+ # What the transform wants is the x-shift per unit of y, and y grows
50
+ # downward here, so the tangent changes sign on the way in.
51
+ @shear_deg = (params[:shear] || 0).to_f
52
+ @shear_k = (-Math.tan(@shear_deg * Math::PI / 180)).round(3)
53
+ @shear_plane = params[:shear_plane] || "on"
44
54
  @line_styles = "<line style='fill: none; stroke:#{@col_line}; stroke-width:#{@global[:stroke_normal]}; stroke-linejoin:round; stroke-linecap:round;' x1='X1' y1='Y1' x2='X2' y2='Y2' />\n"
45
55
  @polyline_styles = "<polyline style='stroke:#{@col_line}; stroke-width:#{@global[:stroke_normal]}; fill:none; stroke-linejoin:round; stroke-linecap:round;'
46
56
  points='CHIX CHIY MIDX1 MIDY1 MIDX2 MIDY2 PARX PARY' />\n"
47
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"
48
- @text_styles = "<text white-space='pre' alignment-baseline='text-top' style='fill: COLOR; storoke-width: 0; font-size: fontsize' x='X_VALUE' y='Y_VALUE'>CONTENT</text>\n"
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"
49
59
  @tree_data = String.new
50
60
  @visited_x = {}
51
61
  @visited_y = {}
@@ -90,10 +100,50 @@ module RSyntaxTree
90
100
  y2 = new_y2
91
101
  end
92
102
 
103
+ # A sheared figure needs a canvas of its own shape. The plane is laid
104
+ # out in the space the tree was drawn in — everything inside the shear
105
+ # group shares it — so it is the union of all the ink there: the
106
+ # elements, the region shades, the movement rails, and a rule's name
107
+ # hanging past the last box. The canvas is that plane's four corners
108
+ # after the shear, with the usual margin around them.
109
+ unless @shear_k.zero?
110
+ gap = @global[:h_gap_between_nodes]
111
+ els = @element_list.get_elements
112
+ ink_l = els.map(&:horizontal_indent).min
113
+ ink_t = els.map(&:vertical_indent).min
114
+ ink_r = els.map { |e| e.horizontal_indent + e.content_width }.max
115
+ ink_b = els.map { |e| e.vertical_indent + e.content_height }.max
116
+ ink_r = @rule_name_edge if @rule_name_edge.to_f > ink_r
117
+ [@region_bounds, @path_bounds].compact.each do |b|
118
+ ink_l = [ink_l, b[:min_x]].min
119
+ ink_t = [ink_t, b[:min_y]].min
120
+ ink_r = [ink_r, b[:max_x]].max
121
+ ink_b = [ink_b, b[:max_y]].max
122
+ end
123
+ pad = gap * 1.5
124
+ @plane_rect = { x: (ink_l - pad).round(3), y: (ink_t - pad).round(3),
125
+ w: (ink_r - ink_l + pad * 2).round(3), h: (ink_b - ink_t + pad * 2).round(3) }
126
+ cx0 = @plane_rect[:x] - gap
127
+ cx1 = @plane_rect[:x] + @plane_rect[:w] + gap
128
+ cy0 = @plane_rect[:y] - gap
129
+ cy1 = @plane_rect[:y] + @plane_rect[:h] + gap
130
+ sheared = [cx0 + @shear_k * cy0, cx0 + @shear_k * cy1,
131
+ cx1 + @shear_k * cy0, cx1 + @shear_k * cy1]
132
+ x1 = sheared.min.round(3)
133
+ x2 = (sheared.max - sheared.min).round(3)
134
+ y1 = cy0.round(3)
135
+ y2 = (cy1 - cy0).round(3)
136
+ @width = x2
137
+ @height = y2
138
+ end
139
+
93
140
  extra_lines = @extra_lines.join("\n")
94
141
 
95
- as2 = @global[:h_gap_between_nodes] * 1.0
96
- as4 = as2 * 3
142
+ # Rounded like the stroke widths are: the arithmetic leaves float dust
143
+ # (13.600000000000001) in an attribute of every figure, and the SVG is a
144
+ # deliverable people open in editors.
145
+ as2 = (@global[:h_gap_between_nodes] * 1.0).round(3)
146
+ as4 = (as2 * 3).round(3)
97
147
  # The hatch density follows the type size the way the enclosure it
98
148
  # fills does: the cell is 10/32 of the font size, the line 4/32 —
99
149
  # the ratios the absolute 10 and 4 had at the default size.
@@ -142,10 +192,40 @@ module RSyntaxTree
142
192
  # below tree connectors and labels.
143
193
  shades = @region_shades.join
144
194
 
145
- if @transparent
146
- header + shades + @tree_data + extra_lines + footer
195
+ if @shear_k.zero?
196
+ if @transparent
197
+ header + shades + @tree_data + extra_lines + footer
198
+ else
199
+ header + rect + shades + @tree_data + extra_lines + footer
200
+ end
147
201
  else
148
- header + rect + shades + @tree_data + extra_lines + footer
202
+ # The whole picture shears as one: the plane, the shades, the tree
203
+ # and the rails all live inside the group, so an affine map carries
204
+ # every incidence with it — nothing can newly touch or cross. The
205
+ # background stays outside, painted to the sheared canvas.
206
+ # No plane on a transparent background: what a clear background is
207
+ # for is laying the figure over something else, and an opaque sheet
208
+ # under it is the one thing that would defeat that.
209
+ plane = +""
210
+ if @shear_plane != "off" && !@transparent
211
+ color = @shear_plane == "on" ? REGION_DEFAULT_COLOR : @shear_plane
212
+ radius = @global[:height_connector_to_text] / 2.0
213
+ # Fill alone, no edge. A region shade is bounded because it marks
214
+ # one part of a figure off from the rest; the plane is under all of
215
+ # it, and a line around the whole drawing reads as a frame.
216
+ plane = "<rect x=\"#{@plane_rect[:x]}\" y=\"#{@plane_rect[:y]}\" " \
217
+ "width=\"#{@plane_rect[:w]}\" height=\"#{@plane_rect[:h]}\" " \
218
+ "rx=\"#{radius}\" ry=\"#{radius}\" " \
219
+ "fill=\"#{color}\" fill-opacity=\"#{SHEAR_PLANE_FILL_OPACITY}\" " \
220
+ "stroke=\"none\" />\n"
221
+ end
222
+ body = "<g transform=\"matrix(1,0,#{@shear_k},1,0,0)\">\n" +
223
+ plane + shades + @tree_data + extra_lines + "</g>\n"
224
+ if @transparent
225
+ header + body + footer
226
+ else
227
+ header + rect + body + footer
228
+ end
149
229
  end
150
230
  end
151
231
 
@@ -333,6 +413,20 @@ module RSyntaxTree
333
413
  "#{dasharray}#{markers}/>"
334
414
  end
335
415
 
416
+ # Where the movement paths have been, as a box. The canvas learns of a
417
+ # rail through @width and @height, which only say how far right and down
418
+ # anything got; the plane behind a sheared figure has to enclose the rail
419
+ # on every side, so the corners themselves are kept.
420
+ def note_path_extent(points)
421
+ xs = points.map(&:first)
422
+ ys = points.map(&:last)
423
+ b = (@path_bounds ||= { min_x: xs.min, min_y: ys.min, max_x: xs.max, max_y: ys.max })
424
+ b[:min_x] = [b[:min_x], xs.min].min
425
+ b[:min_y] = [b[:min_y], ys.min].min
426
+ b[:max_x] = [b[:max_x], xs.max].max
427
+ b[:max_y] = [b[:max_y], ys.max].max
428
+ end
429
+
336
430
  def draw_a_path(s_x, s_y, t_x, t_y, target_arrow = :none)
337
431
  spacing = @global[:h_gap_between_nodes] * 1.25
338
432
  min_bulge = @global[:height_connector_to_text]
@@ -365,6 +459,8 @@ module RSyntaxTree
365
459
  new_s_y = s_y + s_offset
366
460
  new_t_y = t_y + t_offset
367
461
 
462
+ note_path_extent([[s_x, new_s_y], [new_x, new_s_y],
463
+ [new_x, new_t_y], [t_x, new_t_y]])
368
464
  @extra_lines << generate_path([[s_x, new_s_y], [new_x, new_s_y],
369
465
  [new_x, new_t_y], [t_x, new_t_y]],
370
466
  @col_path, dashed: dashed, radius: corner_radius,
@@ -383,6 +479,8 @@ module RSyntaxTree
383
479
  new_s_x = s_x - s_offset
384
480
  new_t_x = t_x - t_offset
385
481
 
482
+ note_path_extent([[new_s_x, s_y], [new_s_x, new_y],
483
+ [new_t_x, new_y], [new_t_x, t_y]])
386
484
  @extra_lines << generate_path([[new_s_x, s_y], [new_s_x, new_y],
387
485
  [new_t_x, new_y], [new_t_x, t_y]],
388
486
  @col_path, dashed: dashed, radius: corner_radius,
@@ -421,6 +519,7 @@ module RSyntaxTree
421
519
  this_x = 0
422
520
  this_y = 0
423
521
  prev_line_height = nil
522
+ first_baseline = nil
424
523
  bc = { x: text_x - enclosure_room, y: top, width: element.content_width, height: nil }
425
524
  element.content.each_with_index do |l, idx|
426
525
  case l[:type]
@@ -461,6 +560,7 @@ module RSyntaxTree
461
560
  # after it has to clear all of it.
462
561
  text_y += prev_line_height if idx != 0 && prev_line_height
463
562
  text_y += l[:top_room].to_f
563
+ first_baseline ||= text_y
464
564
  prev_line_height = l[:elements].map { |e| e[:height] }.max
465
565
 
466
566
  l[:elements].each do |e|
@@ -493,6 +593,36 @@ module RSyntaxTree
493
593
  end
494
594
 
495
595
  element.content_height = bc[:height]
596
+
597
+ # Where the label's ink is, for the symmetric clearances of vmargin.
598
+ # A drawn shape — an enclosure, or a label that is one matrix — hands
599
+ # over its own edges; a plain label hands over the font's band around
600
+ # its first and last baselines, so the connectors of one level start
601
+ # level with each other whatever letters happen to hang below.
602
+ drawn_shape = element.enclosure != :none ||
603
+ element.content.any? do |line|
604
+ line[:type] == :text &&
605
+ line[:elements].any? { |e| e[:decoration].include?(:matrix) }
606
+ end
607
+ if drawn_shape
608
+ element.ink_top = bc[:y]
609
+ element.ink_bottom = bc[:y] + bc[:height]
610
+ elsif first_baseline
611
+ # The label's own ink, not the font's whole band: measured from the
612
+ # first and last lines of text as drawn. What the connectors then
613
+ # keep clear of is the union of these across a row (see row_ink), so
614
+ # the ends stay level within a row while a row of capitals pays no
615
+ # tax for descenders nobody wrote.
616
+ text_lines = element.content.select { |l| l[:type] == :text }
617
+ first_text = text_lines.first[:elements].map { |e| e[:text].to_s }.join.gsub(WHITESPACE_BLOCK, " ")
618
+ last_text = text_lines.last[:elements].map { |e| e[:text].to_s }.join.gsub(WHITESPACE_BLOCK, " ")
619
+ f_m = FontMetrics.get_metrics(first_text.strip.empty? ? "X" : first_text,
620
+ @fontset[:family], @fontsize.to_f, :normal, :normal)
621
+ l_m = FontMetrics.get_metrics(last_text.strip.empty? ? "X" : last_text,
622
+ @fontset[:family], @fontsize.to_f, :normal, :normal)
623
+ element.ink_top = first_baseline - f_m.ink_above
624
+ element.ink_bottom = text_y + (l_m.ink_height - l_m.ink_above)
625
+ end
496
626
  @tree_data += text_data.sub(/CONTENT/, new_text)
497
627
  end
498
628
 
@@ -621,11 +751,42 @@ module RSyntaxTree
621
751
  # the sizes Element#measure_lines worked out, inside its own brackets.
622
752
  def render_matrix(e, this_x, text_y, element, col)
623
753
  out = +""
754
+ advance = e[:width]
755
+ # The room measured in front of the block, if it follows something in
756
+ # the same cell. Taken off this_x here so the bracket starts after it,
757
+ # and left in the advance so the row is as wide as it was measured.
758
+ lead = e[:matrix_lead].to_f
759
+ this_x += lead
624
760
  inner_x = this_x + @global[:width_half_x] * MATRIX_BRACKET_ROOM
625
761
  baseline = text_y
626
762
  prev_height = nil
627
763
 
628
764
  e[:matrix].each_with_index do |line, idx|
765
+ # A rule across the matrix, the way one runs across a label: it takes
766
+ # a row of its own and spans the width inside the brackets, so a
767
+ # feature structure can separate its type from its features and a
768
+ # tableau its header from its candidates.
769
+ if %i[border bborder].include?(line[:type])
770
+ # Placed from the baseline above it, the way a rule across a label
771
+ # is: a third of a line under the row before, the rest of the gap
772
+ # under the rule. Measuring from the row it is about to open
773
+ # instead put the space on the wrong side — more above the rule
774
+ # than below, where the ascenders of the next row need it.
775
+ above = baseline
776
+ baseline += prev_height if idx.positive? && prev_height
777
+ prev_height = line[:height].to_f
778
+ y = if idx.zero?
779
+ above - prev_height - @global[:single_line_height] / 8
780
+ else
781
+ above + prev_height - @global[:single_line_height] / 8
782
+ end
783
+ inner_w = e[:width] - (inner_x - this_x) * 2
784
+ stroke = line[:type] == :bborder ? @global[:stroke_bold] : @global[:stroke_normal]
785
+ @extra_lines << "<line style=\"stroke:#{col}; fill:none; stroke-linecap:round; " \
786
+ "stroke-width:#{stroke}; \" x1=\"#{inner_x}\" y1=\"#{y}\" " \
787
+ "x2=\"#{inner_x + inner_w}\" y2=\"#{y}\"></line>"
788
+ next
789
+ end
629
790
  next unless line[:type] == :text
630
791
 
631
792
  baseline += prev_height if idx.positive? && prev_height
@@ -648,8 +809,8 @@ module RSyntaxTree
648
809
  # stacked blocks touching.
649
810
  padding = @global[:single_x_metrics].height * MATRIX_VERTICAL_ROOM
650
811
  top = text_y - @global[:single_x_metrics].height * 0.8 - padding
651
- draw_bracket(this_x, top, e[:width], e[:matrix_height] + padding * 2, col)
652
- [out, this_x + e[:width]]
812
+ draw_bracket(this_x, top, e[:width] - lead, e[:matrix_height] + padding * 2, col)
813
+ [out, this_x + advance - lead]
653
814
  end
654
815
 
655
816
  def draw_rectangle(x1, y1, width, height, col, bline = false)
@@ -1013,14 +1174,62 @@ module RSyntaxTree
1013
1174
  # the parent is the lower of the two, and keeping the top-to-bottom edges
1014
1175
  # sent the line back up through both labels. An empty label has no text to
1015
1176
  # clear, so the line runs to its middle.
1177
+ # The ink extents of a row: the union, across every plain label standing
1178
+ # at one height, of where its type actually reaches. An endpoint measured
1179
+ # from the label's own ink alone gives every label equal air but leaves
1180
+ # the ends of a row's connectors ragged — a line reaches lower over `on`
1181
+ # than over `the`; measured from the whole font's band the ends are level
1182
+ # but a row of capitals pays for descenders nobody wrote. The row's own
1183
+ # union keeps the ends level and charges only for what the row contains.
1184
+ def row_ink
1185
+ @row_ink ||= @element_list.get_elements.each_with_object({}) do |e, rows|
1186
+ next unless e.ink_top && e.ink_bottom
1187
+ next if e.enclosure != :none
1188
+
1189
+ row = (rows[e.vertical_indent.round(1)] ||= { top: e.ink_top, bottom: e.ink_bottom })
1190
+ row[:top] = [row[:top], e.ink_top].min
1191
+ row[:bottom] = [row[:bottom], e.ink_bottom].max
1192
+ end
1193
+ end
1194
+
1195
+ # Where a connector clears an element on the vmargin geometry: the row's
1196
+ # ink for a plain label, the drawn edge for an enclosed one.
1197
+ def ink_over(element)
1198
+ row = element.enclosure == :none && row_ink[element.vertical_indent.round(1)]
1199
+ row ? row[:top] : element.ink_top
1200
+ end
1201
+
1202
+ def ink_under(element)
1203
+ row = element.enclosure == :none && row_ink[element.vertical_indent.round(1)]
1204
+ row ? row[:bottom] : element.ink_bottom
1205
+ end
1206
+
1016
1207
  def connector_edges(child, parent)
1017
1208
  hctt = @global[:height_connector_to_text]
1018
1209
  child_above = child.vertical_indent <= parent.vertical_indent
1019
1210
 
1211
+ # Each endpoint decides for itself. A line through a chain of invisible
1212
+ # joints keeps one real label at most, and when the pair had to agree
1213
+ # on a geometry, the segments beside a joint fell back to the box
1214
+ # measure while their neighbours took the ink one — a row of leaves
1215
+ # under example 080's joints ended thirteen units apart.
1216
+ #
1217
+ # An element whose ink went unrecorded falls back to the measure the
1218
+ # boxes gave. Nothing reaches that now — every drawn label records its
1219
+ # ink — and it is kept as the answer for a label that somehow has none
1220
+ # rather than as a second geometry anyone can ask for.
1221
+ m = @global[:single_x_metrics].height * @global[:vmargin]
1222
+
1020
1223
  child_y = if child.empty_label?
1021
1224
  child.vertical_indent + child.content_height / 2
1022
1225
  elsif child_above
1023
- child.vertical_indent + child.content_height + hctt
1226
+ if m && child.ink_bottom
1227
+ ink_under(child) + m
1228
+ else
1229
+ child.vertical_indent + child.content_height + hctt
1230
+ end
1231
+ elsif m && child.ink_top
1232
+ ink_over(child) - m
1024
1233
  else
1025
1234
  child.vertical_indent + hctt / 2
1026
1235
  end
@@ -1028,11 +1237,31 @@ module RSyntaxTree
1028
1237
  parent_y = if parent.empty_label?
1029
1238
  parent.vertical_indent + parent.content_height / 2
1030
1239
  elsif child_above
1031
- parent.vertical_indent + hctt / 2
1240
+ if m && parent.ink_top
1241
+ ink_over(parent) - m
1242
+ else
1243
+ parent.vertical_indent + hctt / 2
1244
+ end
1245
+ elsif m && parent.ink_bottom
1246
+ ink_under(parent) + m
1032
1247
  else
1033
1248
  parent.vertical_indent + parent.content_height + hctt
1034
1249
  end
1035
1250
 
1251
+ # A tight pitch can put the two endpoints past each other — the gap
1252
+ # between the inks smaller than the clearance on both sides — and a
1253
+ # crossed pair draws the stroke back up through a label. Both then
1254
+ # settle at the middle of the gap, which is the one point that is
1255
+ # clear of the ink on either side whenever the gap exists at all.
1256
+ if m
1257
+ upper_pt, lower_pt = child_above ? [child_y, parent_y] : [parent_y, child_y]
1258
+ if upper_pt > lower_pt
1259
+ mid = (upper_pt + lower_pt) / 2.0
1260
+ child_y = mid
1261
+ parent_y = mid
1262
+ end
1263
+ end
1264
+
1036
1265
  [child_y, parent_y]
1037
1266
  end
1038
1267
 
@@ -1122,14 +1351,41 @@ module RSyntaxTree
1122
1351
  x3 = parent.horizontal_indent + parent.content_width + @global[:height_connector_to_text]
1123
1352
  y3 = parent_y_center
1124
1353
  else
1125
- # TTB: triangle opens horizontally (left-right of child text),
1126
- # apex at parent's bottom
1354
+ # The triangle spans the child's text and points at the parent. Which
1355
+ # way it points is the question the connectors ask as `child_above`,
1356
+ # and the answer is not always the same: bottom-to-top turns the
1357
+ # finished layout over, so the child sits above the parent there and
1358
+ # the base and the apex trade sides. Drawn as though the child were
1359
+ # always below, a bottom-to-top triangle came out inside out — its
1360
+ # base struck through the leaf and its apex through the node — for as
1361
+ # long as the option has existed. No gallery figure carries one: the
1362
+ # bottom-to-top examples are derivations, which draw rules instead.
1127
1363
  x1 = child.horizontal_indent
1128
- y1 = child.vertical_indent + @global[:height_connector_to_text] / 2
1129
1364
  x2 = child.horizontal_indent + child.content_width
1130
- y2 = child.vertical_indent + @global[:height_connector_to_text] / 2
1131
1365
  x3 = parent.horizontal_indent + parent.content_width / 2
1132
- y3 = parent.vertical_indent + parent.content_height + @global[:height_connector_to_text]
1366
+ child_above = child.vertical_indent <= parent.vertical_indent
1367
+ m = child.ink_top && parent.ink_bottom ? @global[:single_x_metrics].height * @global[:vmargin] : nil
1368
+ if child_above
1369
+ y1 = m ? ink_under(child) + m : child.vertical_indent + child.content_height + @global[:height_connector_to_text]
1370
+ y3 = m ? ink_over(parent) - m : parent.vertical_indent + @global[:height_connector_to_text] / 2
1371
+ # The apex must stay clear of the base, or the figure folds through
1372
+ # itself; where there is no room for both clearances they meet in
1373
+ # the middle of what room there is.
1374
+ if m && y3 < y1
1375
+ mid = (y1 + y3) / 2.0
1376
+ y1 = mid
1377
+ y3 = mid
1378
+ end
1379
+ else
1380
+ y1 = m ? ink_over(child) - m : child.vertical_indent + @global[:height_connector_to_text] / 2
1381
+ y3 = m ? ink_under(parent) + m : parent.vertical_indent + parent.content_height + @global[:height_connector_to_text]
1382
+ if m && y3 > y1
1383
+ mid = (y1 + y3) / 2.0
1384
+ y1 = mid
1385
+ y3 = mid
1386
+ end
1387
+ end
1388
+ y2 = y1
1133
1389
  end
1134
1390
 
1135
1391
  polygon_data = @polygon_styles.sub(/X1/, x1.to_s)
@@ -40,6 +40,15 @@ module RSyntaxTree
40
40
  "at the top. Use png, svg or pdf for this figure.",
41
41
  retryable: false)
42
42
  end
43
+ # Refused rather than quietly drawn straight: a shear is the whole point
44
+ # of the figure that asks for it, and TikZ's own slant (xslant) leaves
45
+ # the bounding box unaware of the lean, so standalone output crops.
46
+ if params[:shear].to_f != 0
47
+ raise RSTError.new(+"Error: TikZ output cannot draw a sheared figure",
48
+ code: :invalid_option,
49
+ hint: "Use png, svg or pdf for this figure, or drop the shear option.",
50
+ retryable: false)
51
+ end
43
52
  end
44
53
 
45
54
  # Generate TikZ forest code
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RSyntaxTree
4
- VERSION = "1.13.2"
4
+ VERSION = "2.0.0"
5
5
  end