pangrid 0.5.2 → 0.6.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/README +28 -9
- data/lib/pangrid/clues.rb +80 -0
- data/lib/pangrid/plugin.rb +7 -2
- data/lib/pangrid/plugins/acrosslite.rb +53 -19
- data/lib/pangrid/plugins/csv.rb +18 -13
- data/lib/pangrid/plugins/excel.rb +1 -1
- data/lib/pangrid/plugins/exolve.rb +15 -3
- data/lib/pangrid/plugins/json.rb +19 -3
- data/lib/pangrid/plugins/png.rb +5 -5
- data/lib/pangrid/plugins/qxw.rb +36 -14
- data/lib/pangrid/plugins/reddit.rb +11 -19
- data/lib/pangrid/plugins/text.rb +0 -8
- data/lib/pangrid/utils.rb +17 -0
- data/lib/pangrid/version.rb +1 -1
- data/lib/pangrid/xw.rb +62 -36
- data/lib/pangrid.rb +78 -31
- metadata +21 -9
- data/lib/deps/trollop.rb +0 -838
- data/lib/pangrid/data/webform.html +0 -24
- data/lib/pangrid/frontend/webrick.rb +0 -74
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bae2448ff977c9e56ef72dbab05adaaafe6ce7014b98efa76dc58b31405a2d5f
|
|
4
|
+
data.tar.gz: bed5ea209d24fa963dc2c77815e77dc8c519d041c0936b45a9b49adf257d50a3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 67db80faa5ab209c14991387b3fe7eebd837fc0fc0cdb5adb9cf9d27e1261a56a07f82dc0e5274587ac9b1b088f42fc518ed7649ca6c61ad224add01c13dfcce
|
|
7
|
+
data.tar.gz: f7a5f9c50dbd853e24609385e9f06eac8dc7ebe4ea04a654885d6768e9f53bc3e59c1c0392808c707b830a0e4b4e721acc39d9ac9d465c87b03df4024c72b958
|
data/README
CHANGED
|
@@ -5,18 +5,37 @@ Examples:
|
|
|
5
5
|
# Run from the command line
|
|
6
6
|
$ pangrid -f across-lite-binary -t text -i sample.puz -o sample.txt
|
|
7
7
|
|
|
8
|
-
#
|
|
9
|
-
$ pangrid -
|
|
8
|
+
# Add clues from a text file, e.g. to a grid from qxw, which has no clues
|
|
9
|
+
$ pangrid -f qxw -t across-lite-binary -i grid.qxw -c clues.txt -o puzzle.puz
|
|
10
10
|
|
|
11
11
|
# List available plugins
|
|
12
12
|
$ pangrid -l
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
Options:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
-f, --from=FORMAT Format to convert from
|
|
17
|
+
-t, --to=FORMAT Format to convert to
|
|
18
|
+
-i, --in=FILE Input file
|
|
19
|
+
-o, --out=FILE Output file
|
|
20
|
+
-c, --clues=FILE Add clues from a text file (see below)
|
|
21
|
+
-l, --list List available format plugins
|
|
22
|
+
-v, --version Print version and exit
|
|
23
|
+
-h, --help Show this message
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
Clues file format (-c):
|
|
27
|
+
|
|
28
|
+
Across
|
|
29
|
+
|
|
30
|
+
7 Wonderful view of canal, including outer banks (8)
|
|
31
|
+
9 Bleed badly after hack's first cut with saw (6)
|
|
32
|
+
|
|
33
|
+
Down
|
|
34
|
+
|
|
35
|
+
1 Power to limit a settlement (8)
|
|
36
|
+
|
|
37
|
+
Every clue starts with its number, which is used to match it to the grid, so
|
|
38
|
+
the clues can be in any order. Numbers may be decorated ("7.", "**7.**",
|
|
39
|
+
"7)"). Blank lines are ignored. The clue numbers must match the grid exactly;
|
|
40
|
+
missing or extra clues are reported as an error. The clues replace any that
|
|
41
|
+
were already in the input file.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# A plain text list of clues, to be added to a crossword read from another
|
|
2
|
+
# file (e.g. a grid from qxw, which has no clues of its own).
|
|
3
|
+
#
|
|
4
|
+
# Across
|
|
5
|
+
#
|
|
6
|
+
# 7 Wonderful view of canal, including outer banks (8)
|
|
7
|
+
# 9 Bleed badly after hack's first cut with saw (6)
|
|
8
|
+
#
|
|
9
|
+
# Down
|
|
10
|
+
#
|
|
11
|
+
# 1 Power to limit a settlement (8)
|
|
12
|
+
#
|
|
13
|
+
# Section headers are "Across" and "Down" (case insensitive, optionally
|
|
14
|
+
# decorated, e.g. "**Across**" or "Across:"). Blank lines are ignored. Each
|
|
15
|
+
# clue starts with its number, which is used to match it to the grid, so the
|
|
16
|
+
# clues don't have to be in order.
|
|
17
|
+
|
|
18
|
+
module Pangrid
|
|
19
|
+
|
|
20
|
+
class ClueList
|
|
21
|
+
include PluginUtils
|
|
22
|
+
|
|
23
|
+
attr_reader :across, :down
|
|
24
|
+
|
|
25
|
+
HEADER = /\A[^\w]*(across|down)[^\w]*\z/i
|
|
26
|
+
# The leading clue number, allowing for markup like "**12.**"
|
|
27
|
+
NUMBER = /\A[^\w\s,]*(\d+)\b/
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@across, @down = {}, {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# String -> ClueList
|
|
34
|
+
def self.read(data)
|
|
35
|
+
new.tap {|c| c.read(data)}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def read(data)
|
|
39
|
+
section = nil
|
|
40
|
+
data.each_line.with_index(1) do |line, lineno|
|
|
41
|
+
line = line.strip
|
|
42
|
+
next if line.empty?
|
|
43
|
+
if (m = line.match(HEADER))
|
|
44
|
+
section = m[1].downcase == "across" ? across : down
|
|
45
|
+
next
|
|
46
|
+
end
|
|
47
|
+
check("Line #{lineno}: clue before an Across or Down header") { section }
|
|
48
|
+
n = line[NUMBER, 1]
|
|
49
|
+
check("Line #{lineno}: clue does not start with a number: #{line}") { n }
|
|
50
|
+
n = n.to_i
|
|
51
|
+
check("Line #{lineno}: duplicate clue #{n}") { !section.key?(n) }
|
|
52
|
+
section[n] = strip_clue_numbers([line], [n]).first
|
|
53
|
+
end
|
|
54
|
+
check("No clues found") { !across.empty? || !down.empty? }
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Set the crossword's clues, matching clue numbers against the grid.
|
|
59
|
+
def apply(xw)
|
|
60
|
+
a, d = xw.number
|
|
61
|
+
errors = mismatches("across", a, across) + mismatches("down", d, down)
|
|
62
|
+
check("Clues do not match the grid: " + errors.join("; ")) { errors.empty? }
|
|
63
|
+
xw.across_clues = a.map {|n| across[n]}
|
|
64
|
+
xw.down_clues = d.map {|n| down[n]}
|
|
65
|
+
xw
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def mismatches(dir, grid, clues)
|
|
71
|
+
missing = grid - clues.keys
|
|
72
|
+
extra = clues.keys - grid
|
|
73
|
+
errors = []
|
|
74
|
+
errors << "no #{dir} clue for #{missing.join(', ')}" unless missing.empty?
|
|
75
|
+
errors << "#{dir} clue #{extra.join(', ')} not in grid" unless extra.empty?
|
|
76
|
+
errors
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
end # module Pangrid
|
data/lib/pangrid/plugin.rb
CHANGED
|
@@ -43,9 +43,12 @@ class Plugin
|
|
|
43
43
|
REGISTRY[name] = subclass
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
# Plugins register themselves via `inherited` when their file is first
|
|
47
|
+
# required, so the registry must not be cleared here: `require` will not
|
|
48
|
+
# re-run an already loaded file.
|
|
46
49
|
def self.load_all
|
|
47
|
-
REGISTRY.clear
|
|
48
50
|
FAILED.clear
|
|
51
|
+
MISSING_DEPS.clear
|
|
49
52
|
plugins = Dir.glob(File.dirname(__FILE__) + "/plugins/*.rb")
|
|
50
53
|
plugins.each do |f|
|
|
51
54
|
load_plugin f
|
|
@@ -57,7 +60,9 @@ class Plugin
|
|
|
57
60
|
require filename
|
|
58
61
|
rescue PluginDependencyError => e
|
|
59
62
|
MISSING_DEPS[e.name] = e.gems
|
|
60
|
-
rescue StandardError => e
|
|
63
|
+
rescue StandardError, LoadError => e
|
|
64
|
+
# LoadError is not a StandardError; catch it so that one plugin with a
|
|
65
|
+
# missing dependency cannot take down every other plugin.
|
|
61
66
|
FAILED << "#{File.basename(filename)}: #{e}"
|
|
62
67
|
end
|
|
63
68
|
end
|
|
@@ -7,13 +7,36 @@
|
|
|
7
7
|
# AcrossLiteBinary : read, write
|
|
8
8
|
# AcrossLiteText : read, write
|
|
9
9
|
|
|
10
|
-
require 'ostruct'
|
|
11
|
-
|
|
12
10
|
module Pangrid
|
|
13
11
|
|
|
14
12
|
GRID_CHARS = {:black => '.', :null => '?'}
|
|
15
13
|
FILL_CHARS = {:black => '.', :null => '-'}
|
|
16
14
|
|
|
15
|
+
# The binary format stores text as ISO-8859-1; we use UTF-8 internally.
|
|
16
|
+
module Latin1
|
|
17
|
+
# String -> ISO-8859-1 bytes (binary strings are passed through)
|
|
18
|
+
def self.bytes(s)
|
|
19
|
+
return s.b if [Encoding::BINARY, Encoding::ISO_8859_1].include?(s.encoding)
|
|
20
|
+
s.encode(Encoding::ISO_8859_1, undef: :replace, invalid: :replace,
|
|
21
|
+
replace: '?').b
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# ISO-8859-1 bytes -> UTF-8 String
|
|
25
|
+
def self.decode(s)
|
|
26
|
+
s.dup.force_encoding(Encoding::ISO_8859_1).encode(Encoding::UTF_8)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Binary format extension sections. `data` holds the raw bytes; the other
|
|
31
|
+
# fields are populated by the section-specific readers.
|
|
32
|
+
Extension = Struct.new(:section, :len, :checksum, :data,
|
|
33
|
+
:elapsed, :stopped, # LTIM
|
|
34
|
+
:grid, # GRBS, GEXT
|
|
35
|
+
:rebus, # RTBL
|
|
36
|
+
keyword_init: true)
|
|
37
|
+
|
|
38
|
+
Checksums = Struct.new(:global, :cib, :masked_low, :masked_high, :scrambled)
|
|
39
|
+
|
|
17
40
|
# CRC checksum for binary format
|
|
18
41
|
class Checksum
|
|
19
42
|
attr_accessor :sum
|
|
@@ -36,7 +59,7 @@ class Checksum
|
|
|
36
59
|
end
|
|
37
60
|
|
|
38
61
|
def add_string(s)
|
|
39
|
-
s.bytes.map {|b| add_char b}
|
|
62
|
+
Latin1.bytes(s).bytes.map {|b| add_char b}
|
|
40
63
|
end
|
|
41
64
|
|
|
42
65
|
def add_string_0(s)
|
|
@@ -85,12 +108,12 @@ class AcrossLiteBinary < Plugin
|
|
|
85
108
|
|
|
86
109
|
def initialize
|
|
87
110
|
@xw = XWord.new
|
|
88
|
-
@cs =
|
|
111
|
+
@cs = Checksums.new
|
|
89
112
|
@xw.extensions = []
|
|
90
113
|
end
|
|
91
114
|
|
|
92
115
|
def read(data)
|
|
93
|
-
s = data.
|
|
116
|
+
s = data.b
|
|
94
117
|
|
|
95
118
|
i = s.index(FILE_MAGIC)
|
|
96
119
|
check("Could not recognise AcrossLite binary file") { i }
|
|
@@ -103,20 +126,29 @@ class AcrossLiteBinary < Plugin
|
|
|
103
126
|
xw.version, _, cs.scrambled, _,
|
|
104
127
|
xw.width, xw.height, xw.n_clues, xw.puzzle_type, xw.scrambled_state =
|
|
105
128
|
header.unpack(HEADER_FORMAT)
|
|
129
|
+
check("Could not recognise AcrossLite binary file") { xw.width }
|
|
130
|
+
check("Scrambled (locked) puzzles are not supported") {
|
|
131
|
+
xw.scrambled_state == 0
|
|
132
|
+
}
|
|
106
133
|
|
|
107
134
|
# solution and fill = blocks of w*h bytes each
|
|
108
135
|
size = xw.width * xw.height
|
|
109
|
-
|
|
110
|
-
xw.
|
|
136
|
+
check("AcrossLite binary file is truncated") { s.length >= h_end + 2 * size }
|
|
137
|
+
xw.solution = unpack_solution xw, Latin1.decode(s[h_end, size])
|
|
138
|
+
xw.fill = Latin1.decode(s[h_end + size, size])
|
|
111
139
|
s = s[h_end + 2 * size .. -1]
|
|
112
140
|
|
|
113
141
|
# title, author, copyright, clues * n, notes = zero-terminated strings
|
|
114
142
|
xw.title, xw.author, xw.copyright, *xw.clues, xw.notes, s =
|
|
115
143
|
s.split("\0", xw.n_clues + 5)
|
|
144
|
+
check("Could not read clues from AcrossLite binary file") { s }
|
|
145
|
+
xw.title, xw.author, xw.copyright, xw.notes =
|
|
146
|
+
[xw.title, xw.author, xw.copyright, xw.notes].map {|x| Latin1.decode(x)}
|
|
147
|
+
xw.clues = xw.clues.map {|x| Latin1.decode(x)}
|
|
116
148
|
|
|
117
149
|
# extensions: 8-byte header + len bytes data + \0
|
|
118
150
|
while (s.length > 8) do
|
|
119
|
-
e =
|
|
151
|
+
e = Extension.new
|
|
120
152
|
e.section, e.len, e.checksum = s.unpack(EXT_HEADER_FORMAT)
|
|
121
153
|
check("Unrecognised extension #{e.section}") { EXTENSIONS.include? e.section }
|
|
122
154
|
size = 8 + e.len + 1
|
|
@@ -137,7 +169,9 @@ class AcrossLiteBinary < Plugin
|
|
|
137
169
|
end
|
|
138
170
|
|
|
139
171
|
def write(xw)
|
|
140
|
-
|
|
172
|
+
# work on a copy so that the fields filled in below don't leak back into
|
|
173
|
+
# the caller's puzzle
|
|
174
|
+
@xw = xw = xw.dup
|
|
141
175
|
|
|
142
176
|
# fill in some fields that might not be present (checksums needs this)
|
|
143
177
|
pack_clues
|
|
@@ -148,7 +182,10 @@ class AcrossLiteBinary < Plugin
|
|
|
148
182
|
xw.scrambled_state ||= 0
|
|
149
183
|
xw.version = "1.3"
|
|
150
184
|
xw.notes ||= ""
|
|
151
|
-
|
|
185
|
+
# rebus extensions are regenerated from the grid below
|
|
186
|
+
xw.extensions = (xw.extensions || []).reject {|e|
|
|
187
|
+
%w(GRBS RTBL).include? e.section
|
|
188
|
+
}
|
|
152
189
|
xw.title ||= ""
|
|
153
190
|
xw.author ||= ""
|
|
154
191
|
xw.copyright ||= ""
|
|
@@ -157,15 +194,13 @@ class AcrossLiteBinary < Plugin
|
|
|
157
194
|
xw.encode_rebus!
|
|
158
195
|
if not xw.rebus.empty?
|
|
159
196
|
# GRBS
|
|
160
|
-
e =
|
|
161
|
-
e.section = "GRBS"
|
|
197
|
+
e = Extension.new(section: "GRBS")
|
|
162
198
|
e.grid = xw.to_array({:black => 0, :null => 0}) {|s|
|
|
163
199
|
s.rebus? ? s.solution.symbol.to_i : 0
|
|
164
200
|
}.flatten
|
|
165
201
|
xw.extensions << e
|
|
166
202
|
# RTBL
|
|
167
|
-
e =
|
|
168
|
-
e.section = "RTBL"
|
|
203
|
+
e = Extension.new(section: "RTBL")
|
|
169
204
|
e.rebus = {}
|
|
170
205
|
xw.rebus.each do |long, (k, short)|
|
|
171
206
|
e.rebus[k] = [long, short]
|
|
@@ -182,10 +217,10 @@ class AcrossLiteBinary < Plugin
|
|
|
182
217
|
header = h.pack(HEADER_FORMAT)
|
|
183
218
|
|
|
184
219
|
strings = [xw.title, xw.author, xw.copyright] + xw.clues + [xw.notes]
|
|
185
|
-
strings = strings.map {|x| x + "\0"}.join
|
|
220
|
+
strings = strings.map {|x| Latin1.bytes(x) + "\0".b}.join
|
|
186
221
|
|
|
187
222
|
[header, pack_solution(xw), xw.fill, strings, write_extensions].map {|x|
|
|
188
|
-
|
|
223
|
+
Latin1.bytes(x)
|
|
189
224
|
}.join
|
|
190
225
|
end
|
|
191
226
|
|
|
@@ -233,7 +268,6 @@ class AcrossLiteBinary < Plugin
|
|
|
233
268
|
# record these for file inspection, though they're unlikely to be useful
|
|
234
269
|
if (ltim = get_extension("LTIM"))
|
|
235
270
|
xw.time_elapsed = ltim.elapsed
|
|
236
|
-
xw.paused
|
|
237
271
|
end
|
|
238
272
|
|
|
239
273
|
# we need both grbs and rtbl
|
|
@@ -266,7 +300,7 @@ class AcrossLiteBinary < Plugin
|
|
|
266
300
|
check("Could not read extension RTBL") { m }
|
|
267
301
|
e.rebus = {}
|
|
268
302
|
e.data.scan(rx).each {|_, k, v|
|
|
269
|
-
e.rebus[k.to_i] = [v, '-']
|
|
303
|
+
e.rebus[k.to_i] = [Latin1.decode(v), '-']
|
|
270
304
|
}
|
|
271
305
|
end
|
|
272
306
|
|
|
@@ -346,7 +380,7 @@ class AcrossLiteBinary < Plugin
|
|
|
346
380
|
end
|
|
347
381
|
|
|
348
382
|
def checksums
|
|
349
|
-
c =
|
|
383
|
+
c = Checksums.new
|
|
350
384
|
c.masked_low, c.masked_high = magic_checksums
|
|
351
385
|
c.cib = header_checksum
|
|
352
386
|
c.global = global_checksum
|
data/lib/pangrid/plugins/csv.rb
CHANGED
|
@@ -38,27 +38,32 @@ class CSV < Plugin
|
|
|
38
38
|
check("No header row found. Header needs a 'Width: ' cell in the first column.") { !s.empty? }
|
|
39
39
|
xw = XWord.new
|
|
40
40
|
h = s.shift.map {|c| c.split(/:\s*/)}
|
|
41
|
-
header =
|
|
41
|
+
header = {}
|
|
42
42
|
h.each do |k, v|
|
|
43
43
|
header[k.downcase.strip] = v
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
xw.
|
|
46
|
+
clue_column = header["clues"].to_i
|
|
47
|
+
black, empty = header["black"], header["empty"]
|
|
48
|
+
xw.width = header["width"].to_i
|
|
49
|
+
xw.height = header["height"].to_i
|
|
50
|
+
offset = header["offset"].to_i
|
|
51
|
+
check("Width and Height must be positive") { xw.width > 0 && xw.height > 0 }
|
|
49
52
|
xw.solution = []
|
|
50
|
-
xw.height.times do
|
|
53
|
+
xw.height.times do |y|
|
|
51
54
|
row = s.shift
|
|
52
|
-
check("
|
|
53
|
-
|
|
54
|
-
xw.
|
|
55
|
+
check("Expected #{xw.height} grid rows, found #{y}") { row }
|
|
56
|
+
cells = row[offset, xw.width] || []
|
|
57
|
+
check("Row does not have #{xw.width} cells starting at column " +
|
|
58
|
+
"#{offset}: \n" + row.join(',')) { cells.length == xw.width }
|
|
59
|
+
xw.solution << cells.map do |c|
|
|
55
60
|
cell = Cell.new
|
|
56
|
-
if c ==
|
|
61
|
+
if c == black
|
|
57
62
|
cell.solution = :black
|
|
58
|
-
elsif c ==
|
|
63
|
+
elsif c == empty or c == nil
|
|
59
64
|
cell.solution = :null
|
|
60
65
|
else
|
|
61
|
-
cell.solution = c.gsub
|
|
66
|
+
cell.solution = c.gsub(/[^[:alpha:]]/, '')
|
|
62
67
|
end
|
|
63
68
|
cell
|
|
64
69
|
end
|
|
@@ -66,8 +71,8 @@ class CSV < Plugin
|
|
|
66
71
|
|
|
67
72
|
xw.clues = []
|
|
68
73
|
s.each do |row|
|
|
69
|
-
if row[
|
|
70
|
-
xw.clues << row[
|
|
74
|
+
if row[clue_column]
|
|
75
|
+
xw.clues << row[clue_column]
|
|
71
76
|
end
|
|
72
77
|
end
|
|
73
78
|
unpack_clues(xw)
|
|
@@ -36,7 +36,7 @@ class ExcelXSLX < Plugin
|
|
|
36
36
|
rows = xw.to_array(:black => " ", :null => " ") {|c|
|
|
37
37
|
format_number(c.number) + " " +
|
|
38
38
|
# We can insert the entire word for rebuses
|
|
39
|
-
c.solution
|
|
39
|
+
(c.rebus? ? c.solution.solution : c.solution)
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
styles = xw.to_array(:black => :black, :null => :white) {
|
|
@@ -52,16 +52,28 @@ module ExolveReader
|
|
|
52
52
|
xw = XWord.new
|
|
53
53
|
s = sections(lines)
|
|
54
54
|
s.each do |_, field, data|
|
|
55
|
-
if %w(title
|
|
55
|
+
if %w(title copyright).include? field
|
|
56
56
|
xw[field] = data
|
|
57
|
+
elsif field == "prelude"
|
|
58
|
+
xw.preamble = data
|
|
59
|
+
elsif field == "setter"
|
|
60
|
+
xw.author = data
|
|
57
61
|
elsif %w(height width).include? field
|
|
58
62
|
xw[field] = data.to_i
|
|
59
|
-
elsif %(across down).include? field
|
|
60
|
-
xw["#{field}_clues"] = data
|
|
63
|
+
elsif %w(across down).include? field
|
|
64
|
+
xw["#{field}_clues"] = strip_clue_numbers(data)
|
|
61
65
|
elsif field == "grid"
|
|
62
66
|
xw.solution = parse_grid(data)
|
|
63
67
|
end
|
|
64
68
|
end
|
|
69
|
+
check("exolve-grid missing") { xw.solution && !xw.solution.empty? }
|
|
70
|
+
xw.height ||= xw.solution.length
|
|
71
|
+
xw.width ||= xw.solution[0].length
|
|
72
|
+
check("Grid does not match exolve-width/exolve-height " +
|
|
73
|
+
"(#{xw.width}x#{xw.height})") {
|
|
74
|
+
xw.solution.length == xw.height &&
|
|
75
|
+
xw.solution.all? {|row| row.length == xw.width}
|
|
76
|
+
}
|
|
65
77
|
xw
|
|
66
78
|
end
|
|
67
79
|
|
data/lib/pangrid/plugins/json.rb
CHANGED
|
@@ -25,7 +25,7 @@ class Json < Plugin
|
|
|
25
25
|
s = case cell.solution
|
|
26
26
|
when :black; '#'
|
|
27
27
|
when :null; ''
|
|
28
|
-
when Rebus; cell.solution.
|
|
28
|
+
when Rebus; cell.solution.solution
|
|
29
29
|
else; cell.solution
|
|
30
30
|
end
|
|
31
31
|
|
|
@@ -45,15 +45,30 @@ class Json < Plugin
|
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def read(data)
|
|
48
|
-
|
|
48
|
+
begin
|
|
49
|
+
json = ::JSON.parse(data)
|
|
50
|
+
rescue ::JSON::ParserError => e
|
|
51
|
+
raise PuzzleFormatError, "Invalid JSON: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
check("Expected a JSON object") { json.is_a?(Hash) }
|
|
49
54
|
xw = XWord.new
|
|
50
55
|
|
|
51
56
|
xw.height = json['rows']
|
|
52
57
|
xw.width = json['cols']
|
|
58
|
+
check("rows and cols must be positive integers") {
|
|
59
|
+
[xw.height, xw.width].all? {|i| i.is_a?(Integer) && i > 0}
|
|
60
|
+
}
|
|
61
|
+
check("cells must be a list") { json['cells'].is_a?(Array) }
|
|
53
62
|
xw.solution = Array.new(xw.height) { Array.new(xw.width) }
|
|
54
63
|
json['cells'].each do |c|
|
|
55
64
|
cell = Cell.new
|
|
56
65
|
s = c['contents']
|
|
66
|
+
x, y = c['x'], c['y']
|
|
67
|
+
check("Cell #{c.inspect} is outside the grid") {
|
|
68
|
+
x.is_a?(Integer) && y.is_a?(Integer) &&
|
|
69
|
+
(0...xw.width).cover?(x) && (0...xw.height).cover?(y)
|
|
70
|
+
}
|
|
71
|
+
check("Cell #{c.inspect} needs string contents") { s.is_a?(String) }
|
|
57
72
|
cell.solution =
|
|
58
73
|
case s
|
|
59
74
|
when ""; :null
|
|
@@ -65,9 +80,10 @@ class Json < Plugin
|
|
|
65
80
|
Rebus.new s
|
|
66
81
|
end
|
|
67
82
|
end
|
|
68
|
-
x, y = c['x'], c['y']
|
|
69
83
|
xw.solution[y][x] = cell
|
|
70
84
|
end
|
|
85
|
+
missing = xw.solution.flatten.count(nil)
|
|
86
|
+
check("#{missing} cells are missing from the grid") { missing == 0 }
|
|
71
87
|
xw.across_clues = json['across']
|
|
72
88
|
xw.down_clues = json['down']
|
|
73
89
|
xw
|
data/lib/pangrid/plugins/png.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
require 'chunky_png'
|
|
2
|
-
|
|
3
1
|
module Pangrid
|
|
4
|
-
|
|
2
|
+
|
|
3
|
+
require_for_plugin 'png-thumbnail', ['chunky_png']
|
|
4
|
+
|
|
5
5
|
class PNGThumbnail < Plugin
|
|
6
6
|
attr_reader :scale, :border
|
|
7
7
|
|
|
@@ -21,11 +21,11 @@ module Pangrid
|
|
|
21
21
|
c = cell.black? ? black : white
|
|
22
22
|
png.rect(
|
|
23
23
|
x * scale, y * scale, (x + 1) * scale, (y + 1) * scale,
|
|
24
|
-
|
|
24
|
+
grey, c) # stroke, fill
|
|
25
25
|
end
|
|
26
26
|
if border
|
|
27
27
|
stroke = ChunkyPNG::Color::rgb(*border)
|
|
28
|
-
png.rect(0, 0, xw.width * scale, xw.height * scale,
|
|
28
|
+
png.rect(0, 0, xw.width * scale, xw.height * scale, stroke)
|
|
29
29
|
end
|
|
30
30
|
png.to_blob
|
|
31
31
|
end
|
data/lib/pangrid/plugins/qxw.rb
CHANGED
|
@@ -8,25 +8,33 @@ class Qxw < Plugin
|
|
|
8
8
|
|
|
9
9
|
DESCRIPTION = "QXW grid reader (rectangular grids only)"
|
|
10
10
|
|
|
11
|
-
def
|
|
12
|
-
|
|
13
|
-
lines = data.lines.map(&:chomp)
|
|
14
|
-
gp = lines.find {|x| x =~ /^GP( \d+){6}$/}
|
|
15
|
-
check("Could not read grid size from .qxw file") { gp }
|
|
16
|
-
type, w, h, _ = gp.scan(/\d+/).map(&:to_i)
|
|
17
|
-
check("Only rectangular grids are supported") { type == 0 }
|
|
18
|
-
xw.width = w
|
|
19
|
-
xw.height = h
|
|
20
|
-
xw.solution = []
|
|
11
|
+
def read_black(xw, lines)
|
|
12
|
+
# Read black cells
|
|
21
13
|
grid = lines.select {|x| x =~ /^SQ /}
|
|
22
14
|
grid.each do |line|
|
|
23
15
|
parts = line.scan(/\w+/)
|
|
24
16
|
check(QXW_GRID_ERROR) { parts.length == 6 || parts.length == 7 }
|
|
25
|
-
col, row,
|
|
17
|
+
_, col, row, _, _, b, _ = parts
|
|
18
|
+
col, row, b = col.to_i, row.to_i, b.to_i
|
|
19
|
+
next if b != 1
|
|
20
|
+
cell = Cell.new
|
|
21
|
+
cell.solution = :black
|
|
22
|
+
xw.solution[row] ||= []
|
|
23
|
+
xw.solution[row][col] = cell
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def read_filled(xw, lines)
|
|
28
|
+
# Read filled cells
|
|
29
|
+
grid = lines.select {|x| x =~ /^SQCT /}
|
|
30
|
+
grid.each do |line|
|
|
31
|
+
parts = line.scan(/\w+/)
|
|
32
|
+
check(QXW_GRID_ERROR) { parts.length == 4 || parts.length == 5 }
|
|
33
|
+
_, col, row, d, c = parts
|
|
34
|
+
col, row, d = col.to_i, row.to_i, d.to_i
|
|
35
|
+
next if d != 0 # different char per direction, not supported for now
|
|
26
36
|
cell = Cell.new
|
|
27
|
-
if
|
|
28
|
-
cell.solution = :black
|
|
29
|
-
elsif c == nil
|
|
37
|
+
if c == nil
|
|
30
38
|
cell.solution = :null
|
|
31
39
|
else
|
|
32
40
|
cell.solution = c
|
|
@@ -34,6 +42,20 @@ class Qxw < Plugin
|
|
|
34
42
|
xw.solution[row] ||= []
|
|
35
43
|
xw.solution[row][col] = cell
|
|
36
44
|
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def read(data)
|
|
48
|
+
xw = XWord.new
|
|
49
|
+
lines = data.lines.map(&:chomp)
|
|
50
|
+
gp = lines.find {|x| x =~ /^GP( \d+){6}$/}
|
|
51
|
+
check("Could not read grid size from .qxw file") { gp }
|
|
52
|
+
type, w, h, _ = gp.scan(/\d+/).map(&:to_i)
|
|
53
|
+
check("Only rectangular grids are supported") { type == 0 }
|
|
54
|
+
xw.width = w
|
|
55
|
+
xw.height = h
|
|
56
|
+
xw.solution = []
|
|
57
|
+
read_filled(xw, lines)
|
|
58
|
+
read_black(xw, lines)
|
|
37
59
|
check(QXW_GRID_ERROR) { xw.solution.length == xw.height }
|
|
38
60
|
check(QXW_GRID_ERROR) { xw.solution.all? {|i| i.compact.length == xw.width} }
|
|
39
61
|
|
|
@@ -64,19 +64,10 @@ class RedditBlank < Plugin
|
|
|
64
64
|
c == "" || c == "*.*" || c =~ /^(\^\d+)?[[:alpha:]]*$/ # allow partially filled grids
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
-
#
|
|
68
|
-
def
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if b
|
|
72
|
-
e = b + n.to_s.length
|
|
73
|
-
if cl[0...b] =~ /\W*/ and cl[e + 1] =~ /\W/
|
|
74
|
-
cl[e+1 .. -1].sub(/^\W*\s/, '').strip
|
|
75
|
-
else
|
|
76
|
-
cl.strip
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
|
67
|
+
# The lines following a clue section header, up to the next header
|
|
68
|
+
def clue_section(lines, start, other)
|
|
69
|
+
stop = other > start ? other : lines.length
|
|
70
|
+
lines[(start + 1) ... stop]
|
|
80
71
|
end
|
|
81
72
|
|
|
82
73
|
def read(data)
|
|
@@ -97,8 +88,7 @@ class RedditBlank < Plugin
|
|
|
97
88
|
lines = [lines[(ix - 1)]] + lines[(ix + 1) .. -1]
|
|
98
89
|
grid = lines.take_while {|i| is_grid_row(i)}
|
|
99
90
|
|
|
100
|
-
clues = lines[grid.length .. -1]
|
|
101
|
-
clues = lines.reject {|i| i.strip.empty?}
|
|
91
|
+
clues = lines[grid.length .. -1].map(&:strip).reject(&:empty?)
|
|
102
92
|
|
|
103
93
|
# strip leading |s
|
|
104
94
|
grid = grid.map {|line| line.sub(/^\|/, '')}
|
|
@@ -126,10 +116,12 @@ class RedditBlank < Plugin
|
|
|
126
116
|
across, down = xw.number
|
|
127
117
|
aix = clues.find_index {|row| row =~ /^\W*across/i}
|
|
128
118
|
dix = clues.find_index {|row| row =~ /^\W*down/i}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
119
|
+
check("Could not find across clues") { aix }
|
|
120
|
+
check("Could not find down clues") { dix }
|
|
121
|
+
ac = clue_section(clues, aix, dix).take(across.length)
|
|
122
|
+
dn = clue_section(clues, dix, aix).take(down.length)
|
|
123
|
+
xw.across_clues = strip_clue_numbers(ac, across)
|
|
124
|
+
xw.down_clues = strip_clue_numbers(dn, down)
|
|
133
125
|
|
|
134
126
|
xw
|
|
135
127
|
end
|
data/lib/pangrid/plugins/text.rb
CHANGED
|
@@ -21,14 +21,6 @@ class Text < Plugin
|
|
|
21
21
|
grid + "\n" + ac + "\n\n" + dn + "\n"
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
# rename to 'read' when this is complete
|
|
25
|
-
def read_grid(data)
|
|
26
|
-
s = data.each_line.map(&:strip)
|
|
27
|
-
m = s[0].match(/^Grid (\d+) (\d+)$/)
|
|
28
|
-
check("Grid line missing") { m }
|
|
29
|
-
xw.height, xw.width = m[1].to_i, m[2].to_i
|
|
30
|
-
end
|
|
31
|
-
|
|
32
24
|
def format_clues(numbers, clues, indent)
|
|
33
25
|
numbers.zip(clues).map {|n, c| " "*indent + "#{n.to_s.rjust(2)}. #{c}"}.join("\n")
|
|
34
26
|
end
|
data/lib/pangrid/utils.rb
CHANGED
|
@@ -4,6 +4,23 @@ module PluginUtils
|
|
|
4
4
|
def check(msg = "")
|
|
5
5
|
raise PuzzleFormatError, msg unless yield
|
|
6
6
|
end
|
|
7
|
+
|
|
8
|
+
# Strip leading clue numbers from a list of clues, e.g.
|
|
9
|
+
# "1. Clue", "1\\. Clue", "**1.** Clue", "1 Clue" -> "Clue"
|
|
10
|
+
#
|
|
11
|
+
# If `numbers` is given, clues[i] only has numbers[i] stripped, and clues
|
|
12
|
+
# that don't start with their expected number are left as they are.
|
|
13
|
+
# Otherwise any leading number is stripped.
|
|
14
|
+
#
|
|
15
|
+
# The number must be a whole word followed by whitespace, so "1984 author"
|
|
16
|
+
# is not clue 1, and linked clues like "1, 5 Clue" are left alone.
|
|
17
|
+
def strip_clue_numbers(clues, numbers = nil)
|
|
18
|
+
clues.each_with_index.map do |clue, i|
|
|
19
|
+
next clue.strip if numbers && numbers[i].nil?
|
|
20
|
+
n = numbers ? Regexp.escape(numbers[i].to_s) : '\d+'
|
|
21
|
+
clue.sub(/\A\s*[^\w\s,]*#{n}\b[^\w\s,]*\s+/, '').strip
|
|
22
|
+
end
|
|
23
|
+
end
|
|
7
24
|
end
|
|
8
25
|
|
|
9
26
|
end # module Pangrid
|