commit 309cf825a7e5cdc3825ac4bdb042c5d6b46d33ef
parent 3f15d7853e38859f2c01cad82170d1f216a8ad9b
Author: Michael Camilleri <[email protected]>
Date: Mon, 22 Jun 2026 09:06:20 +0900
Support blank squares in puzzles
Some crossword puzzles read their crossing words straight through
deliberately-blank squares.
This commit updates NYTToXDConverter to read a playable cell with no
answer as a space fill and to discard the blank marker. The space rides
into the grid as an ordinary rebus placeholder, escaped in the Rebus
header as `\space` so it survives the header's whitespace-split grammar;
XD.unescapeRebusValue decodes it on the way back, through a named-escape
scheme left open for further escapes.
Importing the puzzle was not enough to solve it, since the solve model
treated every empty cell as unfinished and wrong. Puzzle.Cell now
reports expectsBlank for a whitespace-only solution, accepts counts an
empty entry as correct only for such a cell, and Game leaves gaps out of
the fill it requires for completion while still flagging a stray letter
dropped into one. A gap is solved by leaving it blank.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
6 files changed, 201 insertions(+), 12 deletions(-)
diff --git a/Crossmate/Models/Game.swift b/Crossmate/Models/Game.swift
@@ -17,8 +17,11 @@ final class Game {
init(puzzle: Puzzle) {
self.puzzle = puzzle
+ // Gap cells (solution is a literal blank) are excluded: they need no
+ // user fill — their correct state is empty — so counting them would
+ // leave the puzzle permanently `.incomplete`.
self.fillableCellCount = puzzle.cells.reduce(0) { count, row in
- count + row.filter { !$0.isBlock && $0.solution != nil }.count
+ count + row.filter { !$0.isBlock && $0.solution != nil && !$0.expectsBlank }.count
}
self.squares = Array(
repeating: Array(repeating: Square(), count: puzzle.width),
@@ -181,7 +184,7 @@ final class Game {
let cell = puzzle.cells[r][c]
guard !cell.isBlock, cell.solution != nil else { continue }
let entry = squares[r][c].entry
- if !entry.isEmpty { filledCellCount += 1 }
+ if !entry.isEmpty, !cell.expectsBlank { filledCellCount += 1 }
if isWrongEntry(entry, for: cell) { wrongCellCount += 1 }
}
}
@@ -190,10 +193,15 @@ final class Game {
private func noteEntryChange(from oldEntry: String, to newEntry: String, for cell: Puzzle.Cell) {
guard cell.solution != nil else { return }
- if oldEntry.isEmpty, !newEntry.isEmpty {
- filledCellCount += 1
- } else if !oldEntry.isEmpty, newEntry.isEmpty {
- filledCellCount -= 1
+ // A gap cell is never part of the fill count (see `fillableCellCount`),
+ // so a stray entry in one must not inflate it — only its wrongness,
+ // tracked below, matters.
+ if !cell.expectsBlank {
+ if oldEntry.isEmpty, !newEntry.isEmpty {
+ filledCellCount += 1
+ } else if !oldEntry.isEmpty, newEntry.isEmpty {
+ filledCellCount -= 1
+ }
}
let wasWrong = isWrongEntry(oldEntry, for: cell)
diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift
@@ -67,9 +67,23 @@ struct Puzzle: Sendable {
let solution: String?
let acceptedSolutions: Set<String>
+ /// Whether this cell's correct state is to be left empty — its solution
+ /// is a literal blank, the "gap" square of a themer like NYT's "THE GAP"
+ /// puzzle, where crossing words read straight through a deliberately
+ /// empty cell. The custom keyboard can't type a space, so a gap is
+ /// solved by entering nothing.
+ var expectsBlank: Bool {
+ guard let solution else { return false }
+ return !solution.isEmpty && solution.allSatisfy(\.isWhitespace)
+ }
+
func accepts(_ entry: String) -> Bool {
- guard !entry.isEmpty else { return false }
let normalizedEntry = Self.normalizedAnswer(entry)
+ if normalizedEntry.isEmpty {
+ // An empty entry is correct only for a gap cell; every other
+ // cell still needs a fill.
+ return expectsBlank
+ }
if let solution, normalizedEntry == Self.normalizedAnswer(solution) {
return true
}
diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift
@@ -237,11 +237,46 @@ struct XD: Sendable {
let key = entry[..<equals]
let val = entry[entry.index(after: equals)...]
guard key.count == 1, let keyChar = key.first, !val.isEmpty else { continue }
- map[keyChar] = String(val)
+ map[keyChar] = unescapeRebusValue(val)
}
return map
}
+ /// Reverses `NYTToXDConverter.escapeRebusValue`. A backslash introduces an
+ /// escape: `\\` is a literal backslash, and `\name` is a named escape whose
+ /// name is the following run of letters (currently just `\space` → a space,
+ /// which lets a gap cell's blank fill survive the whitespace-split header).
+ /// An unrecognised escape is preserved verbatim so an older parser never
+ /// silently drops a value a newer writer produced.
+ private static func unescapeRebusValue(_ value: Substring) -> String {
+ guard value.contains("\\") else { return String(value) }
+ var out = ""
+ var i = value.startIndex
+ while i < value.endIndex {
+ guard value[i] == "\\" else {
+ out.append(value[i])
+ i = value.index(after: i)
+ continue
+ }
+ let next = value.index(after: i)
+ guard next < value.endIndex else { out.append("\\"); break }
+ if value[next] == "\\" {
+ out.append("\\")
+ i = value.index(after: next)
+ continue
+ }
+ var j = next
+ while j < value.endIndex, value[j].isLetter { j = value.index(after: j) }
+ let name = value[next..<j]
+ switch name {
+ case "space": out.append(" ")
+ default: out.append("\\"); out.append(contentsOf: name)
+ }
+ i = j
+ }
+ return out
+ }
+
/// Parses a `Date:` header value as strict ISO `YYYY-MM-DD`. Returns
/// `nil` if the value is missing, empty, or in any other format.
private static func parseDateHeader(_ value: String?) -> Date? {
diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift
@@ -104,10 +104,17 @@ enum NYTToXDConverter {
? rawAnswer.replacingOccurrences(of: "/", with: "")
: rawAnswer
if answer.isEmpty {
- answers.append(nil)
- } else {
- answers.append(answer)
+ // A playable cell (NYT type 1) with no answer is an intentional
+ // blank: the "gap" in themers like the 2006-07-06 "THE GAP"
+ // puzzle, where crossing words read straight through a square
+ // whose solution is a literal space. NYT flags these with a
+ // blank-marker `moreAnswers` ("B"), which we drop by `continue`ing
+ // past the alternates below — the square's only correct state is
+ // empty. Any other answerless cell is a block.
+ answers.append(intValue(dict["type"]) == 1 ? " " : nil)
+ continue
}
+ answers.append(answer)
if let moreAnswers = dict["moreAnswers"] as? [String: Any],
let valid = moreAnswers["valid"] as? [String] {
@@ -262,7 +269,9 @@ enum NYTToXDConverter {
}
if !rebusEntries.isEmpty {
- let rebusStr = rebusEntries.map { "\($0.key)=\($0.value)" }.joined(separator: " ")
+ let rebusStr = rebusEntries
+ .map { "\($0.key)=\(escapeRebusValue($0.value))" }
+ .joined(separator: " ")
metadata.append("Rebus: \(rebusStr)")
}
@@ -317,6 +326,26 @@ enum NYTToXDConverter {
return variants
}
+ /// Escapes a `Rebus:` value for the header's whitespace-delimited,
+ /// `=`-keyed grammar (see `XD.parseRebusHeader`). Because the header splits
+ /// entries on whitespace, a value that *is* whitespace — the space fill of a
+ /// "gap" cell — can't appear literally; it rides in as the named escape
+ /// `\space`. Backslash is the escape introducer, so a literal backslash
+ /// doubles to `\\`. The scheme is deliberately open-ended: further `\name`
+ /// (or `\u{...}`) escapes can join it to carry any character the header
+ /// grammar would otherwise eat. `XD.unescapeRebusValue` is the inverse.
+ private static func escapeRebusValue(_ value: String) -> String {
+ var out = ""
+ for ch in value {
+ switch ch {
+ case "\\": out += "\\\\"
+ case " ": out += "\\space"
+ default: out.append(ch)
+ }
+ }
+ return out
+ }
+
private static func escapeAcceptToken(_ token: String) -> String {
var escaped = ""
for ch in token {
diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift
@@ -223,6 +223,49 @@ struct NYTToXDConverterTests {
#expect(!cell.accepts("R"))
}
+ @Test("Type 1 cell with no answer becomes a space-fill rebus that round-trips")
+ func gapCellBecomesSpaceRebus() throws {
+ // The 2006-07-06 "THE GAP" themer reads crossing words straight through
+ // blank squares: a playable (type 1) cell with no `answer` whose only
+ // correct state is empty. NYT tags it with a blank-marker `moreAnswers`
+ // ("B") we must ignore. Here "TO BE" has its gap at index 2.
+ let cells: [[String: Any]] = [
+ ["answer": "T"],
+ ["answer": "O"],
+ ["type": 1, "moreAnswers": ["valid": ["B"]]],
+ ["answer": "B"],
+ ["answer": "E"]
+ ]
+ let clue: [String: Any] = [
+ "label": "1",
+ "direction": "Across",
+ "cells": [0, 1, 2, 3, 4],
+ "text": [["plain": "Repeated part of a soliloquy"]]
+ ]
+ let root: [String: Any] = [
+ "publicationDate": "2025-03-03",
+ "body": [[
+ "dimensions": ["width": 5, "height": 1],
+ "cells": cells,
+ "clues": [clue]
+ ]]
+ ]
+ let data = try JSONSerialization.data(withJSONObject: root)
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+
+ // The space fill rides in as a grid placeholder, escaped as `\space` so
+ // it survives the whitespace-split Rebus header grammar.
+ #expect(header("Rebus", in: xd) == "1=\\space")
+ #expect(xd.contains("\nTO1BE\n")) // grid uses the placeholder, never a literal gap
+ #expect(xd.contains("~ TO BE")) // the clue answer carries the real space
+ #expect(!xd.contains("Accept")) // the "B" blank marker is dropped, not an alternate
+
+ let puzzle = Puzzle(xd: try XD.parse(xd))
+ #expect(puzzle.cells[0][2].solution == " ")
+ #expect(puzzle.cells[0][0].solution == "T")
+ #expect(puzzle.cells[0][3].solution == "B")
+ }
+
@Test("NYT type 2 cells emit circled specials")
func typeTwoCellsEmitCircledSpecials() throws {
let data = try puzzleJSON(
diff --git a/Tests/Unit/XDAcceptTests.swift b/Tests/Unit/XDAcceptTests.swift
@@ -39,6 +39,66 @@ struct XDAcceptTests {
#expect(xd.cmVersion == 3)
}
+ @Test("Rebus value escapes decode: \\space to a blank, \\\\ to a backslash")
+ func rebusValueEscapesDecode() throws {
+ // `\space` lets a gap cell's blank fill survive the whitespace-split
+ // Rebus header; `\\` is the defensive literal-backslash escape. Grid
+ // "A1B2": cell 1 is the space fill, cell 2 a backslash-bearing fill.
+ let puzzle = Puzzle(xd: try XD.parse(#"""
+ Title: Gaps
+ CmVer: 5
+ Rebus: 1=\space 2=C\\D
+
+
+ A1B2
+
+
+ A1. Row ~ A BC\D
+ """#))
+
+ #expect(puzzle.cells[0][0].solution == "A")
+ #expect(puzzle.cells[0][1].solution == " ")
+ #expect(puzzle.cells[0][2].solution == "B")
+ #expect(puzzle.cells[0][3].solution == "C\\D")
+ }
+
+ @Test("A gap cell is solved by leaving it blank")
+ func gapCellSolvedWhenBlank() throws {
+ // "TO BE" with the gap at col 2 (the space fill). The gap is correct
+ // when empty and needs no fill for completion; a stray letter in it is
+ // wrong.
+ let source = #"""
+ Title: Gap
+ CmVer: 5
+ Rebus: 1=\space
+
+
+ TO1BE
+
+
+ A1. Repeated part of a soliloquy ~ TO BE
+ """#
+ let puzzle = Puzzle(xd: try XD.parse(source))
+
+ let gap = puzzle.cells[0][2]
+ #expect(gap.expectsBlank)
+ #expect(gap.accepts("")) // blank is the correct state
+ #expect(!gap.accepts("B")) // a letter is not
+ #expect(!puzzle.cells[0][0].accepts("")) // ordinary cells still need a fill
+
+ let game = Game(puzzle: Puzzle(xd: try XD.parse(source)))
+ game.setLetter("T", atRow: 0, atCol: 0, pencil: false)
+ game.setLetter("O", atRow: 0, atCol: 1, pencil: false)
+ game.setLetter("B", atRow: 0, atCol: 3, pencil: false)
+ game.setLetter("E", atRow: 0, atCol: 4, pencil: false)
+ // The gap at col 2 is left empty.
+ #expect(game.completionState == .solved)
+
+ // A stray letter in the gap surfaces as an error rather than completion.
+ game.setLetter("X", atRow: 0, atCol: 2, pencil: false)
+ #expect(game.completionState == .filledWithErrors)
+ }
+
@Test("Special symbols parse per cell")
func specialSymbolsParsePerCell() throws {
let puzzle = Puzzle(xd: try XD.parse("""