crossmate

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

XDDecorationWriter.swift (6078B)


      1 import Foundation
      2 
      3 /// Renders the `.xd` `## Decorations` section. Shared by every converter that
      4 /// writes `.xd` so the key allocation and definition-line grammar live in one
      5 /// place, symmetric with `XD.parseDecorations` — the only reader.
      6 enum XDDecorationWriter {
      7     enum WriteError: LocalizedError {
      8         case tooManyDistinctDecorations(Int)
      9 
     10         var errorDescription: String? {
     11             switch self {
     12             case .tooManyDistinctDecorations(let count):
     13                 return "Too many distinct decoration stacks (\(count)); ran out of design-grid keys."
     14             }
     15         }
     16     }
     17 
     18     /// Keys preferred for the two decorations that predate the section, so an
     19     /// ordinary circles-and-shading puzzle keeps a design grid a person can read
     20     /// at a glance rather than one keyed by arbitrary digits.
     21     private static let mnemonicKeys: [Puzzle.Decoration: Character] = [
     22         Puzzle.Decoration(content: .mark(.circled), phase: .before): "O",
     23         Puzzle.Decoration(content: .mark(.shaded), phase: .before): "S",
     24     ]
     25 
     26     /// Every character the design grid may use. Digits and letters come first
     27     /// because they read best; the symbol tail only matters for grid art, which
     28     /// can run to dozens of distinct per-cell images.
     29     ///
     30     /// Three exclusions: `.` means "no decoration", whitespace would make a grid
     31     /// line look like a definition line (that's how the two are told apart), and
     32     /// `#` is dropped so an all-`#` design row can't be mistaken for a `##`
     33     /// section header the way an all-block *puzzle* row nearly is.
     34     private static let poolKeys: [Character] = {
     35         let reserved: Set<Character> = [".", "#", "O", "S"]
     36         let symbols = (UInt8(33)...UInt8(126))
     37             .map { Character(UnicodeScalar($0)) }
     38             .filter { !$0.isLetter && !$0.isNumber && !reserved.contains($0) }
     39         // `0` is left out for the same reason `XD.rebusPlaceholders` omits it:
     40         // it reads as `O` in a dense grid of single characters.
     41         let letters = Array("123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
     42             .filter { !reserved.contains($0) }
     43         return letters + symbols
     44     }()
     45 
     46     /// Renders the section, or `nil` when there is nothing to draw. The returned
     47     /// string includes its own `## Decorations` header, so a caller appends it to
     48     /// the section list exactly like the metadata, grid and clue sections.
     49     static func section(
     50         decorations: [GridPosition: [Puzzle.Decoration]],
     51         width: Int,
     52         height: Int
     53     ) throws -> String? {
     54         let populated = decorations.filter { !$0.value.isEmpty }
     55         guard !populated.isEmpty else { return nil }
     56 
     57         // Discover distinct stacks in row-major order so key assignment — and
     58         // therefore the emitted text — is identical for identical input. Without
     59         // this the output would vary run to run with dictionary ordering, making
     60         // converter output undiffable and round-trip tests unassertable.
     61         var order: [[Puzzle.Decoration]] = []
     62         var seen: Set<[Puzzle.Decoration]> = []
     63         for row in 0..<height {
     64             for col in 0..<width {
     65                 guard let stack = populated[GridPosition(row: row, col: col)] else { continue }
     66                 if seen.insert(stack).inserted {
     67                     order.append(stack)
     68                 }
     69             }
     70         }
     71 
     72         // Mnemonics are claimed first so a stack entitled to one is never beaten
     73         // to it by an earlier stack drawing from the pool.
     74         var used: Set<Character> = []
     75         var keys: [[Puzzle.Decoration]: Character] = [:]
     76         for stack in order where stack.count == 1 {
     77             guard let key = mnemonicKeys[stack[0]], !used.contains(key) else { continue }
     78             keys[stack] = key
     79             used.insert(key)
     80         }
     81         var pool = poolKeys.makeIterator()
     82         for stack in order where keys[stack] == nil {
     83             guard let key = pool.next() else {
     84                 throw WriteError.tooManyDistinctDecorations(order.count)
     85             }
     86             keys[stack] = key
     87             used.insert(key)
     88         }
     89 
     90         var gridLines: [String] = []
     91         gridLines.reserveCapacity(height)
     92         for row in 0..<height {
     93             var line = ""
     94             for col in 0..<width {
     95                 if let stack = populated[GridPosition(row: row, col: col)], let key = keys[stack] {
     96                     line.append(key)
     97                 } else {
     98                     line.append(".")
     99                 }
    100             }
    101             gridLines.append(line)
    102         }
    103 
    104         var definitionLines: [String] = []
    105         for stack in order {
    106             guard let key = keys[stack] else { continue }
    107             for decoration in stack {
    108                 definitionLines.append(line(key: key, decoration: decoration))
    109             }
    110         }
    111 
    112         return "## Decorations\n\n"
    113             + gridLines.joined(separator: "\n")
    114             + "\n\n"
    115             + definitionLines.joined(separator: "\n")
    116     }
    117 
    118     private static func line(key: Character, decoration: Puzzle.Decoration) -> String {
    119         var out = "\(key). " + pair(decoration.content)
    120         // `before` is the default, so it's left off — the common case stays the
    121         // short one, and a reader only sees a phase where it matters.
    122         if decoration.phase == .after { out += " after" }
    123         return out
    124     }
    125 
    126     private static func pair(_ content: Puzzle.Decoration.Content) -> String {
    127         switch content {
    128         case .mark(let special):
    129             return "mark=" + (special == .circled ? "circle" : "shaded")
    130         case .color(let layer, let light, let dark):
    131             let kind = layer == .background ? "bg" : "fg"
    132             guard let dark else { return "\(kind)=\(light)" }
    133             return "\(kind)=\(light);\(dark)"
    134         case .text(let value):
    135             return "text=\(value)"
    136         case .data(let mimeType, let encoding, let payload):
    137             return "data=\(mimeType);\(encoding),\(payload)"
    138         }
    139     }
    140 }