Coverage for puz.py: 100%

846 statements  

« 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 

2 

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 

9 

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

11 

12HEADER_FORMAT = '''< 

13 H 11s xH 

14 Q 4s 2sH 

15 12s BBH 

16 H H ''' 

17 

18HEADER_CKSUM_FORMAT = '<BBH H H ' 

19 

20EXTENSION_HEADER_FORMAT = '< 4s H H ' 

21 

22MASKSTRING = 'ICHEATED' 

23 

24ENCODING = 'ISO-8859-1' 

25ENCODING_UTF8 = 'UTF-8' 

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

27 

28ACROSSDOWN = b'ACROSS&DOWN' 

29 

30BLACKSQUARE = '.' 

31BLACKSQUARE2 = ':' # used for diagramless puzzles 

32BLANKSQUARE = '-' 

33 

34 

35class PuzzleType(IntEnum): 

36 Normal = 0x0001 

37 Diagramless = 0x0401 

38 

39 

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 

49 

50 

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 

62 

63 

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

70 

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

72 RebusSolutions = b'RTBL', 

73 

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

77 

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

81 

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

83 # currently incorrect: 0x20, 

84 # hinted: 0x40, 

85 # circled: 0x80 

86 Markup = b'GEXT' 

87 

88 

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

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

91 

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

95 

96 _puzzle: Puzzle | None 

97 

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

99 super().__init__(data) 

100 self._puzzle = puzzle 

101 

102 @property 

103 def number(self) -> int: 

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

105 

106 @property 

107 def text(self) -> str: 

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

109 

110 @property 

111 def length(self) -> int: 

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

113 

114 @property 

115 def direction(self) -> str: 

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

117 

118 @property 

119 def row(self) -> int: 

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

121 

122 @property 

123 def col(self) -> int: 

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

125 

126 @property 

127 def cell(self) -> int: 

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

129 

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) 

134 

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) 

139 

140 

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

148 

149 

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

157 

158 

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 

167 

168 

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) 

175 

176 

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 

184 

185 

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

191 

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 

218 

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

220 s = PuzzleBuffer(data) 

221 

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

229 

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

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

232 

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] 

248 

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 

253 

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

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

256 

257 self.title = s.read_string() 

258 self.author = s.read_string() 

259 self.copyright = s.read_string() 

260 

261 self.clues = [s.read_string() for _ in range(numclues)] 

262 self.notes = s.read_string() 

263 

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) 

274 

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

279 

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 ) 

291 

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

293 puzzle_bytes = self.tobytes() 

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

295 f.write(puzzle_bytes) 

296 

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

303 

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

305 s.write(self.preamble) 

306 

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) 

313 

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

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

316 

317 s.write_string(self.title) 

318 s.write_string(self.author) 

319 s.write_string(self.copyright) 

320 

321 for clue in self.clues: 

322 s.write_string(clue) 

323 

324 s.write_string(self.notes) 

325 

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

337 

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

341 

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) 

347 

348 return s.tobytes() 

349 

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

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

352 

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

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

355 

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

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

358 

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' 

362 

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 

367 

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

372 

373 def has_timer(self) -> bool: 

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

375 

376 def remove_timer(self) -> None: 

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

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

379 

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

384 

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 

389 

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

394 

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

399 

400 def blacksquare(self) -> str: 

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

402 

403 def is_solution_locked(self) -> bool: 

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

405 

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 

412 

413 # clear the scrambled bit and cksum 

414 self.solution = unscrambled 

415 self.scrambled_cksum = 0 

416 self.solution_state = SolutionState.Unlocked 

417 

418 return True 

419 

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 

429 

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 

442 

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 

448 

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) 

453 

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) 

464 

465 for clue in self.clues: 

466 if clue: 

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

468 

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) 

472 

473 return cksum 

474 

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) 

481 

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 ] 

489 

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 ) 

499 

500 return cksum_magic 

501 

502 def grid(self) -> Grid: 

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

504 

505 def solution_grid(self) -> Grid: 

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

507 

508 

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

516 

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 

521 

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

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

524 

525 def length(self) -> int: 

526 return len(self.data) 

527 

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

532 

533 def read_to_end(self) -> bytes: 

534 start = self.pos 

535 self.pos = self.length() 

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

537 

538 def read_string(self) -> str: 

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

540 

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) 

545 

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 

554 

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

556 self.data.extend(s) 

557 

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

561 

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

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

564 

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

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

567 

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 

577 

578 def tobytes(self) -> bytes: 

579 return bytes(self.data) 

580 

581 

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 

587 

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

589 return index // width 

590 

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 

597 

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 

604 

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 

640 

641 return across, down 

642 

643 

644@runtime_checkable 

645class PuzzleHelper(Protocol): 

646 def save(self) -> None: 

647 ... 

648 

649 

650class DefaultClueNumbering(PuzzleHelper): 

651 def __repr__(self) -> str: 

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

653 

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 

659 

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

665 

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 

668 

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 

673 

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

675 return index // self.width 

676 

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 

683 

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 

690 

691 

692class ClueNumbering(DefaultClueNumbering): 

693 def __repr__(self) -> str: 

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

695 

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 

702 

703 

704class Grid: 

705 def __repr__(self) -> str: 

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

707 

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 

713 

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

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

716 

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

718 return row * self.width + col 

719 

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

726 

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

729 

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

732 

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

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

735 

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

737 return self.rows() 

738 

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

740 for row in range(self.height): 

741 yield self.get_row(row) 

742 

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

744 for col in range(self.width): 

745 yield self.get_column(col) 

746 

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

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

749 

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

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

752 

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

755 

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

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

758 

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

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

761 

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

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

764 

765 

766class Rebus(PuzzleHelper): 

767 def __repr__(self) -> str: 

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

769 

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

771 self.puzzle = puzzle 

772 

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

774 

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 

777 

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 

782 

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

788 

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

795 

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) 

800 

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 } 

808 

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 

817 

818 def has_rebus(self) -> bool: 

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

820 

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

822 return self.table[index] > 0 

823 

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

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

826 

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

828 if isinstance(squares, int): 

829 squares = [squares] 

830 

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 

834 

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 

841 

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 

855 

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 

862 

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 

867 

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 

872 

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

874 if isinstance(squares, int): 

875 squares = [squares] 

876 

877 for i in squares: 

878 self.table[i] = 0 

879 self._dirty = True 

880 

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 

889 

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

891 if self.is_rebus_square(index): 

892 self.fill[index] = value 

893 

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) 

912 

913 

914class Markup(PuzzleHelper): 

915 def __repr__(self) -> str: 

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

917 

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) 

923 

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 

931 

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) 

935 

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] 

939 

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) 

943 

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 

950 

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) 

956 

957 

958class TimerStatus(IntEnum): 

959 Running = 0 

960 Stopped = 1 

961 

962 

963class Timer(PuzzleHelper): 

964 def __repr__(self) -> str: 

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

966 

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

971 

972 self.elapsed_seconds = int(elapsed_str) 

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

974 

975 def is_running(self) -> bool: 

976 return self.status == TimerStatus.Running 

977 

978 def is_stopped(self) -> bool: 

979 return self.status == TimerStatus.Stopped 

980 

981 def save(self) -> None: 

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

983 

984 

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) 

993 

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

995 cksum = (cksum + b) & 0xffff 

996 

997 return cksum 

998 

999 

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

1001 for ch in chars: 

1002 s = s.replace(ch, replacement) 

1003 return s 

1004 

1005 

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) 

1010 

1011 

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 

1020 

1021 

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

1023 

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' 

1030 

1031 return s 

1032 

1033 

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) 

1039 

1040 

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) 

1048 

1049 return s 

1050 

1051 

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

1055 

1056 

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

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

1059 

1060 

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 ) 

1066 

1067 

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 ) 

1074 

1075 

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

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

1078 

1079 

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

1083 

1084 

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

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

1087 

1088 

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 

1093 

1094 Each char in s is replaced by the corresponding 

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

1096 

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) 

1102 

1103 

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

1105 if isinstance(c, int): 

1106 c = chr(c) 

1107 return c in [BLACKSQUARE, BLACKSQUARE2] 

1108 

1109 

1110# 

1111# functions for parsing / serializing primitives 

1112# 

1113 

1114 

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

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

1117 

1118 

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

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

1121 

1122 

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) 

1127 

1128 

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

1132 

1133 

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

1135 d = text_file_as_dict(s) 

1136 

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

1145 

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) 

1180 

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

1207 

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 

1224 

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) 

1230 

1231 return p 

1232 

1233 

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) 

1247 

1248 if k: 

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

1250 return d 

1251 

1252 

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

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

1255 lines: list[str] = [] 

1256 

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

1260 

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' 

1264 

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

1271 

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

1280 

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] 

1296 

1297 circled_squares: set[int] = set(p.markup().get_markup_squares(GridMarkup.Circled)) if has_mark else set() 

1298 

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) 

1312 

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

1321 

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

1330 

1331 lines.append('<NOTEPAD>') 

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

1333 

1334 return '\n'.join(lines)