crossmate

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

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 }