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