PUZToXDConverter.swift (17366B)
1 import Foundation 2 3 /// Converts Across Lite `.puz` files to Crossmate's `.xd` source format. 4 enum PUZToXDConverter { 5 struct ConversionError: LocalizedError { 6 let message: String 7 var errorDescription: String? { message } 8 } 9 10 private struct ClueEntry { 11 let number: Int 12 let direction: Direction 13 let cells: [Int] 14 let text: String 15 } 16 17 private enum Direction { 18 case across 19 case down 20 21 var prefix: String { 22 switch self { 23 case .across: "A" 24 case .down: "D" 25 } 26 } 27 } 28 29 /// Upper bound on raw `.puz` bytes accepted by `convert`. Real Across 30 /// Lite files are tens of kilobytes. Independent of `XD.maxSourceBytes` 31 /// because a `.puz` can hide a large ignored tail (string table, 32 /// extensions) behind a small grid; enforcing it here covers every call 33 /// site before any header parsing or string-table walk. 34 static let maxSourceBytes = 262_144 35 36 static func convert(puzData data: Data) throws -> String { 37 guard data.count <= maxSourceBytes else { 38 throw ConversionError(message: "Across Lite file is too large.") 39 } 40 // The offsets below are absolute, but a Data slice keeps its parent's 41 // indices, so re-base a slice onto fresh zero-based storage first. 42 // (The extension parsers already index relative to startIndex — they 43 // receive slices of this data — but the header/string-table walk here 44 // assumes zero-based subscripts throughout.) 45 let data = data.startIndex == 0 ? data : Data(data) 46 guard data.count >= 0x34 else { 47 throw ConversionError(message: "Across Lite file is too short.") 48 } 49 guard asciiString(in: data, range: 0x02..<0x0D) == "ACROSS&DOWN" else { 50 throw ConversionError(message: "Not an Across Lite puzzle.") 51 } 52 53 let width = Int(data[0x2C]) 54 let height = Int(data[0x2D]) 55 let clueCount = Int(littleEndianUInt16(in: data, at: 0x2E)) 56 guard width > 0, height > 0 else { 57 throw ConversionError(message: "Across Lite puzzle has invalid dimensions.") 58 } 59 60 let cellCount = width * height 61 let solutionStart = 0x34 62 let fillStart = solutionStart + cellCount 63 let stringsStart = fillStart + cellCount 64 guard data.count >= stringsStart else { 65 throw ConversionError(message: "Across Lite grid is incomplete.") 66 } 67 68 let solutionBytes = Array(data[solutionStart..<fillStart]) 69 let stringTable = try parseNullTerminatedStrings( 70 in: data, 71 from: stringsStart, 72 count: clueCount + 4 73 ) 74 guard stringTable.strings.count >= clueCount + 3 else { 75 throw ConversionError(message: "Across Lite string table is incomplete.") 76 } 77 78 let title = displayTitle(fromPUZTitle: stringTable.strings[0]) 79 let author = displayAuthor(fromPUZAuthor: stringTable.strings[1]) 80 let copyright = stringTable.strings[2] 81 let clueTexts = Array(stringTable.strings[3..<(3 + clueCount)]) 82 let extensions = parseExtensions(in: data, from: stringTable.endOffset) 83 let rebus = parseRebus(extensions: extensions, cellCount: cellCount) 84 let circledCells = parseCircledCells(extensions: extensions, cellCount: cellCount) 85 86 let entries = try buildClues( 87 solutionBytes: solutionBytes, 88 width: width, 89 height: height, 90 clueTexts: clueTexts 91 ) 92 93 // Each distinct rebus fill claims one grid placeholder from 94 // `XD.rebusPlaceholders`; cells sharing a fill reuse the same key 95 // (mirrors `NYTToXDConverter`). Circling is no longer part of that 96 // identity — it rides in the `## Decorations` section keyed by position — 97 // so a circled and an uncircled occurrence of one fill now share a key. 98 // Keying by cell index instead would burn a fresh character per cell and 99 // walk straight into the grid/header reserved set (`=`, `@`, ...) and, 100 // past ~200 cells, off the end of the alphabet — hence the bounds check 101 // rather than raw arithmetic. 102 var rebusKeys: [Int: Character] = [:] 103 var rebusEntries: [(Character, String)] = [] 104 var rebusLookup: [String: Character] = [:] 105 for index in 0..<cellCount where isOpen(solutionBytes[index]) { 106 guard let value = rebus[index], !value.isEmpty else { continue } 107 let normalized = value.uppercased() 108 let key: Character 109 if let existing = rebusLookup[normalized] { 110 key = existing 111 } else { 112 guard rebusLookup.count < XD.rebusPlaceholders.count else { 113 throw ConversionError( 114 message: "Across Lite puzzle has too many distinct rebus fills (\(rebusLookup.count + 1)); ran out of grid placeholders." 115 ) 116 } 117 key = XD.rebusPlaceholders[rebusLookup.count] 118 rebusLookup[normalized] = key 119 rebusEntries.append((key, normalized)) 120 } 121 rebusKeys[index] = key 122 } 123 124 var metadata: [String] = [] 125 if !title.isEmpty { metadata.append("Title: \(title)") } 126 metadata.append("ConVer: \(XD.currentConverterVersion)") 127 if !author.isEmpty { metadata.append("Author: \(author)") } 128 if !copyright.isEmpty { metadata.append("Copyright: \(copyright)") } 129 if !rebusEntries.isEmpty { 130 let header = rebusEntries.map { "\($0.0)=\($0.1)" }.joined(separator: " ") 131 metadata.append("Rebus: \(header)") 132 } 133 134 let gridLines = (0..<height).map { row -> String in 135 var line = "" 136 for col in 0..<width { 137 let index = row * width + col 138 let byte = solutionBytes[index] 139 guard isOpen(byte) else { 140 line += "#" 141 continue 142 } 143 if let key = rebusKeys[index] { 144 line.append(key) 145 continue 146 } 147 // A circled cell keeps its letter now that the circle is 148 // expressed in `## Decorations` rather than displacing the fill. 149 line += String(UnicodeScalar(byte)).uppercased() 150 } 151 return line 152 } 153 154 let acrossLines = entries 155 .filter { $0.direction == .across } 156 .map { clueLine($0, solutionBytes: solutionBytes, rebus: rebus) } 157 let downLines = entries 158 .filter { $0.direction == .down } 159 .map { clueLine($0, solutionBytes: solutionBytes, rebus: rebus) } 160 161 var sections = [ 162 metadata.joined(separator: "\n"), 163 gridLines.joined(separator: "\n"), 164 (acrossLines + [""] + downLines).joined(separator: "\n") 165 ] 166 var decorations: [GridPosition: [Puzzle.Decoration]] = [:] 167 for index in circledCells { 168 decorations[GridPosition(row: index / width, col: index % width)] = [ 169 Puzzle.Decoration(content: .mark(.circled), phase: .before) 170 ] 171 } 172 if let decorationSection = try XDDecorationWriter.section( 173 decorations: decorations, 174 width: width, 175 height: height 176 ) { 177 sections.append(decorationSection) 178 } 179 return sections.joined(separator: "\n\n\n") 180 } 181 182 private static func buildClues( 183 solutionBytes: [UInt8], 184 width: Int, 185 height: Int, 186 clueTexts: [String] 187 ) throws -> [ClueEntry] { 188 var entries: [ClueEntry] = [] 189 var clueIndex = 0 190 var number = 1 191 192 for row in 0..<height { 193 for col in 0..<width { 194 let index = row * width + col 195 guard isOpen(solutionBytes[index]) else { continue } 196 197 let startsAcross = !isOpen(solutionBytes, row: row, col: col - 1, width: width, height: height) 198 && isOpen(solutionBytes, row: row, col: col + 1, width: width, height: height) 199 let startsDown = !isOpen(solutionBytes, row: row - 1, col: col, width: width, height: height) 200 && isOpen(solutionBytes, row: row + 1, col: col, width: width, height: height) 201 202 if startsAcross || startsDown { 203 if startsAcross { 204 guard clueTexts.indices.contains(clueIndex) else { 205 throw ConversionError(message: "Across Lite clue table ended early.") 206 } 207 entries.append(ClueEntry( 208 number: number, 209 direction: .across, 210 cells: wordCells(fromRow: row, col: col, deltaRow: 0, deltaCol: 1, width: width, height: height, solutionBytes: solutionBytes), 211 text: clueTexts[clueIndex] 212 )) 213 clueIndex += 1 214 } 215 if startsDown { 216 guard clueTexts.indices.contains(clueIndex) else { 217 throw ConversionError(message: "Across Lite clue table ended early.") 218 } 219 entries.append(ClueEntry( 220 number: number, 221 direction: .down, 222 cells: wordCells(fromRow: row, col: col, deltaRow: 1, deltaCol: 0, width: width, height: height, solutionBytes: solutionBytes), 223 text: clueTexts[clueIndex] 224 )) 225 clueIndex += 1 226 } 227 number += 1 228 } 229 } 230 } 231 232 guard clueIndex == clueTexts.count else { 233 throw ConversionError(message: "Across Lite clue table has unused clues.") 234 } 235 return entries 236 } 237 238 private static func clueLine( 239 _ entry: ClueEntry, 240 solutionBytes: [UInt8], 241 rebus: [Int: String] 242 ) -> String { 243 let answer = entry.cells.map { index -> String in 244 if let rebusValue = rebus[index], !rebusValue.isEmpty { 245 return rebusValue 246 } 247 return String(UnicodeScalar(solutionBytes[index])) 248 }.joined().uppercased() 249 return "\(entry.direction.prefix)\(entry.number). \(entry.text) ~ \(answer)" 250 } 251 252 private static func wordCells( 253 fromRow row: Int, 254 col: Int, 255 deltaRow: Int, 256 deltaCol: Int, 257 width: Int, 258 height: Int, 259 solutionBytes: [UInt8] 260 ) -> [Int] { 261 var cells: [Int] = [] 262 var r = row 263 var c = col 264 while isOpen(solutionBytes, row: r, col: c, width: width, height: height) { 265 cells.append(r * width + c) 266 r += deltaRow 267 c += deltaCol 268 } 269 return cells 270 } 271 272 private static func parseNullTerminatedStrings( 273 in data: Data, 274 from start: Int, 275 count: Int 276 ) throws -> (strings: [String], endOffset: Int) { 277 var strings: [String] = [] 278 var offset = start 279 while strings.count < count && offset < data.count { 280 guard let end = data[offset...].firstIndex(of: 0) else { break } 281 strings.append(decodeString(data[offset..<end])) 282 offset = data.index(after: end) 283 } 284 guard strings.count == count else { 285 throw ConversionError(message: "Across Lite string table is incomplete.") 286 } 287 return (strings, offset) 288 } 289 290 private static func displayTitle(fromPUZTitle title: String) -> String { 291 let letters = title.unicodeScalars.filter { CharacterSet.letters.contains($0) } 292 guard !letters.isEmpty, 293 letters.allSatisfy({ CharacterSet.uppercaseLetters.contains($0) }) 294 else { return title } 295 296 var result = "" 297 var startOfWord = true 298 for scalar in title.unicodeScalars { 299 if CharacterSet.letters.contains(scalar) { 300 let string = String(scalar) 301 result += startOfWord ? string.uppercased() : string.lowercased() 302 startOfWord = false 303 } else { 304 result += String(scalar) 305 startOfWord = !CharacterSet.decimalDigits.contains(scalar) 306 } 307 } 308 return result 309 } 310 311 /// `.puz` author strings frequently embed the credit phrasing 312 /// ("by Jane Doe", "Edited by John Smith"). Strip a leading 313 /// "by"/"edited by" and surrounding whitespace so the stored 314 /// `Author:` metadata is a clean name, matching the NYT path; the 315 /// credits view supplies the "By " label at render time. 316 private static func displayAuthor(fromPUZAuthor author: String) -> String { 317 var result = author.trimmingCharacters(in: .whitespacesAndNewlines) 318 for prefix in ["edited by ", "by "] where result.lowercased().hasPrefix(prefix) { 319 result = String(result.dropFirst(prefix.count)) 320 .trimmingCharacters(in: .whitespacesAndNewlines) 321 break 322 } 323 return result 324 } 325 326 private static func parseExtensions(in data: Data, from start: Int) -> [String: Data] { 327 var extensions: [String: Data] = [:] 328 var offset = start 329 while offset + 8 <= data.count { 330 guard let code = String(data: data[offset..<(offset + 4)], encoding: .ascii) else { break } 331 let length = Int(littleEndianUInt16(in: data, at: offset + 4)) 332 let payloadStart = offset + 8 333 let payloadEnd = payloadStart + length 334 guard payloadEnd <= data.count else { break } 335 extensions[code] = data[payloadStart..<payloadEnd] 336 offset = payloadEnd 337 if offset < data.count, data[offset] == 0 { 338 offset += 1 339 } 340 } 341 return extensions 342 } 343 344 private static func parseCircledCells( 345 extensions: [String: Data], 346 cellCount: Int 347 ) -> Set<Int> { 348 guard let data = extensions["GEXT"], data.count >= cellCount else { return [] } 349 var cells: Set<Int> = [] 350 for offset in 0..<cellCount { 351 let dataIndex = data.index(data.startIndex, offsetBy: offset) 352 if data[dataIndex] & 0x80 != 0 { 353 cells.insert(offset) 354 } 355 } 356 return cells 357 } 358 359 private static func parseRebus( 360 extensions: [String: Data], 361 cellCount: Int 362 ) -> [Int: String] { 363 guard let grid = extensions["GRBS"], grid.count >= cellCount else { return [:] } 364 let table = parseRebusTable(extensions["RTBL"]) 365 var rebus: [Int: String] = [:] 366 for offset in 0..<cellCount { 367 let gridIndex = grid.index(grid.startIndex, offsetBy: offset) 368 // The grid byte is 1-indexed: 0 means "no rebus" and `1 + n` refers 369 // to `RTBL` key `n` (see the .puz FileFormat reference). Subtract one 370 // to recover the table key. 371 let gridByte = Int(grid[gridIndex]) 372 guard gridByte > 0, let value = table[gridByte - 1] else { continue } 373 rebus[offset] = value 374 } 375 return rebus 376 } 377 378 private static func parseRebusTable(_ data: Data?) -> [Int: String] { 379 guard let data else { return [:] } 380 let source = decodeString(data) 381 var table: [Int: String] = [:] 382 for rawEntry in source.split(separator: ";") { 383 let entry = rawEntry.trimmingCharacters(in: .whitespacesAndNewlines) 384 guard let colon = entry.firstIndex(of: ":"), 385 let key = Int(entry[..<colon].trimmingCharacters(in: .whitespaces)) 386 else { continue } 387 let value = entry[entry.index(after: colon)...].trimmingCharacters(in: .whitespaces) 388 if !value.isEmpty { 389 table[key] = value 390 } 391 } 392 return table 393 } 394 395 private static func isOpen(_ byte: UInt8) -> Bool { 396 byte != UInt8(ascii: ".") && byte != 0 397 } 398 399 private static func isOpen( 400 _ solutionBytes: [UInt8], 401 row: Int, 402 col: Int, 403 width: Int, 404 height: Int 405 ) -> Bool { 406 guard row >= 0, row < height, col >= 0, col < width else { return false } 407 return isOpen(solutionBytes[row * width + col]) 408 } 409 410 private static func littleEndianUInt16(in data: Data, at offset: Int) -> UInt16 { 411 UInt16(data[offset]) | (UInt16(data[offset + 1]) << 8) 412 } 413 414 private static func asciiString(in data: Data, range: Range<Int>) -> String? { 415 guard data.count >= range.upperBound else { return nil } 416 return String(data: data[range], encoding: .ascii) 417 } 418 419 private static func decodeString(_ data: Data) -> String { 420 if let value = String(data: data, encoding: .windowsCP1252) { 421 return value.trimmingCharacters(in: .newlines) 422 } 423 if let value = String(data: data, encoding: .isoLatin1) { 424 return value.trimmingCharacters(in: .newlines) 425 } 426 return String(decoding: data, as: UTF8.self).trimmingCharacters(in: .newlines) 427 } 428 }