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 +4 -4
- data/CHANGELOG.md +25 -0
- data/lib/rsyntaxtree/element.rb +31 -10
- data/lib/rsyntaxtree/escape.rb +105 -0
- data/lib/rsyntaxtree/markup_parser.rb +12 -3
- data/lib/rsyntaxtree/notation_core.md +8 -1
- data/lib/rsyntaxtree/string_parser.rb +1 -1
- data/lib/rsyntaxtree/svg_graph.rb +5 -1
- data/lib/rsyntaxtree/version.rb +1 -1
- data/lib/rsyntaxtree.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2cab83f32f9b3adab3a84ef896406e8683cf3a9abcecfe132540bda198475a10
|
|
4
|
+
data.tar.gz: 29d0804c7d76c1661103e9dcbc922ea7b916ba503f3fb0c9a8f2d78c8e8008d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/rsyntaxtree/element.rb
CHANGED
|
@@ -33,11 +33,8 @@ module RSyntaxTree
|
|
|
33
33
|
@vertical_indent = 0 # Drawing offset
|
|
34
34
|
content = content.strip
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
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 =
|
|
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
|
|
129
|
-
body
|
|
130
|
-
|
|
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(">", '>')
|
|
372
393
|
.gsub("<", '<')
|
|
@@ -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\\-\\[\\]
|
|
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
|
-
|
|
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
|
data/lib/rsyntaxtree/version.rb
CHANGED
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.
|
|
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
|