PUZToXDConverter.swift (13548B)
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 = 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("Special: 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 let key = rebusKeys[index] { 112 line.append(key) 113 continue 114 } 115 let letter = String(UnicodeScalar(byte)) 116 line += circledCells.contains(index) ? letter.lowercased() : letter.uppercased() 117 } 118 return line 119 } 120 121 let acrossLines = entries 122 .filter { $0.direction == .across } 123 .map { clueLine($0, solutionBytes: solutionBytes, rebus: rebus) } 124 let downLines = entries 125 .filter { $0.direction == .down } 126 .map { clueLine($0, solutionBytes: solutionBytes, rebus: rebus) } 127 128 return [ 129 metadata.joined(separator: "\n"), 130 gridLines.joined(separator: "\n"), 131 (acrossLines + [""] + downLines).joined(separator: "\n") 132 ].joined(separator: "\n\n\n") 133 } 134 135 private static func buildClues( 136 solutionBytes: [UInt8], 137 width: Int, 138 height: Int, 139 clueTexts: [String] 140 ) throws -> [ClueEntry] { 141 var entries: [ClueEntry] = [] 142 var clueIndex = 0 143 var number = 1 144 145 for row in 0..<height { 146 for col in 0..<width { 147 let index = row * width + col 148 guard isOpen(solutionBytes[index]) else { continue } 149 150 let startsAcross = !isOpen(solutionBytes, row: row, col: col - 1, width: width, height: height) 151 && isOpen(solutionBytes, row: row, col: col + 1, width: width, height: height) 152 let startsDown = !isOpen(solutionBytes, row: row - 1, col: col, width: width, height: height) 153 && isOpen(solutionBytes, row: row + 1, col: col, width: width, height: height) 154 155 if startsAcross || startsDown { 156 if startsAcross { 157 guard clueTexts.indices.contains(clueIndex) else { 158 throw ConversionError(message: "Across Lite clue table ended early.") 159 } 160 entries.append(ClueEntry( 161 number: number, 162 direction: .across, 163 cells: wordCells(fromRow: row, col: col, deltaRow: 0, deltaCol: 1, width: width, height: height, solutionBytes: solutionBytes), 164 text: clueTexts[clueIndex] 165 )) 166 clueIndex += 1 167 } 168 if startsDown { 169 guard clueTexts.indices.contains(clueIndex) else { 170 throw ConversionError(message: "Across Lite clue table ended early.") 171 } 172 entries.append(ClueEntry( 173 number: number, 174 direction: .down, 175 cells: wordCells(fromRow: row, col: col, deltaRow: 1, deltaCol: 0, width: width, height: height, solutionBytes: solutionBytes), 176 text: clueTexts[clueIndex] 177 )) 178 clueIndex += 1 179 } 180 number += 1 181 } 182 } 183 } 184 185 guard clueIndex == clueTexts.count else { 186 throw ConversionError(message: "Across Lite clue table has unused clues.") 187 } 188 return entries 189 } 190 191 private static func clueLine( 192 _ entry: ClueEntry, 193 solutionBytes: [UInt8], 194 rebus: [Int: String] 195 ) -> String { 196 let answer = entry.cells.map { index -> String in 197 if let rebusValue = rebus[index], !rebusValue.isEmpty { 198 return rebusValue 199 } 200 return String(UnicodeScalar(solutionBytes[index])) 201 }.joined().uppercased() 202 return "\(entry.direction.prefix)\(entry.number). \(entry.text) ~ \(answer)" 203 } 204 205 private static func wordCells( 206 fromRow row: Int, 207 col: Int, 208 deltaRow: Int, 209 deltaCol: Int, 210 width: Int, 211 height: Int, 212 solutionBytes: [UInt8] 213 ) -> [Int] { 214 var cells: [Int] = [] 215 var r = row 216 var c = col 217 while isOpen(solutionBytes, row: r, col: c, width: width, height: height) { 218 cells.append(r * width + c) 219 r += deltaRow 220 c += deltaCol 221 } 222 return cells 223 } 224 225 private static func parseNullTerminatedStrings( 226 in data: Data, 227 from start: Int, 228 count: Int 229 ) throws -> (strings: [String], endOffset: Int) { 230 var strings: [String] = [] 231 var offset = start 232 while strings.count < count && offset < data.count { 233 guard let end = data[offset...].firstIndex(of: 0) else { break } 234 strings.append(decodeString(data[offset..<end])) 235 offset = data.index(after: end) 236 } 237 guard strings.count == count else { 238 throw ConversionError(message: "Across Lite string table is incomplete.") 239 } 240 return (strings, offset) 241 } 242 243 private static func displayTitle(fromPUZTitle title: String) -> String { 244 let letters = title.unicodeScalars.filter { CharacterSet.letters.contains($0) } 245 guard !letters.isEmpty, 246 letters.allSatisfy({ CharacterSet.uppercaseLetters.contains($0) }) 247 else { return title } 248 249 var result = "" 250 var startOfWord = true 251 for scalar in title.unicodeScalars { 252 if CharacterSet.letters.contains(scalar) { 253 let string = String(scalar) 254 result += startOfWord ? string.uppercased() : string.lowercased() 255 startOfWord = false 256 } else { 257 result += String(scalar) 258 startOfWord = !CharacterSet.decimalDigits.contains(scalar) 259 } 260 } 261 return result 262 } 263 264 private static func parseExtensions(in data: Data, from start: Int) -> [String: Data] { 265 var extensions: [String: Data] = [:] 266 var offset = start 267 while offset + 8 <= data.count { 268 guard let code = String(data: data[offset..<(offset + 4)], encoding: .ascii) else { break } 269 let length = Int(littleEndianUInt16(in: data, at: offset + 4)) 270 let payloadStart = offset + 8 271 let payloadEnd = payloadStart + length 272 guard payloadEnd <= data.count else { break } 273 extensions[code] = data[payloadStart..<payloadEnd] 274 offset = payloadEnd 275 if offset < data.count, data[offset] == 0 { 276 offset += 1 277 } 278 } 279 return extensions 280 } 281 282 private static func parseCircledCells( 283 extensions: [String: Data], 284 cellCount: Int 285 ) -> Set<Int> { 286 guard let data = extensions["GEXT"], data.count >= cellCount else { return [] } 287 var cells: Set<Int> = [] 288 for index in 0..<cellCount where data[index] & 0x80 != 0 { 289 cells.insert(index) 290 } 291 return cells 292 } 293 294 private static func parseRebus( 295 extensions: [String: Data], 296 cellCount: Int 297 ) -> [Int: String] { 298 guard let grid = extensions["GRBS"], grid.count >= cellCount else { return [:] } 299 let table = parseRebusTable(extensions["RTBL"]) 300 var rebus: [Int: String] = [:] 301 for index in 0..<cellCount { 302 let key = Int(grid[index]) 303 guard key > 0, let value = table[key] else { continue } 304 rebus[index] = value 305 } 306 return rebus 307 } 308 309 private static func parseRebusTable(_ data: Data?) -> [Int: String] { 310 guard let data else { return [:] } 311 let source = decodeString(data) 312 var table: [Int: String] = [:] 313 for rawEntry in source.split(separator: ";") { 314 let entry = rawEntry.trimmingCharacters(in: .whitespacesAndNewlines) 315 guard let colon = entry.firstIndex(of: ":"), 316 let key = Int(entry[..<colon].trimmingCharacters(in: .whitespaces)) 317 else { continue } 318 let value = entry[entry.index(after: colon)...].trimmingCharacters(in: .whitespaces) 319 if !value.isEmpty { 320 table[key] = value 321 } 322 } 323 return table 324 } 325 326 private static func isOpen(_ byte: UInt8) -> Bool { 327 byte != UInt8(ascii: ".") && byte != 0 328 } 329 330 private static func isOpen( 331 _ solutionBytes: [UInt8], 332 row: Int, 333 col: Int, 334 width: Int, 335 height: Int 336 ) -> Bool { 337 guard row >= 0, row < height, col >= 0, col < width else { return false } 338 return isOpen(solutionBytes[row * width + col]) 339 } 340 341 private static func littleEndianUInt16(in data: Data, at offset: Int) -> UInt16 { 342 UInt16(data[offset]) | (UInt16(data[offset + 1]) << 8) 343 } 344 345 private static func asciiString(in data: Data, range: Range<Int>) -> String? { 346 guard data.count >= range.upperBound else { return nil } 347 return String(data: data[range], encoding: .ascii) 348 } 349 350 private static func decodeString(_ data: Data) -> String { 351 if let value = String(data: data, encoding: .windowsCP1252) { 352 return value.trimmingCharacters(in: .newlines) 353 } 354 if let value = String(data: data, encoding: .isoLatin1) { 355 return value.trimmingCharacters(in: .newlines) 356 } 357 return String(decoding: data, as: UTF8.self).trimmingCharacters(in: .newlines) 358 } 359 }