Clear grid and verify solution implemented
[getaclue.git] / crosswordpuzzle.py
1 # Get A Clue (C) 2010 V. Harishankar
2 # Crossword puzzle maker program
3 # Licensed under the GNU GPL v3
4
5 # Class for the puzzle data representation
6
7 # for export to PNG image
8 import cairo
9
10 class GridItem:
11 # initialize the item
12 def __init__ (self, item_char='.', item_guess = None, across_start = False,
13 down_start = False, occupied_across = False,
14 occupied_down = False, num = 0, clue_across = None,
15 clue_down = None, revealed = False):
16 # character in the cell
17 self.char = item_char
18 # guess of character in cell
19 self.guess = item_guess
20 # is the cell the start of an across word?
21 self.across_start = across_start
22 # is the cell the start of a down word?
23 self.down_start = down_start
24 # is the cell occupied by a letter in an across word?
25 self.occupied_across = occupied_across
26 # is the cell occupied by a letter in a down word?
27 self.occupied_down = occupied_down
28 # numbering of the cell if it is the start of a word
29 self.numbered = num
30 # clue across if the cell is the start of an across word
31 self.clue_across = clue_across
32 # clue down if the cell is the start of a down word
33 self.clue_down = clue_down
34 # is the letter revealed or hidden?
35 self.revealed = revealed
36
37 # clear the across data
38 def clear_across_data (self):
39 self.across_start = False
40 self.occupied_across = False
41 self.clue_across = None
42 # if no down word starting at item
43 if self.down_start is False:
44 self.numbered = 0
45 # if no down word at the item
46 if self.occupied_down is False:
47 self.char = '.'
48 self.revealed = False
49 self.guess = None
50
51 # clear the down data
52 def clear_down_data (self):
53 self.down_start = False
54 self.occupied_down = False
55 self.clue_down = None
56 # if no across word starting at item
57 if self.across_start is False:
58 self.numbered = 0
59 # if no across word at the item
60 if self.occupied_across is False:
61 self.char = '.'
62 self.revealed = False
63 self.guess = None
64
65 # reset a grid item completely - use only to destroy whole grid, otherwise
66 # use either clear_across_data () or clear_down_data () for erasing single
67 # words
68 def reset (self):
69 # character in the cell
70 self.char = '.'
71 # guess of character in cell
72 self.guess = None
73 # is the cell the start of an across word?
74 self.across_start = False
75 # is the cell the start of a down word?
76 self.down_start = False
77 # is the cell occupied by a letter in an across word?
78 self.occupied_across = False
79 # is the cell occupied by a letter in a down word?
80 self.occupied_down = False
81 # numbering of the cell if it is the start of a word
82 self.numbered = 0
83 # clue across if the cell is the start of an across word
84 self.clue_across = None
85 # clue down if the cell is the start of a down word
86 self.clue_down = None
87 # is the letter revealed or hidden?
88 self.revealed = False
89
90 # exception for too long words
91 class TooLongWordException (Exception):
92 def __init__ (self, word, length):
93 self.word = word
94 self.length = length
95
96 # exception for intersecting words
97 class IntersectWordException (Exception):
98 def __init__ (self, word, length):
99 self.word = word
100 self.length = length
101
102 # exception when grid is sought to be changed when frozen
103 class FrozenGridException (Exception):
104 def __init__ (self):
105 self.msg = "Grid is frozen and cannot be edited"
106
107 # exception when no word is found at a position
108 class NoWordException (Exception):
109 def __init__ (self, row, col):
110 self.pos = (row, col)
111
112 # exception when a word number is not found in the grid
113 class NoNumberException (Exception):
114 def __init__ (self, num):
115 self.num = num
116
117 # exception when no words are present in the grid
118 class NoWordsException (Exception):
119 def __init__ (self):
120 self.msg = "No words in grid"
121
122 # exception to raise when solution is imcomplete when trying to verify it
123 class IncompleteSolutionException (Exception):
124 def __init__ (self):
125 self.msg = "Solution incomplete"
126
127 class CrosswordPuzzle:
128 def __init__ (self, rows, cols):
129 # define number of rows and columns
130 self.rows = rows
131 self.cols = cols
132
133 # initialize the list to hold the grid
134 self.data = []
135
136 # initial state of the grid is unfrozen
137 self.frozen_grid = False
138
139 # create the grid data
140 for i in range (rows):
141 self.data.append ([])
142 for j in range (cols):
143 self.data[i].append (GridItem ())
144
145 # export to an image
146 def export_image (self, pngfile, htmlfile=None, puztitle="Crossword Puzzle",
147 solution=True):
148 # don't export if grid is not frozen
149 self.assert_frozen_grid ()
150
151 # create cairo image surface and context
152 px = 30
153 surf = cairo.ImageSurface (cairo.FORMAT_RGB24, self.cols*px, self.rows*px)
154 ctx = cairo.Context (surf)
155
156 ctx.set_source_rgb (1, 1, 1)
157 ctx.rectangle (0, 0, self.cols*px, self.rows*px)
158 ctx.fill ()
159
160 # get the clues across and down
161 clues_across = self.get_clues_across ()
162 clues_down = self.get_clues_down ()
163
164
165 # traverse through the grid
166 for row in range (self.rows):
167 for col in range (self.cols):
168 # if grid is un-occupied
169 if (self.data[row][col].occupied_across is False and
170 self.data[row][col].occupied_down is False):
171 ctx.set_source_rgb (0, 0, 0)
172 ctx.rectangle (col*px, row*px, px, px)
173 ctx.fill ()
174 # grid is occupied
175 else:
176 ctx.set_source_rgb (1, 1, 1)
177 ctx.rectangle (col*px, row*px, px, px)
178 ctx.fill ()
179 ctx.set_source_rgb (0, 0, 0)
180 ctx.rectangle (col*px, row*px, px, px)
181 ctx.stroke ()
182 # if solution is not to be provided, number the grid
183 if solution is False:
184 if self.data[row][col].numbered <> 0:
185 ctx.select_font_face ("Serif")
186 ctx.set_font_size (10)
187 ctx.move_to (col*px+5, row*px+10)
188 ctx.show_text (str(self.data[row][col].numbered))
189 # display the words
190 else:
191 ctx.select_font_face ("Serif")
192 ctx.set_font_size (16)
193 ctx.move_to (col*px+10, row*px+20)
194 ctx.show_text (self.data[row][col].char)
195
196 surf.write_to_png (open (pngfile, "wb"))
197
198 # if solution is false, publish the clues and the image in a HTML file
199 if htmlfile and solution is False:
200 html_contents = ["<html>", "<head>", "<title>"]
201 html_contents.append (puztitle)
202 html_contents.append ("</title>")
203 html_contents.append ("</head>")
204 html_contents.append ("<body>")
205 html_contents.append ("<h1>" + puztitle + "</h1>")
206 html_contents.append ('<img src="' + pngfile + '" alt="puzzle" />')
207
208 html_contents.append ("<h2>Across clues</h2>")
209 html_contents.append ("<p>")
210 for word, clue in clues_across:
211 # clue should be: <num> - clue text (chars)
212 clue_str = str (self.data[word[1]][word[2]].numbered) + " - " \
213 + clue + " (" + str (word[3]) + ")"
214 html_contents.append (clue_str)
215 html_contents.append ("<br />")
216 html_contents.append ("</p>")
217
218 html_contents.append ("<h2>Down clues</h2>")
219 html_contents.append ("<p>")
220 for word, clue in clues_down:
221 clue_str = str (self.data[word[1]][word[2]].numbered) + " - " \
222 + clue + " (" + str (word[3]) + ")"
223 html_contents.append (clue_str)
224 html_contents.append ("<br />")
225 html_contents.append ("</p>")
226 html_contents.append ("</body>")
227 html_contents.append ("</html>")
228
229 html_str = "\r\n".join (html_contents)
230
231 fhtml = open (htmlfile, "wb")
232 fhtml.write (html_str)
233 fhtml.close ()
234
235 # get the AcrossLite(TM) data for exporting
236 def export_acrosslite (self, title, author, copyright):
237 # don't export if grid is not frozen
238 self.assert_frozen_grid ()
239
240 across_data = []
241 across_data.append ("<ACROSS PUZZLE>\r\n")
242 across_data.append ("<TITLE>\r\n")
243 across_data.append (title + "\r\n")
244 across_data.append ("<AUTHOR>\r\n")
245 across_data.append (author + "\r\n")
246 across_data.append ("<COPYRIGHT>\r\n")
247 across_data.append (copyright + "\r\n")
248 across_data.append ("<SIZE>\r\n")
249 str_size = str (self.cols) + "x" + str (self.rows)
250 across_data.append (str_size + "\r\n")
251 across_data.append ("<GRID>\r\n")
252 for row in range (self.rows):
253 for col in range (self.cols):
254 if (self.data[row][col].occupied_across is True or
255 self.data[row][col].occupied_down is True):
256 across_data.append (self.data[row][col].char)
257 else:
258 across_data.append (".")
259 across_data.append ("\r\n")
260
261 across_data.append ("<ACROSS>\r\n")
262 clues_across = self.get_clues_across ()
263 for word, clue in clues_across:
264 if clue:
265 across_data.append (clue + "\r\n")
266 else:
267 across_data.append ("(No clue yet)\r\n")
268
269 across_data.append ("<DOWN>\r\n")
270 clues_down = self.get_clues_down ()
271 for word, clue in clues_down:
272 if clue:
273 across_data.append (clue + "\r\n")
274 else:
275 across_data.append ("(No clue yet\r\n")
276
277 acrosslite_str = "".join (across_data)
278 return acrosslite_str
279
280 # get all the clues for across
281 def get_clues_across (self):
282 clues = []
283 # traverse the grid
284 for row in range (self.rows):
285 for col in range (self.cols):
286 if (self.data[row][col].occupied_across is True and
287 self.data[row][col].across_start is True):
288 word_across = self.get_word_across (row, col)
289 clues.append ((word_across, self.data[row][col].clue_across))
290 # if no across words are found at all
291 if not clues:
292 raise NoWordsException
293
294 return clues
295
296 # get all the clues for down
297 def get_clues_down (self):
298 clues = []
299 # traverse the grid
300 for row in range (self.rows):
301 for col in range (self.cols):
302 if (self.data[row][col].occupied_down is True and
303 self.data[row][col].down_start is True):
304 word_down = self.get_word_down (row, col)
305 clues.append ((word_down, self.data[row][col].clue_down))
306 # if no down words are found at all
307 if not clues:
308 raise NoWordsException
309
310 return clues
311
312 # getting an across word at a number (note that the grid should be
313 # frozen for calling this otherwise a FrozenGridException will be raised)
314 def get_word_across_at_num (self, num):
315 # assert that the grid is frozen
316 self.assert_frozen_grid ()
317
318 # traverse the grid
319 for row in range (self.rows):
320 for col in range (self.cols):
321 if self.data[row][col].numbered == num:
322 word = self.get_word_across (row, col)
323 return word
324
325 # if number is not found
326 raise NoNumberException (num)
327
328 # getting a down word at a number (note that the grid should be frozen
329 # for calling this otherwise a FrozenGridException will be raised)
330 def get_word_down_at_num (self, num):
331 # assert that the grid is frozen
332 self.assert_frozen_grid ()
333
334 # traverse the grid
335 for row in range (self.rows):
336 for col in range (self.cols):
337 if self.data[row][col].numbered == num:
338 word = self.get_word_down (row, col)
339 return word
340
341 # if number is not found
342 raise NoNumberException (num)
343
344 # getting the position of a number on the grid (note that the grid should
345 # be frozen for calling this otherwise a FrozenGridException will be raised)
346 def get_position_of_num (self, num):
347 # assert that the grid is frozen
348 self.assert_frozen_grid ()
349
350 # traverse the grid
351 for row in range (self.rows):
352 for col in range (self.cols):
353 if self.data[row][col].numbered == num:
354 return (row, col)
355
356 # if number is not found
357 raise NoNumberException (num)
358
359 # getting a down word at a position
360 def get_word_down (self, row, col):
361 # if index is out of bounds
362 if row >= self.rows or col >= self.cols:
363 raise NoWordException (row, col)
364
365 # if there is no occupied down letter at that position
366 if self.data[row][col].occupied_down is False:
367 raise NoWordException (row, col)
368
369 # now traverse the grid to find the beginning of the word
370 i = row
371 while i >= 0:
372 # if it is occupied down and is the beginning of the word
373 if (self.data[i][col].occupied_down is True and
374 self.data[i][col].down_start is True):
375 start_row = i
376 break
377 i -= 1
378
379 i = start_row
380 word_chars = []
381 # now seek the end of the word
382 while i < self.rows:
383 if self.data[i][col].occupied_down is True:
384 word_chars.append (self.data[i][col].char)
385 else:
386 break
387 i += 1
388
389 word = "".join (word_chars)
390
391 # return the word, starting row, column and length as a tuple
392 return (word, start_row, col, len(word))
393
394 # getting an across word at a position
395 def get_word_across (self, row, col):
396 # if index is out of bounds
397 if row >= self.rows or col >= self.cols:
398 raise NoWordException (row, col)
399
400 # if there is no occupied across letter at that position
401 if self.data[row][col].occupied_across is False:
402 raise NoWordException (row, col)
403
404 # now traverse the grid to look for the beginning of the word
405 i = col
406 while i >= 0:
407 # if it is occupied across and is the beginning of the word
408 if (self.data[row][i].occupied_across is True and
409 self.data[row][i].across_start is True):
410 start_col = i
411 break
412 i -= 1
413
414 i = start_col
415 word_chars = []
416 # now seek the end of the word
417 while i < self.cols:
418 if self.data[row][i].occupied_across is True:
419 word_chars.append (self.data[row][i].char)
420 else:
421 break
422 i += 1
423
424 word = "".join (word_chars)
425
426 # return the word, starting column, row and length as a tuple
427 return (word, row, start_col, len(word))
428
429 # setting a down word
430 def set_word_down (self, row, col, word):
431 # if the grid is frozen then abort
432 self.assert_unfrozen_grid ()
433
434 # if the word length greater than totalrows - startrow
435 if len(word) > self.rows - row:
436 raise TooLongWordException (word, len(word))
437
438 # is the word intersecting any other word?
439 for i in range (len(word)):
440 # on the same column
441 if self.data[row+i][col].occupied_down is True:
442 raise IntersectWordException (word, len(word))
443 # on the previous column except first column
444 if col > 0:
445 # except the first and last col
446 if i > 0 and i < len(word) - 1:
447 if self.data[row+i][col-1].occupied_down is True:
448 raise IntersectWordException (word, len(word))
449 # if the previous column is the end of an across word
450 if (self.data[row+i][col-1].occupied_across is True and
451 self.data[row+i][col].occupied_across is False):
452 raise IntersectWordException (word, len(word))
453
454 # on the next column except last column
455 if col < len(word) - 1:
456 # except the first and last row check if there is any
457 # down word in previous column
458 if i > 0 and i < len(word) - 1:
459 if self.data[row+i][col+1].occupied_down is True:
460 raise IntersectWordException (word, len(word))
461 # check if there is any across word starting in the
462 # next column
463 if self.data[row+i][col+1].across_start is True:
464 raise IntersectWordException (word, len(word))
465
466 # also check the character before and after
467 if (row > 0 and self.data[row-1][col].occupied_down is True
468 and self.data[row-1][col].occupied_across is True):
469 raise IntersectWordException (word, len(word))
470 if (row + len(word) < self.rows and
471 self.data[row+len(word)][col].occupied_across is True and
472 self.data[row+len(word)][col].occupied_down is True):
473 raise IntersectWordException (word, len(word))
474
475 # set the down start to true
476 self.data[row][col].down_start = True
477 # set the word
478 for i in range (len(word)):
479 self.data[row+i][col].occupied_down = True
480 self.data[row+i][col].char = word[i].upper ()
481
482
483 # setting an across word
484 def set_word_across (self, row, col, word):
485 # if the grid is frozen then abort
486 self.assert_unfrozen_grid ()
487
488 # is the word length greater than totalcols - startcol?
489 if len(word) > self.cols - col:
490 raise TooLongWordException (word, len(word))
491
492 # is the word intersecting any other word?
493 for i in range (len(word)):
494 # on the same row
495 if self.data[row][col+i].occupied_across is True:
496 raise IntersectWordException (word, len(word))
497 # on a previous row except first row
498 if row > 0:
499 # if not the first or last col
500 if i > 0 and i < len(word) - 1:
501 if self.data[row-1][col+i].occupied_across is True:
502 raise IntersectWordException (word, len(word))
503 # if the previous row is the end of a down word
504 if (self.data[row-1][col+i].occupied_down is True and
505 self.data[row][col+i].occupied_down is False):
506 raise IntersectWordException (word, len(word))
507
508 # on a next row
509 if (row < (self.rows - 1)):
510 # except the first and last letter check if there is
511 # any across intersection
512 if i > 0 and i < len (word) - 1:
513 if self.data[row+1][col+i].occupied_across is True:
514 raise IntersectWordException (word, len(word))
515 # if a down word is starting at any column below the
516 # word
517 if self.data[row+1][col+i].down_start is True:
518 raise IntersectWordException (word, len(word))
519
520 # also check the character beyond and before and after
521 if (col > 0 and (self.data[row][col-1].occupied_across is True or
522 self.data[row][col-1].occupied_down is True)):
523 raise IntersectWordException (word, len(word))
524 if (col + len(word) < self.cols and
525 (self.data[row][col+len(word)].occupied_across is True or
526 self.data[row][col+len(word)].occupied_down is True)):
527 raise IntersectWordException (word, len(word))
528
529 # set across start to true
530 self.data[row][col].across_start = True
531
532 # set the word
533 for i in range (len(word)):
534 self.data[row][col+i].char = word[i].upper ()
535 self.data[row][col+i].occupied_across = True
536
537 # freeze the grid numbers etc.
538 def freeze_grid (self):
539 # numbering
540 numbering = 1
541 # run through the grid
542 for row in range (self.rows):
543 for col in range (self.cols):
544 # if grid is blank set the character to #
545 if (self.data[row][col].occupied_across is False
546 and self.data[row][col].occupied_down is False):
547 self.data[row][col].char = "#"
548 elif (self.data[row][col].across_start is True or
549 self.data[row][col].down_start is True):
550 self.data[row][col].numbered = numbering
551 numbering += 1
552
553 self.frozen_grid = True
554
555 # unfreeze the grid numbers etc.
556 def unfreeze_grid (self):
557 # run through the grid
558 for row in range (self.rows):
559 for col in range (self.cols):
560 self.data[row][col].numbered = 0
561 if (self.data[row][col].occupied_across is False and
562 self.data[row][col].occupied_down is False):
563 self.data[row][col].char = '.'
564
565 self.frozen_grid = False
566
567 # raise an exception if the grid is frozen
568 def assert_unfrozen_grid (self):
569 if self.frozen_grid is True:
570 raise FrozenGridException
571
572 # raise an exception if the grid is NOT frozen
573 def assert_frozen_grid (self):
574 if self.frozen_grid is False:
575 raise FrozenGridException
576
577 # reset the entire grid
578 def reset_grid (self):
579 # run through the grid
580 for row in range (self.rows):
581 for col in range (self.cols):
582 # re-initialize all data
583 self.data[row][col].reset ()
584
585 self.frozen_grid = False
586
587 # remove an across word at position
588 def remove_word_across (self, row, col):
589 # if grid is frozen don't allow removal of word
590 self.assert_unfrozen_grid ()
591
592 word, brow, bcol, l = self.get_word_across (row, col)
593
594 # traverse from the beginning to end of the word and erase it
595 c = bcol
596 while c < self.cols:
597 if self.data[brow][c].occupied_across is True:
598 self.data[brow][c].clear_across_data ()
599 else:
600 break
601 c += 1
602
603 # remove a down word at position
604 def remove_word_down (self, row, col):
605 # if grid is frozen don't allow removal of word
606 self.assert_unfrozen_grid ()
607
608 word, brow, bcol, l = self.get_word_down (row, col)
609 # traverse from the beginn to end of the word and erase it
610 r = brow
611 while r < self.rows:
612 if self.data[r][bcol].occupied_down is True:
613 self.data[r][bcol].clear_down_data ()
614 else:
615 break
616 r += 1
617
618 # reveal/unreveal a word at position
619 def reveal_word_across (self, row, col, revealed=True):
620 # set the revealed flag for the word at the position
621 word= self.get_word_across (row, col)
622
623 c = word[2]
624 while c < self.cols:
625 if self.data[word[1]][c].occupied_across is True:
626 self.data[word[1]][c].revealed = revealed
627 else:
628 break
629 c += 1
630
631 # reveal/unreveal a word at position
632 def reveal_word_down (self, row, col, revealed=True):
633 # set the revealed flag for the word at the position
634 word = self.get_word_down (row, col)
635
636 r = word[1]
637 while r < self.rows:
638 if self.data[r][word[2]].occupied_down is True:
639 self.data[r][word[2]].revealed = revealed
640 else:
641 break
642 r += 1
643
644 # reveal/hide the entire solution by resetting revealed flag at all cells
645 def reveal_solution (self, revealed=True):
646 # run through the grid and set revealed to False
647 for row in range (self.rows):
648 for col in range (self.cols):
649 self.data[row][col].revealed = revealed
650
651 # clear the guesses for the board
652 def clear_guesses (self):
653 # run through the grid and set the guesses to None
654 for row in range (self.rows):
655 for col in range (self.cols):
656 self.data[row][col].guess = None
657
658 # verify the solution - return True if all guessed characters are correct
659 # return False if some of them are wrong.
660 # if the board is not completed as yet, raise a IncompleteSolutionException
661 def is_solution_correct (self):
662 # run through the grid and check for each character in occupied cells
663 flag = True
664 for row in range (self.rows):
665 for col in range (self.cols):
666 if (self.data[row][col].occupied_across is True or
667 self.data[row][col].occupied_down is True):
668 # if there is no guess at a particular location raise
669 # the incomplete solution exception
670 if not self.data[row][col].guess:
671 raise IncompleteSolutionException
672 # if a character doesn't match, return False
673 if self.data[row][col].char <> self.data[row][col].guess:
674 flag = False
675
676 # finally return result
677 return flag
678