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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e04bea2f4ebdb7947ec122f28a4a8feaa9cb4e7c4a27206abb6ef0fe7e6f254c
4
- data.tar.gz: d5f32ec895146fe2141c5f68dcf775477b7a381577239e8225d8d8a155fe0c10
3
+ metadata.gz: bae2448ff977c9e56ef72dbab05adaaafe6ce7014b98efa76dc58b31405a2d5f
4
+ data.tar.gz: bed5ea209d24fa963dc2c77815e77dc8c519d041c0936b45a9b49adf257d50a3
5
5
  SHA512:
6
- metadata.gz: d8057bad79f07c4d761f136a8258171b5b7a3711d5d72d589282d7219153fbf6bbb28c29315ab0ee99eba74ef465d334dbef71fd7657e8881dfe42ad944076ac
7
- data.tar.gz: e8bf9a1582e69ab4279505d19f957dbe3668cb471cd2644660fbaff073a08f3b608de57db830f637c6a97ada3f255e79bcbc59bab45f0e8d9e9ff345cc0636f8
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
- # Start the webserver
9
- $ pangrid -w
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
- -f, --from=<s> Format to convert from
17
- -t, --to=<s> Format to convert to
18
- -i, --in=<s> Input file
19
- -o, --out=<s> Output file
20
- -l, --list List available format plugins
21
- -w, --web Launch webserver
22
- -h, --help Show this message
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
@@ -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 = OpenStruct.new
111
+ @cs = Checksums.new
89
112
  @xw.extensions = []
90
113
  end
91
114
 
92
115
  def read(data)
93
- s = data.force_encoding("ISO-8859-1")
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
- xw.solution = unpack_solution xw, s[h_end, size]
110
- xw.fill = s[h_end + size, size]
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 = OpenStruct.new
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
- @xw = xw
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
- xw.extensions ||= []
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 = OpenStruct.new
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 = OpenStruct.new
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
- x.force_encoding("ISO-8859-1")
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 = OpenStruct.new
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
@@ -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 = OpenStruct.new
41
+ header = {}
42
42
  h.each do |k, v|
43
43
  header[k.downcase.strip] = v
44
44
  end
45
45
 
46
- header.clues = header.clues.to_i
47
- xw.width = header.width.to_i
48
- xw.height = header.height.to_i
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("Row does not have #{xw.width} cells: \n" +
53
- row.join(',')) { row.length == xw.width }
54
- xw.solution << row.map do |c|
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 == header.black
61
+ if c == black
57
62
  cell.solution = :black
58
- elsif c == header.empty or c == nil
63
+ elsif c == empty or c == nil
59
64
  cell.solution = :null
60
65
  else
61
- cell.solution = c.gsub /[^[:alpha:]]/, ''
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[header.clues]
70
- xw.clues << row[header.clues]
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 setter copyright prelude).include? field
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
 
@@ -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.inspect
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
- json = ::JSON.parse(data)
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
@@ -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
- stroke_color = grey, fill_color = c)
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, stroke_color = stroke)
28
+ png.rect(0, 0, xw.width * scale, xw.height * scale, stroke)
29
29
  end
30
30
  png.to_blob
31
31
  end
@@ -8,25 +8,33 @@ class Qxw < Plugin
8
8
 
9
9
  DESCRIPTION = "QXW grid reader (rectangular grids only)"
10
10
 
11
- def read(data)
12
- xw = XWord.new
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, b, c = parts[1].to_i, parts[2].to_i, parts[5].to_i, parts[6]
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 b == 1
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
- # make a best-attempt effort to strip off the clue number
68
- def strip_clues(nums, clues)
69
- nums.zip(clues).each_with_index.map do |(n, cl), i|
70
- b = cl.index(n.to_s)
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
- xw.across_clues = clues[(aix + 1) .. (aix + across.length)]
130
- xw.down_clues = clues[(dix + 1) .. (dix + down.length)]
131
- xw.across_clues = strip_clues(across, xw.across_clues)
132
- xw.down_clues = strip_clues(down, xw.down_clues)
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
@@ -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