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:
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))
+ }
+ }
+ }
}