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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README +22 -0
- data/bin/pangrid +5 -0
- data/lib/deps/trollop.rb +838 -0
- data/lib/pangrid.rb +69 -0
- data/lib/pangrid/frontend/webrick.rb +74 -0
- data/lib/pangrid/plugin.rb +105 -0
- data/lib/pangrid/plugins/acrosslite.rb +466 -0
- data/lib/pangrid/plugins/csv.rb +83 -0
- data/lib/pangrid/plugins/excel.rb +66 -0
- data/lib/pangrid/plugins/reddit.rb +62 -0
- data/lib/pangrid/plugins/text.rb +34 -0
- data/lib/pangrid/utils.rb +9 -0
- data/lib/pangrid/version.rb +3 -0
- data/lib/pangrid/xw.rb +152 -0
- metadata +62 -0
data/lib/pangrid.rb
ADDED
@@ -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
|
+
→
|
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
|