Coverage for puz.py: 100%
846 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-01 07:41 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-01 07:41 +0000
1from __future__ import annotations # for Python 3.9 and earlier
3import importlib.metadata
4import string
5import struct
6from collections.abc import Iterable, Iterator
7from enum import Enum, IntEnum
8from typing import Any, Protocol, cast, runtime_checkable
10__version__ = importlib.metadata.version('puzpy')
12HEADER_FORMAT = '''<
13 H 11s xH
14 Q 4s 2sH
15 12s BBH
16 H H '''
18HEADER_CKSUM_FORMAT = '<BBH H H '
20EXTENSION_HEADER_FORMAT = '< 4s H H '
22MASKSTRING = 'ICHEATED'
24ENCODING = 'ISO-8859-1'
25ENCODING_UTF8 = 'UTF-8'
26ENCODING_ERRORS = 'strict' # raises an exception for bad chars; change to 'replace' for laxer handling
28ACROSSDOWN = b'ACROSS&DOWN'
30BLACKSQUARE = '.'
31BLACKSQUARE2 = ':' # used for diagramless puzzles
32BLANKSQUARE = '-'
35class PuzzleType(IntEnum):
36 Normal = 0x0001
37 Diagramless = 0x0401
40# the following diverges from the documentation
41# but works for the files I've tested
42class SolutionState(IntEnum):
43 # solution is available in plaintext
44 Unlocked = 0x0000
45 # solution is not present in the file
46 NotProvided = 0x0002
47 # solution is locked (scrambled) with a key
48 Locked = 0x0004
51class GridMarkup(IntEnum):
52 # ordinary grid cell
53 Default = 0x00
54 # marked incorrect at some point
55 PreviouslyIncorrect = 0x10
56 # currently showing incorrect
57 Incorrect = 0x20
58 # user got a hint
59 Revealed = 0x40
60 # circled
61 Circled = 0x80
64# refer to Extensions as Extensions.Rebus, Extensions.Markup
65class Extensions(bytes, Enum):
66 # grid of rebus indices: 0 for non-rebus;
67 # i+1 for key i into RebusSolutions map
68 # should be same size as the grid
69 Rebus = b'GRBS',
71 # map of rebus solution entries eg 0:HEART;1:DIAMOND;17:CLUB;23:SPADE;
72 RebusSolutions = b'RTBL',
74 # user's rebus entries; binary grid format: one null-terminated string per cell (in grid order).
75 # empty cells are a single null byte; filled rebus cells are the fill string followed by a null byte.
76 RebusFill = b'RUSR',
78 # timer state: 'a,b' where a is the number of seconds elapsed and
79 # b is a boolean (0,1) for whether the timer is running
80 Timer = b'LTIM',
82 # grid cell markup: previously incorrect: 0x10;
83 # currently incorrect: 0x20,
84 # hinted: 0x40,
85 # circled: 0x80
86 Markup = b'GEXT'
89class ClueEntry(dict[str, Any]):
90 """A clue entry in a crossword puzzle.
92 Supports legacy dict-style access (e.g. entry['num']) for backwards
93 compatibility, as well as named property access (e.g. entry.number).
94 """
96 _puzzle: Puzzle | None
98 def __init__(self, data: dict[str, Any], puzzle: Puzzle | None = None) -> None:
99 super().__init__(data)
100 self._puzzle = puzzle
102 @property
103 def number(self) -> int:
104 return cast(int, self['num'])
106 @property
107 def text(self) -> str:
108 return cast(str, self['clue'])
110 @property
111 def length(self) -> int:
112 return cast(int, self['len'])
114 @property
115 def direction(self) -> str:
116 return cast(str, self['dir'])
118 @property
119 def row(self) -> int:
120 return cast(int, self['row'])
122 @property
123 def col(self) -> int:
124 return cast(int, self['col'])
126 @property
127 def cell(self) -> int:
128 return cast(int, self['cell'])
130 @property
131 def solution(self) -> str:
132 assert self._puzzle is not None, 'ClueEntry has no puzzle reference'
133 return Grid(self._puzzle.solution, self._puzzle.width, self._puzzle.height).get_string_for_clue(self)
135 @property
136 def fill(self) -> str:
137 assert self._puzzle is not None, 'ClueEntry has no puzzle reference'
138 return Grid(self._puzzle.fill, self._puzzle.width, self._puzzle.height).get_string_for_clue(self)
141def read(filename: str) -> Puzzle:
142 """
143 Read a .puz file and return the Puzzle object.
144 raises PuzzleFormatError if there's any problem with the file format.
145 """
146 with open(filename, 'rb') as f:
147 return load(f.read())
150def read_text(filename: str) -> Puzzle:
151 """
152 Read an Across Lite .txt text format file and return the Puzzle object.
153 raises PuzzleFormatError if there's any problem with the file format.
154 """
155 with open(filename, encoding='utf-8', errors='replace') as f:
156 return load_text(f.read())
159def load(data: bytes) -> Puzzle:
160 """
161 Read .puz file data and return the Puzzle object.
162 raises PuzzleFormatError if there's any problem with the file format.
163 """
164 puz = Puzzle()
165 puz.load(data)
166 return puz
169def load_text(text: str) -> Puzzle:
170 """
171 Parse Across Lite Text format from a string and return a Puzzle object.
172 raises PuzzleFormatError if there's any problem with the format.
173 """
174 return from_text_format(text)
177class PuzzleFormatError(Exception):
178 """
179 Indicates a format error in the .puz file. May be thrown due to
180 invalid headers, invalid checksum validation, or other format issues.
181 """
182 def __init__(self, message: str = '') -> None:
183 self.message = message
186class Puzzle:
187 """Represents a puzzle
188 """
189 def __repr__(self) -> str:
190 return f'Puzzle({self.width}x{self.height}, title={self.title!r}, author={self.author!r})'
192 def __init__(self, version: str | bytes = '1.3') -> None:
193 """Initializes a blank puzzle
194 """
195 self.preamble = b''
196 self.postscript: bytes | str = b''
197 self.title = ''
198 self.author = ''
199 self.copyright = ''
200 self.width = 0
201 self.height = 0
202 self.set_version(version)
203 self.encoding = ENCODING
204 # these are bytes that might be unused
205 self.unk1 = b'\0' * 2
206 self.unk2 = b'\0' * 12
207 self.scrambled_cksum = 0
208 self.fill = ''
209 self.solution = ''
210 self.clues: list[str] = []
211 self.notes = ''
212 self.extensions: dict[bytes, bytes] = {}
213 # the folowing is so that we can round-trip values in order:
214 self._extensions_order: list[bytes] = []
215 self.puzzletype = PuzzleType.Normal
216 self.solution_state = SolutionState.Unlocked
217 self.helpers: dict[str, PuzzleHelper] = {} # add-ons like Rebus and Markup
219 def load(self, data: bytes) -> None:
220 s = PuzzleBuffer(data)
222 # advance to start - files may contain some data before the
223 # start of the puzzle use the ACROSS&DOWN magic string as a waypoint
224 # save the preamble for round-tripping
225 if not s.seek_to(ACROSSDOWN, -2):
226 raise PuzzleFormatError("Data does not appear to represent a "
227 "puzzle. Are you sure you didn't intend "
228 "to use read?")
230 # save whatever we just jumped over so that we can round-trip it on save.
231 self.preamble = bytes(s.data[:s.pos])
233 puzzle_data = s.unpack(HEADER_FORMAT)
234 cksum_gbl = puzzle_data[0]
235 # acrossDown = puzzle_data[1]
236 cksum_hdr = puzzle_data[2]
237 cksum_magic = puzzle_data[3]
238 self.fileversion = puzzle_data[4]
239 # since we don't know the role of these bytes, just round-trip them
240 self.unk1 = puzzle_data[5]
241 self.scrambled_cksum = puzzle_data[6]
242 self.unk2 = puzzle_data[7]
243 self.width = puzzle_data[8]
244 self.height = puzzle_data[9]
245 numclues = puzzle_data[10]
246 self.puzzletype = puzzle_data[11]
247 self.solution_state = puzzle_data[12]
249 self.version = self.fileversion[:3]
250 # Once we have fileversion we can guess the encoding
251 self.encoding = ENCODING if self.version_tuple()[0] < 2 else ENCODING_UTF8
252 s.encoding = self.encoding
254 self.solution = s.read(self.width * self.height).decode(self.encoding)
255 self.fill = s.read(self.width * self.height).decode(self.encoding)
257 self.title = s.read_string()
258 self.author = s.read_string()
259 self.copyright = s.read_string()
261 self.clues = [s.read_string() for _ in range(numclues)]
262 self.notes = s.read_string()
264 ext_cksum: dict[bytes, int] = {}
265 while s.can_unpack(EXTENSION_HEADER_FORMAT):
266 code, length, cksum = s.unpack(EXTENSION_HEADER_FORMAT)
267 ext_cksum[code] = cksum
268 # extension data is represented as a null-terminated string,
269 # but since the data can contain nulls we can't use read_string
270 self.extensions[code] = s.read(length)
271 s.read(1) # extensions have a trailing byte
272 # save the codes in order for round-tripping
273 self._extensions_order.append(code)
275 # sometimes there's some extra garbage at
276 # the end of the file, usually \r\n
277 if s.can_read():
278 self.postscript = s.read_to_end()
280 if cksum_gbl != self.global_cksum():
281 raise PuzzleFormatError('global checksum does not match')
282 if cksum_hdr != self.header_cksum():
283 raise PuzzleFormatError('header checksum does not match')
284 if cksum_magic != self.magic_cksum():
285 raise PuzzleFormatError('magic checksum does not match')
286 for code, cksum_ext in ext_cksum.items():
287 if cksum_ext != data_cksum(self.extensions[code]):
288 raise PuzzleFormatError(
289 f'extension {code} checksum does not match'
290 )
292 def save(self, filename: str) -> None:
293 puzzle_bytes = self.tobytes()
294 with open(filename, 'wb') as f:
295 f.write(puzzle_bytes)
297 def tobytes(self) -> bytes:
298 s = PuzzleBuffer(encoding=self.encoding)
299 # commit any changes from helpers
300 for h in self.helpers.values():
301 if isinstance(h, PuzzleHelper):
302 h.save()
304 # include any preamble text we might have found on read
305 s.write(self.preamble)
307 s.pack(HEADER_FORMAT,
308 self.global_cksum(), ACROSSDOWN,
309 self.header_cksum(), self.magic_cksum(),
310 self.fileversion, self.unk1, self.scrambled_cksum,
311 self.unk2, self.width, self.height,
312 len(self.clues), self.puzzletype, self.solution_state)
314 s.write(self.encode(self.solution))
315 s.write(self.encode(self.fill))
317 s.write_string(self.title)
318 s.write_string(self.author)
319 s.write_string(self.copyright)
321 for clue in self.clues:
322 s.write_string(clue)
324 s.write_string(self.notes)
326 # do a bit of extra work here to ensure extensions round-trip in the
327 # order they were read. this makes verification easier. But allow
328 # for the possibility that extensions were added or removed from
329 # self.extensions
330 ext = dict(self.extensions)
331 for code in self._extensions_order:
332 data = ext.pop(code, None)
333 if data:
334 s.pack(EXTENSION_HEADER_FORMAT, code,
335 len(data), data_cksum(data))
336 s.write(data + b'\0')
338 for code, data in ext.items():
339 s.pack(EXTENSION_HEADER_FORMAT, code, len(data), data_cksum(data))
340 s.write(data + b'\0')
342 # postscript is initialized, read, and stored as bytes. In case it is
343 # overwritten as a string, this try/except converts it back.
344 postscript_bytes = self.postscript.encode(self.encoding, ENCODING_ERRORS) \
345 if isinstance(self.postscript, str) else self.postscript
346 s.write(postscript_bytes)
348 return s.tobytes()
350 def encode(self, s: str) -> bytes:
351 return s.encode(self.encoding, ENCODING_ERRORS)
353 def encode_zstring(self, s: str) -> bytes:
354 return self.encode(s) + b'\0'
356 def version_tuple(self) -> tuple[int, ...]:
357 return tuple(map(int, self.version.split(b'.')))
359 def set_version(self, version: str | bytes) -> None:
360 self.version = version.encode('utf-8') if isinstance(version, str) else bytes(version)
361 self.fileversion = self.version + b'\0'
363 def has_rebus(self) -> bool:
364 if Extensions.Rebus in self.extensions or 'rebus' in self.helpers:
365 return self.rebus().has_rebus()
366 return False
368 def rebus(self) -> Rebus:
369 if 'rebus' not in self.helpers:
370 self.helpers['rebus'] = Rebus(self)
371 return cast('Rebus', self.helpers['rebus'])
373 def has_timer(self) -> bool:
374 return Extensions.Timer in self.extensions or 'timer' in self.helpers
376 def remove_timer(self) -> None:
377 self.extensions.pop(Extensions.Timer, None)
378 self.helpers.pop('timer', None)
380 def timer(self) -> Timer:
381 if 'timer' not in self.helpers:
382 self.helpers['timer'] = Timer(self)
383 return cast('Timer', self.helpers['timer'])
385 def has_markup(self) -> bool:
386 if Extensions.Markup in self.extensions or 'markup' in self.helpers:
387 return self.markup().has_markup()
388 return False
390 def markup(self) -> Markup:
391 if 'markup' not in self.helpers:
392 self.helpers['markup'] = Markup(self)
393 return cast('Markup', self.helpers['markup'])
395 def clue_numbering(self) -> ClueNumbering:
396 if 'clues' not in self.helpers:
397 self.helpers['clues'] = ClueNumbering(self)
398 return cast('ClueNumbering', self.helpers['clues'])
400 def blacksquare(self) -> str:
401 return BLACKSQUARE2 if self.puzzletype == PuzzleType.Diagramless else BLACKSQUARE
403 def is_solution_locked(self) -> bool:
404 return bool(self.solution_state == SolutionState.Locked)
406 def unlock_solution(self, key: int) -> bool:
407 if self.is_solution_locked():
408 unscrambled = unscramble_solution(self.solution, self.width, self.height, key,
409 ignore_chars=self.blacksquare())
410 if not self.check_answers(unscrambled):
411 return False
413 # clear the scrambled bit and cksum
414 self.solution = unscrambled
415 self.scrambled_cksum = 0
416 self.solution_state = SolutionState.Unlocked
418 return True
420 def lock_solution(self, key: int) -> None:
421 if not self.is_solution_locked():
422 # set the scrambled bit and cksum
423 self.scrambled_cksum = scrambled_cksum(self.solution, self.width, self.height,
424 ignore_chars=self.blacksquare(), encoding=self.encoding)
425 self.solution_state = SolutionState.Locked
426 scrambled = scramble_solution(self.solution, self.width, self.height, key,
427 ignore_chars=self.blacksquare())
428 self.solution = scrambled
430 def check_answers(self, fill: str, strict: bool = True) -> bool:
431 if self.is_solution_locked():
432 if not strict:
433 raise ValueError('non-strict checking not possible when solution is locked')
434 scrambled = scrambled_cksum(fill, self.width, self.height,
435 ignore_chars=self.blacksquare(), encoding=self.encoding)
436 return scrambled == self.scrambled_cksum
437 if fill == self.solution:
438 return True
439 if not strict:
440 return all(a == b for a, b in zip(fill, self.solution) if b != self.blacksquare() and a != BLANKSQUARE)
441 return False
443 def check_rebus_answers(self, strict: bool = True) -> bool:
444 if self.has_rebus():
445 return self.rebus().check_rebus_fill(strict=strict)
446 # if no rebus just return True since there's nothing to check
447 return True
449 def header_cksum(self, cksum: int = 0) -> int:
450 return data_cksum(struct.pack(HEADER_CKSUM_FORMAT,
451 self.width, self.height, len(self.clues),
452 self.puzzletype, self.solution_state), cksum)
454 def text_cksum(self, cksum: int = 0) -> int:
455 # for the checksum to work these fields must be added in order with
456 # null termination, followed by all non-empty clues without null
457 # termination, followed by notes (but only for version >= 1.3)
458 if self.title:
459 cksum = data_cksum(self.encode_zstring(self.title), cksum)
460 if self.author:
461 cksum = data_cksum(self.encode_zstring(self.author), cksum)
462 if self.copyright:
463 cksum = data_cksum(self.encode_zstring(self.copyright), cksum)
465 for clue in self.clues:
466 if clue:
467 cksum = data_cksum(self.encode(clue), cksum)
469 # notes included in global cksum starting v1.3 of format
470 if self.version_tuple() >= (1, 3) and self.notes:
471 cksum = data_cksum(self.encode_zstring(self.notes), cksum)
473 return cksum
475 def global_cksum(self) -> int:
476 cksum = self.header_cksum()
477 cksum = data_cksum(self.encode(self.solution), cksum)
478 cksum = data_cksum(self.encode(self.fill), cksum)
479 # extensions do not seem to be included in global cksum
480 return self.text_cksum(cksum)
482 def magic_cksum(self) -> int:
483 cksums = [
484 self.header_cksum(),
485 data_cksum(self.encode(self.solution)),
486 data_cksum(self.encode(self.fill)),
487 self.text_cksum()
488 ]
490 cksum_magic = 0
491 for (i, cksum) in enumerate(reversed(cksums)):
492 cksum_magic <<= 8
493 cksum_magic |= (
494 ord(MASKSTRING[len(cksums) - i - 1]) ^ (cksum & 0x00ff)
495 )
496 cksum_magic |= (
497 (ord(MASKSTRING[len(cksums) - i - 1 + 4]) ^ (cksum >> 8)) << 32
498 )
500 return cksum_magic
502 def grid(self) -> Grid:
503 return Grid(self.fill, self.width, self.height)
505 def solution_grid(self) -> Grid:
506 return Grid(self.solution, self.width, self.height)
509class PuzzleBuffer:
510 """PuzzleBuffer class
511 wraps a bytes object and provides .puz-specific methods for
512 reading and writing data
513 """
514 def __repr__(self) -> str:
515 return f'PuzzleBuffer(pos={self.pos}, length={len(self.data)}, encoding={self.encoding!r})'
517 def __init__(self, data: bytes | None = None, encoding: str = ENCODING):
518 self.data = bytearray(data) if data else bytearray()
519 self.encoding = encoding
520 self.pos = 0
522 def can_read(self, n_bytes: int = 1) -> bool:
523 return self.pos + n_bytes <= len(self.data)
525 def length(self) -> int:
526 return len(self.data)
528 def read(self, n_bytes: int) -> bytes:
529 start = self.pos
530 self.pos += n_bytes
531 return bytes(self.data[start:self.pos])
533 def read_to_end(self) -> bytes:
534 start = self.pos
535 self.pos = self.length()
536 return bytes(self.data[start:self.pos])
538 def read_string(self) -> str:
539 return self.read_until(b'\0')
541 def read_until(self, c: bytes) -> str:
542 start = self.pos
543 self.seek_to(c, 1) # read past
544 return str(self.data[start:self.pos-1], self.encoding)
546 def seek_to(self, s: bytes, offset: int = 0) -> bool:
547 try:
548 self.pos = self.data.index(s, self.pos) + offset
549 return True
550 except ValueError:
551 # s not found, advance to end
552 self.pos = self.length()
553 return False
555 def write(self, s: bytes) -> None:
556 self.data.extend(s)
558 def write_string(self, s: str | None) -> None:
559 s = s or ''
560 self.data.extend(s.encode(self.encoding, ENCODING_ERRORS) + b'\0')
562 def pack(self, struct_format: str, *values: Any) -> None:
563 self.data.extend(struct.pack(struct_format, *values))
565 def can_unpack(self, struct_format: str) -> bool:
566 return self.can_read(struct.calcsize(struct_format))
568 def unpack(self, struct_format: str) -> tuple[Any, ...]:
569 start = self.pos
570 try:
571 res = struct.unpack_from(struct_format, self.data, self.pos)
572 self.pos += struct.calcsize(struct_format)
573 return res
574 except struct.error as err:
575 message = f'could not unpack values at {start} for format {struct_format}'
576 raise PuzzleFormatError(message) from err
578 def tobytes(self) -> bytes:
579 return bytes(self.data)
582# clue numbering helper
583def get_grid_numbering(grid: str, width: int, height: int) -> tuple[list[ClueEntry], list[ClueEntry]]:
584 # Add numbers to the grid based on positions of black squares
585 def col(index: int) -> int:
586 return index % width
588 def row(index: int) -> int:
589 return index // width
591 def len_across(index: int) -> int:
592 c = 0
593 for c in range(width - col(index)):
594 if is_blacksquare(grid[index + c]):
595 return c
596 return c + 1
598 def len_down(index: int) -> int:
599 c = 0
600 for c in range(height - row(index)):
601 if is_blacksquare(grid[index + c*width]):
602 return c
603 return c + 1
605 across: list[ClueEntry] = []
606 down: list[ClueEntry] = []
607 count = 0 # count is the index into the clues list; 0-based and counts across and down together
608 num = 1 # num is the clue number that gets printed in the grid
609 for i in range(len(grid)): # i is the cell index in row-major order
610 if not is_blacksquare(grid[i]):
611 lastc = count
612 is_across = col(i) == 0 or is_blacksquare(grid[i - 1])
613 if is_across and len_across(i) > 1:
614 across.append(ClueEntry({
615 'num': num,
616 'clue': None, # filled in by caller
617 'clue_index': count,
618 'cell': i,
619 'row': row(i),
620 'col': col(i),
621 'len': len_across(i),
622 'dir': 'across',
623 }))
624 count += 1
625 is_down = row(i) == 0 or is_blacksquare(grid[i - width])
626 if is_down and len_down(i) > 1:
627 down.append(ClueEntry({
628 'num': num,
629 'clue': None, # filled in by caller
630 'clue_index': count,
631 'cell': i,
632 'row': row(i),
633 'col': col(i),
634 'len': len_down(i),
635 'dir': 'down'
636 }))
637 count += 1
638 if count > lastc:
639 num += 1
641 return across, down
644@runtime_checkable
645class PuzzleHelper(Protocol):
646 def save(self) -> None:
647 ...
650class DefaultClueNumbering(PuzzleHelper):
651 def __repr__(self) -> str:
652 return f'DefaultClueNumbering(across={len(self.across)}, down={len(self.down)})'
654 def __init__(self, grid: str, clues: list[str], width: int, height: int) -> None:
655 self.grid = grid
656 self.clues = clues
657 self.width = width
658 self.height = height
660 self.across, self.down = get_grid_numbering(grid, width, height)
661 for entry in self.across:
662 entry['clue'] = clues[entry['clue_index']]
663 for entry in self.down:
664 entry['clue'] = clues[entry['clue_index']]
666 def save(self) -> None:
667 pass # clue numbering is derived from the grid and clues, so no need to save anything back to the puzzle
669 # The following methods are no longer in use, but left here in case
670 # anyone was using them externally. They may be removed in a future release.
671 def col(self, index: int) -> int:
672 return index % self.width
674 def row(self, index: int) -> int:
675 return index // self.width
677 def len_across(self, index: int) -> int:
678 c = 0
679 for c in range(self.width - self.col(index)):
680 if is_blacksquare(self.grid[index + c]):
681 return c
682 return c + 1
684 def len_down(self, index: int) -> int:
685 c = 0
686 for c in range(self.height - self.row(index)):
687 if is_blacksquare(self.grid[index + c*self.width]):
688 return c
689 return c + 1
692class ClueNumbering(DefaultClueNumbering):
693 def __repr__(self) -> str:
694 return f'ClueNumbering(across={len(self.across)}, down={len(self.down)})'
696 def __init__(self, puzzle: Puzzle) -> None:
697 super().__init__(puzzle.solution, puzzle.clues, puzzle.width, puzzle.height)
698 for entry in self.across:
699 entry._puzzle = puzzle
700 for entry in self.down:
701 entry._puzzle = puzzle
704class Grid:
705 def __repr__(self) -> str:
706 return f'Grid({self.width}x{self.height})'
708 def __init__(self, grid: str, width: int, height: int) -> None:
709 self.grid = grid
710 self.width = width
711 self.height = height
712 assert len(self.grid) == self.width * self.height
714 def get_cell(self, row: int, col: int) -> str:
715 return self.grid[self.get_cell_index(row, col)]
717 def get_cell_index(self, row: int, col: int) -> int:
718 return row * self.width + col
720 def get_range(self, row: int, col: int, length: int, dir: str = 'across') -> list[str]: # noqa: A002
721 if dir == 'across':
722 return self.get_range_across(row, col, length)
723 if dir == 'down':
724 return self.get_range_down(row, col, length)
725 raise AssertionError("dir not one of 'across' or 'down'")
727 def get_range_across(self, row: int, col: int, length: int) -> list[str]:
728 return [self.grid[self.get_cell_index(row, col + i)] for i in range(length)]
730 def get_range_down(self, row: int, col: int, length: int) -> list[str]:
731 return [self.grid[self.get_cell_index(row + i, col)] for i in range(length)]
733 def get_range_for_clue(self, clue: ClueEntry) -> list[str]:
734 return self.get_range(clue['row'], clue['col'], clue['len'], clue['dir'])
736 def __iter__(self) -> Iterator[list[str]]:
737 return self.rows()
739 def rows(self) -> Iterator[list[str]]:
740 for row in range(self.height):
741 yield self.get_row(row)
743 def cols(self) -> Iterator[list[str]]:
744 for col in range(self.width):
745 yield self.get_column(col)
747 def get_row(self, row: int) -> list[str]:
748 return self.get_range_across(row, 0, self.width)
750 def get_column(self, col: int) -> list[str]:
751 return self.get_range_down(0, col, self.height)
753 def get_string(self, row: int, col: int, length: int, dir: str = 'across') -> str: # noqa: A002
754 return ''.join(self.get_range(row, col, length, dir))
756 def get_string_across(self, row: int, col: int, length: int) -> str:
757 return ''.join(self.get_range_across(row, col, length))
759 def get_string_down(self, row: int, col: int, length: int) -> str:
760 return ''.join(self.get_range_down(row, col, length))
762 def get_string_for_clue(self, clue: ClueEntry) -> str:
763 return ''.join(self.get_range_for_clue(clue))
766class Rebus(PuzzleHelper):
767 def __repr__(self) -> str:
768 return f'Rebus(squares={len(self.get_rebus_squares())}, solutions={len(self.solutions)})'
770 def __init__(self, puzzle: Puzzle) -> None:
771 self.puzzle = puzzle
773 N = self.puzzle.width * self.puzzle.height
775 self._dirty = False # track whether there are unsaved changes to the rebus helper that need to be committed
776 # to the puzzle before saving
778 # the rebus table has the same number of entries as the grid and maps 1:1.
779 # cell values v > 0 represent rebus squares, where v corresponds to a solution key k=v-1 in the solutions map.
780 # 0 values indicate non-rebus squares.
781 self.table: list[int] = [0] * N
783 # the solutions table is a map of rebus solution key k (an int) to the corresponding solution string,
784 # eg 0:HEART;1:DIAMOND;17:CLUB;23:SPADE; k values need not be consecutive or in any order, but are
785 # typically numbered sequentially starting from 0. When k values appear in the rebus table, they are
786 # 1-indexed (ie v=k+1).
787 self.solutions: dict[int, str] = {}
789 # the fill table has the same number of entries as the grid and maps 1:1. Each cell value is a string
790 # representing the user's current fill for that cell, eg "STAR". Non-filled cells and non-rebus cells
791 # are empty strings.
792 # When a cell is a rebus entry, the corresponding cell in puzzle.fill will often be set to the first
793 # letter of the rebus, eg 'S' for "STAR".
794 self.fill: list[str] = []
796 # parse rebus data
797 if Extensions.Rebus in self.puzzle.extensions:
798 rebus_data = self.puzzle.extensions[Extensions.Rebus]
799 self.table = parse_bytes(rebus_data)
801 if Extensions.RebusSolutions in self.puzzle.extensions:
802 raw_solution_data = self.puzzle.extensions[Extensions.RebusSolutions]
803 solutions_str = raw_solution_data.decode(puzzle.encoding)
804 self.solutions = {
805 int(item[0]): item[1]
806 for item in parse_dict(solutions_str).items()
807 }
809 if Extensions.RebusFill in self.puzzle.extensions:
810 s = PuzzleBuffer(self.puzzle.extensions[Extensions.RebusFill], encoding=puzzle.encoding)
811 fill: list[str] = []
812 while s.can_read():
813 fill.append(s.read_string())
814 self.fill = (fill + [''] * N)[:N]
815 else:
816 self.fill = [''] * N
818 def has_rebus(self) -> bool:
819 return Extensions.Rebus in self.puzzle.extensions or any(self.table)
821 def is_rebus_square(self, index: int) -> bool:
822 return self.table[index] > 0
824 def get_rebus_squares(self) -> list[int]:
825 return [i for i, k in enumerate(self.table) if k > 0]
827 def add_rebus_squares(self, squares: int | list[int], solution: str) -> None:
828 if isinstance(squares, int):
829 squares = [squares]
831 k = self.add_rebus_solution(solution)
832 for i in squares:
833 self.table[i] = k + 1 # rebus value is 1-indexed because 0 is reserved for non-rebus squares
835 def add_rebus_solution(self, solution: str) -> int:
836 k = next((i for i, s in self.solutions.items() if s == solution), -1)
837 if k < 0:
838 k = (max(self.solutions) + 1) if self.solutions else 0
839 self.solutions[k] = solution
840 return k
842 def check_rebus_fill(self, indexes: int | list[int] | None = None, strict: bool = True) -> bool:
843 if isinstance(indexes, int):
844 indexes = [indexes]
845 if indexes is None:
846 indexes = self.get_rebus_squares()
847 for index in indexes:
848 if not self.is_rebus_square(index):
849 raise ValueError(f'index {index} is not a rebus square')
850 solution = self.get_rebus_solution(index)
851 fill = self.get_rebus_fill(index)
852 if solution != fill and (fill or strict):
853 return False
854 return True
856 def get_rebus_solution(self, index: int) -> str | None:
857 if self.is_rebus_square(index):
858 # rebus value is 1-indexed because 0 is reserved for non-rebus squares
859 # so we need to subtract 1 to get the correct solution from the map
860 return self.solutions[self.table[index] - 1]
861 return None
863 def set_rebus_solution(self, index: int, solution: str) -> None:
864 if self.is_rebus_square(index):
865 solution_index = self.add_rebus_solution(solution)
866 self.table[index] = solution_index + 1
868 def get_rebus_fill(self, index: int) -> str | None:
869 if self.is_rebus_square(index):
870 return self.fill[index] or None
871 return None
873 def remove_rebus_squares(self, squares: int | list[int]) -> None:
874 if isinstance(squares, int):
875 squares = [squares]
877 for i in squares:
878 self.table[i] = 0
879 self._dirty = True
881 def remove_rebus_solution(self, solution: str | int) -> None:
882 k = next((i for i, s in self.solutions.items() if s == solution), -1) if isinstance(solution, str) else solution
883 if k >= 0:
884 del self.solutions[k]
885 for i, v in enumerate(self.table):
886 if v == k + 1: # rebus value is 1-indexed because 0 is reserved for non-rebus squares
887 self.table[i] = 0
888 self._dirty = True
890 def set_rebus_fill(self, index: int, value: str) -> None:
891 if self.is_rebus_square(index):
892 self.fill[index] = value
894 def save(self) -> None:
895 if self.has_rebus():
896 self.puzzle.extensions[Extensions.Rebus] = pack_bytes(self.table)
897 if self.solutions:
898 self.puzzle.extensions[Extensions.RebusSolutions] = self.puzzle.encode(dict_to_string(self.solutions))
899 else:
900 self.puzzle.extensions.pop(Extensions.RebusSolutions, None)
901 if any(self.fill) or Extensions.RebusFill in self.puzzle.extensions:
902 s = PuzzleBuffer(encoding=self.puzzle.encoding)
903 for cell_fill in self.fill:
904 s.write_string(cell_fill)
905 self.puzzle.extensions[Extensions.RebusFill] = s.tobytes()
906 else:
907 self.puzzle.extensions.pop(Extensions.RebusFill, None)
908 elif self._dirty:
909 self.puzzle.extensions.pop(Extensions.Rebus, None)
910 self.puzzle.extensions.pop(Extensions.RebusSolutions, None)
911 self.puzzle.extensions.pop(Extensions.RebusFill, None)
914class Markup(PuzzleHelper):
915 def __repr__(self) -> str:
916 return f'Markup(marked_squares={len(self.get_markup_squares())})'
918 def __init__(self, puzzle: Puzzle) -> None:
919 self.puzzle = puzzle
920 self._dirty = False # track whether there are unsaved changes to the markup helper that need to be committed
921 markup_data = self.puzzle.extensions.get(Extensions.Markup, b'')
922 self.markup = parse_bytes(markup_data) or [0] * (self.puzzle.width * self.puzzle.height)
924 def clear_markup_squares(self, indices: list[int] | int, markup_types: list[GridMarkup] | GridMarkup | None = None) -> None: # noqa: E501
925 if isinstance(indices, int):
926 indices = [indices]
927 markup_mask = sum(markup_types) if isinstance(markup_types, list) else markup_types if markup_types else 0xff
928 for i in indices:
929 self.markup[i] &= ~markup_mask
930 self._dirty = True
932 def has_markup(self, markup_types: list[GridMarkup] | GridMarkup | None = None) -> bool:
933 markup_mask = sum(markup_types) if isinstance(markup_types, list) else markup_types if markup_types else 0xff
934 return any(bool(b & markup_mask) for b in self.markup)
936 def get_markup_squares(self, markup_types: list[GridMarkup] | GridMarkup | None = None) -> list[int]:
937 markup_mask = sum(markup_types) if isinstance(markup_types, list) else markup_types if markup_types else 0xff
938 return [i for i, b in enumerate(self.markup) if b & markup_mask]
940 def is_markup_square(self, index: int, markup_types: list[GridMarkup] | GridMarkup | None = None) -> bool:
941 markup_mask = sum(markup_types) if isinstance(markup_types, list) else markup_types if markup_types else 0xff
942 return bool(self.markup[index] & markup_mask)
944 def set_markup_squares(self, indices: list[int] | int, markup_type: list[GridMarkup] | GridMarkup | None = None) -> None:
945 if isinstance(indices, int):
946 indices = [indices]
947 markup_mask = sum(markup_type) if isinstance(markup_type, list) else markup_type if markup_type else 0xff
948 for i in indices:
949 self.markup[i] |= markup_mask
951 def save(self) -> None:
952 if self.has_markup():
953 self.puzzle.extensions[Extensions.Markup] = pack_bytes(self.markup)
954 elif self._dirty:
955 self.puzzle.extensions.pop(Extensions.Markup, None)
958class TimerStatus(IntEnum):
959 Running = 0
960 Stopped = 1
963class Timer(PuzzleHelper):
964 def __repr__(self) -> str:
965 return f'Timer(elapsed_seconds={self.elapsed_seconds}, status={self.status.name})'
967 def __init__(self, puzzle: Puzzle) -> None:
968 self.puzzle = puzzle
969 timer_data = self.puzzle.extensions.get(Extensions.Timer, b'0,1')
970 elapsed_str, status_str = timer_data.decode().split(',')
972 self.elapsed_seconds = int(elapsed_str)
973 self.status = TimerStatus(int(status_str))
975 def is_running(self) -> bool:
976 return self.status == TimerStatus.Running
978 def is_stopped(self) -> bool:
979 return self.status == TimerStatus.Stopped
981 def save(self) -> None:
982 self.puzzle.extensions[Extensions.Timer] = f'{self.elapsed_seconds},{self.status}'.encode()
985# helper functions for cksums and scrambling
986def data_cksum(data: bytes, cksum: int = 0) -> int:
987 for b in data:
988 # right-shift one with wrap-around
989 lowbit = (cksum & 0x0001)
990 cksum = (cksum >> 1)
991 if lowbit:
992 cksum = (cksum | 0x8000)
994 # then add in the data and clear any carried bit past 16
995 cksum = (cksum + b) & 0xffff
997 return cksum
1000def replace_chars(s: str, chars: str, replacement: str = '') -> str:
1001 for ch in chars:
1002 s = s.replace(ch, replacement)
1003 return s
1006def scramble_solution(solution: str, width: int, height: int, key: int, ignore_chars: str = BLACKSQUARE) -> str:
1007 sq = square(solution, width, height)
1008 data = restore(sq, scramble_string(replace_chars(sq, ignore_chars), key))
1009 return square(data, height, width)
1012def scramble_string(s: str, key: int) -> str:
1013 """
1014 s is the puzzle's solution in column-major order, omitting black squares:
1015 i.e. if the puzzle is:
1016 C A T
1017 # # A
1018 # # R
1019 solution is CATAR
1022 Key is a 4-digit number in the range 1000 <= key <= 9999
1024 """
1025 digits = key_digits(key)
1026 for k in digits: # foreach digit in the key
1027 s = shift(s, digits) # for each char by each digit in the key in sequence
1028 s = s[k:] + s[:k] # cut the sequence around the key digit
1029 s = shuffle(s) # do a 1:1 shuffle of the 'deck'
1031 return s
1034def unscramble_solution(scrambled: str, width: int, height: int, key: int, ignore_chars: str = BLACKSQUARE) -> str:
1035 # width and height are reversed here
1036 sq = square(scrambled, width, height)
1037 data = restore(sq, unscramble_string(replace_chars(sq, ignore_chars), key))
1038 return square(data, height, width)
1041def unscramble_string(s: str, key: int) -> str:
1042 digits = key_digits(key)
1043 l = len(s) # noqa: E741
1044 for k in digits[::-1]:
1045 s = unshuffle(s)
1046 s = s[l-k:] + s[:l-k]
1047 s = unshift(s, digits)
1049 return s
1052def scrambled_cksum(scrambled: str, width: int, height: int, ignore_chars: str = BLACKSQUARE, encoding: str = ENCODING) -> int:
1053 data = replace_chars(square(scrambled, width, height), ignore_chars)
1054 return data_cksum(data.encode(encoding, ENCODING_ERRORS))
1057def key_digits(key: int) -> list[int]:
1058 return [int(c) for c in str(key).zfill(4)]
1061def square(data: str, w: int, h: int) -> str:
1062 aa = [data[i:i+w] for i in range(0, len(data), w)]
1063 return ''.join(
1064 [''.join([aa[r][c] for r in range(h)]) for c in range(w)]
1065 )
1068def shift(s: str, key: list[int]) -> str:
1069 atoz = string.ascii_uppercase
1070 return ''.join(
1071 atoz[(atoz.index(c) + key[i % len(key)]) % len(atoz)]
1072 for i, c in enumerate(s)
1073 )
1076def unshift(s: str, key: list[int]) -> str:
1077 return shift(s, [-k for k in key])
1080def shuffle(s: str) -> str:
1081 mid = len(s) // 2
1082 return ''.join(a + b for a, b in zip(s[mid:], s[:mid])) + (s[-1] if len(s) % 2 else '')
1085def unshuffle(s: str) -> str:
1086 return s[1::2] + s[::2]
1089def restore(s: str, t: Iterable[str]) -> str:
1090 """
1091 s is the source string, it can contain '.'
1092 t is the target, it's smaller than s by the number of '.'s in s
1094 Each char in s is replaced by the corresponding
1095 char in t, jumping over '.'s in s.
1097 >>> restore('ABC.DEF', 'XYZABC')
1098 'XYZ.ABC'
1099 """
1100 t = (c for c in t)
1101 return ''.join(next(t) if not is_blacksquare(c) else c for c in s)
1104def is_blacksquare(c: str | int) -> bool:
1105 if isinstance(c, int):
1106 c = chr(c)
1107 return c in [BLACKSQUARE, BLACKSQUARE2]
1110#
1111# functions for parsing / serializing primitives
1112#
1115def parse_bytes(s: bytes) -> list[int]:
1116 return list(struct.unpack('B' * len(s), s))
1119def pack_bytes(a: list[int]) -> bytes:
1120 return struct.pack('B' * len(a), *a)
1123# dict string format is k1:v1;k2:v2;...;kn:vn;
1124# (for whatever reason there's a trailing ';')
1125def parse_dict(s: str) -> dict[str, str]:
1126 return dict(p.split(':', 1) for p in s.split(';') if ':' in p)
1129def dict_to_string(d: dict[int, str]) -> str:
1130 # Across Lite format right-aligns keys in a 2-char field: ' 0:VAL;', '13:VAL;'
1131 return ';'.join(f'{k:>2}:{v}' for k, v in d.items()) + ';'
1134def from_text_format(s: str) -> Puzzle:
1135 d = text_file_as_dict(s)
1137 if 'ACROSS PUZZLE' in d:
1138 # file_version = 'v1'
1139 pass
1140 elif 'ACROSS PUZZLE v2' in d:
1141 # file_version = 'v2'
1142 pass
1143 else:
1144 raise PuzzleFormatError('Not a valid Across Lite text puzzle')
1146 p = Puzzle()
1147 across_clues: list[str] = []
1148 down_clues: list[str] = []
1149 if 'TITLE' in d:
1150 p.title = d['TITLE']
1151 if 'AUTHOR' in d:
1152 p.author = d['AUTHOR']
1153 if 'COPYRIGHT' in d:
1154 p.copyright = d['COPYRIGHT']
1155 if 'SIZE' in d:
1156 w, h = d['SIZE'].split('x')
1157 p.width = int(w)
1158 p.height = int(h)
1159 # parse REBUS section before GRID — markers in the grid reference it
1160 # format: marker:EXTENDED_SOLUTION:SHORT_CHAR (one per line)
1161 # optional flag line: MARK; (circles all lowercase-letter cells in the grid)
1162 rebus_map: dict[str, tuple[str, str]] = {} # marker char -> (extended_solution, short_char)
1163 mark_flag = False
1164 if 'REBUS' in d:
1165 for line in d['REBUS'].splitlines():
1166 line = line.strip()
1167 if not line:
1168 continue
1169 if ':' not in line:
1170 # flag line, e.g. "MARK;"
1171 if 'MARK' in [f.strip().upper() for f in line.split(';') if f.strip()]:
1172 mark_flag = True
1173 else:
1174 parts = line.split(':')
1175 marker = parts[0]
1176 extended = parts[1] if len(parts) > 1 else ''
1177 short = parts[2] if len(parts) > 2 else (extended[0] if extended else marker)
1178 if marker:
1179 rebus_map[marker] = (extended, short)
1181 rebus_cells: dict[int, str] = {} # cell index -> extended solution
1182 mark_cells: list[int] = [] # cell indices to be circled (MARK flag)
1183 if 'GRID' in d:
1184 solution_lines = d['GRID'].splitlines()
1185 raw = ''.join(line.strip() for line in solution_lines if line.strip())
1186 if rebus_map or mark_flag:
1187 solution_chars: list[str] = []
1188 for i, c in enumerate(raw):
1189 if c in rebus_map:
1190 extended, short = rebus_map[c]
1191 solution_chars.append(short)
1192 rebus_cells[i] = extended
1193 elif mark_flag and c.islower() and not is_blacksquare(c):
1194 solution_chars.append(c.upper())
1195 mark_cells.append(i)
1196 else:
1197 solution_chars.append(c)
1198 p.solution = ''.join(solution_chars)
1199 else:
1200 p.solution = raw
1201 if 'ACROSS' in d:
1202 across_clues.extend(line.strip() for line in d['ACROSS'].splitlines() if line.strip())
1203 if 'DOWN' in d:
1204 down_clues.extend(line.strip() for line in d['DOWN'].splitlines() if line.strip())
1205 if 'NOTEPAD' in d:
1206 p.notes = d['NOTEPAD']
1208 if p.solution:
1209 if BLACKSQUARE2 in p.solution:
1210 p.puzzletype = PuzzleType.Diagramless
1211 p.fill = ''.join(c if is_blacksquare(c) else BLANKSQUARE for c in p.solution)
1212 across, down = get_grid_numbering(p.fill, p.width, p.height)
1213 # we have to match puzfile's expected clue ordering or we won't be able to
1214 # write the puzzle out as a valid .puz file
1215 p.clues = [''] * (len(across) + len(down))
1216 for i in range(len(across)):
1217 clue = across_clues[i] if i < len(across_clues) else ''
1218 across[i]['clue'] = clue
1219 p.clues[across[i]['clue_index']] = clue
1220 for i in range(len(down)):
1221 clue = down_clues[i] if i < len(down_clues) else ''
1222 down[i]['clue'] = clue
1223 p.clues[down[i]['clue_index']] = clue
1225 if rebus_cells:
1226 for i, extended in rebus_cells.items():
1227 p.rebus().add_rebus_squares(i, extended)
1228 if mark_cells:
1229 p.markup().set_markup_squares(mark_cells, GridMarkup.Circled)
1231 return p
1234def text_file_as_dict(s: str) -> dict[str, str]:
1235 d: dict[str, str] = {}
1236 k = ''
1237 v: list[str] = []
1238 for line in s.splitlines():
1239 line = line.strip()
1240 if line.startswith('<') and line.endswith('>'):
1241 if k:
1242 d[k] = '\n'.join(v)
1243 k = line[1:-1]
1244 v = []
1245 else:
1246 v.append(line)
1248 if k:
1249 d[k] = '\n'.join(v)
1250 return d
1253def to_text_format(p: Puzzle, text_version: str = 'v1') -> str:
1254 TAB = '\t' # most lines begin indented with whitespace
1255 lines: list[str] = []
1257 has_rebus = p.has_rebus()
1258 # text only supports circled cells using the MARK flag
1259 has_mark = p.has_markup() and p.markup().has_markup([GridMarkup.Circled])
1261 # REBUS section and MARK flag require v2 format; auto-upgrade if needed
1262 if (has_rebus or has_mark) and text_version == 'v1':
1263 text_version = 'v2'
1265 if text_version == 'v1':
1266 lines.append('<ACROSS PUZZLE>')
1267 elif text_version:
1268 lines.append(f'<ACROSS PUZZLE {text_version}>')
1269 else:
1270 raise ValueError("invalid text_version")
1272 lines.append('<TITLE>')
1273 lines.append(TAB + p.title)
1274 lines.append('<AUTHOR>')
1275 lines.append(TAB + p.author)
1276 lines.append('<COPYRIGHT>')
1277 lines.append(TAB + p.copyright)
1278 lines.append('<SIZE>')
1279 lines.append(TAB + f'{p.width}x{p.height}')
1281 # assign a single-char marker to each unique rebus solution
1282 # digits 1-9 then lowercase a-z, matching the v2 spec convention
1283 solution_to_marker: dict[str, str] = {}
1284 rebus_squares: set[int] = set()
1285 solution_to_short_char: dict[str, str] = {}
1286 if has_rebus:
1287 _marker_chars = [str(i) for i in range(1, 10)] + list('abcdefghijklmnopqrstuvwxyz')
1288 for idx, solution in enumerate(p.rebus().solutions.values()):
1289 if idx < len(_marker_chars):
1290 solution_to_marker[solution] = _marker_chars[idx]
1291 rebus_squares = set(p.rebus().get_rebus_squares())
1292 for i in rebus_squares:
1293 sol = p.rebus().get_rebus_solution(i)
1294 if sol and sol not in solution_to_short_char:
1295 solution_to_short_char[sol] = p.solution[i]
1297 circled_squares: set[int] = set(p.markup().get_markup_squares(GridMarkup.Circled)) if has_mark else set()
1299 lines.append('<GRID>')
1300 for row_idx in range(p.height):
1301 row = ''
1302 for col_idx in range(p.width):
1303 i = row_idx * p.width + col_idx
1304 if i in rebus_squares:
1305 sol = p.rebus().get_rebus_solution(i)
1306 row += solution_to_marker.get(sol or '', p.solution[i])
1307 elif i in circled_squares:
1308 row += p.solution[i].lower()
1309 else:
1310 row += p.solution[i]
1311 lines.append(TAB + row)
1313 if has_rebus or has_mark:
1314 lines.append('<REBUS>')
1315 if has_mark:
1316 lines.append(TAB + 'MARK;')
1317 if has_rebus:
1318 for solution, marker in solution_to_marker.items():
1319 short_char = solution_to_short_char.get(solution, solution[0])
1320 lines.append(TAB + f'{marker}:{solution}:{short_char}')
1322 # get clues in across/down order
1323 numbering = p.clue_numbering()
1324 lines.append('<ACROSS>')
1325 for clue in numbering.across:
1326 lines.append(TAB + (clue['clue'] or ''))
1327 lines.append('<DOWN>')
1328 for clue in numbering.down:
1329 lines.append(TAB + (clue['clue'] or ''))
1331 lines.append('<NOTEPAD>')
1332 lines.append(p.notes) # no tab here, idk why
1334 return '\n'.join(lines)