rsyntaxtree 2.2.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: fe4cd7a8b28ffa968f99ff228e3a182a64d420e0405cd27e120b5f4ff02fe48e
4
- data.tar.gz: 23a64934f448707de2b55cad4c206d65fab18aa7d6c1cd3aab0b1871efd9be1e
3
+ metadata.gz: 2cab83f32f9b3adab3a84ef896406e8683cf3a9abcecfe132540bda198475a10
4
+ data.tar.gz: 29d0804c7d76c1661103e9dcbc922ea7b916ba503f3fb0c9a8f2d78c8e8008d6
5
5
  SHA512:
6
- metadata.gz: 2dfd53e10a62187649fb3d7285fa38189ef25cf56d6af7ceea27fba3cc7358b1f7d528db6466607061120fab61a2ff7bd9b84fa0b989e4313faaac306af42235
7
- data.tar.gz: 7399787ea179bdd17910fa9695cc292a18cb5b8e9b3948e943bf32fd52fd05f89e6ec0e2792138197d56c839b9960bab678489a976590667eca9b4dd80679029
6
+ metadata.gz: 22ddead5114613c3a1c82ce9774911e4c414721495b66ed152f904f55ba8aef3d8dde957a473dc563c764635f2892f58f7f2d0d74dd6142427e3de279e34d5c4
7
+ data.tar.gz: 4be4281b2e805ac45c0c01a0cc4d67b837fac67118c807812e88038ecf08fef63952897ba8fab3c66d2ec2b5a66d84428de4e038d1409ee766ab371c17fa907c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
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
+
28
+ ## [2.3.0] - 2026-09-01
29
+
30
+ ### Added
31
+ - `RSTError::CODES` lists every error code the library can report. A program
32
+ keyed by code — one that translates the diagnosis, or tallies what kinds of
33
+ mistake a writer makes — needs the whole set rather than the codes it has
34
+ happened to see, and until now that set could only be recovered by reading
35
+ five files and the repair table. The list only grows: a published code is
36
+ not renamed or removed.
37
+
38
+ ### Fixed
39
+ - Whitespace alone is an empty input rather than a defect in the library. It
40
+ reached the drawing, which walked off the end of an empty tree and reported
41
+ `internal_error`, telling the caller that their own fixable input was the
42
+ library's fault.
43
+ - A bracketless input of a single character draws. The tokenizer counted its
44
+ cursor as exhausted one character early, so `A`, and `<>` once it is one
45
+ whitespace block, produced no tokens at all — though the manual has always
46
+ said a label on its own draws as one leaf.
47
+
3
48
  ## [2.2.0] - 2026-09-01
4
49
 
5
50
  ### Changed
@@ -97,9 +142,8 @@ looking at what was left. Every figure in the gallery draws exactly as it did.
97
142
 
98
143
  ### Added
99
144
  - `shear`: the drawn figure tilts by the given angle (degrees, positive
100
- leaning the top to the right) and lies on a plane drawn behind it, so the
101
- lean reads as a surface seen at an angle rather than as a mistake. The
102
- whole picture shears as one affine piece — layout untouched, region
145
+ leaning the top to the right) and lies on a plane drawn behind it, which
146
+ shows the tilt as a surface. The whole picture shears as one affine piece layout untouched, region
103
147
  shades coming out parallelograms on their own, and nothing able to newly
104
148
  touch or cross. The plane is fill without an edge — a region shade is
105
149
  bounded because it marks one part of a figure off from the rest, and a
@@ -245,9 +289,8 @@ looking at what was left. Every figure in the gallery draws exactly as it did.
245
289
  - Derivations. A derivation puts the words first and the result last, and
246
290
  joins what each step combines with one rule drawn across all of it rather
247
291
  than with a line to each daughter. `derivation` draws the rules and
248
- `direction: btt` turns the tree over; together they give the format
249
- categorial grammar is written in, and `derivation` on its own marks the
250
- spans of an ordinary tree. The name of each step rides in the label after a
292
+ `direction: btt` turns the tree over; together they draw a CCG derivation,
293
+ and `derivation` on its own marks the spans of an ordinary tree. The name of each step rides in the label after a
251
294
  column break and is set beside the end of its rule. A derivation runs down
252
295
  the page and is drawn with its rules, so `direction: ltr` and hiding the
253
296
  default connectors are refused rather than approximated.
@@ -379,15 +422,15 @@ RMagick dependency.
379
422
  rule that work never uses.
380
423
  - A matrix nested in a label, written between `#(` and `#)`. The value of an
381
424
  attribute can be another attribute-value matrix, to any depth, which is what
382
- a feature path such as SYNSEM | LOCAL | CATEGORY | HEAD needs and what HPSG,
383
- SBCG and LFG are written in. The nested matrix draws its own brackets and
425
+ a feature path such as SYNSEM | LOCAL | CATEGORY | HEAD needs, and what
426
+ HPSG and LFG are written in. The nested matrix draws its own brackets and
384
427
  lays out its own columns, and the rows after it clear its full height.
385
428
  - `\t` in a label cuts the line into cells. Every line is cut at the same
386
429
  points and each column is drawn at the width of its widest cell, so the
387
430
  parts line up down the label. Together with the bracket enclosure and the
388
431
  horizontal rule this gives attribute-value matrices — the feature structures
389
- of HPSG, SBCG and LFG — without spacing each row by hand, which is how the
390
- gallery's HPSG example used to do it.
432
+ of HPSG, LFG and the like — without spacing each row by hand, which is how
433
+ the gallery's HPSG example used to do it.
391
434
  - `color: gray`, a scheme that keeps node and leaf labels black and draws the
392
435
  connectors, triangles and movement paths in grey. It is for diagrams whose
393
436
  links outnumber their labels — an ontology, a network of constructions —
@@ -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.
@@ -64,7 +64,12 @@ module RSyntaxTree
64
64
  end
65
65
 
66
66
  def self.valid?(data)
67
- raise RSTError.new(+"Error: input text is empty", code: :empty_input, retryable: false) if data.empty?
67
+ # Whitespace alone is an empty input, not a tree with one blank label:
68
+ # nothing survives the parse, and what used to happen is that the
69
+ # drawing walked off the end of an empty element list and reported a
70
+ # defect in the library for what the caller could see and fix. A space
71
+ # meant as a label is written <>, which is not whitespace here.
72
+ raise RSTError.new(+"Error: input text is empty", code: :empty_input, retryable: false) if data.strip.empty?
68
73
 
69
74
  if /\[\s*\]/m =~ data
70
75
  raise RSTError.new(+"Error: inside the brackets is empty", code: :empty_brackets,
@@ -204,7 +209,11 @@ module RSyntaxTree
204
209
  token = ""
205
210
  i = 0
206
211
 
207
- return "" if (@pos + 1) >= data.length
212
+ # The cursor is exhausted when it reaches the end, not one before it:
213
+ # counting from @pos + 1 skipped the last character, so an input of a
214
+ # single character — "A", or "<>" once it is one whitespace block —
215
+ # produced no tokens at all and drew from an empty element list.
216
+ return "" if @pos >= data.length
208
217
 
209
218
  escape = false
210
219
  while ((@pos + i) < data.length) && !gottoken
@@ -247,7 +256,7 @@ module RSyntaxTree
247
256
  # `\#` arrived at the grammar as a bare '#', which opens an enclosure,
248
257
  # so a label written with a hash in it lost the hash and everything
249
258
  # after it went inside brackets instead.
250
- when /[nt{}<>^+*_=~|%\-#]/
259
+ when /[nt{}<>^+*_=~|%\-#@']/
251
260
  if escape
252
261
  token += '\\' + ch
253
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.2.0"
4
+ VERSION = "2.4.0"
5
5
  end
data/lib/rsyntaxtree.rb CHANGED
@@ -133,6 +133,50 @@ class RSTError < StandardError
133
133
  super(msg.gsub(WHITESPACE_BLOCK, "<>"))
134
134
  end
135
135
 
136
+ # Every code an error from this library can carry. Consumers dispatch on
137
+ # these — a translation keyed by code, a harness counting what kinds of
138
+ # mistake occur — and both need to know the whole set, not only the codes
139
+ # they have happened to see: a kind of mistake nobody made is not the same
140
+ # as a kind that cannot happen. Until this list existed the set was only
141
+ # discoverable by reading five files and a repair table.
142
+ #
143
+ # Written out rather than computed, because it is a contract and a contract
144
+ # should be readable at a glance and visible in a diff. A test keeps it
145
+ # honest by finding the codes the library actually raises and comparing.
146
+ #
147
+ # The list grows and does not churn: a code once published is not renamed
148
+ # or removed, so a consumer keyed by code — a translation table, a tally of
149
+ # what kinds of mistake a writer makes — only ever has entries to add.
150
+ #
151
+ # Eight are named by the repair table, which diagnoses a label by rewriting
152
+ # it and asking the parser whether the rewrite reads: angle_brackets,
153
+ # bare_hyphen, incomplete_path, invalid_color, rule_name_without_derivation,
154
+ # stray_triangle, unclosed_markup, unclosed_matrix. invalid_markup is where
155
+ # a label lands whose mistake no single rewrite fits, which in practice
156
+ # means more than one. unknown_color and label_split are mistakes inside a
157
+ # label too, found before the repair table is reached.
158
+ CODES = %i[
159
+ angle_brackets
160
+ bare_hyphen
161
+ empty_brackets
162
+ empty_input
163
+ incomplete_path
164
+ internal_error
165
+ invalid_color
166
+ invalid_markup
167
+ invalid_option
168
+ label_split
169
+ path_multiple_ends
170
+ path_single_end
171
+ result_too_big
172
+ rule_name_without_derivation
173
+ stray_triangle
174
+ unbalanced_brackets
175
+ unclosed_markup
176
+ unclosed_matrix
177
+ unknown_color
178
+ ].freeze
179
+
136
180
  # Where the notation is written down. A hint repairs the mistake in front of
137
181
  # it and says nothing about the rest, which is enough for a reader who knows
138
182
  # the notation and not enough for one who was guessing at it. Given once, at
@@ -166,6 +210,7 @@ require_relative 'rsyntaxtree/tikz_generator'
166
210
  require_relative 'rsyntaxtree/version'
167
211
  require_relative 'rsyntaxtree/string_parser'
168
212
  require_relative 'rsyntaxtree/format_converter'
213
+ require_relative 'rsyntaxtree/escape'
169
214
 
170
215
  require 'cgi'
171
216
  require 'rsvg2'
@@ -435,8 +480,21 @@ module RSyntaxTree
435
480
  !OFF.include?(value.to_s.strip.downcase)
436
481
  end
437
482
 
483
+ # Whether the input is nothing to draw. Whitespace alone counts: it
484
+ # leaves no label behind, and reporting it as a defect in the library
485
+ # told the caller their own fixable input was our fault. Asked without
486
+ # strip when the bytes are not valid UTF-8, because strip raises there
487
+ # and that input is not blank — it is malformed, which the caller
488
+ # learns further down.
489
+ def self.blank?(text)
490
+ s = text.to_s
491
+ return true if s.empty?
492
+
493
+ s.valid_encoding? && s.strip.empty?
494
+ end
495
+
438
496
  def self.check_data(text, params = {})
439
- raise RSTError.new(+"Error: input text is empty", code: :empty_input, retryable: false) if text.to_s == ""
497
+ raise RSTError.new(+"Error: input text is empty", code: :empty_input, retryable: false) if blank?(text)
440
498
 
441
499
  begin
442
500
  StringParser.valid?(text)
@@ -510,7 +568,7 @@ module RSyntaxTree
510
568
  def self.diagnose(text, params = {})
511
569
  errors = []
512
570
  note = nil
513
- if text.to_s == ""
571
+ if blank?(text)
514
572
  errors << RSTError.new(+"Error: input text is empty", code: :empty_input, retryable: false)
515
573
  else
516
574
  begin
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.2.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