rsyntaxtree 2.3.0 → 2.4.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: 8c57f743f27cc6830495cfe9da3ce4483fec13491af8e80d4437357cc9c37968
4
- data.tar.gz: d051b212a5abe64711fd42622bce06cea77c1e8bfc78a8315dfc1246f0dc22d0
3
+ metadata.gz: 2cab83f32f9b3adab3a84ef896406e8683cf3a9abcecfe132540bda198475a10
4
+ data.tar.gz: 29d0804c7d76c1661103e9dcbc922ea7b916ba503f3fb0c9a8f2d78c8e8008d6
5
5
  SHA512:
6
- metadata.gz: e214718246e31cf0d42a03f91ee4b7e714cf1576f3f60fb70b6d0fb9d2d22a24e2b04e832d9e8be2df0b664b5506bf8a0994f659384a85469a4023535409c615
7
- data.tar.gz: 5f7638450414b9cb53ac9fe2cdc08e7776af24ea50a0de75166dfbb3d47b9d4a27ab1d45a026012ef6bd9fdcc76400b1cf88f7df5095dac20cd78c629c3ccc33
6
+ metadata.gz: 22ddead5114613c3a1c82ce9774911e4c414721495b66ed152f904f55ba8aef3d8dde957a473dc563c764635f2892f58f7f2d0d74dd6142427e3de279e34d5c4
7
+ data.tar.gz: 4be4281b2e805ac45c0c01a0cc4d67b837fac67118c807812e88038ecf08fef63952897ba8fab3c66d2ec2b5a66d84428de4e038d1409ee766ab371c17fa907c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.0] - 2026-09-02
4
+
5
+ ### Added
6
+ - `RSyntaxTree.escape(text, as:, hyphen:, apostrophe:)` returns notation that
7
+ draws as the given text, for a program writing notation from strings it did
8
+ not choose. The context (`:word`, `:phrase`, `:label`, `:cell`) decides what
9
+ whitespace means; the hyphen mode has to be given because no one spelling of
10
+ a hyphen serves both; `apostrophe: :keep` keeps a straight apostrophe. The
11
+ test draws every result and reads the text back, so the rules are exercised
12
+ rather than restated. The one text with no spelling under `hyphen: literal`
13
+ — a line of nothing but hyphens, which is the rule — raises `ArgumentError`.
14
+ - `\'` keeps a straight apostrophe, which the notation otherwise sets as a
15
+ curly one. `\@` is an at sign.
16
+
17
+ ### Fixed
18
+ - `\+` before digits is a plus sign. The path markers at the end of a label
19
+ (`+1`, `+>2`) were detected without regard to a backslash, so no label could
20
+ contain "+1" at all: every "C++11" and "2+2" was read as one end of a
21
+ movement path.
22
+ - A label containing a backslash could lose characters in the drawing. The
23
+ text was placed into the SVG as a replacement string, which Ruby reads for
24
+ backreferences: `\+` and `\'` were two of them.
25
+ - An escaped character the tokenizer did not know (`\'`, `\@`) lost its
26
+ backslash before the markup was read.
27
+
3
28
  ## [2.3.0] - 2026-09-01
4
29
 
5
30
  ### Added
@@ -33,11 +33,8 @@ module RSyntaxTree
33
33
  @vertical_indent = 0 # Drawing offset
34
34
  content = content.strip
35
35
 
36
- @path = if /.+?\^?((?:\+-?>?<?\d+)+)\^?\z/m =~ content
37
- $1.sub(/\A\+/, "").split("+")
38
- else
39
- []
40
- end
36
+ _body, path_text = Element.split_path(content)
37
+ @path = path_text.delete_prefix("^").delete_suffix("^").sub(/\A\+/, "").split("+")
41
38
 
42
39
  @fontset = fontset
43
40
  @fontsize = fontsize
@@ -64,7 +61,7 @@ module RSyntaxTree
64
61
  end
65
62
  end
66
63
 
67
- @raw_content = content.sub(/\^?(?:\+-?>?<?\d+)+\^?\z/, '')
64
+ @raw_content = Element.split_path(content).first
68
65
 
69
66
  parsed = Markup.parse(prepare_markup(content))
70
67
 
@@ -125,9 +122,33 @@ module RSyntaxTree
125
122
  # something else: a `---` rule line of its own, and the `+-2` markers
126
123
  # at the end. Shared by swap_hyphen_markup and escape_hyphens.
127
124
  def self.hyphen_safe_lines(text)
128
- path = text[/\^?(?:\+-?>?<?\d+)+\^?\z/]
129
- body = path ? text[0...-path.length] : text
130
- [body.split('\n', -1), path.to_s]
125
+ body, path = split_path(text)
126
+ [body.split('\n', -1), path]
127
+ end
128
+
129
+ # The run of path markers a label ends in: `+1`, `+>2`, `+-3`, several of
130
+ # them, an optional `^` on either side. A marker's `+` is a marker only
131
+ # when the backslashes before it are even in number: `x\+1` is a plus
132
+ # sign and a digit, `x\\+1` a backslash and a marker. Detection used to
133
+ # ignore the backslash, so a label could not contain "+1" at all — every
134
+ # "C++11" and "2+2" was read as one end of a movement path.
135
+ PATH_MARKERS = /(?:\+-?>?<?\d+)+/
136
+
137
+ # Splits a label into what is drawn and its trailing path text, the
138
+ # latter empty when there is none. A label that is nothing but markers
139
+ # has no path: there would be nothing to draw them from.
140
+ def self.split_path(text)
141
+ i = 0
142
+ while (i = text.index("+", i))
143
+ tail = text[i..]
144
+ if i.positive? && tail.match?(/\A#{PATH_MARKERS}\^?\z/) &&
145
+ text[0...i][/\\*\z/].length.even?
146
+ i -= 1 if i > 1 && text[i - 1] == "^"
147
+ return [text[0...i], text[i..]]
148
+ end
149
+ i += 1
150
+ end
151
+ [text, ""]
131
152
  end
132
153
 
133
154
  def swap_hyphen_markup(text)
@@ -366,7 +387,7 @@ module RSyntaxTree
366
387
  # as a curly apostrophe (U+2019) for smarter typography, e.g. the
367
388
  # X-bar prime in "T'". Applied before metrics so the measured glyph
368
389
  # matches the rendered one.
369
- text = text.gsub("'", "’")
390
+ text = text.gsub("'", "’").gsub(Markup::KEPT_APOSTROPHE, "'")
370
391
  e[:text] = text.gsub(" ", WHITESPACE_BLOCK)
371
392
  .gsub(">", '&#62;')
372
393
  .gsub("<", '&#60;')
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ #==========================
4
+ # escape.rb
5
+ #==========================
6
+ #
7
+ # Turns arbitrary text into notation that draws as that text.
8
+ # Copyright (c) 2007-2026 Yoichiro Hasebe <yohasebe@gmail.com>
9
+
10
+ module RSyntaxTree
11
+ # The characters the notation reads as markup, each written as itself by
12
+ # a backslash. The backslash comes first so that the others are escaped
13
+ # once, not twice. A yen sign is here because the reader treats it as a
14
+ # backslash for keyboards that have no other.
15
+ ESCAPED_CHARACTERS = %w[\\ [ ] < > ^ + * _ = ~ | # { } % @ ¥].freeze
16
+
17
+ ESCAPE_CONTEXTS = %i[word phrase label cell].freeze
18
+
19
+ # The web interface's transport spellings of characters a form cannot
20
+ # carry, read out of the input before anything else. Under hyphen: markup
21
+ # the hyphens are escaped and nothing matches; under :literal they would
22
+ # be read, so a backslash goes in after the first hyphen — an escape the
23
+ # reader does not know, whose backslash it drops — and the text survives.
24
+ TRANSPORT_PLACEHOLDERS = %w[AMP PERCENT PRIME SCOLON OABRACKET CABRACKET].freeze
25
+
26
+ # A line of three or more hyphens and nothing else is the horizontal rule.
27
+ RULE_LINE = /\A-{3,}\z/
28
+
29
+ # Notation that draws as +text+, for a program writing notation from
30
+ # strings it did not choose — a tagger's tokens, a corpus's words.
31
+ # Written here rather than in every such program, because a copy of the
32
+ # escaping rules kept elsewhere would drift from the notation as it
33
+ # changes, and the drift would show as a different figure drawn without
34
+ # complaint. The test for this method draws each result and checks that
35
+ # the text came out, so the rules are never restated, only exercised.
36
+ #
37
+ # +as+ says where the text will stand, which decides what whitespace
38
+ # means: a :word is one leaf or one label (a space inside it becomes <>),
39
+ # a :phrase is a leaf of several words (spaces stay, so the leaf gets a
40
+ # triangle), a :label is a node label (like a word, with a newline kept
41
+ # as a line break), and a :cell is one cell of a column-aligned or
42
+ # matrix label (tabs and newlines become the column and row breaks).
43
+ #
44
+ # +hyphen+ must match the option the figure is drawn with. Under :markup
45
+ # a hyphen is escaped; under :literal a bare hyphen is already itself,
46
+ # and the escaped form means an underline instead — so there is no
47
+ # notation that reads as a hyphen under both, and the caller has to say.
48
+ #
49
+ # +apostrophe+ :curly leaves a straight apostrophe to be set as a curly
50
+ # one, which is what the notation does; :keep escapes it so it stays
51
+ # straight, for text that must come out exactly as given.
52
+ #
53
+ # One text has no spelling under hyphen: :literal — a line of nothing but
54
+ # three or more hyphens, which is the horizontal rule and draws as one —
55
+ # and asking for it raises ArgumentError rather than returning notation
56
+ # that draws an empty leaf. Empty and all-whitespace text is returned as
57
+ # the whitespace it holds, which draws as a blank; a caller building a
58
+ # tree from tokens will usually want to drop such tokens first.
59
+ def self.escape(text, as: :word, hyphen: :markup, apostrophe: :curly)
60
+ unless ESCAPE_CONTEXTS.include?(as)
61
+ raise ArgumentError, "as: must be one of #{ESCAPE_CONTEXTS.join(', ')}"
62
+ end
63
+ raise ArgumentError, "hyphen: must be :markup or :literal" unless %i[markup literal].include?(hyphen)
64
+ raise ArgumentError, "apostrophe: must be :curly or :keep" unless %i[curly keep].include?(apostrophe)
65
+
66
+ s = text.to_s
67
+ if as == :phrase
68
+ words = s.split(/\s+/).reject(&:empty?)
69
+ # One word alone is the whole leaf; with company, hyphens are text.
70
+ refuse_rule_line(s, hyphen) if hyphen == :literal && words.length == 1 && words.first.match?(RULE_LINE)
71
+ return words.map { |w| escape_text(w, :word, hyphen, apostrophe) }.join(" ")
72
+ end
73
+
74
+ if hyphen == :literal
75
+ # A newline is a line break in a label or a cell, and a space in a word.
76
+ lines = %i[label cell].include?(as) ? s.split(/\r\n?|\n/, -1) : [s]
77
+ lines.each { |line| refuse_rule_line(s, hyphen) if line.match?(RULE_LINE) }
78
+ end
79
+ escape_text(s, as, hyphen, apostrophe)
80
+ end
81
+
82
+ # The character work, once the text has been accepted.
83
+ def self.escape_text(s, as, hyphen, apostrophe)
84
+ # Every replacement is a block: as a replacement string a backslash is
85
+ # read for backreferences, and `\\'` and `\\+` are two of them.
86
+ out = s.gsub(/[#{Regexp.escape(ESCAPED_CHARACTERS.join)}]/) { |c| "\\#{c}" }
87
+ out = out.gsub("-") { "\\-" } if hyphen == :markup
88
+ if hyphen == :literal
89
+ out = out.gsub(/-(#{TRANSPORT_PLACEHOLDERS.join('|')})-/) { "-\\#{Regexp.last_match(1)}-" }
90
+ end
91
+ out = out.gsub("'") { "\\'" } if apostrophe == :keep
92
+ out = out.gsub(/\r\n?|\n/) { "\\n" } if %i[label cell].include?(as)
93
+ out = out.gsub("\t") { "\\t" } if as == :cell
94
+ # A CRLF is one break, so one space, as it is one break in a label.
95
+ out.gsub(/\r\n|\s/) { "<>" }
96
+ end
97
+ private_class_method :escape_text
98
+
99
+ def self.refuse_rule_line(text, hyphen)
100
+ raise ArgumentError,
101
+ "no notation draws #{text.inspect} under hyphen: #{hyphen.inspect}: a line of " \
102
+ "nothing but hyphens is the horizontal rule. Draw with hyphen: :markup."
103
+ end
104
+ private_class_method :refuse_rule_line
105
+ end
@@ -39,9 +39,13 @@ class MarkupParser < Parslet::Parser
39
39
 
40
40
  rule(:path) { (str('+') >> str('-').maybe >> (str('>') | str('<')).maybe >> match('\d').repeat(1)).as(:path) }
41
41
  # rule(:escaped) { str('\\') >> match('[#<>{}\\^+*_=~\|\n\-]').as(:chr) }
42
- rule(:escaped) { str('\\') >> match('[#<>{}\\\\^+*_=~\\|\\n\\-\\[\\]%]').as(:chr) }
42
+ rule(:escaped) { str('\\') >> match('[#<>{}\\\\^+*_=~\\|\\n\\-\\[\\]%@]').as(:chr) }
43
+ # A straight apostrophe is set as a curly one; `\'` asks for the straight
44
+ # one itself. It comes through as a placeholder so the substitution, which
45
+ # runs later on the joined text, can tell the two apart.
46
+ rule(:kept_apostrophe) { str("\\'").as(:kept_apostrophe) }
43
47
  rule(:non_escaped) { ((match('[#<>{}\\^+*_=~\|\-]') | str('\\n') | str('\\t')).absent? >> any).as(:chr) }
44
- rule(:text) { (escaped | non_escaped).repeat(1).as(:text) }
48
+ rule(:text) { (kept_apostrophe | escaped | non_escaped).repeat(1).as(:text) }
45
49
 
46
50
  # Column separator. Every line of a label is cut at these into cells, and
47
51
  # each column is laid out at the width of its widest cell, which is what an
@@ -87,7 +91,7 @@ class MarkupParser < Parslet::Parser
87
91
  rule(:matrix_line) { (rule_line | double_rule_line | matrix_markup.repeat(1).as(:line)) >> (cr | str('#)').present?) }
88
92
  rule(:matrix_markup) { (matrix | tabstop | matrix_text | decoration | shape | bstroke) }
89
93
  # Text inside a matrix stops at the closing delimiter as well.
90
- rule(:matrix_text) { (escaped | (str('#)').absent? >> non_escaped)).repeat(1).as(:text) }
94
+ rule(:matrix_text) { (kept_apostrophe | escaped | (str('#)').absent? >> non_escaped)).repeat(1).as(:text) }
91
95
 
92
96
  rule(:markup) { (matrix | tabstop | text | decoration | shape | bstroke) }
93
97
 
@@ -107,10 +111,15 @@ class MarkupParser < Parslet::Parser
107
111
  end
108
112
 
109
113
  module Markup
114
+ # Stands in for an escaped apostrophe until the curly substitution has
115
+ # run. A private-use character no label carries.
116
+ KEPT_APOSTROPHE = "\uE000"
117
+
110
118
  @parser = MarkupParser.new
111
119
 
112
120
  @evaluator = Parslet::Transform.new do
113
121
  rule(chr: simple(:chr)) { chr.to_s }
122
+ rule(kept_apostrophe: simple(:kept)) { KEPT_APOSTROPHE }
114
123
  rule(text: sequence(:text)) { { text: text.join(""), decoration: [] } }
115
124
 
116
125
  rule(tabstop: subtree(:empty)) {
@@ -24,9 +24,16 @@ figure that was meant.
24
24
  `\n` or `\t` in it: there it can split a value that carries markup, and it
25
25
  breaks a nested #( ... #) matrix. Inside any label with columns, write a
26
26
  space as `<>`: 'a<>toy', not 'a toy'.
27
- - A straight apostrophe is typeset as the curly ’ (U+2019).
27
+ - A straight apostrophe is typeset as the curly ’ (U+2019); \' keeps it
28
+ straight.
29
+ - A `+` followed by digits at the end of a label is a movement path (+1, +>2).
30
+ \+ is a plus sign, so C\+\+11 and 2\+2 draw as written.
28
31
  - A backslash takes the character after it, whatever it is: \q draws q, and
29
32
  C:\path draws C:path. A backslash itself is \\.
33
+ - A program building labels from text it did not choose should not copy
34
+ these rules: RSyntaxTree.escape(text, as: :word) returns notation that
35
+ draws as the text (as: :phrase, :label or :cell for the other places;
36
+ hyphen: to match the hyphen option; apostrophe: :keep to keep it straight).
30
37
  - The prefixes of a label compose in one order, and only this one:
31
38
  `^` → `#`/`##`/`###` → `%` → `@color:`. So ^#%@red:NP is read whole, while
32
39
  @red:%NP leaves the % as a literal character and silently drops the shade.
@@ -256,7 +256,7 @@ module RSyntaxTree
256
256
  # `\#` arrived at the grammar as a bare '#', which opens an enclosure,
257
257
  # so a label written with a hash in it lost the hash and everything
258
258
  # after it went inside brackets instead.
259
- when /[nt{}<>^+*_=~|%\-#]/
259
+ when /[nt{}<>^+*_=~|%\-#@']/
260
260
  if escape
261
261
  token += '\\' + ch
262
262
  escape = false
@@ -623,7 +623,11 @@ module RSyntaxTree
623
623
  element.ink_top = first_baseline - f_m.ink_above
624
624
  element.ink_bottom = text_y + (l_m.ink_height - l_m.ink_above)
625
625
  end
626
- @tree_data += text_data.sub(/CONTENT/, new_text)
626
+ # The block form, so the label goes in as it is. As a replacement
627
+ # string it was read for backreferences, and `\+` — the last matched
628
+ # group, of which there is none — took a backslash and a plus sign out
629
+ # of any label that carried them.
630
+ @tree_data += text_data.sub(/CONTENT/) { new_text }
627
631
  end
628
632
 
629
633
  # Draws one run of a label — a stretch of text with its decorations, or the
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RSyntaxTree
4
- VERSION = "2.3.0"
4
+ VERSION = "2.4.0"
5
5
  end
data/lib/rsyntaxtree.rb CHANGED
@@ -210,6 +210,7 @@ require_relative 'rsyntaxtree/tikz_generator'
210
210
  require_relative 'rsyntaxtree/version'
211
211
  require_relative 'rsyntaxtree/string_parser'
212
212
  require_relative 'rsyntaxtree/format_converter'
213
+ require_relative 'rsyntaxtree/escape'
213
214
 
214
215
  require 'cgi'
215
216
  require 'rsvg2'
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.3.0
4
+ version: 2.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yoichiro Hasebe
@@ -128,6 +128,7 @@ files:
128
128
  - lib/rsyntaxtree/color_names.rb
129
129
  - lib/rsyntaxtree/element.rb
130
130
  - lib/rsyntaxtree/elementlist.rb
131
+ - lib/rsyntaxtree/escape.rb
131
132
  - lib/rsyntaxtree/format_converter.rb
132
133
  - lib/rsyntaxtree/json_graph.rb
133
134
  - lib/rsyntaxtree/markup_parser.rb