pangrid 0.2.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.
@@ -0,0 +1,69 @@
1
+ require_relative 'deps/trollop'
2
+
3
+ require_relative 'pangrid/version'
4
+ require_relative 'pangrid/plugin'
5
+ require_relative 'pangrid/frontend/webrick'
6
+
7
+ module Pangrid
8
+ def self.run_command_line
9
+ # command line options
10
+ p = Trollop::Parser.new do
11
+ version "pangrid #{VERSION}"
12
+ opt :from, "Format to convert from", :type => :string
13
+ opt :to, "Format to convert to", :type => :string
14
+ opt :in, "Input file", :type => :string
15
+ opt :out, "Output file", :type => :string
16
+ opt :list, "List available format plugins"
17
+ opt :web, "Launch webserver"
18
+ end
19
+
20
+ Trollop::with_standard_exception_handling p do
21
+ opts = p.parse ARGV
22
+
23
+ if opts[:web]
24
+ run_webserver 1234
25
+ elsif opts[:list] || [:from, :to, :in, :out].all? {|k| opts[k]}
26
+ run opts
27
+ else
28
+ p.educate
29
+ end
30
+ end
31
+ end
32
+
33
+ def self.run(opts)
34
+ Plugin.load_all
35
+
36
+ if opts[:list]
37
+ Plugin.list_all
38
+ return
39
+ end
40
+
41
+ # run the converter
42
+ #
43
+ from = Plugin.get(opts[:from])
44
+ to = Plugin.get(opts[:to])
45
+
46
+ if !from or !from.method_defined? :read
47
+ $stderr.puts "No reader for #{opts[:from]}"
48
+ return
49
+ end
50
+
51
+ if !to or !to.method_defined? :write
52
+ $stderr.puts "No writer for #{opts[:to]}"
53
+ return
54
+ end
55
+
56
+ if !File.exist? opts[:in]
57
+ $stderr.puts "Cannot find file #{opts[:in]}"
58
+ return
59
+ end
60
+
61
+ reader = from.new
62
+ writer = to.new
63
+ input = IO.read(opts[:in])
64
+ output = writer.write(reader.read(input))
65
+ File.open(opts[:out], "w") do |f|
66
+ f.print output
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,74 @@
1
+ require "webrick"
2
+
3
+ module Pangrid
4
+
5
+ FORM = <<HERE
6
+ <html>
7
+ <body>
8
+ <form method="POST" enctype="multipart/form-data">
9
+ <input type="file" name="filedata" />
10
+ <select name="from">
11
+ <option value="across-lite-binary">AcrossLite binary (.puz)</option>
12
+ <option value="across-lite-text">AcrossLite text</option>
13
+ </select>
14
+ &rarr;
15
+ <select name="to">
16
+ <option value="reddit-blank">Reddit (blank)</option>
17
+ <option value="reddit-filled">Reddit (filled)</option>
18
+ <option value="text">Text</option>
19
+ </select>
20
+ <input type="submit" />
21
+ </form>
22
+ <hr>
23
+ <div>
24
+ <pre>%s</pre>
25
+ </div>
26
+ </body>
27
+ </html>
28
+ HERE
29
+
30
+ class Servlet < WEBrick::HTTPServlet::AbstractServlet
31
+ def do_GET (request, response)
32
+ response.status = 200
33
+ response.content_type = "text/html"
34
+ response.body = FORM % ""
35
+ end
36
+
37
+ def do_POST(request, response)
38
+ input = request.query["filedata"]
39
+ from = Plugin.get(request.query["from"])
40
+ to = Plugin.get(request.query["to"])
41
+ reader = from.new
42
+ writer = to.new
43
+ out = nil
44
+
45
+ begin
46
+ out = writer.write(reader.read(input))
47
+ rescue Exception => e
48
+ out = e.inspect
49
+ end
50
+
51
+ response.status = 200
52
+ response.content_type = "text/html"
53
+ response.body = FORM % WEBrick::HTMLUtils.escape(out)
54
+ end
55
+ end
56
+
57
+ def self.run_webserver(port)
58
+ puts "-------------------------------------------"
59
+ puts "Open your web browser and load"
60
+ puts " http://localhost:#{port}"
61
+ puts "-------------------------------------------"
62
+
63
+ Plugin.load_all
64
+
65
+ log_stream = File.open('pangrid-webrick-access.log', 'w+')
66
+ log = [ [log_stream, WEBrick::AccessLog::COMMON_LOG_FORMAT] ]
67
+
68
+ server = WEBrick::HTTPServer.new(:Port => port, :AccessLog => log)
69
+ server.mount "/", Servlet
70
+ trap("INT") { server.shutdown }
71
+ server.start
72
+ end
73
+
74
+ end # module Pangrid
@@ -0,0 +1,105 @@
1
+ require_relative 'xw'
2
+ require_relative 'utils'
3
+
4
+ module Pangrid
5
+
6
+ class PluginDependencyError < StandardError
7
+ attr_accessor :name, :gems
8
+
9
+ def initialize(name, gems)
10
+ @name, @gems = name, gems
11
+ end
12
+ end
13
+
14
+ # Load all the gem dependencies of a plugin
15
+ def self.require_for_plugin(name, gems)
16
+ missing = []
17
+ gems.each do |gem|
18
+ begin
19
+ require gem
20
+ rescue LoadError => e
21
+ # If requiring a gem raises something other than LoadError let it
22
+ # propagate upwards.
23
+ missing << gem
24
+ end
25
+ end
26
+ if !missing.empty?
27
+ raise PluginDependencyError.new(name, missing)
28
+ end
29
+ end
30
+
31
+ class Plugin
32
+ include PluginUtils
33
+
34
+ REGISTRY = {}
35
+ FAILED = []
36
+ MISSING_DEPS = {}
37
+
38
+ def self.inherited(subclass)
39
+ name = class_to_name(subclass.name)
40
+ #puts "Registered #{subclass} as #{name}"
41
+ REGISTRY[name] = subclass
42
+ end
43
+
44
+ def self.load_all
45
+ REGISTRY.clear
46
+ FAILED.clear
47
+ plugins = Dir.glob(File.dirname(__FILE__) + "/plugins/*.rb")
48
+ plugins.each do |f|
49
+ load_plugin f
50
+ end
51
+ end
52
+
53
+ def self.load_plugin(filename)
54
+ begin
55
+ require filename
56
+ rescue PluginDependencyError => e
57
+ MISSING_DEPS[e.name] = e.gems
58
+ rescue StandardError => e
59
+ FAILED << "#{File.basename(filename)}: #{e}"
60
+ end
61
+ end
62
+
63
+ def self.list_all
64
+ puts "-------------------------------------------------------"
65
+ puts "Available plugins:"
66
+ puts "-------------------------------------------------------"
67
+ REGISTRY.keys.sort.each do |name|
68
+ plugin = REGISTRY[name]
69
+ provides = [:read, :write].select {|m| plugin.method_defined? m}
70
+ provides = provides.map {|m| {read: 'from', write: 'to'}[m]}
71
+ puts " " + name + " [" + provides.join(", ") + "]"
72
+ end
73
+ if !MISSING_DEPS.empty?
74
+ puts
75
+ puts "-------------------------------------------------------"
76
+ puts "Missing dependencies for plugins:"
77
+ puts "-------------------------------------------------------"
78
+ MISSING_DEPS.keys.sort.each do |name|
79
+ puts " " + name + ": gem install " + MISSING_DEPS[name].join(" ")
80
+ end
81
+ end
82
+ if !FAILED.empty?
83
+ puts
84
+ puts "The following plugins could not load due to errors:"
85
+ puts "-------------------------------------------------------"
86
+ FAILED.each do |error|
87
+ puts " " + error
88
+ end
89
+ end
90
+ end
91
+
92
+ def self.get(name)
93
+ REGISTRY[name]
94
+ end
95
+
96
+ # utility functions
97
+ def self.class_to_name(str)
98
+ str.gsub(/.*:/, '').
99
+ gsub(/([A-Z]+)([A-Z][a-z])/,'\1-\2').
100
+ gsub(/([a-z\d])([A-Z])/,'\1-\2').
101
+ downcase
102
+ end
103
+ end
104
+
105
+ end # module Pangrid
@@ -0,0 +1,466 @@
1
+ # AcrossLite is a file format used by the New York Times to distribute crosswords.
2
+ #
3
+ # Binary format: http://code.google.com/p/puz/
4
+ # Text format: http://www.litsoft.com/across/docs/AcrossTextFormat.pdf
5
+ #
6
+ # provides:
7
+ # AcrossLiteBinary : read, write
8
+ # AcrossLiteText : read, write
9
+
10
+ require 'ostruct'
11
+
12
+ module Pangrid
13
+
14
+ GRID_CHARS = {:black => '.', :null => '.'}
15
+
16
+ # CRC checksum for binary format
17
+ class Checksum
18
+ attr_accessor :sum
19
+
20
+ def self.of_string s
21
+ c = self.new(0)
22
+ c.add_string s
23
+ c.sum
24
+ end
25
+
26
+ def initialize(seed)
27
+ @sum = seed
28
+ end
29
+
30
+ def add_char(b)
31
+ low = sum & 0x0001
32
+ @sum = sum >> 1
33
+ @sum = sum | 0x8000 if low == 1
34
+ @sum = (sum + b) & 0xffff
35
+ end
36
+
37
+ def add_string(s)
38
+ s.bytes.map {|b| add_char b}
39
+ end
40
+
41
+ def add_string_0(s)
42
+ add_string (s + "\0") unless s.empty?
43
+ end
44
+ end
45
+
46
+ module AcrossLiteUtils
47
+ # String -> Cell[][]
48
+ def unpack_solution(xw, s)
49
+ s.each_char.map {|c|
50
+ Cell.new(:solution => c == '.' ? :black : c)
51
+ }.each_slice(xw.width).to_a
52
+ end
53
+
54
+ # {xw | solution = Cell[][]} -> String
55
+ def pack_solution(xw)
56
+ # acrosslite doesn't support non-rectangular grids, so map null squares to
57
+ # black too
58
+ xw.to_array(GRID_CHARS).map(&:join).join
59
+ end
60
+
61
+ # {xw | solution = Cell[][]} -> String
62
+ def empty_fill(xw)
63
+ # when converting from another format -> binary we won't typically have fill
64
+ # information, since that is an internal property of the acrosslite player
65
+ grid = xw.to_array(GRID_CHARS) {|c| '-'}
66
+ grid.map(&:join).join
67
+ end
68
+ end
69
+
70
+ # Binary format
71
+ class AcrossLiteBinary < Plugin
72
+ include AcrossLiteUtils
73
+
74
+ # crossword, checksums
75
+ attr_accessor :xw, :cs
76
+
77
+ HEADER_FORMAT = "v A12 v V2 A4 v2 A12 c2 v3"
78
+ HEADER_CHECKSUM_FORMAT = "c2 v3"
79
+ EXT_HEADER_FORMAT = "A4 v2"
80
+ EXTENSIONS = %w(LTIM GRBS RTBL GEXT)
81
+ FILE_MAGIC = "ACROSS&DOWN\0"
82
+
83
+ def initialize
84
+ @xw = XWord.new
85
+ @cs = OpenStruct.new
86
+ @xw.extensions = []
87
+ end
88
+
89
+ def read(data)
90
+ s = data.force_encoding("ISO-8859-1")
91
+
92
+ i = s.index(FILE_MAGIC)
93
+ check("Could not recognise AcrossLite binary file") { i }
94
+
95
+ # read the header
96
+ h_start, h_end = i - 2, i - 2 + 0x34
97
+ header = s[h_start .. h_end]
98
+
99
+ cs.global, _, cs.cib, cs.masked_low, cs.masked_high,
100
+ xw.version, _, cs.scrambled, _,
101
+ xw.width, xw.height, xw.n_clues, xw.puzzle_type, xw.scrambled_state =
102
+ header.unpack(HEADER_FORMAT)
103
+
104
+ # solution and fill = blocks of w*h bytes each
105
+ size = xw.width * xw.height
106
+ xw.solution = unpack_solution xw, s[h_end, size]
107
+ xw.fill = s[h_end + size, size]
108
+ s = s[h_end + 2 * size .. -1]
109
+
110
+ # title, author, copyright, clues * n, notes = zero-terminated strings
111
+ xw.title, xw.author, xw.copyright, *xw.clues, xw.notes, s =
112
+ s.split("\0", xw.n_clues + 5)
113
+
114
+ # extensions: 8-byte header + len bytes data + \0
115
+ while (s.length > 8) do
116
+ e = OpenStruct.new
117
+ e.section, e.len, e.checksum = s.unpack(EXT_HEADER_FORMAT)
118
+ check("Unrecognised extension #{e.section}") { EXTENSIONS.include? e.section }
119
+ size = 8 + e.len + 1
120
+ break if s.length < size
121
+ e.data = s[8 ... size]
122
+ self.send(:"read_#{e.section.downcase}", e)
123
+ xw.extensions << e
124
+ s = s[size .. -1]
125
+ end
126
+
127
+ # verify checksums
128
+ check("Failed checksum") { checksums == cs }
129
+
130
+ process_extensions
131
+ unpack_clues
132
+
133
+ xw
134
+ end
135
+
136
+ def write(xw)
137
+ @xw = xw
138
+
139
+ # fill in some fields that might not be present (checksums needs this)
140
+ pack_clues
141
+ xw.n_clues = xw.clues.length
142
+ xw.fill ||= empty_fill(xw)
143
+ xw.puzzle_type ||= 1
144
+ xw.scrambled_state ||= 0
145
+ xw.version = "1.3"
146
+ xw.notes ||= ""
147
+ xw.extensions ||= []
148
+
149
+ # extensions
150
+ xw.encode_rebus!
151
+ if not xw.rebus.empty?
152
+ # GRBS
153
+ e = OpenStruct.new
154
+ e.section = "GRBS"
155
+ e.grid = xw.to_array({:black => 0, :null => 0}) {|s|
156
+ s.rebus? ? s.solution.symbol.to_i : 0
157
+ }.flatten
158
+ xw.extensions << e
159
+ # RTBL
160
+ e = OpenStruct.new
161
+ e.section = "RTBL"
162
+ e.rebus = {}
163
+ xw.rebus.each do |long, (k, short)|
164
+ e.rebus[k] = [long, short]
165
+ end
166
+ xw.extensions << e
167
+ end
168
+
169
+ # calculate checksums
170
+ @cs = checksums
171
+
172
+ h = [cs.global, FILE_MAGIC, cs.cib, cs.masked_low, cs.masked_high,
173
+ xw.version + "\0", 0, cs.scrambled, "\0" * 12,
174
+ xw.width, xw.height, xw.n_clues, xw.puzzle_type, xw.scrambled_state]
175
+ header = h.pack(HEADER_FORMAT)
176
+
177
+ strings = [xw.title, xw.author, xw.copyright] + xw.clues + [xw.notes]
178
+ strings = strings.map {|x| x + "\0"}.join
179
+
180
+ [header, pack_solution(xw), xw.fill, strings, write_extensions].map {|x|
181
+ x.force_encoding("ISO-8859-1")
182
+ }.join
183
+ end
184
+
185
+ private
186
+ # sort incoming clues in xw.clues -> across and down
187
+ def unpack_clues
188
+ across, down = xw.number
189
+ clues = across.map {|x| [x, :a]} + down.map {|x| [x, :d]}
190
+ clues.sort!
191
+ xw.across_clues = []
192
+ xw.down_clues = []
193
+ clues.zip(xw.clues).each do |(n, dir), clue|
194
+ if dir == :a
195
+ xw.across_clues << clue
196
+ else
197
+ xw.down_clues << clue
198
+ end
199
+ end
200
+ end
201
+
202
+ # combine across and down clues -> xw.clues
203
+ def pack_clues
204
+ across, down = xw.number
205
+ clues = across.map {|x| [x, :a]} + down.map {|x| [x, :d]}
206
+ clues.sort!
207
+ ac, dn = xw.across_clues.dup, xw.down_clues.dup
208
+ xw.clues = []
209
+ clues.each do |n, dir|
210
+ if dir == :a
211
+ xw.clues << ac.shift
212
+ else
213
+ xw.clues << dn.shift
214
+ end
215
+ end
216
+ check("Extra across clue") { ac.empty? }
217
+ check("Extra down clue") { dn.empty? }
218
+ end
219
+
220
+ def get_extension(s)
221
+ return nil unless xw.extensions
222
+ xw.extensions.find {|e| e.section == s}
223
+ end
224
+
225
+ def process_extensions
226
+ # record these for file inspection, though they're unlikely to be useful
227
+ if (ltim = get_extension("LTIM"))
228
+ xw.time_elapsed = ltim.elapsed
229
+ xw.paused
230
+ end
231
+
232
+ # we need both grbs and rtbl
233
+ grbs, rtbl = get_extension("GRBS"), get_extension("RTBL")
234
+ if grbs and rtbl
235
+ grbs.grid.each_with_index do |n, i|
236
+ if n > 0 and (v = rtbl.rebus[n])
237
+ x, y = i % xw.width, i / xw.width
238
+ cell = xw.solution[y][x]
239
+ cell.solution = Rebus.new(v[0])
240
+ end
241
+ end
242
+ end
243
+ end
244
+
245
+ def read_ltim(e)
246
+ m = e.data.match /^(\d+),(\d+)\0$/
247
+ check("Could not read extension LTIM") { m }
248
+ e.elapsed = m[1].to_i
249
+ e.stopped = m[2] == "1"
250
+ end
251
+
252
+ def write_ltim(e)
253
+ e.elapsed.to_s + "," + (e.stopped ? "1" : "0") + "\0"
254
+ end
255
+
256
+ def read_rtbl(e)
257
+ rx = /(([\d ]\d):(\w+);)/
258
+ m = e.data.match /^#{rx}*\0$/
259
+ check("Could not read extension RTBL") { m }
260
+ e.rebus = {}
261
+ e.data.scan(rx).each {|_, k, v|
262
+ e.rebus[k.to_i] = [v, '-']
263
+ }
264
+ end
265
+
266
+ def write_rtbl(e)
267
+ e.rebus.keys.sort.map {|x|
268
+ x.to_s.rjust(2) + ":" + e.rebus[x][0] + ";"
269
+ }.join
270
+ end
271
+
272
+ def read_gext(e)
273
+ e.grid = e.data.bytes
274
+ end
275
+
276
+ def write_gext(e)
277
+ e.grid.map(&:chr).join
278
+ end
279
+
280
+ def read_grbs(e)
281
+ e.grid = e.data.bytes.map {|b| b == 0 ? 0 : b - 1 }
282
+ end
283
+
284
+ def write_grbs(e)
285
+ e.grid.map {|x| x == 0 ? 0 : x + 1}.map(&:chr).join
286
+ end
287
+
288
+ def write_extensions
289
+ xw.extensions.map {|e|
290
+ e.data = self.send(:"write_#{e.section.downcase}", e)
291
+ e.len = e.data.length
292
+ e.data += "\0"
293
+ e.checksum = Checksum.of_string(e.data)
294
+ [e.section, e.len, e.checksum].pack(EXT_HEADER_FORMAT) +
295
+ e.data
296
+ }.join
297
+ end
298
+
299
+ # checksums
300
+ def text_checksum(seed)
301
+ c = Checksum.new(seed)
302
+ c.add_string_0 xw.title
303
+ c.add_string_0 xw.author
304
+ c.add_string_0 xw.copyright
305
+ xw.clues.each {|cl| c.add_string cl}
306
+ if (xw.version == '1.3')
307
+ c.add_string_0 xw.notes
308
+ end
309
+ c.sum
310
+ end
311
+
312
+ def header_checksum
313
+ h = [xw.width, xw.height, xw.n_clues, xw.puzzle_type, xw.scrambled_state]
314
+ Checksum.of_string h.pack(HEADER_CHECKSUM_FORMAT)
315
+ end
316
+
317
+ def global_checksum
318
+ c = Checksum.new header_checksum
319
+ c.add_string pack_solution(xw)
320
+ c.add_string xw.fill
321
+ text_checksum c.sum
322
+ end
323
+
324
+ def magic_checksums
325
+ mask = "ICHEATED".bytes
326
+ sums = [
327
+ text_checksum(0),
328
+ Checksum.of_string(xw.fill),
329
+ Checksum.of_string(pack_solution(xw)),
330
+ header_checksum
331
+ ]
332
+
333
+ l, h = 0, 0
334
+ sums.each_with_index do |sum, i|
335
+ l = (l << 8) | (mask[3 - i] ^ (sum & 0xff))
336
+ h = (h << 8) | (mask[7 - i] ^ (sum >> 8))
337
+ end
338
+ [l, h]
339
+ end
340
+
341
+ def checksums
342
+ c = OpenStruct.new
343
+ c.masked_low, c.masked_high = magic_checksums
344
+ c.cib = header_checksum
345
+ c.global = global_checksum
346
+ c.scrambled = 0
347
+ c
348
+ end
349
+ end
350
+
351
+ # Text format
352
+ class AcrossLiteText < Plugin
353
+ include AcrossLiteUtils
354
+
355
+ attr_accessor :xw, :rebus
356
+
357
+ def initialize
358
+ @xw = XWord.new
359
+ end
360
+
361
+ def read(data)
362
+ s = data.each_line.map(&:strip)
363
+ # first line must be <ACROSS PUZZLE> or <ACROSS PUZZLE V2>
364
+ xw.version = { "<ACROSS PUZZLE>" => 1, "<ACROSS PUZZLE V2>" => 2 }[s.shift]
365
+ check("Could not recognise Across Lite text file") { !xw.version.nil? }
366
+ header, section = "START", []
367
+ s.each do |line|
368
+ if line =~ /^<(.*)>/
369
+ process_section header, section
370
+ header = $1
371
+ section = []
372
+ else
373
+ section << line
374
+ end
375
+ end
376
+ process_section header, section
377
+ xw
378
+ end
379
+
380
+ def write(xw)
381
+ @xw = xw
382
+
383
+ # scan the grid for rebus squares and replace them with lookup keys
384
+ xw.encode_rebus!
385
+
386
+ sections = [
387
+ ['TITLE', [xw.title]],
388
+ ['AUTHOR', [xw.author]],
389
+ ['COPYRIGHT', [xw.copyright]],
390
+ ['SIZE', ["#{xw.height}x#{xw.width}"]],
391
+ ['GRID', write_grid],
392
+ ['REBUS', write_rebus],
393
+ ['ACROSS', xw.across_clues],
394
+ ['DOWN', xw.down_clues],
395
+ ['NOTEPAD', xw.notes.to_s.split("\n")]
396
+ ]
397
+ out = ["<ACROSS PUZZLE V2>"]
398
+ sections.each do |h, s|
399
+ next if s.nil? || s.empty?
400
+ out << "<#{h}>"
401
+ s.each {|l| out << " #{l}"}
402
+ end
403
+ out.join("\n") + "\n"
404
+ end
405
+
406
+ private
407
+
408
+ def process_section(header, section)
409
+ case header
410
+ when "START"
411
+ return
412
+ when "TITLE", "AUTHOR", "COPYRIGHT"
413
+ check { section.length == 1 }
414
+ xw[header.downcase] = section[0]
415
+ when "NOTEPAD"
416
+ xw.notes = section.join("\n")
417
+ when "SIZE"
418
+ check { section.length == 1 && section[0] =~ /^\d+x\d+/ }
419
+ xw.height, xw.width = section[0].split('x').map(&:to_i)
420
+ when "GRID"
421
+ check { xw.width && xw.height }
422
+ check { section.length == xw.height }
423
+ check { section.all? {|line| line.length == xw.width } }
424
+ xw.solution = unpack_solution xw, section.join
425
+ when "REBUS"
426
+ check { section.length > 0 }
427
+ check("Text format v1 does not support <REBUS>") {xw.version == 2}
428
+ # flag list (currently MARK or nothing)
429
+ xw.mark = section[0] == "MARK;"
430
+ section.shift if xw.mark
431
+ section.each do |line|
432
+ check { line =~ /^.+:.+:.$/ }
433
+ sym, long, short = line.split(':')
434
+ xw.each_cell do |c|
435
+ if c.solution == sym
436
+ c.solution = Rebus.new(long, short)
437
+ end
438
+ end
439
+ end
440
+ xw.encode_rebus!
441
+
442
+ when "ACROSS"
443
+ xw.across_clues = section
444
+ when "DOWN"
445
+ xw.down_clues = section
446
+ else
447
+ raise PuzzleFormatError, "Unrecognised header #{header}"
448
+ end
449
+ end
450
+
451
+ def write_grid
452
+ xw.to_array(GRID_CHARS).map(&:join)
453
+ end
454
+
455
+ def write_rebus
456
+ out = []
457
+ out << "MARK;" if xw.mark
458
+ xw.rebus.keys.sort.each do |long|
459
+ key, short = xw.rebus[long]
460
+ out << "#{key}:#{long}:#{short}"
461
+ end
462
+ out
463
+ end
464
+ end
465
+
466
+ end # module Pangrid