Coverage for puz.py: 100%
852 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-29 06:23 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-29 06:23 +0000
1from __future__ import annotations # for Python 3.9 and earlier
3import functools
4import importlib.metadata
5import operator
6import math
7import string
8import struct
9from enum import Enum, IntEnum
10from typing import Any, Iterable, Iterator, Protocol, cast, runtime_checkable
12__version__ = importlib.metadata.version('puzpy')
14HEADER_FORMAT = '''<
15 H 11s xH
16 Q 4s 2sH
17 12s BBH
18 H H '''
20HEADER_CKSUM_FORMAT = '<BBH H H '
22EXTENSION_HEADER_FORMAT = '< 4s H H '
24MASKSTRING = 'ICHEATED'
26ENCODING = 'ISO-8859-1'
27ENCODING_UTF8 = 'UTF-8'
28ENCODING_ERRORS = 'strict' # raises an exception for bad chars; change to 'replace' for laxer handling
30ACROSSDOWN = b'ACROSS&DOWN'
32BLACKSQUARE = '.'
33BLACKSQUARE2 = ':' # used for diagramless puzzles
34BLANKSQUARE = '-'
37class PuzzleType(IntEnum):
38 Normal = 0x0001
39 Diagramless = 0x0401
42# the following diverges from the documentation
43# but works for the files I've tested
44class SolutionState(IntEnum):
45 # solution is available in plaintext
46 Unlocked = 0x0000
47 # solution is not present in the file
48 NotProvided = 0x0002
49 # solution is locked (scrambled) with a key
50 Locked = 0x0004
53class GridMarkup(IntEnum):
54 # ordinary grid cell
55 Default = 0x00
56 # marked incorrect at some point
57 PreviouslyIncorrect = 0x10
58 # currently showing incorrect
59 Incorrect = 0x20
60 # user got a hint
61 Revealed = 0x40
62 # circled
63 Circled = 0x80
66# refer to Extensions as Extensions.Rebus, Extensions.Markup
67class Extensions(bytes, Enum):
68 # grid of rebus indices: 0 for non-rebus;
69 # i+1 for key i into RebusSolutions map
70 # should be same size as the grid
71 Rebus = b'GRBS',
73 # map of rebus solution entries eg 0:HEART;1:DIAMOND;17:CLUB;23:SPADE;
74 RebusSolutions = b'RTBL',
76 # user's rebus entries; binary grid format: one null-terminated string per cell (in grid order).
77 # empty cells are a single null byte; filled rebus cells are the fill string followed by a null byte.
78 RebusFill = b'RUSR',
80 # timer state: 'a,b' where a is the number of seconds elapsed and
81 # b is a boolean (0,1) for whether the timer is running
82 Timer = b'LTIM',
84 # grid cell markup: previously incorrect: 0x10;
85 # currently incorrect: 0x20,
86 # hinted: 0x40,
87 # circled: 0x80
88 Markup = b'GEXT'
91class ClueEntry(dict[str, Any]):
92 """A clue entry in a crossword puzzle.
94 Supports legacy dict-style access (e.g. entry['num']) for backwards
95 compatibility, as well as named property access (e.g. entry.number).
96 """
98 _puzzle: 'Puzzle | None'
100 def __init__(self, data: dict[str, Any], puzzle: 'Puzzle | None' = None) -> None:
101 super().__init__(data)
102 self._puzzle = puzzle
104 @property
105 def number(self) -> int:
106 return cast(int, self['num'])
108 @property
109 def text(self) -> str:
110 return cast(str, self['clue'])
112 @property
113 def length(self) -> int:
114 return cast(int, self['len'])
116 @property
117 def direction(self) -> str:
118 return cast(str, self['dir'])
120 @property
121 def row(self) -> int:
122 return cast(int, self['row'])
124 @property
125 def col(self) -> int:
126 return cast(int, self['col'])
128 @property
129 def cell(self) -> int:
130 return cast(int, self['cell'])
132 @property
133 def solution(self) -> str:
134 assert self._puzzle is not None, 'ClueEntry has no puzzle reference'
135 return Grid(self._puzzle.solution, self._puzzle.width, self._puzzle.height).get_string_for_clue(self)
137 @property
138 def fill(self) -> str:
139 assert self._puzzle is not None, 'ClueEntry has no puzzle reference'
140 return Grid(self._puzzle.fill, self._puzzle.width, self._puzzle.height).get_string_for_clue(self)
143def read(filename: str) -> 'Puzzle':
144 """
145 Read a .puz file and return the Puzzle object.
146 raises PuzzleFormatError if there's any problem with the file format.
147 """
148 with open(filename, 'rb') as f:
149 return load(f.read())
152def read_text(filename: str) -> 'Puzzle':
153 """
154 Read an Across Lite .txt text format file and return the Puzzle object.
155 raises PuzzleFormatError if there's any problem with the file format.
156 """
157 with open(filename, 'r', encoding='utf-8', errors='replace') as f:
158 return load_text(f.read())
161def load(data: bytes) -> 'Puzzle':
162 """
163 Read .puz file data and return the Puzzle object.
164 raises PuzzleFormatError if there's any problem with the file format.
165 """
166 puz = Puzzle()
167 puz.load(data)
168 return puz
171def load_text(text: str) -> 'Puzzle':
172 """
173 Parse Across Lite Text format from a string and return a Puzzle object.
174 raises PuzzleFormatError if there's any problem with the format.
175 """
176 return from_text_format(text)
179class PuzzleFormatError(Exception):
180 """
181 Indicates a format error in the .puz file. May be thrown due to
182 invalid headers, invalid checksum validation, or other format issues.
183 """
184 def __init__(self, message: str = '') -> None:
185 self.message = message
188class Puzzle:
189 """Represents a puzzle
190 """
191 def __repr__(self) -> str:
192 return f'Puzzle({self.width}x{self.height}, title={self.title!r}, author={self.author!r})'
194 def __init__(self, version: str | bytes = '1.3') -> None:
195 """Initializes a blank puzzle
196 """
197 self.preamble = b''
198 self.postscript: bytes | str = b''
199 self.title = ''
200 self.author = ''
201 self.copyright = ''
202 self.width = 0
203 self.height = 0
204 self.set_version(version)
205 self.encoding = ENCODING
206 # these are bytes that might be unused
207 self.unk1 = b'\0' * 2
208 self.unk2 = b'\0' * 12
209 self.scrambled_cksum = 0
210 self.fill = ''
211 self.solution = ''
212 self.clues: list[str] = []
213 self.notes = ''
214 self.extensions: dict[bytes, bytes] = {}
215 # the folowing is so that we can round-trip values in order:
216 self._extensions_order: list[bytes] = []
217 self.puzzletype = PuzzleType.Normal
218 self.solution_state = SolutionState.Unlocked
219 self.helpers: dict[str, 'PuzzleHelper'] = {} # add-ons like Rebus and Markup
221 def load(self, data: bytes) -> None:
222 s = PuzzleBuffer(data)
224 # advance to start - files may contain some data before the
225 # start of the puzzle use the ACROSS&DOWN magic string as a waypoint
226 # save the preamble for round-tripping
227 if not s.seek_to(ACROSSDOWN, -2):
228 raise PuzzleFormatError("Data does not appear to represent a "
229 "puzzle. Are you sure you didn't intend "
230 "to use read?")
232 # save whatever we just jumped over so that we can round-trip it on save.
233 self.preamble = bytes(s.data[:s.pos])
235 puzzle_data = s.unpack(HEADER_FORMAT)
236 cksum_gbl = puzzle_data[0]
237 # acrossDown = puzzle_data[1]
238 cksum_hdr = puzzle_data[2]
239 cksum_magic = puzzle_data[3]
240 self.fileversion = puzzle_data[4]
241 # since we don't know the role of these bytes, just round-trip them
242 self.unk1 = puzzle_data[5]
243 self.scrambled_cksum = puzzle_data[6]
244 self.unk2 = puzzle_data[7]
245 self.width = puzzle_data[8]
246 self.height = puzzle_data[9]
247 numclues = puzzle_data[10]
248 self.puzzletype = puzzle_data[11]
249 self.solution_state = puzzle_data[12]
251 self.version = self.fileversion[:3]
252 # Once we have fileversion we can guess the encoding
253 self.encoding = ENCODING if self.version_tuple()[0] < 2 else ENCODING_UTF8
254 s.encoding = self.encoding
256 self.solution = s.read(self.width * self.height).decode(self.encoding)
257 self.fill = s.read(self.width * self.height).decode(self.encoding)
259 self.title = s.read_string()
260 self.author = s.read_string()
261 self.copyright = s.read_string()
263 self.clues = [s.read_string() for i in range(0, numclues)]
264 self.notes = s.read_string()
266 ext_cksum = {}
267 while s.can_unpack(EXTENSION_HEADER_FORMAT):
268 code, length, cksum = s.unpack(EXTENSION_HEADER_FORMAT)
269 ext_cksum[code] = cksum
270 # extension data is represented as a null-terminated string,
271 # but since the data can contain nulls we can't use read_string
272 self.extensions[code] = s.read(length)
273 s.read(1) # extensions have a trailing byte
274 # save the codes in order for round-tripping
275 self._extensions_order.append(code)
277 # sometimes there's some extra garbage at
278 # the end of the file, usually \r\n
279 if s.can_read():
280 self.postscript = s.read_to_end()
282 if cksum_gbl != self.global_cksum():
283 raise PuzzleFormatError('global checksum does not match')
284 if cksum_hdr != self.header_cksum():
285 raise PuzzleFormatError('header checksum does not match')
286 if cksum_magic != self.magic_cksum():
287 raise PuzzleFormatError('magic checksum does not match')
288 for code, cksum_ext in ext_cksum.items():
289 if cksum_ext != data_cksum(self.extensions[code]):
290 raise PuzzleFormatError(
291 'extension %s checksum does not match' % code
292 )
294 def save(self, filename: str) -> None:
295 puzzle_bytes = self.tobytes()
296 with open(filename, 'wb') as f:
297 f.write(puzzle_bytes)
299 def tobytes(self) -> bytes:
300 s = PuzzleBuffer(encoding=self.encoding)
301 # commit any changes from helpers
302 for h in self.helpers.values():
303 if isinstance(h, PuzzleHelper):
304 h.save()
306 # include any preamble text we might have found on read
307 s.write(self.preamble)
309 s.pack(HEADER_FORMAT,
310 self.global_cksum(), ACROSSDOWN,
311 self.header_cksum(), self.magic_cksum(),
312 self.fileversion, self.unk1, self.scrambled_cksum,
313 self.unk2, self.width, self.height,
314 len(self.clues), self.puzzletype, self.solution_state)
316 s.write(self.encode(self.solution))
317 s.write(self.encode(self.fill))
319 s.write_string(self.title)
320 s.write_string(self.author)
321 s.write_string(self.copyright)
323 for clue in self.clues:
324 s.write_string(clue)
326 s.write_string(self.notes)
328 # do a bit of extra work here to ensure extensions round-trip in the
329 # order they were read. this makes verification easier. But allow
330 # for the possibility that extensions were added or removed from
331 # self.extensions
332 ext = dict(self.extensions)
333 for code in self._extensions_order:
334 data = ext.pop(code, None)
335 if data:
336 s.pack(EXTENSION_HEADER_FORMAT, code,
337 len(data), data_cksum(data))
338 s.write(data + b'\0')
340 for code, data in ext.items():
341 s.pack(EXTENSION_HEADER_FORMAT, code, len(data), data_cksum(data))
342 s.write(data + b'\0')
344 # postscript is initialized, read, and stored as bytes. In case it is
345 # overwritten as a string, this try/except converts it back.
346 postscript_bytes = self.postscript.encode(self.encoding, ENCODING_ERRORS) \
347 if isinstance(self.postscript, str) else self.postscript
348 s.write(postscript_bytes)
350 return s.tobytes()
352 def encode(self, s: str) -> bytes:
353 return s.encode(self.encoding, ENCODING_ERRORS)
355 def encode_zstring(self, s: str) -> bytes:
356 return self.encode(s) + b'\0'
358 def version_tuple(self) -> tuple[int, ...]:
359 return tuple(map(int, self.version.split(b'.')))
361 def set_version(self, version: str | bytes) -> None:
362 self.version = version.encode('utf-8') if isinstance(version, str) else bytes(version)
363 self.fileversion = self.version + b'\0'
365 def has_rebus(self) -> bool:
366 if Extensions.Rebus in self.extensions or 'rebus' in self.helpers:
367 return self.rebus().has_rebus()
368 return False
370 def rebus(self) -> 'Rebus':
371 if 'rebus' not in self.helpers:
372 self.helpers['rebus'] = Rebus(self)
373 return cast('Rebus', self.helpers['rebus'])
375 def has_timer(self) -> bool:
376 return Extensions.Timer in self.extensions or 'timer' in self.helpers
378 def remove_timer(self) -> None:
379 self.extensions.pop(Extensions.Timer, None)
380 self.helpers.pop('timer', None)
382 def timer(self) -> 'Timer':
383 if 'timer' not in self.helpers:
384 self.helpers['timer'] = Timer(self)
385 return cast('Timer', self.helpers['timer'])
387 def has_markup(self) -> bool:
388 if Extensions.Markup in self.extensions or 'markup' in self.helpers:
389 return self.markup().has_markup()
390 return False
392 def markup(self) -> 'Markup':
393 if 'markup' not in self.helpers:
394 self.helpers['markup'] = Markup(self)
395 return cast('Markup', self.helpers['markup'])
397 def clue_numbering(self) -> 'ClueNumbering':
398 if 'clues' not in self.helpers:
399 self.helpers['clues'] = ClueNumbering(self)
400 return cast('ClueNumbering', self.helpers['clues'])
402 def blacksquare(self) -> str:
403 return BLACKSQUARE2 if self.puzzletype == PuzzleType.Diagramless else BLACKSQUARE
405 def is_solution_locked(self) -> bool:
406 return bool(self.solution_state == SolutionState.Locked)
408 def unlock_solution(self, key: int) -> bool:
409 if self.is_solution_locked():
410 unscrambled = unscramble_solution(self.solution, self.width, self.height, key,
411 ignore_chars=self.blacksquare())
412 if not self.check_answers(unscrambled):
413 return False
415 # clear the scrambled bit and cksum
416 self.solution = unscrambled
417 self.scrambled_cksum = 0
418 self.solution_state = SolutionState.Unlocked
420 return True
422 def lock_solution(self, key: int) -> None:
423 if not self.is_solution_locked():
424 # set the scrambled bit and cksum
425 self.scrambled_cksum = scrambled_cksum(self.solution, self.width, self.height,
426 ignore_chars=self.blacksquare(), encoding=self.encoding)
427 self.solution_state = SolutionState.Locked
428 scrambled = scramble_solution(self.solution, self.width, self.height, key,
429 ignore_chars=self.blacksquare())
430 self.solution = scrambled
432 def check_answers(self, fill: str, strict: bool = True) -> bool:
433 if self.is_solution_locked():
434 if not strict:
435 raise ValueError('non-strict checking not possible when solution is locked')
436 scrambled = scrambled_cksum(fill, self.width, self.height,
437 ignore_chars=self.blacksquare(), encoding=self.encoding)
438 return scrambled == self.scrambled_cksum
439 elif fill == self.solution:
440 return True
441 elif not strict:
442 return all(a == b for a, b in zip(fill, self.solution) if b != self.blacksquare() and a != BLANKSQUARE)
443 return False
445 def check_rebus_answers(self, strict: bool = True) -> bool:
446 if self.has_rebus():
447 return self.rebus().check_rebus_fill(strict=strict)
448 # if no rebus just return True since there's nothing to check
449 return True
451 def header_cksum(self, cksum: int = 0) -> int:
452 return data_cksum(struct.pack(HEADER_CKSUM_FORMAT,
453 self.width, self.height, len(self.clues),
454 self.puzzletype, self.solution_state), cksum)
456 def text_cksum(self, cksum: int = 0) -> int:
457 # for the checksum to work these fields must be added in order with
458 # null termination, followed by all non-empty clues without null
459 # termination, followed by notes (but only for version >= 1.3)
460 if self.title:
461 cksum = data_cksum(self.encode_zstring(self.title), cksum)
462 if self.author:
463 cksum = data_cksum(self.encode_zstring(self.author), cksum)
464 if self.copyright:
465 cksum = data_cksum(self.encode_zstring(self.copyright), cksum)
467 for clue in self.clues:
468 if clue:
469 cksum = data_cksum(self.encode(clue), cksum)
471 # notes included in global cksum starting v1.3 of format
472 if self.version_tuple() >= (1, 3) and self.notes:
473 cksum = data_cksum(self.encode_zstring(self.notes), cksum)
475 return cksum
477 def global_cksum(self) -> int:
478 cksum = self.header_cksum()
479 cksum = data_cksum(self.encode(self.solution), cksum)
480 cksum = data_cksum(self.encode(self.fill), cksum)
481 cksum = self.text_cksum(cksum)
482 # extensions do not seem to be included in global cksum
483 return cksum
485 def magic_cksum(self) -> int:
486 cksums = [
487 self.header_cksum(),
488 data_cksum(self.encode(self.solution)),
489 data_cksum(self.encode(self.fill)),
490 self.text_cksum()
491 ]
493 cksum_magic = 0
494 for (i, cksum) in enumerate(reversed(cksums)):
495 cksum_magic <<= 8
496 cksum_magic |= (
497 ord(MASKSTRING[len(cksums) - i - 1]) ^ (cksum & 0x00ff)
498 )
499 cksum_magic |= (
500 (ord(MASKSTRING[len(cksums) - i - 1 + 4]) ^ (cksum >> 8)) << 32
501 )
503 return cksum_magic
505 def grid(self) -> 'Grid':
506 return Grid(self.fill, self.width, self.height)
508 def solution_grid(self) -> 'Grid':
509 return Grid(self.solution, self.width, self.height)
512class PuzzleBuffer:
513 """PuzzleBuffer class
514 wraps a bytes object and provides .puz-specific methods for
515 reading and writing data
516 """
517 def __repr__(self) -> str:
518 return f'PuzzleBuffer(pos={self.pos}, length={len(self.data)}, encoding={self.encoding!r})'
520 def __init__(self, data: bytes | None = None, encoding: str = ENCODING):
521 self.data = bytearray(data) if data else bytearray()
522 self.encoding = encoding
523 self.pos = 0
525 def can_read(self, n_bytes: int = 1) -> bool:
526 return self.pos + n_bytes <= len(self.data)
528 def length(self) -> int:
529 return len(self.data)
531 def read(self, n_bytes: int) -> bytes:
532 start = self.pos
533 self.pos += n_bytes
534 return bytes(self.data[start:self.pos])
536 def read_to_end(self) -> bytes:
537 start = self.pos
538 self.pos = self.length()
539 return bytes(self.data[start:self.pos])
541 def read_string(self) -> str:
542 return self.read_until(b'\0')
544 def read_until(self, c: bytes) -> str:
545 start = self.pos
546 self.seek_to(c, 1) # read past
547 return str(self.data[start:self.pos-1], self.encoding)
549 def seek_to(self, s: bytes, offset: int = 0) -> bool:
550 try:
551 self.pos = self.data.index(s, self.pos) + offset
552 return True
553 except ValueError:
554 # s not found, advance to end
555 self.pos = self.length()
556 return False
558 def write(self, s: bytes) -> None:
559 self.data.extend(s)
561 def write_string(self, s: str | None) -> None:
562 s = s or ''
563 self.data.extend(s.encode(self.encoding, ENCODING_ERRORS) + b'\0')
565 def pack(self, struct_format: str, *values: Any) -> None:
566 self.data.extend(struct.pack(struct_format, *values))
568 def can_unpack(self, struct_format: str) -> bool:
569 return self.can_read(struct.calcsize(struct_format))
571 def unpack(self, struct_format: str) -> tuple[Any, ...]:
572 start = self.pos
573 try:
574 res = struct.unpack_from(struct_format, self.data, self.pos)
575 self.pos += struct.calcsize(struct_format)
576 return res
577 except struct.error:
578 message = 'could not unpack values at {} for format {}'.format(
579 start, struct_format
580 )
581 raise PuzzleFormatError(message)
583 def tobytes(self) -> bytes:
584 return bytes(self.data)
587# clue numbering helper
588def get_grid_numbering(grid: str, width: int, height: int) -> tuple[list[ClueEntry], list[ClueEntry]]:
589 # Add numbers to the grid based on positions of black squares
590 def col(index: int) -> int:
591 return index % width
593 def row(index: int) -> int:
594 return int(math.floor(index / width))
596 def len_across(index: int) -> int:
597 c = 0
598 for c in range(0, width - col(index)):
599 if is_blacksquare(grid[index + c]):
600 return c
601 return c + 1
603 def len_down(index: int) -> int:
604 c = 0
605 for c in range(0, height - row(index)):
606 if is_blacksquare(grid[index + c*width]):
607 return c
608 return c + 1
610 across: list[ClueEntry] = []
611 down: list[ClueEntry] = []
612 count = 0 # count is the index into the clues list; 0-based and counts across and down together
613 num = 1 # num is the clue number that gets printed in the grid
614 for i in range(0, len(grid)): # i is the cell index in row-major order
615 if not is_blacksquare(grid[i]):
616 lastc = count
617 is_across = col(i) == 0 or is_blacksquare(grid[i - 1])
618 if is_across and len_across(i) > 1:
619 across.append(ClueEntry({
620 'num': num,
621 'clue': None, # filled in by caller
622 'clue_index': count,
623 'cell': i,
624 'row': row(i),
625 'col': col(i),
626 'len': len_across(i),
627 'dir': 'across',
628 }))
629 count += 1
630 is_down = row(i) == 0 or is_blacksquare(grid[i - width])
631 if is_down and len_down(i) > 1:
632 down.append(ClueEntry({
633 'num': num,
634 'clue': None, # filled in by caller
635 'clue_index': count,
636 'cell': i,
637 'row': row(i),
638 'col': col(i),
639 'len': len_down(i),
640 'dir': 'down'
641 }))
642 count += 1
643 if count > lastc:
644 num += 1
646 return across, down
649@runtime_checkable
650class PuzzleHelper(Protocol):
651 def save(self) -> None:
652 ...
655class DefaultClueNumbering(PuzzleHelper):
656 def __repr__(self) -> str:
657 return f'DefaultClueNumbering(across={len(self.across)}, down={len(self.down)})'
659 def __init__(self, grid: str, clues: list[str], width: int, height: int) -> None:
660 self.grid = grid
661 self.clues = clues
662 self.width = width
663 self.height = height
665 self.across, self.down = get_grid_numbering(grid, width, height)
666 for entry in self.across:
667 entry['clue'] = clues[entry['clue_index']]
668 for entry in self.down:
669 entry['clue'] = clues[entry['clue_index']]
671 def save(self) -> None:
672 pass # clue numbering is derived from the grid and clues, so no need to save anything back to the puzzle
674 # The following methods are no longer in use, but left here in case
675 # anyone was using them externally. They may be removed in a future release.
676 def col(self, index: int) -> int:
677 return index % self.width
679 def row(self, index: int) -> int:
680 return int(math.floor(index / self.width))
682 def len_across(self, index: int) -> int:
683 c = 0
684 for c in range(0, self.width - self.col(index)):
685 if is_blacksquare(self.grid[index + c]):
686 return c
687 return c + 1
689 def len_down(self, index: int) -> int:
690 c = 0
691 for c in range(0, self.height - self.row(index)):
692 if is_blacksquare(self.grid[index + c*self.width]):
693 return c
694 return c + 1
697class ClueNumbering(DefaultClueNumbering):
698 def __repr__(self) -> str:
699 return f'ClueNumbering(across={len(self.across)}, down={len(self.down)})'
701 def __init__(self, puzzle: 'Puzzle') -> None:
702 super().__init__(puzzle.solution, puzzle.clues, puzzle.width, puzzle.height)
703 for entry in self.across:
704 entry._puzzle = puzzle
705 for entry in self.down:
706 entry._puzzle = puzzle
709class Grid:
710 def __repr__(self) -> str:
711 return f'Grid({self.width}x{self.height})'
713 def __init__(self, grid: str, width: int, height: int) -> None:
714 self.grid = grid
715 self.width = width
716 self.height = height
717 assert len(self.grid) == self.width * self.height
719 def get_cell(self, row: int, col: int) -> str:
720 return self.grid[self.get_cell_index(row, col)]
722 def get_cell_index(self, row: int, col: int) -> int:
723 return row * self.width + col
725 def get_range(self, row: int, col: int, length: int, dir: str = 'across') -> list[str]:
726 if dir == 'across':
727 return self.get_range_across(row, col, length)
728 elif dir == 'down':
729 return self.get_range_down(row, col, length)
730 else:
731 assert False, "dir not one of 'across' or 'down'"
733 def get_range_across(self, row: int, col: int, length: int) -> list[str]:
734 return [self.grid[self.get_cell_index(row, col + i)] for i in range(length)]
736 def get_range_down(self, row: int, col: int, length: int) -> list[str]:
737 return [self.grid[self.get_cell_index(row + i, col)] for i in range(length)]
739 def get_range_for_clue(self, clue: ClueEntry) -> list[str]:
740 return self.get_range(clue['row'], clue['col'], clue['len'], clue['dir'])
742 def __iter__(self) -> Iterator[list[str]]:
743 return self.rows()
745 def rows(self) -> Iterator[list[str]]:
746 for row in range(self.height):
747 yield self.get_row(row)
749 def cols(self) -> Iterator[list[str]]:
750 for col in range(self.width):
751 yield self.get_column(col)
753 def get_row(self, row: int) -> list[str]:
754 return self.get_range_across(row, 0, self.width)
756 def get_column(self, col: int) -> list[str]:
757 return self.get_range_down(0, col, self.height)
759 def get_string(self, row: int, col: int, length: int, dir: str = 'across') -> str:
760 return ''.join(self.get_range(row, col, length, dir))
762 def get_string_across(self, row: int, col: int, length: int) -> str:
763 return ''.join(self.get_range_across(row, col, length))
765 def get_string_down(self, row: int, col: int, length: int) -> str:
766 return ''.join(self.get_range_down(row, col, length))
768 def get_string_for_clue(self, clue: ClueEntry) -> str:
769 return ''.join(self.get_range_for_clue(clue))
772class Rebus(PuzzleHelper):
773 def __repr__(self) -> str:
774 return f'Rebus(squares={len(self.get_rebus_squares())}, solutions={len(self.solutions)})'
776 def __init__(self, puzzle: Puzzle) -> None:
777 self.puzzle = puzzle
779 N = self.puzzle.width * self.puzzle.height
781 self._dirty = False # track whether there are unsaved changes to the rebus helper that need to be committed
782 # to the puzzle before saving
784 # the rebus table has the same number of entries as the grid and maps 1:1.
785 # cell values v > 0 represent rebus squares, where v corresponds to a solution key k=v-1 in the solutions map.
786 # 0 values indicate non-rebus squares.
787 self.table: list[int] = [0] * N
789 # the solutions table is a map of rebus solution key k (an int) to the corresponding solution string,
790 # eg 0:HEART;1:DIAMOND;17:CLUB;23:SPADE; k values need not be consecutive or in any order, but are
791 # typically numbered sequentially starting from 0. When k values appear in the rebus table, they are
792 # 1-indexed (ie v=k+1).
793 self.solutions: dict[int, str] = {}
795 # the fill table has the same number of entries as the grid and maps 1:1. Each cell value is a string
796 # representing the user's current fill for that cell, eg "STAR". Non-filled cells and non-rebus cells
797 # are empty strings.
798 # When a cell is a rebus entry, the corresponding cell in puzzle.fill will often be set to the first
799 # letter of the rebus, eg 'S' for "STAR".
800 self.fill: list[str] = []
802 # parse rebus data
803 if Extensions.Rebus in self.puzzle.extensions:
804 rebus_data = self.puzzle.extensions[Extensions.Rebus]
805 self.table = parse_bytes(rebus_data)
807 if Extensions.RebusSolutions in self.puzzle.extensions:
808 raw_solution_data = self.puzzle.extensions[Extensions.RebusSolutions]
809 solutions_str = raw_solution_data.decode(puzzle.encoding)
810 self.solutions = {
811 int(item[0]): item[1]
812 for item in parse_dict(solutions_str).items()
813 }
815 if Extensions.RebusFill in self.puzzle.extensions:
816 s = PuzzleBuffer(self.puzzle.extensions[Extensions.RebusFill], encoding=puzzle.encoding)
817 fill = []
818 while s.can_read():
819 fill.append(s.read_string())
820 self.fill = (fill + [''] * N)[:N]
821 else:
822 self.fill = [''] * N
824 def has_rebus(self) -> bool:
825 return Extensions.Rebus in self.puzzle.extensions or any(self.table)
827 def is_rebus_square(self, index: int) -> bool:
828 return self.table[index] > 0
830 def get_rebus_squares(self) -> list[int]:
831 return [i for i, k in enumerate(self.table) if k > 0]
833 def add_rebus_squares(self, squares: int | list[int], solution: str) -> None:
834 if isinstance(squares, int):
835 squares = [squares]
837 k = self.add_rebus_solution(solution)
838 for i in squares:
839 self.table[i] = k + 1 # rebus value is 1-indexed because 0 is reserved for non-rebus squares
841 def add_rebus_solution(self, solution: str) -> int:
842 k = next((i for i, s in self.solutions.items() if s == solution), -1)
843 if k < 0:
844 k = (max(self.solutions) + 1) if self.solutions else 0
845 self.solutions[k] = solution
846 return k
848 def check_rebus_fill(self, indexes: int | list[int] | None = None, strict: bool = True) -> bool:
849 if isinstance(indexes, int):
850 indexes = [indexes]
851 if indexes is None:
852 indexes = self.get_rebus_squares()
853 for index in indexes:
854 if not self.is_rebus_square(index):
855 raise ValueError(f'index {index} is not a rebus square')
856 solution = self.get_rebus_solution(index)
857 fill = self.get_rebus_fill(index)
858 if solution != fill and (fill or strict):
859 return False
860 return True
862 def get_rebus_solution(self, index: int) -> str | None:
863 if self.is_rebus_square(index):
864 # rebus value is 1-indexed because 0 is reserved for non-rebus squares
865 # so we need to subtract 1 to get the correct solution from the map
866 return self.solutions[self.table[index] - 1]
867 return None
869 def set_rebus_solution(self, index: int, solution: str) -> None:
870 if self.is_rebus_square(index):
871 solution_index = self.add_rebus_solution(solution)
872 self.table[index] = solution_index + 1
874 def get_rebus_fill(self, index: int) -> str | None:
875 if self.is_rebus_square(index):
876 return self.fill[index] or None
877 return None
879 def remove_rebus_squares(self, squares: int | list[int]) -> None:
880 if isinstance(squares, int):
881 squares = [squares]
883 for i in squares:
884 self.table[i] = 0
885 self._dirty = True
887 def remove_rebus_solution(self, solution: str | int) -> None:
888 if isinstance(solution, str):
889 k = next((i for i, s in self.solutions.items() if s == solution), -1)
890 else:
891 k = solution
892 if k >= 0:
893 del self.solutions[k]
894 for i, v in enumerate(self.table):
895 if v == k + 1: # rebus value is 1-indexed because 0 is reserved for non-rebus squares
896 self.table[i] = 0
897 self._dirty = True
899 def set_rebus_fill(self, index: int, value: str) -> None:
900 if self.is_rebus_square(index):
901 self.fill[index] = value
903 def save(self) -> None:
904 if self.has_rebus():
905 self.puzzle.extensions[Extensions.Rebus] = pack_bytes(self.table)
906 if self.solutions:
907 self.puzzle.extensions[Extensions.RebusSolutions] = self.puzzle.encode(dict_to_string(self.solutions))
908 else:
909 self.puzzle.extensions.pop(Extensions.RebusSolutions, None)
910 if any(self.fill) or Extensions.RebusFill in self.puzzle.extensions:
911 s = PuzzleBuffer(encoding=self.puzzle.encoding)
912 for cell_fill in self.fill:
913 s.write_string(cell_fill)
914 self.puzzle.extensions[Extensions.RebusFill] = s.tobytes()
915 else:
916 self.puzzle.extensions.pop(Extensions.RebusFill, None)
917 elif self._dirty:
918 self.puzzle.extensions.pop(Extensions.Rebus, None)
919 self.puzzle.extensions.pop(Extensions.RebusSolutions, None)
920 self.puzzle.extensions.pop(Extensions.RebusFill, None)
923class Markup(PuzzleHelper):
924 def __repr__(self) -> str:
925 return f'Markup(marked_squares={len(self.get_markup_squares())})'
927 def __init__(self, puzzle: Puzzle) -> None:
928 self.puzzle = puzzle
929 self._dirty = False # track whether there are unsaved changes to the markup helper that need to be committed
930 markup_data = self.puzzle.extensions.get(Extensions.Markup, b'')
931 self.markup = parse_bytes(markup_data) or [0] * (self.puzzle.width * self.puzzle.height)
933 def clear_markup_squares(self, indices: list[int] | int, markup_types: list[GridMarkup] | GridMarkup | None = None) -> None: # noqa: E501
934 if isinstance(indices, int):
935 indices = [indices]
936 markup_mask = sum(markup_types) if isinstance(markup_types, list) else markup_types if markup_types else 0xff
937 for i in indices:
938 self.markup[i] &= ~markup_mask
939 self._dirty = True
941 def has_markup(self, markup_types: list[GridMarkup] | GridMarkup | None = None) -> bool:
942 markup_mask = sum(markup_types) if isinstance(markup_types, list) else markup_types if markup_types else 0xff
943 return any(bool(b & markup_mask) for b in self.markup)
945 def get_markup_squares(self, markup_types: list[GridMarkup] | GridMarkup | None = None) -> list[int]:
946 markup_mask = sum(markup_types) if isinstance(markup_types, list) else markup_types if markup_types else 0xff
947 return [i for i, b in enumerate(self.markup) if b & markup_mask]
949 def is_markup_square(self, index: int, markup_types: list[GridMarkup] | GridMarkup | None = None) -> bool:
950 markup_mask = sum(markup_types) if isinstance(markup_types, list) else markup_types if markup_types else 0xff
951 return bool(self.markup[index] & markup_mask)
953 def set_markup_squares(self, indices: list[int] | int, markup_type: list[GridMarkup] | GridMarkup | None = None) -> None:
954 if isinstance(indices, int):
955 indices = [indices]
956 markup_mask = sum(markup_type) if isinstance(markup_type, list) else markup_type if markup_type else 0xff
957 for i in indices:
958 self.markup[i] |= markup_mask
960 def save(self) -> None:
961 if self.has_markup():
962 self.puzzle.extensions[Extensions.Markup] = pack_bytes(self.markup)
963 elif self._dirty:
964 self.puzzle.extensions.pop(Extensions.Markup, None)
967class TimerStatus(IntEnum):
968 Running = 0
969 Stopped = 1
972class Timer(PuzzleHelper):
973 def __repr__(self) -> str:
974 return f'Timer(elapsed_seconds={self.elapsed_seconds}, status={self.status.name})'
976 def __init__(self, puzzle: Puzzle) -> None:
977 self.puzzle = puzzle
978 timer_data = self.puzzle.extensions.get(Extensions.Timer, b'0,1')
979 elapsed_str, status_str = timer_data.decode().split(',')
981 self.elapsed_seconds = int(elapsed_str)
982 self.status = TimerStatus(int(status_str))
984 def is_running(self) -> bool:
985 return self.status == TimerStatus.Running
987 def is_stopped(self) -> bool:
988 return self.status == TimerStatus.Stopped
990 def save(self) -> None:
991 self.puzzle.extensions[Extensions.Timer] = f'{self.elapsed_seconds},{self.status}'.encode()
994# helper functions for cksums and scrambling
995def data_cksum(data: bytes, cksum: int = 0) -> int:
996 for b in data:
997 # right-shift one with wrap-around
998 lowbit = (cksum & 0x0001)
999 cksum = (cksum >> 1)
1000 if lowbit:
1001 cksum = (cksum | 0x8000)
1003 # then add in the data and clear any carried bit past 16
1004 cksum = (cksum + b) & 0xffff
1006 return cksum
1009def replace_chars(s: str, chars: str, replacement: str = '') -> str:
1010 for ch in chars:
1011 s = s.replace(ch, replacement)
1012 return s
1015def scramble_solution(solution: str, width: int, height: int, key: int, ignore_chars: str = BLACKSQUARE) -> str:
1016 sq = square(solution, width, height)
1017 data = restore(sq, scramble_string(replace_chars(sq, ignore_chars), key))
1018 return square(data, height, width)
1021def scramble_string(s: str, key: int) -> str:
1022 """
1023 s is the puzzle's solution in column-major order, omitting black squares:
1024 i.e. if the puzzle is:
1025 C A T
1026 # # A
1027 # # R
1028 solution is CATAR
1031 Key is a 4-digit number in the range 1000 <= key <= 9999
1033 """
1034 digits = key_digits(key)
1035 for k in digits: # foreach digit in the key
1036 s = shift(s, digits) # for each char by each digit in the key in sequence
1037 s = s[k:] + s[:k] # cut the sequence around the key digit
1038 s = shuffle(s) # do a 1:1 shuffle of the 'deck'
1040 return s
1043def unscramble_solution(scrambled: str, width: int, height: int, key: int, ignore_chars: str = BLACKSQUARE) -> str:
1044 # width and height are reversed here
1045 sq = square(scrambled, width, height)
1046 data = restore(sq, unscramble_string(replace_chars(sq, ignore_chars), key))
1047 return square(data, height, width)
1050def unscramble_string(s: str, key: int) -> str:
1051 digits = key_digits(key)
1052 l = len(s) # noqa: E741
1053 for k in digits[::-1]:
1054 s = unshuffle(s)
1055 s = s[l-k:] + s[:l-k]
1056 s = unshift(s, digits)
1058 return s
1061def scrambled_cksum(scrambled: str, width: int, height: int, ignore_chars: str = BLACKSQUARE, encoding: str = ENCODING) -> int:
1062 data = replace_chars(square(scrambled, width, height), ignore_chars)
1063 return data_cksum(data.encode(encoding, ENCODING_ERRORS))
1066def key_digits(key: int) -> list[int]:
1067 return [int(c) for c in str(key).zfill(4)]
1070def square(data: str, w: int, h: int) -> str:
1071 aa = [data[i:i+w] for i in range(0, len(data), w)]
1072 return ''.join(
1073 [''.join([aa[r][c] for r in range(0, h)]) for c in range(0, w)]
1074 )
1077def shift(s: str, key: list[int]) -> str:
1078 atoz = string.ascii_uppercase
1079 return ''.join(
1080 atoz[(atoz.index(c) + key[i % len(key)]) % len(atoz)]
1081 for i, c in enumerate(s)
1082 )
1085def unshift(s: str, key: list[int]) -> str:
1086 return shift(s, [-k for k in key])
1089def shuffle(s: str) -> str:
1090 mid = int(math.floor(len(s) / 2))
1091 items = functools.reduce(operator.add, zip(s[mid:], s[:mid]))
1092 return ''.join(items) + (s[-1] if len(s) % 2 else '')
1095def unshuffle(s: str) -> str:
1096 return s[1::2] + s[::2]
1099def restore(s: str, t: Iterable[str]) -> str:
1100 """
1101 s is the source string, it can contain '.'
1102 t is the target, it's smaller than s by the number of '.'s in s
1104 Each char in s is replaced by the corresponding
1105 char in t, jumping over '.'s in s.
1107 >>> restore('ABC.DEF', 'XYZABC')
1108 'XYZ.ABC'
1109 """
1110 t = (c for c in t)
1111 return ''.join(next(t) if not is_blacksquare(c) else c for c in s)
1114def is_blacksquare(c: str | int) -> bool:
1115 if isinstance(c, int):
1116 c = chr(c)
1117 return c in [BLACKSQUARE, BLACKSQUARE2]
1120#
1121# functions for parsing / serializing primitives
1122#
1125def parse_bytes(s: bytes) -> list[int]:
1126 return list(struct.unpack('B' * len(s), s))
1129def pack_bytes(a: list[int]) -> bytes:
1130 return struct.pack('B' * len(a), *a)
1133# dict string format is k1:v1;k2:v2;...;kn:vn;
1134# (for whatever reason there's a trailing ';')
1135def parse_dict(s: str) -> dict[str, str]:
1136 return dict(p.split(':', 1) for p in s.split(';') if ':' in p)
1139def dict_to_string(d: dict[int, str]) -> str:
1140 # Across Lite format right-aligns keys in a 2-char field: ' 0:VAL;', '13:VAL;'
1141 return ';'.join(f'{k:>2}:{v}' for k, v in d.items()) + ';'
1144def from_text_format(s: str) -> Puzzle:
1145 d = text_file_as_dict(s)
1147 if 'ACROSS PUZZLE' in d:
1148 # file_version = 'v1'
1149 pass
1150 elif 'ACROSS PUZZLE v2' in d:
1151 # file_version = 'v2'
1152 pass
1153 else:
1154 raise PuzzleFormatError('Not a valid Across Lite text puzzle')
1156 p = Puzzle()
1157 across_clues: list[str] = []
1158 down_clues: list[str] = []
1159 if 'TITLE' in d:
1160 p.title = d['TITLE']
1161 if 'AUTHOR' in d:
1162 p.author = d['AUTHOR']
1163 if 'COPYRIGHT' in d:
1164 p.copyright = d['COPYRIGHT']
1165 if 'SIZE' in d:
1166 w, h = d['SIZE'].split('x')
1167 p.width = int(w)
1168 p.height = int(h)
1169 # parse REBUS section before GRID — markers in the grid reference it
1170 # format: marker:EXTENDED_SOLUTION:SHORT_CHAR (one per line)
1171 # optional flag line: MARK; (circles all lowercase-letter cells in the grid)
1172 rebus_map: dict[str, tuple[str, str]] = {} # marker char -> (extended_solution, short_char)
1173 mark_flag = False
1174 if 'REBUS' in d:
1175 for line in d['REBUS'].splitlines():
1176 line = line.strip()
1177 if not line:
1178 continue
1179 if ':' not in line:
1180 # flag line, e.g. "MARK;"
1181 if 'MARK' in [f.strip().upper() for f in line.split(';') if f.strip()]:
1182 mark_flag = True
1183 else:
1184 parts = line.split(':')
1185 marker = parts[0]
1186 extended = parts[1] if len(parts) > 1 else ''
1187 short = parts[2] if len(parts) > 2 else (extended[0] if extended else marker)
1188 if marker:
1189 rebus_map[marker] = (extended, short)
1191 rebus_cells: dict[int, str] = {} # cell index -> extended solution
1192 mark_cells: list[int] = [] # cell indices to be circled (MARK flag)
1193 if 'GRID' in d:
1194 solution_lines = d['GRID'].splitlines()
1195 raw = ''.join(line.strip() for line in solution_lines if line.strip())
1196 if rebus_map or mark_flag:
1197 solution_chars: list[str] = []
1198 for i, c in enumerate(raw):
1199 if c in rebus_map:
1200 extended, short = rebus_map[c]
1201 solution_chars.append(short)
1202 rebus_cells[i] = extended
1203 elif mark_flag and c.islower() and not is_blacksquare(c):
1204 solution_chars.append(c.upper())
1205 mark_cells.append(i)
1206 else:
1207 solution_chars.append(c)
1208 p.solution = ''.join(solution_chars)
1209 else:
1210 p.solution = raw
1211 if 'ACROSS' in d:
1212 across_clues.extend(line.strip() for line in d['ACROSS'].splitlines() if line.strip())
1213 if 'DOWN' in d:
1214 down_clues.extend(line.strip() for line in d['DOWN'].splitlines() if line.strip())
1215 if 'NOTEPAD' in d:
1216 p.notes = d['NOTEPAD']
1218 if p.solution:
1219 if BLACKSQUARE2 in p.solution:
1220 p.puzzletype = PuzzleType.Diagramless
1221 p.fill = ''.join(c if is_blacksquare(c) else BLANKSQUARE for c in p.solution)
1222 across, down = get_grid_numbering(p.fill, p.width, p.height)
1223 # we have to match puzfile's expected clue ordering or we won't be able to
1224 # write the puzzle out as a valid .puz file
1225 p.clues = [''] * (len(across) + len(down))
1226 for i in range(len(across)):
1227 clue = across_clues[i] if i < len(across_clues) else ''
1228 across[i]['clue'] = clue
1229 p.clues[across[i]['clue_index']] = clue
1230 for i in range(len(down)):
1231 clue = down_clues[i] if i < len(down_clues) else ''
1232 down[i]['clue'] = clue
1233 p.clues[down[i]['clue_index']] = clue
1235 if rebus_cells:
1236 for i, extended in rebus_cells.items():
1237 p.rebus().add_rebus_squares(i, extended)
1238 if mark_cells:
1239 p.markup().set_markup_squares(mark_cells, GridMarkup.Circled)
1241 return p
1244def text_file_as_dict(s: str) -> dict[str, str]:
1245 d: dict[str, str] = {}
1246 k = ''
1247 v: list[str] = []
1248 for line in s.splitlines():
1249 line = line.strip()
1250 if line.startswith('<') and line.endswith('>'):
1251 if k:
1252 d[k] = '\n'.join(v)
1253 k = line[1:-1]
1254 v = []
1255 else:
1256 v.append(line)
1258 if k:
1259 d[k] = '\n'.join(v)
1260 return d
1263def to_text_format(p: Puzzle, text_version: str = 'v1') -> str:
1264 TAB = '\t' # most lines begin indented with whitespace
1265 lines = []
1267 has_rebus = p.has_rebus()
1268 # text only supports circled cells using the MARK flag
1269 has_mark = p.has_markup() and p.markup().has_markup([GridMarkup.Circled])
1271 # REBUS section and MARK flag require v2 format; auto-upgrade if needed
1272 if (has_rebus or has_mark) and text_version == 'v1':
1273 text_version = 'v2'
1275 if text_version == 'v1':
1276 lines.append('<ACROSS PUZZLE>')
1277 elif text_version:
1278 lines.append(f'<ACROSS PUZZLE {text_version}>')
1279 else:
1280 raise ValueError("invalid text_version")
1282 lines.append('<TITLE>')
1283 lines.append(TAB + p.title)
1284 lines.append('<AUTHOR>')
1285 lines.append(TAB + p.author)
1286 lines.append('<COPYRIGHT>')
1287 lines.append(TAB + p.copyright)
1288 lines.append('<SIZE>')
1289 lines.append(TAB + f'{p.width}x{p.height}')
1291 # assign a single-char marker to each unique rebus solution
1292 # digits 1-9 then lowercase a-z, matching the v2 spec convention
1293 solution_to_marker: dict[str, str] = {}
1294 rebus_squares: set[int] = set()
1295 solution_to_short_char: dict[str, str] = {}
1296 if has_rebus:
1297 _marker_chars = [str(i) for i in range(1, 10)] + list('abcdefghijklmnopqrstuvwxyz')
1298 for idx, solution in enumerate(p.rebus().solutions.values()):
1299 if idx < len(_marker_chars):
1300 solution_to_marker[solution] = _marker_chars[idx]
1301 rebus_squares = set(p.rebus().get_rebus_squares())
1302 for i in rebus_squares:
1303 sol = p.rebus().get_rebus_solution(i)
1304 if sol and sol not in solution_to_short_char:
1305 solution_to_short_char[sol] = p.solution[i]
1307 circled_squares = set(p.markup().get_markup_squares(GridMarkup.Circled)) if has_mark else set()
1309 lines.append('<GRID>')
1310 for row_idx in range(p.height):
1311 row = ''
1312 for col_idx in range(p.width):
1313 i = row_idx * p.width + col_idx
1314 if i in rebus_squares:
1315 sol = p.rebus().get_rebus_solution(i)
1316 row += solution_to_marker.get(sol or '', p.solution[i])
1317 elif i in circled_squares:
1318 row += p.solution[i].lower()
1319 else:
1320 row += p.solution[i]
1321 lines.append(TAB + row)
1323 if has_rebus or has_mark:
1324 lines.append('<REBUS>')
1325 if has_mark:
1326 lines.append(TAB + 'MARK;')
1327 if has_rebus:
1328 for solution, marker in solution_to_marker.items():
1329 short_char = solution_to_short_char.get(solution, solution[0])
1330 lines.append(TAB + f'{marker}:{solution}:{short_char}')
1332 # get clues in across/down order
1333 numbering = p.clue_numbering()
1334 lines.append('<ACROSS>')
1335 for clue in numbering.across:
1336 lines.append(TAB + (clue['clue'] or ''))
1337 lines.append('<DOWN>')
1338 for clue in numbering.down:
1339 lines.append(TAB + (clue['clue'] or ''))
1341 lines.append('<NOTEPAD>')
1342 lines.append(p.notes) # no tab here, idk why
1344 return '\n'.join(lines)