crossmate

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

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 }