pangrid 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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