PUZToXDConverter.swift (14569B)
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 static func convert(puzData data: Data) throws -> String { 30 guard data.count >= 0x34 else { 31 throw ConversionError(message: "Across Lite file is too short.") 32 } 33 guard asciiString(in: data, range: 0x02..<0x0D) == "ACROSS&DOWN" else { 34 throw ConversionError(message: "Not an Across Lite puzzle.") 35 } 36 37 let width = Int(data[0x2C]) 38 let height = Int(data[0x2D]) 39 let clueCount = Int(littleEndianUInt16(in: data, at: 0x2E)) 40 guard width > 0, height > 0 else { 41 throw ConversionError(message: "Across Lite puzzle has invalid dimensions.") 42 } 43 44 let cellCount = width * height 45 let solutionStart = 0x34 46 let fillStart = solutionStart + cellCount 47 let stringsStart = fillStart + cellCount 48 guard data.count >= stringsStart else { 49 throw ConversionError(message: "Across Lite grid is incomplete.") 50 } 51 52 let solutionBytes = Array(data[solutionStart..<fillStart]) 53 let stringTable = try parseNullTerminatedStrings( 54 in: data, 55 from: stringsStart, 56 count: clueCount + 4 57 ) 58 guard stringTable.strings.count >= clueCount + 3 else { 59 throw ConversionError(message: "Across Lite string table is incomplete.") 60 } 61 62 let title = displayTitle(fromPUZTitle: stringTable.strings[0]) 63 let author = displayAuthor(fromPUZAuthor: stringTable.strings[1]) 64 let copyright = stringTable.strings[2] 65 let clueTexts = Array(stringTable.strings[3..<(3 + clueCount)]) 66 let extensions = parseExtensions(in: data, from: stringTable.endOffset) 67 let rebus = parseRebus(extensions: extensions, cellCount: cellCount) 68 let circledCells = parseCircledCells(extensions: extensions, cellCount: cellCount) 69 70 let entries = try buildClues( 71 solutionBytes: solutionBytes, 72 width: width, 73 height: height, 74 clueTexts: clueTexts 75 ) 76 77 var rebusKeys: [Int: Character] = [:] 78 var rebusEntries: [(Character, String)] = [] 79 var nextRebusKey: UInt8 = Character("1").asciiValue! 80 for index in 0..<cellCount where isOpen(solutionBytes[index]) { 81 guard let value = rebus[index], !value.isEmpty else { continue } 82 guard rebusKeys[index] == nil else { continue } 83 let key = Character(UnicodeScalar(nextRebusKey)) 84 rebusKeys[index] = key 85 rebusEntries.append((key, value.uppercased())) 86 nextRebusKey += 1 87 } 88 89 var metadata: [String] = [] 90 if !title.isEmpty { metadata.append("Title: \(title)") } 91 metadata.append("CmVer: \(XD.currentCmVersion)") 92 if !author.isEmpty { metadata.append("Author: \(author)") } 93 if !copyright.isEmpty { metadata.append("Copyright: \(copyright)") } 94 if !rebusEntries.isEmpty { 95 let header = rebusEntries.map { "\($0.0)=\($0.1)" }.joined(separator: " ") 96 metadata.append("Rebus: \(header)") 97 } 98 if !circledCells.isEmpty { 99 metadata.append("Specials: @=circle") 100 } 101 102 let gridLines = (0..<height).map { row -> String in 103 var line = "" 104 for col in 0..<width { 105 let index = row * width + col 106 let byte = solutionBytes[index] 107 guard isOpen(byte) else { 108 line += "#" 109 continue 110 } 111 if circledCells.contains(index) { 112 line += "@" 113 continue 114 } 115 if let key = rebusKeys[index] { 116 line.append(key) 117 continue 118 } 119 line += String(UnicodeScalar(byte)).uppercased() 120 } 121 return line 122 } 123 124 let acrossLines = entries 125 .filter { $0.direction == .across } 126 .map { clueLine($0, solutionBytes: solutionBytes, rebus: rebus) } 127 let downLines = entries 128 .filter { $0.direction == .down } 129 .map { clueLine($0, solutionBytes: solutionBytes, rebus: rebus) } 130 131 return [ 132 metadata.joined(separator: "\n"), 133 gridLines.joined(separator: "\n"), 134 (acrossLines + [""] + downLines).joined(separator: "\n") 135 ].joined(separator: "\n\n\n") 136 } 137 138 private static func buildClues( 139 solutionBytes: [UInt8], 140 width: Int, 141 height: Int, 142 clueTexts: [String] 143 ) throws -> [ClueEntry] { 144 var entries: [ClueEntry] = [] 145 var clueIndex = 0 146 var number = 1 147 148 for row in 0..<height { 149 for col in 0..<width { 150 let index = row * width + col 151 guard isOpen(solutionBytes[index]) else { continue } 152 153 let startsAcross = !isOpen(solutionBytes, row: row, col: col - 1, width: width, height: height) 154 && isOpen(solutionBytes, row: row, col: col + 1, width: width, height: height) 155 let startsDown = !isOpen(solutionBytes, row: row - 1, col: col, width: width, height: height) 156 && isOpen(solutionBytes, row: row + 1, col: col, width: width, height: height) 157 158 if startsAcross || startsDown { 159 if startsAcross { 160 guard clueTexts.indices.contains(clueIndex) else { 161 throw ConversionError(message: "Across Lite clue table ended early.") 162 } 163 entries.append(ClueEntry( 164 number: number, 165 direction: .across, 166 cells: wordCells(fromRow: row, col: col, deltaRow: 0, deltaCol: 1, width: width, height: height, solutionBytes: solutionBytes), 167 text: clueTexts[clueIndex] 168 )) 169 clueIndex += 1 170 } 171 if startsDown { 172 guard clueTexts.indices.contains(clueIndex) else { 173 throw ConversionError(message: "Across Lite clue table ended early.") 174 } 175 entries.append(ClueEntry( 176 number: number, 177 direction: .down, 178 cells: wordCells(fromRow: row, col: col, deltaRow: 1, deltaCol: 0, width: width, height: height, solutionBytes: solutionBytes), 179 text: clueTexts[clueIndex] 180 )) 181 clueIndex += 1 182 } 183 number += 1 184 } 185 } 186 } 187 188 guard clueIndex == clueTexts.count else { 189 throw ConversionError(message: "Across Lite clue table has unused clues.") 190 } 191 return entries 192 } 193 194 private static func clueLine( 195 _ entry: ClueEntry, 196 solutionBytes: [UInt8], 197 rebus: [Int: String] 198 ) -> String { 199 let answer = entry.cells.map { index -> String in 200 if let rebusValue = rebus[index], !rebusValue.isEmpty { 201 return rebusValue 202 } 203 return String(UnicodeScalar(solutionBytes[index])) 204 }.joined().uppercased() 205 return "\(entry.direction.prefix)\(entry.number). \(entry.text) ~ \(answer)" 206 } 207 208 private static func wordCells( 209 fromRow row: Int, 210 col: Int, 211 deltaRow: Int, 212 deltaCol: Int, 213 width: Int, 214 height: Int, 215 solutionBytes: [UInt8] 216 ) -> [Int] { 217 var cells: [Int] = [] 218 var r = row 219 var c = col 220 while isOpen(solutionBytes, row: r, col: c, width: width, height: height) { 221 cells.append(r * width + c) 222 r += deltaRow 223 c += deltaCol 224 } 225 return cells 226 } 227 228 private static func parseNullTerminatedStrings( 229 in data: Data, 230 from start: Int, 231 count: Int 232 ) throws -> (strings: [String], endOffset: Int) { 233 var strings: [String] = [] 234 var offset = start 235 while strings.count < count && offset < data.count { 236 guard let end = data[offset...].firstIndex(of: 0) else { break } 237 strings.append(decodeString(data[offset..<end])) 238 offset = data.index(after: end) 239 } 240 guard strings.count == count else { 241 throw ConversionError(message: "Across Lite string table is incomplete.") 242 } 243 return (strings, offset) 244 } 245 246 private static func displayTitle(fromPUZTitle title: String) -> String { 247 let letters = title.unicodeScalars.filter { CharacterSet.letters.contains($0) } 248 guard !letters.isEmpty, 249 letters.allSatisfy({ CharacterSet.uppercaseLetters.contains($0) }) 250 else { return title } 251 252 var result = "" 253 var startOfWord = true 254 for scalar in title.unicodeScalars { 255 if CharacterSet.letters.contains(scalar) { 256 let string = String(scalar) 257 result += startOfWord ? string.uppercased() : string.lowercased() 258 startOfWord = false 259 } else { 260 result += String(scalar) 261 startOfWord = !CharacterSet.decimalDigits.contains(scalar) 262 } 263 } 264 return result 265 } 266 267 /// `.puz` author strings frequently embed the credit phrasing 268 /// ("by Jane Doe", "Edited by John Smith"). Strip a leading 269 /// "by"/"edited by" and surrounding whitespace so the stored 270 /// `Author:` metadata is a clean name, matching the NYT path; the 271 /// credits view supplies the "By " label at render time. 272 private static func displayAuthor(fromPUZAuthor author: String) -> String { 273 var result = author.trimmingCharacters(in: .whitespacesAndNewlines) 274 for prefix in ["edited by ", "by "] where result.lowercased().hasPrefix(prefix) { 275 result = String(result.dropFirst(prefix.count)) 276 .trimmingCharacters(in: .whitespacesAndNewlines) 277 break 278 } 279 return result 280 } 281 282 private static func parseExtensions(in data: Data, from start: Int) -> [String: Data] { 283 var extensions: [String: Data] = [:] 284 var offset = start 285 while offset + 8 <= data.count { 286 guard let code = String(data: data[offset..<(offset + 4)], encoding: .ascii) else { break } 287 let length = Int(littleEndianUInt16(in: data, at: offset + 4)) 288 let payloadStart = offset + 8 289 let payloadEnd = payloadStart + length 290 guard payloadEnd <= data.count else { break } 291 extensions[code] = data[payloadStart..<payloadEnd] 292 offset = payloadEnd 293 if offset < data.count, data[offset] == 0 { 294 offset += 1 295 } 296 } 297 return extensions 298 } 299 300 private static func parseCircledCells( 301 extensions: [String: Data], 302 cellCount: Int 303 ) -> Set<Int> { 304 guard let data = extensions["GEXT"], data.count >= cellCount else { return [] } 305 var cells: Set<Int> = [] 306 for offset in 0..<cellCount { 307 let dataIndex = data.index(data.startIndex, offsetBy: offset) 308 if data[dataIndex] & 0x80 != 0 { 309 cells.insert(offset) 310 } 311 } 312 return cells 313 } 314 315 private static func parseRebus( 316 extensions: [String: Data], 317 cellCount: Int 318 ) -> [Int: String] { 319 guard let grid = extensions["GRBS"], grid.count >= cellCount else { return [:] } 320 let table = parseRebusTable(extensions["RTBL"]) 321 var rebus: [Int: String] = [:] 322 for offset in 0..<cellCount { 323 let gridIndex = grid.index(grid.startIndex, offsetBy: offset) 324 let key = Int(grid[gridIndex]) 325 guard key > 0, let value = table[key] else { continue } 326 rebus[offset] = value 327 } 328 return rebus 329 } 330 331 private static func parseRebusTable(_ data: Data?) -> [Int: String] { 332 guard let data else { return [:] } 333 let source = decodeString(data) 334 var table: [Int: String] = [:] 335 for rawEntry in source.split(separator: ";") { 336 let entry = rawEntry.trimmingCharacters(in: .whitespacesAndNewlines) 337 guard let colon = entry.firstIndex(of: ":"), 338 let key = Int(entry[..<colon].trimmingCharacters(in: .whitespaces)) 339 else { continue } 340 let value = entry[entry.index(after: colon)...].trimmingCharacters(in: .whitespaces) 341 if !value.isEmpty { 342 table[key] = value 343 } 344 } 345 return table 346 } 347 348 private static func isOpen(_ byte: UInt8) -> Bool { 349 byte != UInt8(ascii: ".") && byte != 0 350 } 351 352 private static func isOpen( 353 _ solutionBytes: [UInt8], 354 row: Int, 355 col: Int, 356 width: Int, 357 height: Int 358 ) -> Bool { 359 guard row >= 0, row < height, col >= 0, col < width else { return false } 360 return isOpen(solutionBytes[row * width + col]) 361 } 362 363 private static func littleEndianUInt16(in data: Data, at offset: Int) -> UInt16 { 364 UInt16(data[offset]) | (UInt16(data[offset + 1]) << 8) 365 } 366 367 private static func asciiString(in data: Data, range: Range<Int>) -> String? { 368 guard data.count >= range.upperBound else { return nil } 369 return String(data: data[range], encoding: .ascii) 370 } 371 372 private static func decodeString(_ data: Data) -> String { 373 if let value = String(data: data, encoding: .windowsCP1252) { 374 return value.trimmingCharacters(in: .newlines) 375 } 376 if let value = String(data: data, encoding: .isoLatin1) { 377 return value.trimmingCharacters(in: .newlines) 378 } 379 return String(decoding: data, as: UTF8.self).trimmingCharacters(in: .newlines) 380 } 381 }