Coverage for puz.py: 100%

852 statements  

« 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 

2 

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 

11 

12__version__ = importlib.metadata.version('puzpy') 

13 

14HEADER_FORMAT = '''< 

15 H 11s xH 

16 Q 4s 2sH 

17 12s BBH 

18 H H ''' 

19 

20HEADER_CKSUM_FORMAT = '<BBH H H ' 

21 

22EXTENSION_HEADER_FORMAT = '< 4s H H ' 

23 

24MASKSTRING = 'ICHEATED' 

25 

26ENCODING = 'ISO-8859-1' 

27ENCODING_UTF8 = 'UTF-8' 

28ENCODING_ERRORS = 'strict' # raises an exception for bad chars; change to 'replace' for laxer handling 

29 

30ACROSSDOWN = b'ACROSS&DOWN' 

31 

32BLACKSQUARE = '.' 

33BLACKSQUARE2 = ':' # used for diagramless puzzles 

34BLANKSQUARE = '-' 

35 

36 

37class PuzzleType(IntEnum): 

38 Normal = 0x0001 

39 Diagramless = 0x0401 

40 

41 

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 

51 

52 

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 

64 

65 

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', 

72 

73 # map of rebus solution entries eg 0:HEART;1:DIAMOND;17:CLUB;23:SPADE; 

74 RebusSolutions = b'RTBL', 

75 

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', 

79 

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', 

83 

84 # grid cell markup: previously incorrect: 0x10; 

85 # currently incorrect: 0x20, 

86 # hinted: 0x40, 

87 # circled: 0x80 

88 Markup = b'GEXT' 

89 

90 

91class ClueEntry(dict[str, Any]): 

92 """A clue entry in a crossword puzzle. 

93 

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 """ 

97 

98 _puzzle: 'Puzzle | None' 

99 

100 def __init__(self, data: dict[str, Any], puzzle: 'Puzzle | None' = None) -> None: 

101 super().__init__(data) 

102 self._puzzle = puzzle 

103 

104 @property 

105 def number(self) -> int: 

106 return cast(int, self['num']) 

107 

108 @property 

109 def text(self) -> str: 

110 return cast(str, self['clue']) 

111 

112 @property 

113 def length(self) -> int: 

114 return cast(int, self['len']) 

115 

116 @property 

117 def direction(self) -> str: 

118 return cast(str, self['dir']) 

119 

120 @property 

121 def row(self) -> int: 

122 return cast(int, self['row']) 

123 

124 @property 

125 def col(self) -> int: 

126 return cast(int, self['col']) 

127 

128 @property 

129 def cell(self) -> int: 

130 return cast(int, self['cell']) 

131 

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) 

136 

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) 

141 

142 

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()) 

150 

151 

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()) 

159 

160 

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 

169 

170 

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) 

177 

178 

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 

186 

187 

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})' 

193 

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 

220 

221 def load(self, data: bytes) -> None: 

222 s = PuzzleBuffer(data) 

223 

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?") 

231 

232 # save whatever we just jumped over so that we can round-trip it on save. 

233 self.preamble = bytes(s.data[:s.pos]) 

234 

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] 

250 

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 

255 

256 self.solution = s.read(self.width * self.height).decode(self.encoding) 

257 self.fill = s.read(self.width * self.height).decode(self.encoding) 

258 

259 self.title = s.read_string() 

260 self.author = s.read_string() 

261 self.copyright = s.read_string() 

262 

263 self.clues = [s.read_string() for i in range(0, numclues)] 

264 self.notes = s.read_string() 

265 

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) 

276 

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() 

281 

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 ) 

293 

294 def save(self, filename: str) -> None: 

295 puzzle_bytes = self.tobytes() 

296 with open(filename, 'wb') as f: 

297 f.write(puzzle_bytes) 

298 

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() 

305 

306 # include any preamble text we might have found on read 

307 s.write(self.preamble) 

308 

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) 

315 

316 s.write(self.encode(self.solution)) 

317 s.write(self.encode(self.fill)) 

318 

319 s.write_string(self.title) 

320 s.write_string(self.author) 

321 s.write_string(self.copyright) 

322 

323 for clue in self.clues: 

324 s.write_string(clue) 

325 

326 s.write_string(self.notes) 

327 

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') 

339 

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') 

343 

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) 

349 

350 return s.tobytes() 

351 

352 def encode(self, s: str) -> bytes: 

353 return s.encode(self.encoding, ENCODING_ERRORS) 

354 

355 def encode_zstring(self, s: str) -> bytes: 

356 return self.encode(s) + b'\0' 

357 

358 def version_tuple(self) -> tuple[int, ...]: 

359 return tuple(map(int, self.version.split(b'.'))) 

360 

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' 

364 

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 

369 

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']) 

374 

375 def has_timer(self) -> bool: 

376 return Extensions.Timer in self.extensions or 'timer' in self.helpers 

377 

378 def remove_timer(self) -> None: 

379 self.extensions.pop(Extensions.Timer, None) 

380 self.helpers.pop('timer', None) 

381 

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']) 

386 

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 

391 

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']) 

396 

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']) 

401 

402 def blacksquare(self) -> str: 

403 return BLACKSQUARE2 if self.puzzletype == PuzzleType.Diagramless else BLACKSQUARE 

404 

405 def is_solution_locked(self) -> bool: 

406 return bool(self.solution_state == SolutionState.Locked) 

407 

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 

414 

415 # clear the scrambled bit and cksum 

416 self.solution = unscrambled 

417 self.scrambled_cksum = 0 

418 self.solution_state = SolutionState.Unlocked 

419 

420 return True 

421 

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 

431 

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 

444 

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 

450 

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) 

455 

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) 

466 

467 for clue in self.clues: 

468 if clue: 

469 cksum = data_cksum(self.encode(clue), cksum) 

470 

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) 

474 

475 return cksum 

476 

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 

484 

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 ] 

492 

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 ) 

502 

503 return cksum_magic 

504 

505 def grid(self) -> 'Grid': 

506 return Grid(self.fill, self.width, self.height) 

507 

508 def solution_grid(self) -> 'Grid': 

509 return Grid(self.solution, self.width, self.height) 

510 

511 

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})' 

519 

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 

524 

525 def can_read(self, n_bytes: int = 1) -> bool: 

526 return self.pos + n_bytes <= len(self.data) 

527 

528 def length(self) -> int: 

529 return len(self.data) 

530 

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]) 

535 

536 def read_to_end(self) -> bytes: 

537 start = self.pos 

538 self.pos = self.length() 

539 return bytes(self.data[start:self.pos]) 

540 

541 def read_string(self) -> str: 

542 return self.read_until(b'\0') 

543 

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) 

548 

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 

557 

558 def write(self, s: bytes) -> None: 

559 self.data.extend(s) 

560 

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') 

564 

565 def pack(self, struct_format: str, *values: Any) -> None: 

566 self.data.extend(struct.pack(struct_format, *values)) 

567 

568 def can_unpack(self, struct_format: str) -> bool: 

569 return self.can_read(struct.calcsize(struct_format)) 

570 

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) 

582 

583 def tobytes(self) -> bytes: 

584 return bytes(self.data) 

585 

586 

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 

592 

593 def row(index: int) -> int: 

594 return int(math.floor(index / width)) 

595 

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 

602 

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 

609 

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 

645 

646 return across, down 

647 

648 

649@runtime_checkable 

650class PuzzleHelper(Protocol): 

651 def save(self) -> None: 

652 ... 

653 

654 

655class DefaultClueNumbering(PuzzleHelper): 

656 def __repr__(self) -> str: 

657 return f'DefaultClueNumbering(across={len(self.across)}, down={len(self.down)})' 

658 

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 

664 

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']] 

670 

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 

673 

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 

678 

679 def row(self, index: int) -> int: 

680 return int(math.floor(index / self.width)) 

681 

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 

688 

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 

695 

696 

697class ClueNumbering(DefaultClueNumbering): 

698 def __repr__(self) -> str: 

699 return f'ClueNumbering(across={len(self.across)}, down={len(self.down)})' 

700 

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 

707 

708 

709class Grid: 

710 def __repr__(self) -> str: 

711 return f'Grid({self.width}x{self.height})' 

712 

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 

718 

719 def get_cell(self, row: int, col: int) -> str: 

720 return self.grid[self.get_cell_index(row, col)] 

721 

722 def get_cell_index(self, row: int, col: int) -> int: 

723 return row * self.width + col 

724 

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'" 

732 

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)] 

735 

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)] 

738 

739 def get_range_for_clue(self, clue: ClueEntry) -> list[str]: 

740 return self.get_range(clue['row'], clue['col'], clue['len'], clue['dir']) 

741 

742 def __iter__(self) -> Iterator[list[str]]: 

743 return self.rows() 

744 

745 def rows(self) -> Iterator[list[str]]: 

746 for row in range(self.height): 

747 yield self.get_row(row) 

748 

749 def cols(self) -> Iterator[list[str]]: 

750 for col in range(self.width): 

751 yield self.get_column(col) 

752 

753 def get_row(self, row: int) -> list[str]: 

754 return self.get_range_across(row, 0, self.width) 

755 

756 def get_column(self, col: int) -> list[str]: 

757 return self.get_range_down(0, col, self.height) 

758 

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)) 

761 

762 def get_string_across(self, row: int, col: int, length: int) -> str: 

763 return ''.join(self.get_range_across(row, col, length)) 

764 

765 def get_string_down(self, row: int, col: int, length: int) -> str: 

766 return ''.join(self.get_range_down(row, col, length)) 

767 

768 def get_string_for_clue(self, clue: ClueEntry) -> str: 

769 return ''.join(self.get_range_for_clue(clue)) 

770 

771 

772class Rebus(PuzzleHelper): 

773 def __repr__(self) -> str: 

774 return f'Rebus(squares={len(self.get_rebus_squares())}, solutions={len(self.solutions)})' 

775 

776 def __init__(self, puzzle: Puzzle) -> None: 

777 self.puzzle = puzzle 

778 

779 N = self.puzzle.width * self.puzzle.height 

780 

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 

783 

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 

788 

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] = {} 

794 

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] = [] 

801 

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) 

806 

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 } 

814 

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 

823 

824 def has_rebus(self) -> bool: 

825 return Extensions.Rebus in self.puzzle.extensions or any(self.table) 

826 

827 def is_rebus_square(self, index: int) -> bool: 

828 return self.table[index] > 0 

829 

830 def get_rebus_squares(self) -> list[int]: 

831 return [i for i, k in enumerate(self.table) if k > 0] 

832 

833 def add_rebus_squares(self, squares: int | list[int], solution: str) -> None: 

834 if isinstance(squares, int): 

835 squares = [squares] 

836 

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 

840 

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 

847 

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 

861 

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 

868 

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 

873 

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 

878 

879 def remove_rebus_squares(self, squares: int | list[int]) -> None: 

880 if isinstance(squares, int): 

881 squares = [squares] 

882 

883 for i in squares: 

884 self.table[i] = 0 

885 self._dirty = True 

886 

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 

898 

899 def set_rebus_fill(self, index: int, value: str) -> None: 

900 if self.is_rebus_square(index): 

901 self.fill[index] = value 

902 

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) 

921 

922 

923class Markup(PuzzleHelper): 

924 def __repr__(self) -> str: 

925 return f'Markup(marked_squares={len(self.get_markup_squares())})' 

926 

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) 

932 

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 

940 

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) 

944 

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] 

948 

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) 

952 

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 

959 

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) 

965 

966 

967class TimerStatus(IntEnum): 

968 Running = 0 

969 Stopped = 1 

970 

971 

972class Timer(PuzzleHelper): 

973 def __repr__(self) -> str: 

974 return f'Timer(elapsed_seconds={self.elapsed_seconds}, status={self.status.name})' 

975 

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(',') 

980 

981 self.elapsed_seconds = int(elapsed_str) 

982 self.status = TimerStatus(int(status_str)) 

983 

984 def is_running(self) -> bool: 

985 return self.status == TimerStatus.Running 

986 

987 def is_stopped(self) -> bool: 

988 return self.status == TimerStatus.Stopped 

989 

990 def save(self) -> None: 

991 self.puzzle.extensions[Extensions.Timer] = f'{self.elapsed_seconds},{self.status}'.encode() 

992 

993 

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) 

1002 

1003 # then add in the data and clear any carried bit past 16 

1004 cksum = (cksum + b) & 0xffff 

1005 

1006 return cksum 

1007 

1008 

1009def replace_chars(s: str, chars: str, replacement: str = '') -> str: 

1010 for ch in chars: 

1011 s = s.replace(ch, replacement) 

1012 return s 

1013 

1014 

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) 

1019 

1020 

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 

1029 

1030 

1031 Key is a 4-digit number in the range 1000 <= key <= 9999 

1032 

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' 

1039 

1040 return s 

1041 

1042 

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) 

1048 

1049 

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) 

1057 

1058 return s 

1059 

1060 

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)) 

1064 

1065 

1066def key_digits(key: int) -> list[int]: 

1067 return [int(c) for c in str(key).zfill(4)] 

1068 

1069 

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 ) 

1075 

1076 

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 ) 

1083 

1084 

1085def unshift(s: str, key: list[int]) -> str: 

1086 return shift(s, [-k for k in key]) 

1087 

1088 

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 '') 

1093 

1094 

1095def unshuffle(s: str) -> str: 

1096 return s[1::2] + s[::2] 

1097 

1098 

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 

1103 

1104 Each char in s is replaced by the corresponding 

1105 char in t, jumping over '.'s in s. 

1106 

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) 

1112 

1113 

1114def is_blacksquare(c: str | int) -> bool: 

1115 if isinstance(c, int): 

1116 c = chr(c) 

1117 return c in [BLACKSQUARE, BLACKSQUARE2] 

1118 

1119 

1120# 

1121# functions for parsing / serializing primitives 

1122# 

1123 

1124 

1125def parse_bytes(s: bytes) -> list[int]: 

1126 return list(struct.unpack('B' * len(s), s)) 

1127 

1128 

1129def pack_bytes(a: list[int]) -> bytes: 

1130 return struct.pack('B' * len(a), *a) 

1131 

1132 

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) 

1137 

1138 

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()) + ';' 

1142 

1143 

1144def from_text_format(s: str) -> Puzzle: 

1145 d = text_file_as_dict(s) 

1146 

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') 

1155 

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) 

1190 

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'] 

1217 

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 

1234 

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) 

1240 

1241 return p 

1242 

1243 

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) 

1257 

1258 if k: 

1259 d[k] = '\n'.join(v) 

1260 return d 

1261 

1262 

1263def to_text_format(p: Puzzle, text_version: str = 'v1') -> str: 

1264 TAB = '\t' # most lines begin indented with whitespace 

1265 lines = [] 

1266 

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]) 

1270 

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' 

1274 

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") 

1281 

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}') 

1290 

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] 

1306 

1307 circled_squares = set(p.markup().get_markup_squares(GridMarkup.Circled)) if has_mark else set() 

1308 

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) 

1322 

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}') 

1331 

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 '')) 

1340 

1341 lines.append('<NOTEPAD>') 

1342 lines.append(p.notes) # no tab here, idk why 

1343 

1344 return '\n'.join(lines)