crossmate

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

commit c6dfbc0e018dc319859f86298278f737823e1d90
parent d64acf0bd3beeba45cd25f02d5ac1ba338e2aaab
Author: Michael Camilleri <[email protected]>
Date:   Tue, 12 May 2026 17:04:50 +0900

Render circled cells in converted puzzles

This commit replaces specialCellIndices(body:) with specialCellInfo(body:)
which scans each per-cell <g> in certain JSON input files for both markers; the
same rect-fill check as prior to this commit plus a <circle> child element. The
function returns the affected indices together with the kind ("shaded" or
"circle"), and the metadata writer now emits the matching Special: header.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/Models/XD.swift | 2+-
MCrossmate/Services/NYTToXDConverter.swift | 65+++++++++++++++++++++++++++++++++++++++--------------------------
2 files changed, 40 insertions(+), 27 deletions(-)

diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift @@ -4,7 +4,7 @@ import Foundation /// full specification. Supports just enough of the format to parse our /// bundled puzzles: metadata, grid (with rebus), and across/down clues. struct XD: Sendable { - static let currentCmVersion = 1 + static let currentCmVersion = 2 let title: String? let publisher: String? diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift @@ -91,7 +91,7 @@ enum NYTToXDConverter { // -- Find special (shaded/circled) cells from the SVG -- - let specialCells = specialCellIndices(body: body) + let (specialCells, specialKind) = specialCellInfo(body: body) // -- Build grid lines -- @@ -203,8 +203,8 @@ enum NYTToXDConverter { metadata.append("Rebus: \(rebusStr)") } - if !specialCells.isEmpty { - metadata.append("Special: shaded") + if !specialCells.isEmpty, let specialKind { + metadata.append("Special: \(specialKind)") } let relatives = buildRelativeGroups(clues: clues) @@ -413,20 +413,21 @@ enum NYTToXDConverter { return "\(label)\(direction == "Across" ? "A" : "D")" } - /// 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. + /// Walks the structured SVG tree under `body.SVG` to find cells that + /// carry a theme marker. 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. Two markers are recognised: /// - /// 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> { + /// * Shaded cells — the cell's `<rect>` background has `fill="lightgray"`. + /// * Circled cells — the cell group contains a `<circle>` element. + /// + /// The `.xd` format only supports one `Special:` kind per puzzle. If a + /// grid mixes both (not seen in the wild yet), circles take precedence + /// since they're the more visually distinct marker. + private static func specialCellInfo(body: [String: Any]) -> (indices: Set<Int>, kind: String?) { guard let svg = body["SVG"] as? [String: Any], let topChildren = svg["children"] as? [[String: Any]] else { - return [] + return ([], nil) } let cellsGroup = topChildren.first { node in @@ -442,25 +443,37 @@ enum NYTToXDConverter { guard let cellsGroup, let cellGroups = cellsGroup["children"] as? [[String: Any]] else { - return [] + return ([], nil) } - var indices: Set<Int> = [] + var shaded: Set<Int> = [] + var circled: 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 { + guard let inner = cellGroup["children"] as? [[String: Any]] else { continue } - let fill = attrs.first { - ($0["name"] as? String) == "fill" - }?["value"] as? String - if fill == "lightgray" { - indices.insert(index) + if let rect = inner.first, + (rect["name"] as? String) == "rect", + let attrs = rect["attributes"] as? [[String: Any]] { + let fill = attrs.first { + ($0["name"] as? String) == "fill" + }?["value"] as? String + if fill == "lightgray" { + shaded.insert(index) + } + } + if inner.contains(where: { ($0["name"] as? String) == "circle" }) { + circled.insert(index) } } - return indices + + if !circled.isEmpty { + return (circled.union(shaded), "circle") + } + if !shaded.isEmpty { + return (shaded, "shaded") + } + return ([], nil) } /// Extracts an Int from a JSON value that may be NSNumber, Int, or Double.