crossmate

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

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:
MCrossmate/Services/NYTToXDConverter.swift | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
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 }