crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }