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