commit 7b460c39362197673a899079521fb3193c51d88e
parent be2d332ad9c5fc2e1cffca601d79fddf3c8d2970
Author: Michael Camilleri <[email protected]>
Date: Wed, 22 Apr 2026 08:11:58 +0900
Support shaded cells in certain crosswords
This commit adds support for parsing shaded cells from some JSON-based
puzzle sources.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
1 file changed, 64 insertions(+), 2 deletions(-)
diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift
@@ -80,6 +80,10 @@ enum NYTToXDConverter {
}
}
+ // -- Find special (shaded/circled) cells from the SVG --
+
+ let specialCells = specialCellIndices(body: body)
+
// -- Build grid lines --
var gridLines: [String] = []
@@ -91,11 +95,15 @@ enum NYTToXDConverter {
line += "#"
continue
}
+ let isSpecial = specialCells.contains(index)
if answer.count > 1 {
- // Rebus: use the placeholder character
+ // Rebus: use the placeholder character. Lowercase marking
+ // only applies to single-letter cells per the .xd spec, so
+ // shading on a rebus cell would be lost here — no puzzle
+ // we've seen combines the two.
line += String(rebusLookup[answer]!)
} else {
- line += answer
+ line += isSpecial ? answer.lowercased() : answer
}
}
gridLines.append(line)
@@ -170,6 +178,10 @@ enum NYTToXDConverter {
metadata.append("Rebus: \(rebusStr)")
}
+ if !specialCells.isEmpty {
+ metadata.append("Special: shaded")
+ }
+
let relatives = buildRelatives(clues: clues)
if !relatives.isEmpty {
let joined = relatives
@@ -264,6 +276,56 @@ enum NYTToXDConverter {
return groups
}
+ /// Walks the structured SVG tree under `body.SVG` to find cells that are
+ /// rendered with a non-default fill (e.g. `lightgray` for shading). The
+ /// cells group is a `<g data-group="cells">` whose children are per-cell
+ /// `<g>` nodes in `body.cells` order — so the child's array index is the
+ /// cell index. Each per-cell group's first child is a `<rect>` whose
+ /// `fill` attribute carries the theme marker.
+ ///
+ /// Only `lightgray` (shading) is recognised today. Circle-based themes
+ /// mark cells with a `<circle>` element instead and will need their own
+ /// branch when a sample puzzle is available.
+ private static func specialCellIndices(body: [String: Any]) -> Set<Int> {
+ guard let svg = body["SVG"] as? [String: Any],
+ let topChildren = svg["children"] as? [[String: Any]] else {
+ return []
+ }
+
+ let cellsGroup = topChildren.first { node in
+ guard (node["name"] as? String) == "g",
+ let attrs = node["attributes"] as? [[String: Any]] else {
+ return false
+ }
+ return attrs.contains { attr in
+ (attr["name"] as? String) == "data-group"
+ && (attr["value"] as? String) == "cells"
+ }
+ }
+
+ guard let cellsGroup,
+ let cellGroups = cellsGroup["children"] as? [[String: Any]] else {
+ return []
+ }
+
+ var indices: Set<Int> = []
+ for (index, cellGroup) in cellGroups.enumerated() {
+ guard let inner = cellGroup["children"] as? [[String: Any]],
+ let rect = inner.first,
+ (rect["name"] as? String) == "rect",
+ let attrs = rect["attributes"] as? [[String: Any]] else {
+ continue
+ }
+ let fill = attrs.first {
+ ($0["name"] as? String) == "fill"
+ }?["value"] as? String
+ if fill == "lightgray" {
+ indices.insert(index)
+ }
+ }
+ return indices
+ }
+
/// Extracts an Int from a JSON value that may be NSNumber, Int, or Double.
private static func intValue(_ value: Any?) -> Int? {
if let n = value as? Int { return n }