crossmate

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

commit 27a82347e54054650909ebe863501cd15acf2920
parent a3e5c5d609b9298d20b47d4aa179e34d3eb67129
Author: Michael Camilleri <[email protected]>
Date:   Tue, 28 Apr 2026 14:52:38 +0900

Treat italicised clues as related group

If a puzzle includes clues in italicised text, this should be treated as
a related group.

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

Diffstat:
MCrossmate/Models/XD.swift | 7++++---
MCrossmate/Services/NYTToXDConverter.swift | 44+++++++++++++++++++++++++++++++++++++++++++-
MTests/Unit/NYTToXDConverterTests.swift | 49+++++++++++++++++++++++++++++++++++++++++++++++--
3 files changed, 94 insertions(+), 6 deletions(-)

diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift @@ -196,8 +196,9 @@ struct XD: Sendable { /// Parses a `Relatives:` header value into groups of cross-referenced /// clues. Groups are separated by `;`, tokens within a group by `,`, and /// each token is `{number}{A|D}` (e.g. `17A`, `57A`). This is a Crossmate - /// extension to `.xd`; unknown/invalid tokens are silently dropped and - /// groups with fewer than two valid tokens are discarded. + /// extension to `.xd`; unknown/invalid tokens are silently dropped. + /// Single-token groups are allowed so formatted NYT clues can mark their + /// own answer cells as thematic. private static func parseRelativesHeader(_ value: String?) -> [[ClueRef]] { guard let value, !value.isEmpty else { return [] } var groups: [[ClueRef]] = [] @@ -215,7 +216,7 @@ struct XD: Sendable { guard let number = Int(token.dropLast()), number > 0 else { continue } refs.append(ClueRef(number: number, direction: direction)) } - if refs.count >= 2 { groups.append(refs) } + if !refs.isEmpty { groups.append(refs) } } return groups } diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift @@ -183,7 +183,7 @@ enum NYTToXDConverter { metadata.append("Special: shaded") } - let relatives = buildRelatives(clues: clues) + let relatives = buildThematicGroups(clues: clues) if !relatives.isEmpty { let joined = relatives .map { $0.joined(separator: ",") } @@ -237,6 +237,22 @@ enum NYTToXDConverter { return calendar.date(from: comps) } + /// Builds groups of theme-related clues from the v6 data. NYT uses + /// `relatives` for explicit cross-references, but some theme entries are + /// only marked by formatted clue text (for example italicized clues). + private static func buildThematicGroups(clues: [[String: Any]]) -> [[String]] { + var groups = buildRelatives(clues: clues) + groups.append(contentsOf: buildFormattedClueGroups(clues: clues)) + + var seen = Set<Set<String>>() + return groups.filter { group in + let key = Set(group) + guard !key.isEmpty, !seen.contains(key) else { return false } + seen.insert(key) + return true + } + } + /// Builds groups of cross-referenced clues from the v6 per-clue /// `relatives` arrays. Two rules admit a group, everything else is /// discarded: @@ -309,6 +325,32 @@ enum NYTToXDConverter { return groups } + /// NYT marks some theme clues by supplying formatted clue text, commonly + /// `<i>...</i>`, without adding `relatives`. Group all such clue refs so + /// their answer cells can be highlighted by Crossmate's thematic mask. + private static func buildFormattedClueGroups(clues: [[String: Any]]) -> [[String]] { + let tokens = clues.compactMap { clue -> String? in + guard clueHasFormattedText(clue) else { return nil } + return clueToken(clue) + } + return tokens.isEmpty ? [] : [tokens] + } + + private static func clueHasFormattedText(_ clue: [String: Any]) -> Bool { + guard let textArray = clue["text"] as? [[String: Any]] else { return false } + return textArray.contains { textPart in + guard let formatted = textPart["formatted"] as? String else { return false } + return !formatted.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + private static func clueToken(_ clue: [String: Any]) -> String? { + let direction = clue["direction"] as? String ?? "" + let label = intValue(clue["label"]) ?? 0 + guard label > 0, direction == "Across" || direction == "Down" else { return nil } + 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 diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift @@ -14,7 +14,8 @@ struct NYTToXDConverterTests { /// tuple supplies that clue's `relatives` array, or nil to omit the key /// entirely. private func puzzleJSON( - relatives: [[Int]?] + relatives: [[Int]?], + formattedClueIndices: Set<Int> = [] ) throws -> Data { precondition(relatives.count == 6, "Expected 6 clues for a 3×3 open grid") let defs: [(label: Int, direction: String, cells: [Int])] = [ @@ -27,11 +28,16 @@ struct NYTToXDConverterTests { ] var clueDicts: [[String: Any]] = [] for (i, def) in defs.enumerated() { + var text: [String: Any] = ["plain": "clue \(i)"] + if formattedClueIndices.contains(i) { + text["formatted"] = "<i>clue \(i)</i>" + } + var dict: [String: Any] = [ "label": "\(def.label)", "direction": def.direction, "cells": def.cells, - "text": [["plain": "clue \(i)"]] + "text": [text] ] if let rels = relatives[i] { dict["relatives"] = rels @@ -114,6 +120,29 @@ struct NYTToXDConverterTests { #expect(relativesHeader(in: xd) == nil) } + @Test("Formatted clue text produces a thematic group") + func formattedCluesProduceThemeGroup() throws { + let data = try puzzleJSON( + relatives: [nil, nil, nil, nil, nil, nil], + formattedClueIndices: [0, 2] + ) + let xd = try NYTToXDConverter.convert(jsonData: data) + #expect(relativesHeader(in: xd) == "1A,5A") + } + + @Test("Formatted clue text is merged with explicit relatives") + func formattedCluesMergeWithRelatives() throws { + let data = try puzzleJSON( + relatives: [[1, 2], nil, nil, nil, nil, nil], + formattedClueIndices: [3, 4] + ) + let xd = try NYTToXDConverter.convert(jsonData: data) + let header = try #require(relativesHeader(in: xd)) + #expect(header.contains("1A,4A,5A")) + #expect(header.contains("1D,2D")) + #expect(header.contains("; ")) + } + @Test("Multiple groups are semicolon-joined on a single Relatives line") func multipleGroups() throws { // Revealer group {1A, 4A, 5A} + mutual pair {1D, 2D}. @@ -204,4 +233,20 @@ struct NYTToXDConverterTests { } } } + + @Test("Single formatted clue marks its answer cells thematic") + func singleFormattedClueMarksAnswerCells() throws { + let data = try puzzleJSON( + relatives: [nil, nil, nil, nil, nil, nil], + formattedClueIndices: [4] + ) + let xd = try XD.parse(try NYTToXDConverter.convert(jsonData: data)) + let puzzle = Puzzle(xd: xd) + + for r in 0..<3 { + for c in 0..<3 { + #expect(puzzle.thematicMask[r][c] == (c == 1)) + } + } + } }