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