crossmate

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

commit 376ab6278e48755d8ea77e153086ec2447b105d6
parent 8ce8a636ce38fec29dd8a653150e6c5140b64ef3
Author: Michael Camilleri <[email protected]>
Date:   Wed, 22 Jul 2026 13:59:29 +0900

Preserve special styling on rebus squares

A rebus square could not also be shaded/circled because conversion gave
the rebus placeholder precedence over the grid's special symbol.

This commit lets one grid symbol carry both Rebus and Specials mappings.
The converters allocate placeholders by fill and styling so identical
rebuses can remain styled independently, while version bumps refresh
existing external sources and cached puzzle data.

Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>

Diffstat:
MCrossmate/Models/XD.swift | 16++++++++++------
MCrossmate/Services/NYTToXDConverter.swift | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
MCrossmate/Services/PUZToXDConverter.swift | 30+++++++++++++++++-------------
MTests/Unit/NYTToXDConverterTests.swift | 29+++++++++++++++++++++++------
MTests/Unit/PUZToXDConverterTests.swift | 10++++------
MTests/Unit/XDAcceptTests.swift | 18++++++++++++++++++
6 files changed, 127 insertions(+), 53 deletions(-)

diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift @@ -10,14 +10,14 @@ struct XD: Sendable { /// owned NYT game should be re-fetched and re-converted. Only bumped when a /// converter changes; puzzles generated in-house (Crossmake, bundled) carry /// no converter version at all. - static let currentConverterVersion = 9 + static let currentConverterVersion = 10 /// Version of the XD→Puzzle/Core Data processing (`XD.parse`, `Puzzle(xd:)`, /// and the cached summary fields). Held on the game entity, never /// serialized, and compared by `GameStore.preparePuzzleForLoad` to decide /// whether a game must be reparsed and its cache refreshed. Bumped only when /// that processing changes. - static let currentParserVersion = 9 + static let currentParserVersion = 10 /// Upper bound on a `.xd` source handed to `parse`. The largest puzzle we /// ship is ~3 KB, so this is ~80× real content — no genuine puzzle is ever @@ -634,16 +634,20 @@ struct XD: Sendable { if ch == "#" || ch == "_" { return .block } - if let special = specialSymbols[ch] { - return .open(solution: nil, acceptedSolutions: [], special: special) - } // '.' is an open cell with no known solution. if ch == "." { return .open(solution: nil, acceptedSolutions: [], special: nil) } let lowercaseSpecial = ch.isLetter && ch.isLowercase ? standardSpecial : nil if let expansion = rebus[ch] { - return .open(solution: expansion.uppercased(), acceptedSolutions: [], special: lowercaseSpecial) + return .open( + solution: expansion.uppercased(), + acceptedSolutions: [], + special: specialSymbols[ch] ?? lowercaseSpecial + ) + } + if let special = specialSymbols[ch] { + return .open(solution: nil, acceptedSolutions: [], special: special) } if ch.isLetter { return .open(solution: String(ch).uppercased(), acceptedSolutions: [], special: lowercaseSpecial) diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift @@ -7,6 +7,16 @@ enum NYTToXDConverter { var errorDescription: String? { message } } + private enum CellSpecial: String, Hashable { + case circled = "circle" + case shaded + } + + private struct RebusVariant: Hashable { + let answer: String + let special: CellSpecial? + } + /// Whether a cell fill must ride into the grid via a `Rebus:` placeholder /// rather than appear literally. The `.xd` grid is one character per cell and /// the grid parser only takes letters (plus block/special markers and @@ -117,31 +127,36 @@ enum NYTToXDConverter { } } + // -- Find special (shaded/circled) cells from NYT cell data -- + + let specialCells = specialCellInfo(body: body) + // -- Build rebus header if needed -- - // Check for multi-character answers. Each distinct multi-letter fill - // claims the next grid placeholder from `rebusPlaceholders`. - var rebusEntries: [(key: Character, value: String)] = [] - var rebusLookup: [String: Character] = [:] + // Each distinct fill-and-special combination claims a grid placeholder. + // Keeping the special in the identity prevents a shaded and unshaded + // occurrence of the same rebus fill from accidentally sharing styling. + var rebusEntries: [(key: Character, variant: RebusVariant)] = [] + var rebusLookup: [RebusVariant: Character] = [:] - for answer in answers { + for (index, answer) in answers.enumerated() { guard let answer, needsRebusEncoding(answer) else { continue } - if rebusLookup[answer] == nil { + let variant = RebusVariant( + answer: answer, + special: specialKind(at: index, in: specialCells) + ) + if rebusLookup[variant] == nil { guard rebusLookup.count < XD.rebusPlaceholders.count else { throw ConversionError( message: "Too many distinct rebus fills (\(rebusLookup.count + 1)); ran out of grid placeholders." ) } let key = XD.rebusPlaceholders[rebusLookup.count] - rebusLookup[answer] = key - rebusEntries.append((key: key, value: answer)) + rebusLookup[variant] = key + rebusEntries.append((key: key, variant: variant)) } } - // -- Find special (shaded/circled) cells from NYT cell data -- - - let specialCells = specialCellInfo(body: body) - // -- Build grid lines -- var gridLines: [String] = [] @@ -153,14 +168,12 @@ enum NYTToXDConverter { line += "#" continue } - // A cell can be both special and rebus, but a grid character - // can only say one thing: `@`/`*` parse as solution-less - // special cells, so emitting them here would drop the - // multi-char fill and leave the `Rebus:` header referencing a - // key absent from the grid. The fill wins; the special marker - // on that one cell is lost. if needsRebusEncoding(answer) { - line += String(rebusLookup[answer]!) + let variant = RebusVariant( + answer: answer, + special: specialKind(at: index, in: specialCells) + ) + line += String(rebusLookup[variant]!) continue } if specialCells.circled.contains(index) { @@ -279,12 +292,16 @@ enum NYTToXDConverter { if !rebusEntries.isEmpty { let rebusStr = rebusEntries - .map { "\($0.key)=\(escapeRebusValue($0.value))" } + .map { "\($0.key)=\(escapeRebusValue($0.variant.answer))" } .joined(separator: " ") metadata.append("Rebus: \(rebusStr)") } - let specialMappings = specialMappings(circled: specialCells.circled, shaded: specialCells.shaded) + let specialMappings = specialMappings( + circled: specialCells.circled, + shaded: specialCells.shaded, + rebusEntries: rebusEntries + ) if !specialMappings.isEmpty { metadata.append("Specials: \(specialMappings)") } @@ -636,7 +653,20 @@ enum NYTToXDConverter { return (circled, shaded) } - private static func specialMappings(circled: Set<Int>, shaded: Set<Int>) -> String { + private static func specialKind( + at index: Int, + in cells: (circled: Set<Int>, shaded: Set<Int>) + ) -> CellSpecial? { + if cells.circled.contains(index) { return .circled } + if cells.shaded.contains(index) { return .shaded } + return nil + } + + private static func specialMappings( + circled: Set<Int>, + shaded: Set<Int>, + rebusEntries: [(key: Character, variant: RebusVariant)] + ) -> String { var parts: [String] = [] if !circled.isEmpty { parts.append("@=circle") @@ -644,6 +674,9 @@ enum NYTToXDConverter { if !shaded.isEmpty { parts.append("*=shaded") } + parts += rebusEntries.compactMap { entry in + entry.variant.special.map { "\(entry.key)=\($0.rawValue)" } + } return parts.joined(separator: " ") } diff --git a/Crossmate/Services/PUZToXDConverter.swift b/Crossmate/Services/PUZToXDConverter.swift @@ -26,6 +26,11 @@ enum PUZToXDConverter { } } + private struct RebusVariant: Hashable { + let answer: String + let circled: Bool + } + /// Upper bound on raw `.puz` bytes accepted by `convert`. Real Across /// Lite files are tens of kilobytes. Independent of `XD.maxSourceBytes` /// because a `.puz` can hide a large ignored tail (string table, @@ -90,20 +95,21 @@ enum PUZToXDConverter { clueTexts: clueTexts ) - // Each distinct rebus fill claims one grid placeholder from + // Each distinct rebus fill-and-circle combination claims one grid placeholder from // `XD.rebusPlaceholders`; cells sharing a fill reuse the same key // (mirrors `NYTToXDConverter`). Keying by cell index instead would burn // a fresh character per cell and walk straight into the grid/header // reserved set (`=`, `@`, ...) and, past ~200 cells, off the end of the // alphabet — hence the bounds check rather than raw arithmetic. var rebusKeys: [Int: Character] = [:] - var rebusEntries: [(Character, String)] = [] - var rebusLookup: [String: Character] = [:] + var rebusEntries: [(Character, RebusVariant)] = [] + var rebusLookup: [RebusVariant: Character] = [:] for index in 0..<cellCount where isOpen(solutionBytes[index]) { guard let value = rebus[index], !value.isEmpty else { continue } let normalized = value.uppercased() + let variant = RebusVariant(answer: normalized, circled: circledCells.contains(index)) let key: Character - if let existing = rebusLookup[normalized] { + if let existing = rebusLookup[variant] { key = existing } else { guard rebusLookup.count < XD.rebusPlaceholders.count else { @@ -112,8 +118,8 @@ enum PUZToXDConverter { ) } key = XD.rebusPlaceholders[rebusLookup.count] - rebusLookup[normalized] = key - rebusEntries.append((key, normalized)) + rebusLookup[variant] = key + rebusEntries.append((key, variant)) } rebusKeys[index] = key } @@ -124,11 +130,14 @@ enum PUZToXDConverter { if !author.isEmpty { metadata.append("Author: \(author)") } if !copyright.isEmpty { metadata.append("Copyright: \(copyright)") } if !rebusEntries.isEmpty { - let header = rebusEntries.map { "\($0.0)=\($0.1)" }.joined(separator: " ") + let header = rebusEntries.map { "\($0.0)=\($0.1.answer)" }.joined(separator: " ") metadata.append("Rebus: \(header)") } if !circledCells.isEmpty { - metadata.append("Specials: @=circle") + let rebusCircles = rebusEntries + .filter { $0.1.circled } + .map { "\($0.0)=circle" } + metadata.append("Specials: \((["@=circle"] + rebusCircles).joined(separator: " "))") } let gridLines = (0..<height).map { row -> String in @@ -140,11 +149,6 @@ enum PUZToXDConverter { line += "#" continue } - // A cell can be both circled and rebus, but a grid character - // can only say one thing: `@` parses as a solution-less circled - // cell, so emitting it here would drop the multi-char fill and - // leave the `Rebus:` header referencing a key absent from the - // grid. The fill wins; the circle on that one cell is lost. if let key = rebusKeys[index] { line.append(key) continue diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift @@ -390,12 +390,8 @@ struct NYTToXDConverterTests { #expect(puzzle.cells[1][1].special == .shaded) } - @Test("A cell both circled and rebus keeps its fill over the circle") - func circledRebusCellKeepsRebusFill() throws { - // The grid can't say both: '@' parses as a solution-less circled cell, - // so letting the circle win (the old precedence) dropped the rebus - // placeholder from the grid while the Rebus header still referenced it. - // The fill must win; only that cell's circle is sacrificed. + @Test("A cell both circled and rebus keeps its fill and circle") + func circledRebusCellKeepsFillAndCircle() throws { let data = try puzzleJSON( relatives: [nil, nil, nil, nil, nil, nil], letters: ["A", "B", "C", "D", "HEART", "F", "G", "H", "I"], @@ -404,10 +400,31 @@ struct NYTToXDConverterTests { let xd = try NYTToXDConverter.convert(jsonData: data) #expect(header("Rebus", in: xd) == "1=HEART") + #expect(header("Specials", in: xd) == "@=circle 1=circle") #expect(xd.contains("\n@BC\nD1F\nGHI\n")) let puzzle = Puzzle(xd: try XD.parse(xd)) #expect(puzzle.cells[1][1].solution == "HEART") #expect(puzzle.cells[0][0].special == .circled) + #expect(puzzle.cells[1][1].special == .circled) + } + + @Test("Shaded and unshaded occurrences of one rebus use separate placeholders") + func shadedAndUnshadedRebusOccurrencesUseSeparatePlaceholders() throws { + let data = try puzzleJSON( + relatives: [nil, nil, nil, nil, nil, nil], + letters: ["HEART", "B", "C", "D", "HEART", "F", "G", "H", "I"], + cellTypes: [0: 3] + ) + let xd = try NYTToXDConverter.convert(jsonData: data) + + #expect(header("Rebus", in: xd) == "1=HEART 2=HEART") + #expect(header("Specials", in: xd) == "*=shaded 1=shaded") + #expect(xd.contains("\n1BC\nD2F\nGHI\n")) + let puzzle = Puzzle(xd: try XD.parse(xd)) + #expect(puzzle.cells[0][0].solution == "HEART") + #expect(puzzle.cells[0][0].special == .shaded) + #expect(puzzle.cells[1][1].solution == "HEART") + #expect(puzzle.cells[1][1].special == nil) } @Test("Revealer with ≥2 relatives produces a group") diff --git a/Tests/Unit/PUZToXDConverterTests.swift b/Tests/Unit/PUZToXDConverterTests.swift @@ -206,12 +206,8 @@ struct PUZToXDConverterTests { #expect(puzzle.cells[1][1].solution == "HEART") } - @Test("A cell both circled and rebus keeps its fill over the circle") - func circledRebusCellKeepsRebusFill() throws { - // The grid can't say both: '@' parses as a solution-less circled cell, - // so letting the circle win (the old precedence) dropped the rebus - // placeholder from the grid while the Rebus header still referenced it. - // The fill must win; only that cell's circle is sacrificed. + @Test("A cell both circled and rebus keeps its fill and circle") + func circledRebusCellKeepsFillAndCircle() throws { let data = try puzData( width: 3, height: 3, @@ -234,11 +230,13 @@ struct PUZToXDConverterTests { let source = try PUZToXDConverter.convert(puzData: data) #expect(header("Rebus", in: source) == "1=HEART") + #expect(header("Specials", in: source) == "@=circle 1=circle") #expect(source.contains("\n@BC\nD1F\nGHI\n")) let puzzle = Puzzle(xd: try XD.parse(source)) #expect(puzzle.cells[1][1].solution == "HEART") #expect(puzzle.cells[0][0].special == .circled) + #expect(puzzle.cells[1][1].special == .circled) } @Test("Many distinct rebus fills never collide with reserved grid syntax") diff --git a/Tests/Unit/XDAcceptTests.swift b/Tests/Unit/XDAcceptTests.swift @@ -221,6 +221,24 @@ struct XDAcceptTests { #expect(puzzle.cells[1][1].solution == "E") } + @Test("A rebus placeholder can also mark a special cell") + func rebusPlaceholderCanAlsoMarkSpecialCell() throws { + let puzzle = Puzzle(xd: try XD.parse(""" + Title: Shaded Rebus + Rebus: 1=HEART + Specials: 1=shaded + + + 1 + + + A1. Rebus ~ HEART + """)) + + #expect(puzzle.cells[0][0].solution == "HEART") + #expect(puzzle.cells[0][0].special == .shaded) + } + @Test("Conflicting inferred special symbols fail parsing") func conflictingInferredSpecialSymbolsFailParsing() throws { #expect(throws: XD.ParseError.self) {