c0data 0.3.1

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.
data/ext/c0/extconf.rb ADDED
@@ -0,0 +1,7 @@
1
+ require "mkmf"
2
+
3
+ # c0.h lives in the c0-c submodule at the repository root.
4
+ $INCFLAGS << " -I#{File.expand_path('../../c0-c', __dir__)}"
5
+ $CFLAGS << " -Wall -Wextra"
6
+
7
+ create_makefile("c0/c0_ext")
data/lib/c0/body.rb ADDED
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ module C0
4
+ # Zero-copy navigator for a document body: the bytes between a heading and
5
+ # EOT, or the inside of a nested level. Everything it returns is a shared
6
+ # byteslice of the original buffer.
7
+ #
8
+ # Records before the first GS of a file form an unnamed group; records and
9
+ # groups before the first FS form an unnamed file. Enumerates the groups of
10
+ # the first file.
11
+ class Body
12
+ include Enumerable
13
+
14
+ attr_reader :buf, :start, :stop
15
+
16
+ def initialize(buf, start = 0, stop = nil)
17
+ @buf = C0.to_bin(buf)
18
+ @start = start
19
+ @stop = stop || @buf.bytesize
20
+ end
21
+
22
+ # The files, in order (at least one: the unnamed leading file).
23
+ def files
24
+ @files ||= Ext.files(@buf, @start, @stop).map { |ns, ne, s, e| FileView.new(@buf, ns, ne, s, e) }
25
+ end
26
+
27
+ # All groups of all files, in order.
28
+ def groups
29
+ @groups ||= Ext.groups(@buf, @start, @stop).map { |d, ns, ne, s, e| Group.new(@buf, d, ns, ne, s, e) }
30
+ end
31
+
32
+ # Groups of the first file.
33
+ def group_count = first_file.groups.size
34
+
35
+ # Group by index or by name (first file). Raises C0::KeyError for a name
36
+ # with no group.
37
+ def group(index_or_name)
38
+ index_or_name.is_a?(Integer) ? first_file.groups[index_or_name] : first_file.group(index_or_name)
39
+ end
40
+
41
+ alias [] group
42
+
43
+ def each_group(&) = first_file.groups.each(&)
44
+
45
+ alias each each_group
46
+
47
+ def group_names = first_file.groups.map(&:name)
48
+
49
+ # Name of the first file (empty when the body has no FS).
50
+ def name = first_file.name
51
+
52
+ # Records of the body when it is a bare record stream (no FS, no GS): the
53
+ # unnamed group's records.
54
+ def records = first_file.groups.first&.records || []
55
+
56
+ def record_count = records.size
57
+
58
+ def record(index) = records[index]
59
+
60
+ def each_record(&) = records.each(&)
61
+
62
+ # Whether the body is just records: one unnamed file, at most the unnamed group.
63
+ def bare? = Ext.body_bare(@buf, @start, @stop)
64
+
65
+ # The body's bytes.
66
+ def raw = @buf.byteslice(@start, @stop - @start)
67
+
68
+ private
69
+
70
+ def first_file = files[0]
71
+ end
72
+
73
+ # A file within a body: its name and groups. Enumerates the groups.
74
+ class FileView
75
+ include Enumerable
76
+
77
+ attr_reader :start, :stop
78
+
79
+ def initialize(buf, name_start, name_end, start, stop)
80
+ @buf = buf
81
+ @name_start = name_start
82
+ @name_end = name_end
83
+ @start = start
84
+ @stop = stop
85
+ end
86
+
87
+ def name = @buf.byteslice(@name_start, @name_end - @name_start)
88
+
89
+ def groups
90
+ @groups ||= Ext.groups(@buf, @start, @stop).map { |d, ns, ne, s, e| Group.new(@buf, d, ns, ne, s, e) }
91
+ end
92
+
93
+ # The first group with this name. Raises C0::KeyError when there is none.
94
+ def group(name)
95
+ needle = C0.to_bin(name)
96
+ groups.find { |g| g.name == needle } or raise KeyError, "No group named '#{name}'"
97
+ end
98
+
99
+ alias [] group
100
+
101
+ def each(&) = groups.each(&)
102
+
103
+ def raw = @buf.byteslice(@start, @stop - @start)
104
+ end
105
+
106
+ # A group: its depth (GS count; 0 for the unnamed leading group), name,
107
+ # optional header row and records. Enumerates the records.
108
+ class Group
109
+ include Enumerable
110
+
111
+ attr_reader :depth, :start, :stop
112
+
113
+ def initialize(buf, depth, name_start, name_end, start, stop)
114
+ @buf = buf
115
+ @depth = depth
116
+ @name_start = name_start
117
+ @name_end = name_end
118
+ @start = start
119
+ @stop = stop
120
+ end
121
+
122
+ def name = @buf.byteslice(@name_start, @name_end - @name_start)
123
+
124
+ # The header row as a Record, or nil.
125
+ def header_row
126
+ return @header_row if defined?(@header_row)
127
+
128
+ range = Ext.group_header(@buf, @start, @stop)
129
+ @header_row = range && Record.new(@buf, range[0], range[1])
130
+ end
131
+
132
+ def header? = !header_row.nil?
133
+
134
+ alias has_header? header?
135
+
136
+ # Header names, or an empty array.
137
+ def headers = header_row ? header_row.values : []
138
+
139
+ def header_count = header_row ? header_row.field_count : 0
140
+
141
+ def header(index) = headers[index]
142
+
143
+ # Index of a header name, or nil.
144
+ def column(name)
145
+ needle = C0.to_bin(name)
146
+ headers.index(needle)
147
+ end
148
+
149
+ # The live records (header row, notes, commits and dead elements skipped).
150
+ def records
151
+ @records ||= Ext.group_records(@buf, @start, @stop).map { |s, e| Record.new(@buf, s, e) }
152
+ end
153
+
154
+ def record_count = records.size
155
+
156
+ alias size record_count
157
+
158
+ def record(index) = records[index]
159
+
160
+ def each_record(&) = records.each(&)
161
+
162
+ alias each each_record
163
+
164
+ # The group as a table is itself.
165
+ def table = self
166
+
167
+ # The group's contents (after its name).
168
+ def raw = @buf.byteslice(@start, @stop - @start)
169
+ end
170
+
171
+ Table = Group
172
+
173
+ # A record: positional or keyed, never mixed. Zero-copy accessors.
174
+ # Enumerates the logical values of a positional record.
175
+ class Record
176
+ include Enumerable
177
+
178
+ attr_reader :start, :stop
179
+
180
+ def initialize(buf, start, stop)
181
+ @buf = buf
182
+ @start = start
183
+ @stop = stop
184
+ end
185
+
186
+ # The record's bytes (after its RS, trailing layout excluded).
187
+ def raw = @buf.byteslice(@start, @stop - @start)
188
+
189
+ # Whether the record is keyed (its first live unit is an SOH).
190
+ def keyed? = Ext.record_keyed(@buf, @start, @stop)
191
+
192
+ # ---- positional ---------------------------------------------------------
193
+
194
+ # Raw bytes of unit n (its layout included), or of the unit under a key
195
+ # (last wins); nil when there is none.
196
+ def field(index_or_key)
197
+ s, e = index_or_key.is_a?(Integer) ? units[index_or_key] : keyed_range(index_or_key)
198
+ s && @buf.byteslice(s, e - s)
199
+ end
200
+
201
+ # Number of positional units (0 for a keyed record).
202
+ def field_count = units.size
203
+
204
+ alias size field_count
205
+
206
+ def fields = units.map { |s, e| @buf.byteslice(s, e - s) }
207
+
208
+ # Logical value of unit n, or nil when there is none.
209
+ def value(index)
210
+ s, e = units[index]
211
+ s && Ext.value(@buf, s, e)
212
+ end
213
+
214
+ def values = units.map { |s, e| Ext.value(@buf, s, e) }
215
+
216
+ def each(&) = values.each(&)
217
+
218
+ # Unit n (or the unit under a key) as a nested level. Raises C0::Error when
219
+ # it is not one; nil for a key with no unit.
220
+ def level(index_or_key)
221
+ s, e = index_or_key.is_a?(Integer) ? units[index_or_key] : keyed_range(index_or_key)
222
+ return nil if s.nil? && !index_or_key.is_a?(Integer)
223
+ raise KeyError, "no unit #{index_or_key}" if s.nil?
224
+
225
+ ls, le = Ext.unit_level(@buf, s, e)
226
+ Body.new(@buf, ls, le)
227
+ end
228
+
229
+ # Unit n as a flat list: a level holding at most one positional record.
230
+ # Raises C0::Error for any other level.
231
+ def list(index)
232
+ s, e = units[index]
233
+ raise KeyError, "no unit #{index}" if s.nil?
234
+
235
+ Ext.unit_list(@buf, s, e).map { |is, ie| Ext.value(@buf, is, ie) }
236
+ end
237
+
238
+ # Unit n as a Reference.
239
+ def ref(index)
240
+ s, e = units[index]
241
+ raise KeyError, "no unit #{index}" if s.nil?
242
+
243
+ Reference.new(Ext.unit_ref(@buf, s, e).map { |rs, re| @buf.byteslice(rs, re - rs) })
244
+ end
245
+
246
+ # ---- keyed --------------------------------------------------------------
247
+
248
+ # Keys in written order (duplicates included).
249
+ def keys = pair_ranges.map { |ks, ke, _, _| @buf.byteslice(ks, ke - ks) }
250
+
251
+ # [key, logical value] pairs in written order.
252
+ def pairs = pair_ranges.map { |ks, ke, vs, ve| [@buf.byteslice(ks, ke - ks), Ext.value(@buf, vs, ve)] }
253
+
254
+ # Logical value under a key (last wins), or nil.
255
+ def [](key)
256
+ s, e = keyed_range(key)
257
+ s && Ext.value(@buf, s, e)
258
+ end
259
+
260
+ def key?(key) = !keyed_range(key).nil?
261
+
262
+ alias has_key? key?
263
+
264
+ # The keyed record as a Hash (last wins).
265
+ def to_h = pairs.to_h
266
+
267
+ private
268
+
269
+ def units = @units ||= Ext.record_units(@buf, @start, @stop)
270
+
271
+ def pair_ranges = @pair_ranges ||= Ext.record_pairs(@buf, @start, @stop)
272
+
273
+ def keyed_range(key)
274
+ needle = C0.to_bin(key)
275
+ found = nil
276
+ pair_ranges.each do |ks, ke, vs, ve|
277
+ found = [vs, ve] if @buf.byteslice(ks, ke - ks) == needle
278
+ end
279
+ found
280
+ end
281
+ end
282
+
283
+ # A reference: a group name, or a path of two or three segments
284
+ # (group, record id, field name).
285
+ class Reference
286
+ attr_reader :path
287
+
288
+ def initialize(path)
289
+ @path = path
290
+ end
291
+
292
+ # Parse a raw unit (ENQ …). Raises C0::Error when it is not a reference.
293
+ def self.parse(raw)
294
+ b = C0.to_bin(raw)
295
+ new(Ext.unit_ref(b, 0, b.bytesize).map { |s, e| b.byteslice(s, e - s) })
296
+ end
297
+
298
+ def group = @path[0]
299
+
300
+ def record = @path[1]
301
+
302
+ def field = @path[2]
303
+
304
+ def group? = @path.size == 1
305
+
306
+ def ==(other) = other.is_a?(Reference) && other.path == @path
307
+
308
+ alias eql? ==
309
+
310
+ def hash = @path.hash
311
+
312
+ def to_s = @path.join("/")
313
+ end
314
+ end
data/lib/c0/builder.rb ADDED
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module C0
4
+ # Writes C0DATA in canonical compact form. The class and its methods are
5
+ # defined in the C extension (a TypedData object owning a c0_builder); this
6
+ # file adds the Ruby conveniences.
7
+ #
8
+ # buf = C0::Builder.build do |b|
9
+ # b.syn(shape: "tabular")
10
+ # b.group("users", %w[name amount]) do
11
+ # b.record("Alice", "1502.30")
12
+ # b.record("Bob", "340.00")
13
+ # end
14
+ # b.eot
15
+ # end
16
+ #
17
+ # Two layers. The primitives are one method per code (`fs`, `gs`, `rs`,
18
+ # `us`, `sub`, `soh`, `dle`, `can`, `bel`, `enq`, `syn`, `eot`, `etb`, `em`);
19
+ # each writes its code and, when given, what grammatically follows it, so a
20
+ # chain reads like the bytes (`sub` is C0-DIFF only: `rs("old").sub("new")`
21
+ # writes a replace-all, ␞old␚new; in a data body SUB is an error):
22
+ #
23
+ # b.rs("Alice").us("1502.30") # ␞Alice␟1502.30
24
+ # b.rs.soh("host").us("localhost").soh("port").us("5432") # ␞␁host␟localhost␁port␟5432
25
+ # b.gs("users").soh.rs("name").us("amount") # ␝users␁␞name␟amount
26
+ # b.bel.gs("old") # ␇␝old, a dead group
27
+ #
28
+ # The composed methods (`file`, `group`, `header`, `record`, `field`, `map`,
29
+ # `key`, `level`, `list`, `ref`, `note`) are built on the primitives and
30
+ # cannot write ill-formed output. The primitives write what you spell;
31
+ # well-formedness is then yours to keep. Both layers share the record state,
32
+ # so they mix: `b.rs("a").field("b")` writes ␞a␟b.
33
+ #
34
+ # Values are byte-transparent and written in their one spelling: plain when
35
+ # they can be, text (STX … ETX) otherwise, with only the seven codes escaped;
36
+ # there is no STX/ETX primitive, so everything written stays canonical.
37
+ # Names (file, group, header, key, reference segment, ETB payload) must be
38
+ # plain, or a C0::BuildError is raised before anything is written. Every
39
+ # method returns the builder, so calls chain.
40
+ #
41
+ # A record is opened by `record` or `rs` (or implicitly by the first `field`,
42
+ # `key`, `level`, `list` or `ref`) and ended by the next structural call.
43
+ # Keyed units written right after `syn` belong to the heading. Inside
44
+ # `level { }` (or between `dle` and `can`) the builder is at the top of a
45
+ # fresh body.
46
+ class Builder
47
+ # Drive a fresh builder and return its bytes.
48
+ def self.build
49
+ b = new
50
+ yield b
51
+ b.bytes
52
+ end
53
+
54
+ # Alias of `bytes`: the compact form as a binary String.
55
+ def to_s = bytes
56
+
57
+ alias to_slice bytes
58
+
59
+ # Compatibility spelling of `list`.
60
+ def list_field(items) = list(items)
61
+
62
+ def inspect = "#<C0::Builder #{bytesize} bytes, depth #{depth}>"
63
+ end
64
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module C0
4
+ # The heading of a document: the keyed units after SYN. Last wins for a
5
+ # duplicate key. Enumerates [key, value] pairs.
6
+ class Heading
7
+ include Enumerable
8
+
9
+ KNOWN_KEYS = %w[shape version blob].freeze
10
+
11
+ def initialize(buf, start, stop)
12
+ @buf = buf
13
+ @start = start
14
+ @stop = stop
15
+ end
16
+
17
+ # [key, value] in written order: the logical value, or for a nested level
18
+ # (the blob table) its raw bytes.
19
+ def pairs
20
+ @pairs ||= raw_pairs.map do |k, s, e|
21
+ [k, Ext.unit_type(@buf, s, e) == UnitType::LEVEL ? @buf.byteslice(s, e - s) : Ext.value(@buf, s, e)]
22
+ end
23
+ end
24
+
25
+ def each(&) = pairs.each(&)
26
+
27
+ def keys = raw_pairs.map(&:first)
28
+
29
+ # Value for a key (last wins), or nil.
30
+ def [](key)
31
+ needle = C0.to_bin(key)
32
+ found = nil
33
+ pairs.each { |k, v| found = v if k == needle }
34
+ found
35
+ end
36
+
37
+ def key?(key) = !self[key].nil?
38
+
39
+ def empty? = raw_pairs.empty?
40
+
41
+ # The `shape` value as a String, or nil.
42
+ def shape = self["shape"]&.dup&.force_encoding(Encoding::UTF_8)
43
+
44
+ # The `version` value as an Integer; 1 when absent or not a number.
45
+ def version
46
+ v = self["version"]
47
+ v && v.match?(/\A\d+\z/) ? v.to_i : 1
48
+ end
49
+
50
+ # The value under a key as a nested level, or nil when absent or not one.
51
+ def level(key)
52
+ range = raw_range(key)
53
+ return nil unless range && Ext.unit_type(@buf, *range) == UnitType::LEVEL
54
+
55
+ ls, le = Ext.unit_level(@buf, *range)
56
+ Body.new(@buf, ls, le)
57
+ end
58
+
59
+ # The blob table as a Body, when declared as a level.
60
+ def blob = level("blob")
61
+
62
+ # The heading's bytes.
63
+ def raw = @buf.byteslice(@start, @stop - @start)
64
+
65
+ private
66
+
67
+ def raw_pairs
68
+ @raw_pairs ||= Ext.record_pairs(@buf, @start, @stop).map { |ks, ke, vs, ve| [@buf.byteslice(ks, ke - ks), vs, ve] }
69
+ end
70
+
71
+ def raw_range(key)
72
+ needle = C0.to_bin(key)
73
+ found = nil
74
+ raw_pairs.each { |k, s, e| found = [s, e] if k == needle }
75
+ found
76
+ end
77
+ end
78
+
79
+ # One C0DATA document: an optional SYN heading, a body, an optional EOT.
80
+ # `Document.new(buf)` reads the first document in the buffer. Use
81
+ # `Container` for a buffer holding several documents or an EM tail.
82
+ # Enumerates the groups of the body's first file.
83
+ class Document
84
+ include Enumerable
85
+
86
+ SHAPES = %w[diff stream tabular document key-value].freeze
87
+
88
+ attr_reader :buf, :heading, :body, :start, :stop
89
+
90
+ def initialize(buf, start = 0)
91
+ @buf = C0.to_bin(buf)
92
+ load(Ext.document(@buf, start))
93
+ end
94
+
95
+ # A document from the C reader's description (see Container).
96
+ def self.from_ext(buf, desc)
97
+ doc = allocate
98
+ doc.instance_variable_set(:@buf, buf)
99
+ doc.send(:load, desc)
100
+ doc
101
+ end
102
+
103
+ def has_syn? = @has_syn
104
+
105
+ alias syn? has_syn?
106
+
107
+ def has_eot? = @has_eot
108
+
109
+ alias eot? has_eot?
110
+
111
+ # Loud warnings (DESIGN.md "Warnings") as strings; empty for a careful writer's file.
112
+ def warnings
113
+ @warnings ||= begin
114
+ w = []
115
+ w << "no SYN at the start of the document" unless @has_syn
116
+ w << "no EOT at the end of the document" if !@has_eot && heading.shape != "stream"
117
+ heading.keys.each do |k|
118
+ w << "unknown heading key '#{k}'" unless Heading::KNOWN_KEYS.include?(k)
119
+ end
120
+ seen = {}
121
+ heading.keys.each { |k| w << "duplicate heading key '#{k}'" if seen.key?(k) && !w.include?("duplicate heading key '#{k}'"); seen[k] = true }
122
+ if (sh = heading.shape) && !SHAPES.include?(sh)
123
+ w << "unknown shape '#{sh}'"
124
+ end
125
+ if (v = heading["version"]) && v != "1".b
126
+ w << "unknown version '#{v}'"
127
+ end
128
+ w
129
+ end
130
+ end
131
+
132
+ # ---- convenience accessors over the body --------------------------------
133
+
134
+ def name = @body.name
135
+
136
+ def files = @body.files
137
+
138
+ def group_count = @body.group_count
139
+
140
+ def group(index_or_name) = @body.group(index_or_name)
141
+
142
+ alias [] group
143
+
144
+ def each_group(&) = @body.each_group(&)
145
+
146
+ alias each each_group
147
+
148
+ def groups = @body.groups
149
+
150
+ def group_names = @body.group_names
151
+
152
+ def records = @body.records
153
+
154
+ def shape = heading.shape
155
+
156
+ # The document's bytes (SYN through EOT).
157
+ def raw = @buf.byteslice(@start, @stop - @start)
158
+
159
+ private
160
+
161
+ def load(desc)
162
+ @start, @stop, @has_syn, @has_eot, hs, he, bs, be, _bits = desc
163
+ @heading = Heading.new(@buf, hs, he)
164
+ @body = Body.new(@buf, bs, be)
165
+ end
166
+ end
167
+
168
+ # A file or message: one or more documents and an optional raw tail after
169
+ # EM. Enumerates the documents.
170
+ class Container
171
+ include Enumerable
172
+
173
+ attr_reader :buf, :documents, :tail
174
+
175
+ def initialize(buf)
176
+ @buf = C0.to_bin(buf)
177
+ descs, @has_em, em_pos = Ext.documents(@buf)
178
+ @documents = descs.map { |d| Document.from_ext(@buf, d) }
179
+ @tail = @has_em ? @buf.byteslice(em_pos + 1, @buf.bytesize - em_pos - 1) : "".b
180
+ end
181
+
182
+ def has_em? = @has_em
183
+
184
+ alias em? has_em?
185
+
186
+ def each(&) = @documents.each(&)
187
+
188
+ def first = @documents[0]
189
+
190
+ def size = @documents.size
191
+
192
+ # Loud warnings about the container as a whole; each document carries its own.
193
+ def warnings
194
+ @warnings ||= begin
195
+ w = []
196
+ @documents.each_with_index do |doc, i|
197
+ next unless doc.heading.key?("blob")
198
+
199
+ if i.positive?
200
+ w << "blob table in a later heading is ignored"
201
+ elsif doc.heading.blob.nil?
202
+ w << "blob is not a level: declares nothing"
203
+ elsif !doc.heading.blob.groups.first&.header?
204
+ w << "blob table has no header row: declares nothing"
205
+ end
206
+ end
207
+ w << "EM with no blob table: the region is unaddressable" if @has_em && blob_table.nil?
208
+ w
209
+ end
210
+ end
211
+
212
+ # The blob table from the first document's heading, as a Body, or nil.
213
+ def blob_table = first&.heading&.blob
214
+
215
+ # Bytes of a named blob from the tail (columns matched by header name,
216
+ # last row wins), or nil. With no name, the blob table itself.
217
+ def blob(name = nil)
218
+ return blob_table if name.nil?
219
+
220
+ table = blob_table or return nil
221
+ g = table.groups.first or return nil
222
+ ni = g.column("name") || 0
223
+ oi = g.column("offset") || 1
224
+ li = g.column("length") || 2
225
+ needle = C0.to_bin(name)
226
+ found = nil
227
+ g.records.each do |r|
228
+ next unless r.value(ni) == needle
229
+
230
+ off = r.value(oi)
231
+ len = r.value(li)
232
+ next unless off&.match?(/\A\d+\z/) && len&.match?(/\A\d+\z/)
233
+
234
+ off = off.to_i
235
+ len = len.to_i
236
+ next if off > @tail.bytesize || len > @tail.bytesize - off
237
+
238
+ found = @tail.byteslice(off, len)
239
+ end
240
+ found
241
+ end
242
+ end
243
+ end
data/lib/c0/pretty.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module C0
4
+ # Pretty form: Unicode Control Pictures (U+2400 block) for the codes, line
5
+ # breaks and indentation as layout. `format` and `parse` are inverses on
6
+ # well-formed input. GlyphInDataError (a C0::Error) is raised when a value
7
+ # holds a Control Picture character and cannot be shown unambiguously.
8
+ module Pretty
9
+ MODES = { compact: 0, aligned: 1, spaced: 2 }.freeze
10
+
11
+ module_function
12
+
13
+ # The glyph for a code byte.
14
+ def glyph(byte) = (0x2400 + byte).chr(Encoding::UTF_8)
15
+
16
+ # Compact bytes → pretty form (a UTF-8 String). `mode` is :compact (one
17
+ # element per line), :aligned (table columns aligned) or :spaced (aligned,
18
+ # plus a space after each prefix and around the US glyph).
19
+ def format(buf, indent: " ", mode: :compact)
20
+ m = MODES.fetch(mode) { raise ArgumentError, "unknown mode #{mode.inspect}; use #{MODES.keys.inspect}" }
21
+ Ext.pretty_format(C0.to_bin(buf), indent, m)
22
+ end
23
+
24
+ # Pretty form → compact bytes. Glyphs become codes; outside text,
25
+ # whitespace touching a glyph or a line edge is dropped.
26
+ def parse(text) = Ext.pretty_parse(text.to_s.encode(Encoding::UTF_8))
27
+ end
28
+ end