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 }