rsyntaxtree 1.8.2 → 1.9.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: 232efc991d608eb2af9ff19d6816d9f22f9bc4a1006506276507c1b8705f34ec
4
- data.tar.gz: da8e1ab08b470d76d47809b89ae4fd5b7bf0ef7519847ef3bc7e2955814f5521
3
+ metadata.gz: ef69a566b76dec78e1e804475c604162173582605c852900b5cd5c5cf3f2e5eb
4
+ data.tar.gz: 3ee903c09bab59e55e1dd81544768482ddd53c3f0eaf6081364f4bb0c446cfed
5
5
  SHA512:
6
- metadata.gz: 5af80d2af48070f3733c21f19cb543da6a2f443b4e4e2fb4fa2f5a0760584cedcfac5e3b86067623f5f24ba63cfe3267e69692f5ddbc44b3c9fdf35f1de34ace
7
- data.tar.gz: 3d391279ae3b669f87f6c62cfa6b2f44cb607a6f5c9d0adbfd37e147cd9a9d2a8c4f998a3e508c423886a4adfe56610e5041c838ffcd40d47f958e96b45d9d49
6
+ metadata.gz: f9057ca7c228e28351e95fb5b2573818736a738ed5ae2990db33291b884d1aa0d4e8158949b390b84f5151bf0117850521e0b25d1051fd7f5fe080d4559ecdfb
7
+ data.tar.gz: dc727f0091f56ced11c29a25739f8ee73fe912fc8673e8c073a4f3fd7a9de37e4344c392f4e7691773c7abcd7c72f71209f991c1d450e9ddd96e230dbebf4461
data/CHANGELOG.md CHANGED
@@ -1,5 +1,73 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.0] - 2026-08
4
+
5
+ ### Added
6
+ - `hyphen: literal`, which trades the two readings of a hyphen: a bare one is
7
+ a hyphen and `\-underlined\-` underlines. Feature names in HPSG and its
8
+ relatives are full of hyphens, and escaping each one is a poor trade for a
9
+ rule that work never uses.
10
+ - A matrix nested in a label, written between `#(` and `#)`. The value of an
11
+ attribute can be another attribute-value matrix, to any depth, which is what
12
+ a feature path such as SYNSEM | LOCAL | CATEGORY | HEAD needs and what HPSG,
13
+ SBCG and LFG are written in. The nested matrix draws its own brackets and
14
+ lays out its own columns, and the rows after it clear its full height.
15
+ - `\t` in a label cuts the line into cells. Every line is cut at the same
16
+ points and each column is drawn at the width of its widest cell, so the
17
+ parts line up down the label. Together with the bracket enclosure and the
18
+ horizontal rule this gives attribute-value matrices — the feature structures
19
+ of HPSG, SBCG and LFG — without spacing each row by hand, which is how the
20
+ gallery's HPSG example used to do it.
21
+ - `color: gray`, a scheme that keeps node and leaf labels black and draws the
22
+ connectors, triangles and movement paths in grey. It is for diagrams whose
23
+ links outnumber their labels — an ontology, a network of constructions —
24
+ where a page of black lines buries the text. `grey` is accepted too.
25
+ - Gallery examples for two more frameworks: LFG (an annotated c-structure and
26
+ the f-structure it maps to, after Kaplan & Bresnan 1982 and Bresnan 2001)
27
+ and DRT (a discourse representation structure, after Kamp 1981).
28
+ - The example gallery scales each figure by its own width rather than by one
29
+ factor for all of them, so a small tree and a wide one are read at a similar
30
+ apparent size; a figure now fits its row instead of scrolling, loads lazily,
31
+ and carries an anchor of its own.
32
+
33
+ ### Changed
34
+ - Every box and circle in a figure is drawn at one size and on one centre
35
+ line. The size used to come from the line's height, which left a box
36
+ standing a head taller than the numeral inside it, and each shape was
37
+ centred on its own glyph, so the box around `s` sat lower than the one
38
+ around `G`. A shape is now drawn at a fixed fraction of the font size,
39
+ centred on a capital, and grows only for content that will not fit. Every
40
+ figure with a boxed or circled label is redrawn.
41
+ - The SVG's declared width and height agree with its viewBox. They did not,
42
+ so every figure was scaled down by a few percent and letterboxed inside
43
+ its own canvas.
44
+
45
+ ### Fixed
46
+ - TikZ export dropped everything inside a nested matrix: a feature structure
47
+ came out as its outermost attribute names and nothing else. The export still
48
+ cannot draw the brackets, but it keeps what they hold. The documented list of
49
+ what TikZ does not carry now names column alignment, nested matrices and the
50
+ grey line scheme.
51
+ - LSIF records which of the two readings of a hyphen the input was parsed
52
+ under. It records the input verbatim, and the same string means different
53
+ things under the two, so a reader could not re-parse it.
54
+ - A line-type connection with only one end raised a NoMethodError from inside
55
+ the drawing instead of being reported as the input error it is.
56
+ - A line-type connection between two nodes was anchored a full inter-node gap
57
+ outside each of them, so the link fell short of both boxes where the gap was
58
+ wide and reached inside them where it was narrow. It now runs between the
59
+ boxes' own edges, a quarter of a gap short of each. The double arrowhead
60
+ keeps the shape it had but is sized to the link, instead of one fixed size
61
+ that spilled over both boxes on a short link. The same anchors are used in
62
+ left-to-right layout, where a link between siblings had been drawn diagonally
63
+ from the movement-path anchors and now runs straight between the facing
64
+ edges.
65
+ - Two nodes joined by a line-type connection are laid out far enough apart for
66
+ a full-size arrowhead between them. Where the layout had packed them closer
67
+ than the arrow is wide — the two leaves at the foot of the quicksort figure —
68
+ the arrow was drawn small to fit; now the pair is spread and the arrow keeps
69
+ its size. Only pairs that carry a link move, and only when they need to.
70
+
3
71
  ## [1.8.2] - 2026-08
4
72
 
5
73
  ### Changed
data/README.md CHANGED
@@ -171,11 +171,12 @@ Usage:
171
171
  | `-l, --leafstyle` | Leaf style: auto, triangle, bar, nothing | `auto` |
172
172
  | `-n, --fontstyle` | Font style: sans, serif, cjk, mono | `sans` |
173
173
  | `-s, --fontsize` | Font size: 8-26 | `16` |
174
- | `-c, --color` | Color mode: modern, traditional, off | `modern` |
174
+ | `-c, --color` | Color mode: modern, traditional, gray (black text, grey lines), off | `modern` |
175
175
  | `-p, --polyline` | Polyline connectors: on, off | `off` |
176
176
  | `-d, --direction` | Tree layout direction: ttb, ltr | `ttb` |
177
177
  | `--tidy` | Layout scale: off, symmetric, low, medium, high | `off` |
178
178
  | `--hspacing` | Horizontal spacing factor, all layout modes (0.5-3.0) | `1.0` |
179
+ | `--hyphen` | How a hyphen reads: markup (`-underline-`) or literal | `markup` |
179
180
  | `-m, --mirror` | Flip the tree horizontally (RTL convention): on, off | `off` |
180
181
 
181
182
  Run `rsyntaxtree -h` for the full list of options.
data/Rakefile CHANGED
@@ -37,6 +37,11 @@ task :generate do
37
37
  require_relative "dev/generate_examples"
38
38
  end
39
39
 
40
+ desc "Record the pixel size of each gallery figure for the examples page"
41
+ task :figure_sizes do
42
+ ruby "dev/stamp_figure_sizes.rb"
43
+ end
44
+
40
45
  desc "Docker image Build"
41
46
  task :docker_build do
42
47
  `docker build ./ -t rsyntaxtree_devel`
@@ -50,6 +55,12 @@ task :docker_generate do
50
55
  `cat #{docpath}/generate_examples.log`
51
56
  end
52
57
 
58
+ # The examples page scales every figure by the size recorded here, so the
59
+ # record has to be refreshed whenever the figures are.
60
+ ["generate", "docker_generate"].each do |name|
61
+ Rake::Task[name].enhance { Rake::Task["figure_sizes"].invoke }
62
+ end
63
+
53
64
  # Add new task for macOS environment configuration
54
65
 
55
66
  desc "Configure Bundler build options for macOS"
data/bin/rsyntaxtree CHANGED
@@ -18,7 +18,7 @@ CONFIG_VALIDATORS = {
18
18
  fontsize: ->(v) { v.is_a?(Integer) && v >= 8 && v <= 26 ? nil : "must be in the range of 8-26" },
19
19
  linewidth: ->(v) { v.is_a?(Integer) && v >= 1 && v <= 5 ? nil : "must be in the range of 1-5" },
20
20
  vheight: ->(v) { v.is_a?(Numeric) && v >= 0.5 && v <= 5.0 ? nil : "must be in the range of 0.5-5.0" },
21
- color: ->(v) { /\A(modern|traditional|on|off|true|false)\z/ =~ v.to_s ? nil : "must be modern, traditional, or off" },
21
+ color: ->(v) { /\A(modern|traditional|gray|grey|on|off|true|false)\z/ =~ v.to_s ? nil : "must be modern, traditional, gray, or off" },
22
22
  symmetrize: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
23
23
  transparent: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
24
24
  polyline: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
@@ -27,7 +27,8 @@ CONFIG_VALIDATORS = {
27
27
  tidy: ->(v) { /\A(off|symmetric|low|medium|high|on|compact|true|false)\z/ =~ v.to_s ? nil : "must be off, symmetric, low, medium, or high" },
28
28
  hspacing: ->(v) { v.is_a?(Numeric) && v >= 0.5 && v <= 3.0 ? nil : "must be in the range of 0.5-3.0" },
29
29
  tidy_spacing: ->(v) { v.is_a?(Numeric) && v >= 0.5 && v <= 3.0 ? nil : "must be in the range of 0.5-3.0" },
30
- direction: ->(v) { /\A(ttb|ltr)\z/ =~ v.to_s ? nil : "must be ttb or ltr" }
30
+ direction: ->(v) { /\A(ttb|ltr)\z/ =~ v.to_s ? nil : "must be ttb or ltr" },
31
+ hyphen: ->(v) { /\A(markup|literal)\z/ =~ v.to_s ? nil : "must be markup or literal" }
31
32
  }.freeze
32
33
 
33
34
  # Validate configuration and return errors/warnings
@@ -107,7 +108,7 @@ opts = Optimist.options do
107
108
  opt :fontsize, "Size: 8-26", default: 16, short: :s
108
109
  opt :linewidth, "Size: 1-5", default: 1, short: :i
109
110
  opt :vheight, "Connector Height: 0.5-5.0", default: 2.0, short: :v
110
- opt :color, "Color text and bars: modern, traditional, or off", default: "modern", short: :c
111
+ opt :color, "Color text and bars: modern, traditional, gray, or off", default: "modern", short: :c
111
112
  opt :symmetrize, "DEPRECATED alias of --tidy symmetric: on or off", default: "off", short: :y
112
113
  opt :transparent, "Make background transparent: on or off", default: "off", short: :r
113
114
  opt :polyline, "draw polyline connectors: on or off", default: "off", short: :p
@@ -117,17 +118,19 @@ opts = Optimist.options do
117
118
  opt :hspacing, "Horizontal spacing factor (counterpart of vheight): 0.5-3.0", default: 1.0, short: :none
118
119
  opt :tidy_spacing, "DEPRECATED alias of --hspacing: 0.5-3.0", default: 1.0, short: :none
119
120
  opt :direction, "Tree layout direction: ttb (top-to-bottom) or ltr (left-to-right)", default: "ttb", short: :d
121
+ opt :hyphen, "How a hyphen in a label reads: markup (-underline-) or literal (a hyphen; \-underline\- then underlines)", default: "markup", short: :none
120
122
  opt :help, "This is a custom help message", short: :h
121
123
  end
122
124
 
123
125
  Optimist.die :outdir, "must be an exsting directory path" unless FileTest.directory?(opts[:outdir])
124
126
  Optimist.die :direction, "must be ttb or ltr" unless /\A(ttb|ltr)\z/ =~ opts[:direction]
127
+ Optimist.die :hyphen, "must be markup or literal" unless /\A(markup|literal)\z/ =~ opts[:hyphen]
125
128
  Optimist.die :format, "must be png, pdf, svg, or lsif" unless /\A(png|jpg|gif|pdf|svg|lsif)\z/ =~ opts[:format]
126
129
  Optimist.die :leafstyle, "must be auto, triangle, bar, or nothing" unless /\A(auto|triangle|bar|nothing)\z/ =~ opts[:leafstyle]
127
130
  Optimist.die :fontstyle, "must be sans, serif, cjk, or mono" unless /\A(sans|serif|cjk|mono)\z/ =~ opts[:fontstyle]
128
131
  Optimist.die :fontsize, "must be in the range of 8-26" unless opts[:fontsize] >= 8 && opts[:fontsize] <= 26
129
132
  Optimist.die :linewidth, "must be in the range of 1-5" unless opts[:linewidth] >= 1 && opts[:linewidth] <= 5
130
- Optimist.die :color, "must be either modern, traditional, or off" unless /\A(modern|traditional|on|off|true|false)\z/ =~ opts[:color]
133
+ Optimist.die :color, "must be either modern, traditional, gray, or off" unless /\A(modern|traditional|gray|grey|on|off|true|false)\z/ =~ opts[:color]
131
134
  Optimist.die :symmetrize, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:symmetrize]
132
135
  Optimist.die :vheight, "must be in the range of 0.5-5.0" if opts[:vheight] < 0.5 || opts[:vheight] > 5.0
133
136
  Optimist.die :transparent, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:transparent]
@@ -35,6 +35,17 @@ module RSyntaxTree
35
35
  @col_path = "purple"
36
36
  @col_extra = "purple"
37
37
  @col_emph = "red"
38
+ # Text stays black; the lines that hold the diagram together are drawn
39
+ # lighter. A figure whose links outnumber its labels — an ontology, a
40
+ # construction network — reads as a black thicket in the monochrome
41
+ # scheme, and grey scaffolding lets the labels come forward again.
42
+ when "gray"
43
+ @col_node = "black"
44
+ @col_leaf = "black"
45
+ @col_path = "#666666"
46
+ @col_extra = "#666666"
47
+ @col_emph = "black"
48
+ @col_connector = "#666666"
38
49
  else
39
50
  @col_node = "black"
40
51
  @col_leaf = "black"
@@ -44,11 +55,12 @@ module RSyntaxTree
44
55
 
45
56
  @col_bg = "none"
46
57
  @col_fg = "black"
58
+ @col_connector ||= "black"
47
59
 
48
60
  @col_line = if params[:hide_default_connectors] == true
49
61
  "none"
50
62
  else
51
- "black"
63
+ @col_connector
52
64
  end
53
65
 
54
66
  @leafstyle = params[:leafstyle]
@@ -595,6 +607,152 @@ module RSyntaxTree
595
607
  @element_list.get_elements.each { |e| e.horizontal_indent += offset }
596
608
  end
597
609
 
610
+ # --- Line-type connections: room for the arrow ---
611
+
612
+ # The clearance a horizontally linked pair of boxes needs, in units of
613
+ # h_gap_between_nodes. SvgGraph#bothways_arrows draws the two heads at a
614
+ # span of 3 gaps whenever the link is long enough, shrinking only when it
615
+ # is not (span = link length × 0.8 then). The full span therefore wants a
616
+ # link of 3 / 0.8 gaps, and the link itself stops a 0.25-gap margin short
617
+ # of each box. Layout side of the same contract: spread the pair so the
618
+ # full-size arrow fits, and let the clamp cover what cannot be spread.
619
+ LINK_ARROW_CLEARANCE = 3.0 / 0.8 + 0.25 * 2
620
+
621
+ # Pairs of elements joined by a line-type connection (path entries of the
622
+ # -N / ->N / -<N form), mirroring the pool in SvgGraph#draw_paths.
623
+ def link_connection_pairs
624
+ pool = {}
625
+ @element_list.get_elements.each do |e|
626
+ e.path.each do |tr|
627
+ next unless /\A-(>|<)?(\d+)\z/ =~ tr
628
+
629
+ (pool[$2] ||= []) << e
630
+ end
631
+ end
632
+ pool.values.select { |ends| ends.size == 2 }
633
+ end
634
+
635
+ # Ids of the strict ancestors of +element+ (not including the element
636
+ # itself): the nodes that must stay put while a linked pair is spread,
637
+ # so they can be re-centred over their children afterwards.
638
+ def ancestor_ids(element)
639
+ ids = []
640
+ current = element
641
+ until current.parent.to_i.zero?
642
+ current = @element_list.get_id(current.parent)
643
+ ids << current.id
644
+ end
645
+ ids
646
+ end
647
+
648
+ # For every parent with an odd number of children, the offset of the
649
+ # middle child's centre from the centre of the children's span. In a
650
+ # balanced tree that offset is zero and the parent's edge to the middle
651
+ # child is vertical. The spread below moves the right side of a linked
652
+ # pair rightward, so re-centring the parents afterwards puts the span
653
+ # centre — and the parent — off the middle child. Recorded before the
654
+ # spread so the pass can hand the alignment back; see
655
+ # restore_middle_child_alignment.
656
+ def middle_child_alignment
657
+ @element_list.get_elements
658
+ .reject { |e| e.children.empty? || e.children.size.even? }
659
+ .to_h do |p|
660
+ kids = p.children.map { |c| @element_list.get_id(c) }
661
+ centers = kids.map { |k| k.horizontal_indent + k.content_width / 2.0 }
662
+ mid = kids[kids.size / 2]
663
+ [p.id, (mid.horizontal_indent + mid.content_width / 2.0) - (centers.min + centers.max) / 2.0]
664
+ end
665
+ end
666
+
667
+ # Undo whatever the spread did to the middle-child alignment of every
668
+ # odd-child parent, deepest first. The drift is cancelled by shifting
669
+ # the whole side the drift points away from: when the span centre ran
670
+ # right of the middle child, everything right of the middle child's
671
+ # subtree moves right by twice the drift (moving the span centre back
672
+ # by exactly the drift). The side moves rigidly, so no gap inside it
673
+ # changes, and the gap across the middle only widens — no overlaps.
674
+ def restore_middle_child_alignment(before)
675
+ parents = @element_list.get_elements
676
+ .reject { |e| e.children.empty? || e.children.size.even? }
677
+ parents.sort_by { |p| -p.level }.each do |p|
678
+ kids = p.children.map { |c| @element_list.get_id(c) }
679
+ mid = kids[kids.size / 2]
680
+ centers = kids.map { |k| k.horizontal_indent + k.content_width / 2.0 }
681
+ dx = (mid.horizontal_indent + mid.content_width / 2.0) - (centers.min + centers.max) / 2.0
682
+ drift = dx - before[p.id]
683
+ next if drift.abs < 0.01
684
+
685
+ if drift.positive?
686
+ split = get_rightmost(mid.id)
687
+ @element_list.get_elements.each do |e|
688
+ e.horizontal_indent += drift * 2 if e.horizontal_indent >= split - 0.001
689
+ end
690
+ else
691
+ split = get_leftmost(mid.id)
692
+ @element_list.get_elements.each do |e|
693
+ e.horizontal_indent += drift * 2 if e.horizontal_indent + e.content_width <= split + 0.001
694
+ end
695
+ end
696
+ node_centering
697
+ end
698
+ end
699
+
700
+ # Push linked pairs apart until the gap between their boxes holds a
701
+ # full-size arrow. Runs after the tidy passes: a compression pass would
702
+ # simply undo any room reserved in the width calculation, while shifting
703
+ # the right-hand side rightward here can never create an overlap, so
704
+ # tidy's guarantee survives. Only links drawn horizontally need room —
705
+ # the two y ranges must overlap, the same test draw_paths applies. The
706
+ # pair's own ancestors stay put and are re-centred over their children
707
+ # afterwards; everything else right of the split moves by the deficit.
708
+ # LTR is excluded: its sibling links are vertical, and sizing them to the
709
+ # same span would stretch every stacked pair far beyond what the compact
710
+ # stacking justifies — the clamp covers those.
711
+ def spread_linked_pairs
712
+ return if @direction == "ltr"
713
+
714
+ pairs = link_connection_pairs
715
+ return if pairs.empty?
716
+
717
+ hct = @global[:height_connector_to_text]
718
+ required = @global[:h_gap_between_nodes] * LINK_ARROW_CLEARANCE
719
+ alignment_before = middle_child_alignment
720
+ spread = false
721
+ pairs.map { |a, b| [a, b].sort_by(&:horizontal_indent) }
722
+ .sort_by { |a, _b| a.horizontal_indent }
723
+ .each do |a, b|
724
+ a_top = a.vertical_indent + hct / 2
725
+ b_top = b.vertical_indent + hct / 2
726
+ a_bottom = a.vertical_indent + a.content_height + hct
727
+ b_bottom = b.vertical_indent + b.content_height + hct
728
+ next if a_top > b_bottom || b_top > a_bottom
729
+
730
+ gap = b.horizontal_indent - (a.horizontal_indent + a.content_width)
731
+ deficit = required - gap
732
+ next if deficit <= 0.01
733
+
734
+ ancestors = ancestor_ids(a) + ancestor_ids(b)
735
+ split = a.horizontal_indent + a.content_width
736
+ @element_list.get_elements.each do |e|
737
+ e.horizontal_indent += deficit if e.horizontal_indent >= split - 0.001 && !ancestors.include?(e.id)
738
+ end
739
+ spread = true
740
+ end
741
+ # A figure whose links already had their room is left exactly as tidy
742
+ # left it: re-centring it would move nothing but the last decimal.
743
+ return unless spread
744
+
745
+ node_centering
746
+ # The spread pushed the right side of each linked pair rightward, which
747
+ # pulls an odd-child parent's span centre off its middle child (the
748
+ # middle edge slants). Give the alignment back before settling.
749
+ restore_middle_child_alignment(alignment_before)
750
+ normalize_horizontal
751
+ # The dynamic connector height couples level spacing to the horizontal
752
+ # spread, so let it settle against the widened layout.
753
+ calculate_height
754
+ end
755
+
598
756
  # Snapshot / restore of everything the tidy passes mutate.
599
757
  def layout_snapshot
600
758
  @element_list.get_elements.map { |e| [e.horizontal_indent, e.vertical_indent, e.height] }
@@ -724,6 +882,15 @@ module RSyntaxTree
724
882
  restore_layout(snapshot) if layout_overlaps?
725
883
  end
726
884
 
885
+ # Give line-linked pairs room for a full-size arrow (no-op without
886
+ # links). Guarded the same way tidy is: if spreading ever produced an
887
+ # overlap, roll back and let the arrow clamp instead.
888
+ if @element_list.elements.size > 1
889
+ snapshot = layout_snapshot
890
+ spread_linked_pairs
891
+ restore_layout(snapshot) if layout_overlaps?
892
+ end
893
+
727
894
  # Phase 2: swap axes and restore content dimensions for LTR
728
895
  finalize_ltr if @direction == "ltr"
729
896
 
@@ -12,7 +12,7 @@ require_relative "utils"
12
12
 
13
13
  module RSyntaxTree
14
14
  class Element
15
- attr_accessor :id, :parent, :type, :level, :width, :height, :content, :content_width, :content_height, :horizontal_indent, :vertical_indent, :triangle, :enclosure, :children, :font, :fontsize, :contains_phrase, :path, :color, :raw_content, :region, :region_color
15
+ 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
16
16
 
17
17
  def initialize(id, parent, content, level, fontset, fontsize, global)
18
18
  @global = global
@@ -37,7 +37,7 @@ module RSyntaxTree
37
37
  @fontsize = fontsize
38
38
  @raw_content = content.sub(/\^?(?:\+-?>?<?\d+)+\^?\z/, '')
39
39
 
40
- parsed = Markup.parse(content)
40
+ parsed = Markup.parse(@global[:literal_hyphen] ? swap_hyphen_markup(content) : content)
41
41
 
42
42
  if parsed[:status] == :success
43
43
  results = parsed[:results]
@@ -72,11 +72,63 @@ module RSyntaxTree
72
72
  end
73
73
  end
74
74
 
75
+ # With hyphen: literal, the two readings of '-' trade places: a bare one is
76
+ # a hyphen and an escaped one opens and closes an underline. Feature names
77
+ # in HPSG and its relatives are full of hyphens — HEAD-DTR, RELIED-ON — and
78
+ # escaping every one of them is a poor trade for a rule nobody there uses.
79
+ # Swapping the two before parsing leaves the grammar untouched.
80
+ # Two uses of the hyphen are structure rather than markup, and are left
81
+ # alone: a line of nothing but hyphens is the horizontal rule, and the dash
82
+ # in a path suffix (+-1, +->2) is what makes that path dashed. Swapping
83
+ # those turned a rule into the text "---" without a word of complaint.
84
+ def swap_hyphen_markup(text)
85
+ path = text[/\^?(?:\+-?>?<?\d+)+\^?\z/]
86
+ body = path ? text[0...-path.length] : text
87
+ swapped = body.split('\n', -1).map do |line|
88
+ /\A-{3,}\z/.match?(line) ? line : swap_hyphens(line)
89
+ end.join('\n')
90
+ swapped + path.to_s
91
+ end
92
+
93
+ def swap_hyphens(text)
94
+ # A character no label can contain, so the two swaps cannot see each
95
+ # other's output. Written as an escape: a literal NUL in the source
96
+ # makes git and grep treat this file as binary.
97
+ placeholder = "\u0000"
98
+ text.gsub('\\-', placeholder).gsub("-", '\\-').gsub(placeholder, "-")
99
+ end
100
+
75
101
  def setup
102
+ layout = measure_lines(@content)
103
+ @text_width = layout[:width]
104
+ @content_width = layout[:width] + label_enclosure_room * 2
105
+ @content_height = layout[:height]
106
+ end
107
+
108
+ # Room on each side of a label for its own bracket or rectangle. It is part
109
+ # of the width the tree lays the node out at, so that whatever attaches to
110
+ # the node — a connector, a movement arrow, the neighbour beside it —
111
+ # meets the line actually drawn around it. A matrix nested in the label
112
+ # keeps the same room, so the outermost pair of brackets in a feature
113
+ # structure stands as far from its contents as every pair within.
114
+ def label_enclosure_room
115
+ return 0 unless [:brackets, :rectangle, :brectangle].include?(@enclosure)
116
+
117
+ @global[:width_half_x] * MATRIX_BRACKET_ROOM
118
+ end
119
+
120
+ # Measures a list of label lines, filling in the width and height of every
121
+ # element and aligning the columns that \t marks. A nested matrix runs
122
+ # through here again, which is what lets a feature structure hold another.
123
+ # `nested` is set for a matrix inside a label. The first row of a label
124
+ # carries an extra margin that holds the text clear of the connector above
125
+ # it; a nested block sits inside that margin already, so counting it again
126
+ # would leave a bracket half a line taller than the rows it encloses.
127
+ def measure_lines(lines, nested: false)
76
128
  total_width = 0
77
129
  total_height = 0
78
- one_bvm_given = false
79
- @content.each do |content|
130
+ one_bvm_given = nested
131
+ lines.each do |content|
80
132
  content_width = 0
81
133
  case content[:type]
82
134
  when :border, :bborder
@@ -86,7 +138,39 @@ module RSyntaxTree
86
138
  when :text
87
139
  row_width = 0
88
140
  elements_height = []
141
+ # A line of nothing but shapes is spaced by the shapes themselves, so
142
+ # a grid of boxes closes up instead of showing a seam between its
143
+ # rows. A shape sharing a line with text cannot be: the line keeps the
144
+ # text's rhythm, and a box is nearly as tall as a line, so two of them
145
+ # on consecutive rows would come out edge to edge.
146
+ row_holds_text = content[:elements].any? do |e|
147
+ (e[:decoration] & [:box, :circle, :bar]).empty? && !e[:text].to_s.strip.empty?
148
+ end
89
149
  content[:elements].each do |e|
150
+ # A nested matrix is measured by the same code one level down, and
151
+ # reports the size of the block it will occupy in this row: its own
152
+ # content plus the brackets drawn around it.
153
+ if e[:decoration].include?(:matrix)
154
+ inner = measure_lines(e[:matrix], nested: true)
155
+ e[:matrix_width] = inner[:width]
156
+ e[:matrix_height] = inner[:height]
157
+ e[:width] = inner[:width] + matrix_bracket_room * 2
158
+ # Two separate allowances. The block is padded inside its own
159
+ # brackets, above and below, and that padding is part of the row.
160
+ # The gap that keeps the block clear of the rows either side is
161
+ # not: it widens the space the row is entered on, so the bracket
162
+ # is not simply drawn over the line before it.
163
+ e[:height] = inner[:height] + matrix_vertical_room * 2
164
+ # Twice the padding: the bracket is drawn that far above the
165
+ # baseline of its first row, so the first helping only pays for
166
+ # the padding and the second is what actually separates the
167
+ # bracket from the descenders of the line above it.
168
+ content[:top_room] = matrix_vertical_room * 2
169
+ elements_height << e[:height]
170
+ row_width += e[:width]
171
+ next
172
+ end
173
+
90
174
  text = e[:text]
91
175
  # Handle escaped square brackets
92
176
  text = text.gsub('\\[', '[')
@@ -113,6 +197,7 @@ module RSyntaxTree
113
197
  standard_metrics = FontMetrics.get_metrics('X', font, fontsize, :normal, :normal)
114
198
 
115
199
  height = standard_metrics.height
200
+ line_height = height
116
201
  if /\A[<>]+\z/ =~ text
117
202
  width = standard_metrics.width * text.size / 2
118
203
  elsif text.contains_emoji?
@@ -138,11 +223,48 @@ module RSyntaxTree
138
223
  width = metrics.width
139
224
  end
140
225
 
141
- # The following is unchanged
142
226
  if e[:decoration].include?(:box) || e[:decoration].include?(:circle) || e[:decoration].include?(:bar)
227
+ # One size and one height for every enclosure in a figure, so a
228
+ # hatched circle, an empty box and a lettered tag line up and
229
+ # share a diameter. The line's rhythm used to set the size, which
230
+ # left a box standing a head taller than the numeral inside;
231
+ # centring each shape on its own glyph instead made the box
232
+ # around 's' sit lower than the one around 'G'.
233
+ #
234
+ # The shape is centred on a capital — the half-way point of the
235
+ # letters it will usually hold — and drawn at ENCLOSURE_SIZE,
236
+ # which is wide enough that a descender still clears the bottom.
237
+ # Centring on the whole cap-to-descender band instead would sit
238
+ # the shape low around the digits and capitals that fill most
239
+ # tags, since those never reach below the baseline. It grows past
240
+ # ENCLOSURE_SIZE only for content that will not fit.
241
+ band = FontMetrics.get_metrics("Xg", font, fontsize, :normal, :normal)
242
+ descender = band.ink_height - band.ink_above
243
+ centre = standard_metrics.ink_above / 2.0
244
+
245
+ ink = FontMetrics.get_metrics(text, font, fontsize, style, weight)
246
+ ink_height = ink.ink_height.to_f
247
+ # Deep enough for a descender, so the one letter in a hundred that
248
+ # has one does not get a taller box than its neighbours.
249
+ half = [fontsize * ENCLOSURE_SIZE / 2.0, centre + descender].max
250
+
251
+ if ink_height.positive?
252
+ reach = [ink.ink_above - centre, centre - (ink.ink_above - ink_height)].max
253
+ half = reach + fontsize * ENCLOSURE_PADDING if reach > half
254
+ end
255
+
256
+ e[:enc_height] = half * 2
257
+ e[:enc_above] = centre + half
258
+
259
+ height = if row_holds_text
260
+ [height, e[:enc_height] + fontsize * ENCLOSURE_PADDING * 2].max
261
+ else
262
+ e[:enc_height]
263
+ end
264
+
143
265
  e[:content_width] = width
144
266
  width += if e[:text].size == 1
145
- height - width
267
+ [e[:enc_height] - width, 0].max
146
268
  else
147
269
  @global[:width_half_x]
148
270
  end
@@ -155,28 +277,93 @@ module RSyntaxTree
155
277
 
156
278
  e[:height] = height
157
279
 
280
+ # What the label measures is not what its rows advance by. A line
281
+ # of nothing but shapes advances by the shapes, so a grid closes
282
+ # up, but it still measures a full line: the tree places a level by
283
+ # the height of the nodes above it, so a node measured short of the
284
+ # rhythm pulls its own children up and off the row its cousins sit
285
+ # on.
286
+ measured = [height, line_height].max
287
+
158
288
  if one_bvm_given
159
- elements_height << height
289
+ elements_height << measured
160
290
  else
161
291
  one_bvm_given = true
162
- elements_height << height + @global[:box_vertical_margin]
292
+ elements_height << measured + @global[:box_vertical_margin]
163
293
  end
164
294
 
165
295
  e[:width] = width
166
296
  row_width += width
167
297
  end
168
298
 
169
- total_height += elements_height.max
299
+ content[:height] = elements_height.max
300
+ total_height += elements_height.max + content[:top_room].to_f
170
301
  content_width += row_width
171
302
  end
172
303
  total_width = content_width if total_width < content_width
173
304
  end
174
- @content_width = total_width
175
- @content_height = total_height
305
+ { width: align_columns(lines, total_width), height: total_height }
176
306
  end
177
307
 
178
308
  def add_child(child_id)
179
309
  @children << child_id
180
310
  end
311
+
312
+ # Horizontal room a nested matrix needs on each side for its bracket and
313
+ # the air around it.
314
+ def matrix_bracket_room
315
+ @global[:width_half_x] * MATRIX_BRACKET_ROOM
316
+ end
317
+
318
+ # Vertical room a nested matrix keeps between itself and the rows above and
319
+ # below it.
320
+ def matrix_vertical_room
321
+ @global[:single_x_metrics].height * MATRIX_VERTICAL_ROOM
322
+ end
323
+
324
+ private
325
+
326
+ # Lines cut at \t are laid out in columns: every column is as wide as its
327
+ # widest cell, so attributes line up down the left and their values down
328
+ # the right, which is what an attribute-value matrix is. The separator
329
+ # element carries the padding that takes its cell out to the column width,
330
+ # and the widest line decides the label's width.
331
+ #
332
+ # Returns the label width, unchanged when no line has a separator.
333
+ def align_columns(lines, fallback_width)
334
+ rows = lines.select { |c| c[:type] == :text }
335
+ .map { |c| split_into_cells(c[:elements]) }
336
+ return fallback_width unless rows.any? { |cells| cells.size > 1 }
337
+
338
+ columns = rows.map(&:size).max
339
+ widths = Array.new(columns) do |i|
340
+ rows.filter_map { |cells| cells[i]&.sum { |e| e[:width] } }.max || 0
341
+ end
342
+ gutter = @global[:width_half_x]
343
+
344
+ rows.each do |cells|
345
+ cells.each_with_index do |cell, i|
346
+ separator = cell.find { |e| e[:decoration].include?(:tabstop) }
347
+ next unless separator
348
+
349
+ used = cell.sum { |e| e[:width] }
350
+ separator[:width] = widths[i] - used + gutter
351
+ end
352
+ end
353
+
354
+ widths.sum + gutter * (columns - 1)
355
+ end
356
+
357
+ # Each cell keeps the separator that closes it, so the padding has
358
+ # something to sit on.
359
+ def split_into_cells(elements)
360
+ cells = [[]]
361
+ elements.each do |e|
362
+ cells.last << e
363
+ cells << [] if e[:decoration].include?(:tabstop)
364
+ end
365
+ cells.pop if cells.last.empty?
366
+ cells
367
+ end
181
368
  end
182
369
  end
@@ -284,6 +284,11 @@ module RSyntaxTree
284
284
  symmetrize: @params[:symmetrize],
285
285
  mirror: @params[:mirror] == true,
286
286
  direction: @params[:direction] || "ttb",
287
+ # Which of the two readings of a hyphen the input was parsed
288
+ # under. The input is recorded verbatim above, and the same
289
+ # string means different things under the two, so a reader
290
+ # cannot re-parse it without knowing this.
291
+ hyphen: @params[:hyphen] || "markup",
287
292
  polyline: @params[:polyline],
288
293
  hide_default_connectors: @params[:hide_default_connectors],
289
294
  transparent: @params[:transparent]