crossmate

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

NYTToXDConverter.swift (36135B)


      1 import Foundation
      2 
      3 /// Converts NYT puzzle JSON (from `/v6/puzzle/daily/{date}.json`) to `.xd` format.
      4 enum NYTToXDConverter {
      5     struct ConversionError: LocalizedError {
      6         let message: String
      7         var errorDescription: String? { message }
      8     }
      9 
     10     /// Whether a cell fill must ride into the grid via a `Rebus:` placeholder
     11     /// rather than appear literally. The `.xd` grid is one character per cell and
     12     /// the grid parser only takes letters (plus block/special markers and
     13     /// declared placeholders) as direct fills, so anything longer than one
     14     /// character — or a single non-letter character such as the "2"/"3" cells in
     15     /// an "R2D2"/"C3PO" themer — has to be encoded. Routing every non-letter fill
     16     /// through a placeholder means a digit never appears literally in the grid:
     17     /// each grid digit is unambiguously a placeholder reference, so digit fills
     18     /// and digit placeholders can't collide.
     19     private static func needsRebusEncoding(_ answer: String) -> Bool {
     20         if answer.count != 1 { return true }
     21         return !(answer.first?.isLetter ?? false)
     22     }
     23 
     24     /// The asset the puzzle wants drawn over the grid for `key`, if any.
     25     ///
     26     /// Both overlay values are **one-based** indices into `assets`, and the URI
     27     /// must be read from there rather than derived: the host and filename shape
     28     /// have changed repeatedly over the years.
     29     private static func overlayImageURL(jsonData: Data, key: String) throws -> URL? {
     30         guard let root = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
     31               let bodyArray = root["body"] as? [[String: Any]],
     32               let body = bodyArray.first,
     33               let overlays = body["overlays"] as? [String: Any],
     34               let index = intValue(overlays[key]),
     35               let assets = root["assets"] as? [[String: Any]] else {
     36             return nil
     37         }
     38         let position = index - 1
     39         guard assets.indices.contains(position),
     40               let uri = assets[position]["uri"] as? String,
     41               !uri.isEmpty else {
     42             return nil
     43         }
     44         return URL(string: uri)
     45     }
     46 
     47     /// The asset visible from the start of the puzzle, if any.
     48     static func beforeStartImageURL(jsonData: Data) throws -> URL? {
     49         try overlayImageURL(jsonData: jsonData, key: "beforeStart")
     50     }
     51 
     52     /// The asset revealed over the completed grid, if any.
     53     static func afterSolveImageURL(jsonData: Data) throws -> URL? {
     54         try overlayImageURL(jsonData: jsonData, key: "afterSolve")
     55     }
     56 
     57     /// Converts raw JSON data from the NYT puzzle endpoint to an `.xd` source string.
     58     ///
     59     /// The image arguments are the assets named by their corresponding URL
     60     /// helpers, when the caller managed to fetch them. Absent or unusable, the
     61     /// puzzle still converts without that art; an asset failure must never cost
     62     /// the player the underlying crossword.
     63     static func convert(
     64         jsonData: Data,
     65         beforeStartImage: Data? = nil,
     66         afterSolveImage: Data? = nil
     67     ) throws -> String {
     68         guard let root = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else {
     69             throw ConversionError(message: "Invalid JSON root.")
     70         }
     71 
     72         // -- Metadata --
     73 
     74         let publicationDate = root["publicationDate"] as? String ?? ""
     75         let nytTitle = (root["title"] as? String)?
     76             .trimmingCharacters(in: .whitespacesAndNewlines)
     77         let title = if let nytTitle, !nytTitle.isEmpty {
     78             nytTitle
     79         } else {
     80             title(forPublicationDate: publicationDate)
     81         }
     82         let constructors = root["constructors"] as? [String] ?? []
     83         let editor = root["editor"] as? String
     84         let copyright = root["copyright"] as? String
     85 
     86         guard let bodyArray = root["body"] as? [[String: Any]],
     87               let body = bodyArray.first else {
     88             throw ConversionError(message: "Missing body in puzzle JSON.")
     89         }
     90 
     91         guard let dimensions = body["dimensions"] as? [String: Int],
     92               let width = dimensions["width"],
     93               let height = dimensions["height"] else {
     94             throw ConversionError(message: "Missing dimensions.")
     95         }
     96 
     97         guard width > 0, height > 0 else {
     98             throw ConversionError(message: "Invalid dimensions (\(width)x\(height)).")
     99         }
    100 
    101         guard let cells = body["cells"] as? [Any] else {
    102             throw ConversionError(message: "Missing cells.")
    103         }
    104 
    105         let (expectedCellCount, overflowed) = width.multipliedReportingOverflow(by: height)
    106         guard !overflowed, cells.count == expectedCellCount else {
    107             throw ConversionError(message: "Cell count (\(cells.count)) does not match dimensions (\(width)x\(height)).")
    108         }
    109 
    110         guard let clues = body["clues"] as? [[String: Any]] else {
    111             throw ConversionError(message: "Missing clues.")
    112         }
    113 
    114         // -- Parse cells into answers --
    115 
    116         // Each cell is either an empty dict (block) or a dict with "answer", "type", etc.
    117         var answers: [String?] = []  // nil = block, String = answer
    118         var acceptedAnswersByCellIndex: [Int: [String]] = [:]
    119 
    120         for (index, cell) in cells.enumerated() {
    121             guard let dict = cell as? [String: Any], !dict.isEmpty else {
    122                 answers.append(nil)
    123                 continue
    124             }
    125 
    126             let answer = dict["answer"] as? String ?? ""
    127             // NYT encodes a "Schrödinger" square — one correct as either of two
    128             // fills — as a slash-joined answer like "HIT/MISS", with every
    129             // acceptable entry enumerated in moreAnswers.valid. We keep the
    130             // slash form verbatim as the canonical fill: it's ASCII, needs no
    131             // rebus-header escaping, matches NYT's official answer, and reveals
    132             // as "HIT/MISS". The individual sides ("HIT", "MISS") and the single
    133             // crossing letters ("H", "M") ride along as accepted alternates
    134             // below — that's what a player actually enters, since the keyboard
    135             // can't produce a slash.
    136             if answer.isEmpty {
    137                 // A playable cell (NYT type 1) with no answer is an intentional
    138                 // blank: the "gap" in themers like the 2006-07-06 "THE GAP"
    139                 // puzzle, where crossing words read straight through a square
    140                 // whose solution is a literal space. NYT flags these with a
    141                 // blank-marker `moreAnswers` ("B"), which we drop by `continue`ing
    142                 // past the alternates below — the square's only correct state is
    143                 // empty. Any other answerless cell is a block.
    144                 answers.append(intValue(dict["type"]) == 1 ? " " : nil)
    145                 continue
    146             }
    147             answers.append(answer)
    148 
    149             if let moreAnswers = dict["moreAnswers"] as? [String: Any],
    150                let valid = moreAnswers["valid"] as? [String] {
    151                 // Every acceptable entry NYT lists passes through unchanged — the
    152                 // individual sides of a Schrödinger square, single crossing
    153                 // letters, and any other accepted string all round-trip into the
    154                 // Accept metadata.
    155                 let cleaned = valid.filter { !$0.isEmpty && $0 != answer }
    156                 if !cleaned.isEmpty {
    157                     acceptedAnswersByCellIndex[index] = cleaned
    158                 }
    159             }
    160         }
    161 
    162         // -- Find special (shaded/circled) cells from NYT cell data --
    163 
    164         let specialCells = specialCellInfo(body: body)
    165 
    166         // -- Build rebus header if needed --
    167 
    168         // Each distinct fill claims a grid placeholder. The fill alone is the
    169         // identity: circling and shading now ride in the `## Decorations`
    170         // section keyed by position, so a shaded and an unshaded occurrence of
    171         // the same rebus can share one placeholder instead of burning two.
    172         var rebusEntries: [(key: Character, answer: String)] = []
    173         var rebusLookup: [String: Character] = [:]
    174 
    175         for answer in answers {
    176             guard let answer, needsRebusEncoding(answer) else { continue }
    177             if rebusLookup[answer] == nil {
    178                 guard rebusLookup.count < XD.rebusPlaceholders.count else {
    179                     throw ConversionError(
    180                         message: "Too many distinct rebus fills (\(rebusLookup.count + 1)); ran out of grid placeholders."
    181                     )
    182                 }
    183                 let key = XD.rebusPlaceholders[rebusLookup.count]
    184                 rebusLookup[answer] = key
    185                 rebusEntries.append((key: key, answer: answer))
    186             }
    187         }
    188 
    189         // -- Build grid lines --
    190 
    191         var gridLines: [String] = []
    192         for row in 0..<height {
    193             var line = ""
    194             for col in 0..<width {
    195                 let index = row * width + col
    196                 guard let answer = answers[index] else {
    197                     line += "#"
    198                     continue
    199                 }
    200                 if needsRebusEncoding(answer) {
    201                     line += String(rebusLookup[answer]!)
    202                     continue
    203                 }
    204                 // A circled or shaded cell keeps its letter in the grid now that
    205                 // the decoration is expressed separately. The old `@`/`*` markers
    206                 // displaced the fill and left it to be recovered from the clue
    207                 // answers; the grid is both more readable and less derived for it.
    208                 line += answer.uppercased()
    209             }
    210             gridLines.append(line)
    211         }
    212 
    213         // -- Build clue lines --
    214 
    215         // Sort clues: Across first, then Down; within each group, by label number.
    216         let sortedClues = clues.sorted { a, b in
    217             let dirA = (a["direction"] as? String) ?? ""
    218             let dirB = (b["direction"] as? String) ?? ""
    219             if dirA != dirB { return dirA == "Across" }
    220             let labelA = intValue(a["label"]) ?? 0
    221             let labelB = intValue(b["label"]) ?? 0
    222             return labelA < labelB
    223         }
    224 
    225         var acrossClueLines: [String] = []
    226         var downClueLines: [String] = []
    227 
    228         for clue in sortedClues {
    229             let direction = clue["direction"] as? String ?? ""
    230             let label = intValue(clue["label"]) ?? 0
    231 
    232             // Extract clue text from the nested structure:
    233             // "text": [{"plain": "Clue text", "formatted": "<i>Clue text</i>"}]
    234             // When NYT supplies emphasis markup in `formatted` (italic themers,
    235             // etc.) convert it to .xd brace markup; otherwise fall back to the
    236             // `plain` reading. `formatted` is *not* always richer than plain —
    237             // image clues carry a bare symbol there ("¥") — so it is only
    238             // preferred when it actually carries markup.
    239             let clueText: String
    240             if let textArray = clue["text"] as? [[String: Any]],
    241                let firstText = textArray.first {
    242                 if let formatted = firstText["formatted"] as? String,
    243                    let markup = xdMarkup(fromFormatted: formatted) {
    244                     clueText = stripAriaLabelPrefix(markup)
    245                 } else if let plain = firstText["plain"] as? String {
    246                     clueText = stripAriaLabelPrefix(plain)
    247                 } else {
    248                     clueText = ""
    249                 }
    250             } else {
    251                 clueText = ""
    252             }
    253 
    254             // Build answer from cell indices
    255             let cellIndices = clue["cells"] as? [Int] ?? []
    256             var answerStr = ""
    257             for cellIndex in cellIndices {
    258                 guard answers.indices.contains(cellIndex) else {
    259                     throw ConversionError(
    260                         message: "Clue \(label) \(direction) references invalid cell index \(cellIndex)."
    261                     )
    262                 }
    263                 if let answer = answers[cellIndex] {
    264                     answerStr += answer
    265                 }
    266             }
    267 
    268             let prefix = direction == "Across" ? "A" : "D"
    269             let line = "\(prefix)\(label). \(clueText) ~ \(answerStr)"
    270             let acceptLine: String?
    271             let acceptedAnswers = acceptedAnswerVariants(
    272                 cellIndices: cellIndices,
    273                 answers: answers,
    274                 acceptedAnswersByCellIndex: acceptedAnswersByCellIndex
    275             )
    276             if acceptedAnswers.isEmpty {
    277                 acceptLine = nil
    278             } else {
    279                 let escaped = acceptedAnswers.map(escapeAcceptToken).joined(separator: " ")
    280                 acceptLine = "\(prefix)\(label) ^Accept: \(escaped)"
    281             }
    282 
    283             if direction == "Across" {
    284                 acrossClueLines.append(line)
    285                 if let acceptLine { acrossClueLines.append(acceptLine) }
    286             } else {
    287                 downClueLines.append(line)
    288                 if let acceptLine { downClueLines.append(acceptLine) }
    289             }
    290         }
    291 
    292         // -- Assemble .xd source --
    293 
    294         var sections: [String] = []
    295 
    296         // Metadata section
    297         var metadata: [String] = []
    298         metadata.append("Title: \(title)")
    299         metadata.append("ConVer: \(XD.currentConverterVersion)")
    300         metadata.append("Publisher: New York Times")
    301         if !publicationDate.isEmpty {
    302             metadata.append("Date: \(publicationDate)")
    303         }
    304         if !constructors.isEmpty {
    305             metadata.append("Author: \(constructors.joined(separator: ", "))")
    306         }
    307         if let editor {
    308             metadata.append("Editor: \(editor)")
    309         }
    310         if let copyright {
    311             metadata.append("Copyright: \(copyright)")
    312         }
    313 
    314         if !rebusEntries.isEmpty {
    315             let rebusStr = rebusEntries
    316                 .map { "\($0.key)=\(escapeRebusValue($0.answer))" }
    317                 .joined(separator: " ")
    318             metadata.append("Rebus: \(rebusStr)")
    319         }
    320 
    321         let relatives = buildRelativeGroups(clues: clues)
    322         if !relatives.isEmpty {
    323             let joined = relatives
    324                 .map { $0.joined(separator: ",") }
    325                 .joined(separator: "; ")
    326             metadata.append("Relatives: \(joined)")
    327         }
    328 
    329         sections.append(metadata.joined(separator: "\n"))
    330 
    331         // Grid section
    332         sections.append(gridLines.joined(separator: "\n"))
    333 
    334         // Clue sections (across then down, separated by blank line)
    335         let allClueLines = acrossClueLines + [""] + downClueLines
    336         sections.append(allClueLines.joined(separator: "\n"))
    337 
    338         // Decorations last: it is optional, named, and looked up by name rather
    339         // than position, so it can only ever be appended.
    340         var allDecorations = decorations(
    341             circled: specialCells.circled,
    342             shaded: specialCells.shaded,
    343             width: width
    344         )
    345         for (position, tile) in overlayDecorations(
    346             image: beforeStartImage,
    347             phase: .before,
    348             body: body,
    349             width: width,
    350             height: height
    351         ) {
    352             allDecorations[position, default: []].append(tile)
    353         }
    354         for (position, tile) in overlayDecorations(
    355             image: afterSolveImage,
    356             phase: .after,
    357             body: body,
    358             width: width,
    359             height: height
    360         ) {
    361             allDecorations[position, default: []].append(tile)
    362         }
    363         if let decorationSection = try XDDecorationWriter.section(
    364             decorations: allDecorations,
    365             width: width,
    366             height: height
    367         ) {
    368             sections.append(decorationSection)
    369         }
    370 
    371         // The .xd parser splits sections on two or more consecutive blank lines,
    372         // so we need two blank lines (three newlines) between sections.
    373         return sections.joined(separator: "\n\n\n")
    374     }
    375 
    376     private static func acceptedAnswerVariants(
    377         cellIndices: [Int],
    378         answers: [String?],
    379         acceptedAnswersByCellIndex: [Int: [String]]
    380     ) -> [String] {
    381         var variants: [String] = []
    382         var seen = Set<String>()
    383         let canonicalParts = cellIndices.map { answers.indices.contains($0) ? answers[$0] ?? "" : "" }
    384         let canonicalAnswer = canonicalParts.joined()
    385 
    386         for (partIndex, cellIndex) in cellIndices.enumerated() {
    387             guard let accepted = acceptedAnswersByCellIndex[cellIndex] else { continue }
    388             for value in accepted {
    389                 var parts = canonicalParts
    390                 parts[partIndex] = value
    391                 let variant = parts.joined()
    392                 guard variant != canonicalAnswer, seen.insert(variant).inserted else { continue }
    393                 variants.append(variant)
    394             }
    395         }
    396 
    397         return variants
    398     }
    399 
    400     /// Escapes a `Rebus:` value for the header's whitespace-delimited,
    401     /// `=`-keyed grammar (see `XD.parseRebusHeader`). Because the header splits
    402     /// entries on whitespace, a value that *is* whitespace — the space fill of a
    403     /// "gap" cell — can't appear literally; it rides in as the named escape
    404     /// `\space`. Backslash is the escape introducer, so a literal backslash
    405     /// doubles to `\\`. The scheme is deliberately open-ended: further `\name`
    406     /// (or `\u{...}`) escapes can join it to carry any character the header
    407     /// grammar would otherwise eat. `XD.unescapeRebusValue` is the inverse.
    408     private static func escapeRebusValue(_ value: String) -> String {
    409         var out = ""
    410         for ch in value {
    411             switch ch {
    412             case "\\": out += "\\\\"
    413             case " ": out += "\\space"
    414             default: out.append(ch)
    415             }
    416         }
    417         return out
    418     }
    419 
    420     private static func escapeAcceptToken(_ token: String) -> String {
    421         var escaped = ""
    422         for ch in token {
    423             if ch == "\\" || ch.isWhitespace {
    424                 escaped.append("\\")
    425             }
    426             escaped.append(ch)
    427         }
    428         return escaped
    429     }
    430 
    431     private static func title(forPublicationDate publicationDate: String) -> String {
    432         guard let date = date(fromPublicationDate: publicationDate) else {
    433             return "NYT Crossword"
    434         }
    435 
    436         let formatter = DateFormatter()
    437         formatter.calendar = Calendar(identifier: .gregorian)
    438         formatter.locale = Locale(identifier: "en_US_POSIX")
    439         formatter.timeZone = TimeZone(identifier: "America/New_York")
    440         formatter.dateFormat = "EEEE"
    441         return "\(formatter.string(from: date)) Crossword"
    442     }
    443 
    444     private static func date(fromPublicationDate publicationDate: String) -> Date? {
    445         let trimmed = publicationDate.trimmingCharacters(in: .whitespaces)
    446         guard let match = trimmed.firstMatch(of: /^(\d{4})-(\d{2})-(\d{2})$/),
    447               let year = Int(match.1),
    448               let month = Int(match.2),
    449               let day = Int(match.3)
    450         else { return nil }
    451 
    452         var calendar = Calendar(identifier: .gregorian)
    453         calendar.timeZone = TimeZone(identifier: "America/New_York") ?? .gmt
    454         var comps = DateComponents()
    455         comps.calendar = calendar
    456         comps.timeZone = calendar.timeZone
    457         comps.year = year
    458         comps.month = month
    459         comps.day = day
    460         return calendar.date(from: comps)
    461     }
    462 
    463     /// Themer/revealer groups: the structured `relatives` field plus
    464     /// italics-flagged theme answers. These are the connections the
    465     /// constructor did *not* surface in clue text — typically the trick
    466     /// underlying a theme — so they're suitable for catalog/analysis but
    467     /// should not drive any in-grid highlighting that would spoil the solve.
    468     /// Cross-references that live in clue prose ("See 11-Down") are derived
    469     /// at puzzle-load time in `Puzzle.init` instead.
    470     private static func buildRelativeGroups(clues: [[String: Any]]) -> [[String]] {
    471         var groups = buildRelatives(clues: clues)
    472         groups.append(contentsOf: buildFormattedClueGroups(clues: clues))
    473         var seen = Set<Set<String>>()
    474         return groups.filter { group in
    475             let key = Set(group)
    476             guard !key.isEmpty, !seen.contains(key) else { return false }
    477             seen.insert(key)
    478             return true
    479         }
    480     }
    481 
    482     /// Builds groups of cross-referenced clues from the v6 per-clue
    483     /// `relatives` arrays. Two rules admit a group, everything else is
    484     /// discarded:
    485     ///
    486     /// 1. **Revealer** — a clue with ≥2 relatives defines a group consisting
    487     ///    of itself plus every clue it references. The revealer's list is
    488     ///    treated as canonical.
    489     /// 2. **Mutual pair** — two clues that each list the other as their sole
    490     ///    relative form a group of two (the classic "See 14-Across" pattern).
    491     ///
    492     /// Single-direction 1-relative edges (where A references B but B does
    493     /// not reference A back) are dropped. This guards against NYT data
    494     /// errors where a leaf clue points at the wrong revealer.
    495     private static func buildRelatives(clues: [[String: Any]]) -> [[String]] {
    496         // Extract each clue's (label, direction) and relatives array.
    497         var tokens: [String?] = []
    498         var relativeIndices: [[Int]] = []
    499         tokens.reserveCapacity(clues.count)
    500         relativeIndices.reserveCapacity(clues.count)
    501         for clue in clues {
    502             let direction = clue["direction"] as? String ?? ""
    503             let label = intValue(clue["label"]) ?? 0
    504             if label > 0, direction == "Across" || direction == "Down" {
    505                 tokens.append("\(label)\(direction == "Across" ? "A" : "D")")
    506             } else {
    507                 tokens.append(nil)
    508             }
    509             let raw = clue["relatives"] as? [Int] ?? []
    510             let cleaned = Array(Set(raw.filter { $0 >= 0 && $0 < clues.count }))
    511             relativeIndices.append(cleaned)
    512         }
    513 
    514         var groups: [[String]] = []
    515         var seen = Set<Set<Int>>()
    516 
    517         func emit(_ members: Set<Int>) {
    518             guard members.count >= 2, !seen.contains(members) else { return }
    519             seen.insert(members)
    520             let sorted = members.sorted { a, b in
    521                 // Order by (number, direction-is-across-first). Extract from
    522                 // the stored token; fallback to index if a token is missing.
    523                 guard let ta = tokens[a], let tb = tokens[b] else { return a < b }
    524                 let (na, da) = (Int(ta.dropLast()) ?? 0, ta.last!)
    525                 let (nb, db) = (Int(tb.dropLast()) ?? 0, tb.last!)
    526                 if na != nb { return na < nb }
    527                 return da == "A" && db == "D"
    528             }
    529             let toks = sorted.compactMap { tokens[$0] }
    530             if toks.count >= 2 { groups.append(toks) }
    531         }
    532 
    533         // Rule 1: revealers.
    534         for (i, refs) in relativeIndices.enumerated() where refs.count >= 2 {
    535             var members = Set<Int>()
    536             members.insert(i)
    537             for r in refs { members.insert(r) }
    538             emit(members)
    539         }
    540 
    541         // Rule 2: mutual pairs. Only consider clues with exactly one relative
    542         // — revealer-formed groups already cover the multi-relative cases.
    543         for (i, refs) in relativeIndices.enumerated() where refs.count == 1 {
    544             let j = refs[0]
    545             guard j != i, relativeIndices.indices.contains(j) else { continue }
    546             if relativeIndices[j] == [i] {
    547                 emit(Set([i, j]))
    548             }
    549         }
    550 
    551         return groups
    552     }
    553 
    554     /// NYT marks some theme clues by supplying formatted clue text, commonly
    555     /// `<i>...</i>`, without adding `relatives`. Group all such clue refs so
    556     /// their answer cells can be highlighted by Crossmate's thematic mask.
    557     ///
    558     /// A revealer is folded into the same group when its prose names the set —
    559     /// "the answer to each italicized clue", "the five italicized clues". The
    560     /// revealer carries no markup or `relatives` of its own, so this prose
    561     /// reference is the only signal binding it to the themers. The link is only
    562     /// drawn when an italicized set actually exists, which keeps an incidental
    563     /// mention from a clue that isn't a revealer out of the group.
    564     private static func buildFormattedClueGroups(clues: [[String: Any]]) -> [[String]] {
    565         var tokens = clues.compactMap { clue -> String? in
    566             guard clueHasFormattedText(clue) else { return nil }
    567             return clueToken(clue)
    568         }
    569         guard !tokens.isEmpty else { return [] }
    570 
    571         let themers = Set(tokens)
    572         for clue in clues where clueReferencesItalicizedSet(clue) {
    573             guard let token = clueToken(clue), !themers.contains(token) else { continue }
    574             tokens.append(token)
    575         }
    576         return [sortedClueTokens(tokens)]
    577     }
    578 
    579     /// Whether a clue's prose points at the italicized themers — the word
    580     /// "italicized" immediately followed by "clue" or "answer" ("each
    581     /// italicized clue", "answers to the italicized clues"). Italic is the only
    582     /// emphasis NYT pairs with a revealer; bold and underline never are.
    583     private static func clueReferencesItalicizedSet(_ clue: [String: Any]) -> Bool {
    584         cluePlainText(clue).lowercased().contains(/italici[sz]ed\s+(clue|answer)/)
    585     }
    586 
    587     private static func cluePlainText(_ clue: [String: Any]) -> String {
    588         guard let textArray = clue["text"] as? [[String: Any]],
    589               let plain = textArray.first?["plain"] as? String else { return "" }
    590         return plain
    591     }
    592 
    593     /// Orders `{number}{A|D}` tokens by number, Across before Down, so a folded
    594     /// revealer lands in sequence rather than at the end.
    595     private static func sortedClueTokens(_ tokens: [String]) -> [String] {
    596         tokens.sorted { a, b in
    597             let na = Int(a.dropLast()) ?? 0
    598             let nb = Int(b.dropLast()) ?? 0
    599             if na != nb { return na < nb }
    600             return a.last == "A" && b.last == "D"
    601         }
    602     }
    603 
    604     private static func clueHasFormattedText(_ clue: [String: Any]) -> Bool {
    605         guard let textArray = clue["text"] as? [[String: Any]] else { return false }
    606         return textArray.contains { textPart in
    607             guard let formatted = textPart["formatted"] as? String else { return false }
    608             // A non-empty `formatted` field alone isn't a theme signal: image
    609             // clues mirror a bare symbol ("¥") or the plain text there. Only
    610             // genuine emphasis markup marks a themer.
    611             return containsEmphasisMarkup(decodeBasicEntities(formatted))
    612         }
    613     }
    614 
    615     typealias TagMapping = (open: String, close: String, xdOpen: String, xdClose: String)
    616 
    617     /// HTML emphasis tags that mark a *theme* clue. NYT italicizes its themers
    618     /// (`<i>`/`<em>`), the convention this grouping keys on; `<b>`/`<strong>`
    619     /// are included as the same kind of prose emphasis. Underline is handled
    620     /// separately (see `underlineTags`) because NYT uses it for highlight
    621     /// gimmicks — "`<u>John</u> ___`" — not themers, so it must not group.
    622     private static let emphasisTags: [TagMapping] = [
    623         ("<i>", "</i>", "{/", "/}"),
    624         ("<em>", "</em>", "{/", "/}"),
    625         ("<b>", "</b>", "{*", "*}"),
    626         ("<strong>", "</strong>", "{*", "*}"),
    627     ]
    628 
    629     /// Underline markup. Mapped for display fidelity (it is the second most
    630     /// common clue markup in the NYT archive) but deliberately excluded from
    631     /// the theme-grouping signal above.
    632     private static let underlineTags: [TagMapping] = [
    633         ("<u>", "</u>", "{_", "_}"),
    634     ]
    635 
    636     private static var markupTags: [TagMapping] { emphasisTags + underlineTags }
    637 
    638     /// Whether `html` carries emphasis NYT uses to flag a themer. Other markup
    639     /// (underline, sub/sup, layout tags) does not count.
    640     private static func containsEmphasisMarkup(_ html: String) -> Bool {
    641         let lower = html.lowercased()
    642         return emphasisTags.contains { lower.contains($0.open) }
    643     }
    644 
    645     private static func containsConvertibleMarkup(_ html: String) -> Bool {
    646         let lower = html.lowercased()
    647         return markupTags.contains { lower.contains($0.open) }
    648     }
    649 
    650     /// Converts NYT `formatted` clue HTML to `.xd` brace markup, or returns nil
    651     /// when it carries no markup we recognize (so the caller falls back to
    652     /// `plain`). Entities are decoded so prose like `Salt &amp; pepper`
    653     /// round-trips, and any unrecognized residual tags — sub/sup, `<span>`,
    654     /// stray layout markup — are dropped, preserving their text content.
    655     private static func xdMarkup(fromFormatted formatted: String) -> String? {
    656         let decoded = decodeBasicEntities(formatted)
    657         guard containsConvertibleMarkup(decoded) else { return nil }
    658         var out = decoded
    659         for tag in markupTags {
    660             out = out.replacingOccurrences(of: tag.open, with: tag.xdOpen, options: .caseInsensitive)
    661             out = out.replacingOccurrences(of: tag.close, with: tag.xdClose, options: .caseInsensitive)
    662         }
    663         return out.replacing(/<[^>]+>/, with: "")
    664     }
    665 
    666     private static func decodeBasicEntities(_ s: String) -> String {
    667         var out = s
    668         for (entity, char) in [("&lt;", "<"), ("&gt;", ">"), ("&quot;", "\""),
    669                                ("&#39;", "'"), ("&apos;", "'"), ("&amp;", "&")] {
    670             out = out.replacingOccurrences(of: entity, with: char)
    671         }
    672         return out
    673     }
    674 
    675     private static func clueToken(_ clue: [String: Any]) -> String? {
    676         let direction = clue["direction"] as? String ?? ""
    677         let label = intValue(clue["label"]) ?? 0
    678         guard label > 0, direction == "Across" || direction == "Down" else { return nil }
    679         return "\(label)\(direction == "Across" ? "A" : "D")"
    680     }
    681 
    682     private static func specialCellInfo(body: [String: Any]) -> (circled: Set<Int>, shaded: Set<Int>) {
    683         guard let cells = body["cells"] as? [Any] else { return ([], []) }
    684         var circled: Set<Int> = []
    685         var shaded: Set<Int> = []
    686         for (index, cell) in cells.enumerated() {
    687             guard let dict = cell as? [String: Any] else { continue }
    688             switch intValue(dict["type"]) {
    689             case 2:
    690                 circled.insert(index)
    691             case 3:
    692                 shaded.insert(index)
    693             default:
    694                 continue
    695             }
    696         }
    697 
    698         return (circled, shaded)
    699     }
    700 
    701     /// Slices one overlay into per-cell decorations with its original phase.
    702     ///
    703     /// Every failure here is silent and total: no image, unreadable geometry,
    704     /// an asset that doesn't match the board, or a payload over budget all
    705     /// yield no decorations rather than a partial set. A fragmented overlay
    706     /// reads as a bug, whereas missing art still leaves a usable crossword.
    707     private static func overlayDecorations(
    708         image: Data?,
    709         phase: Puzzle.Decoration.Phase,
    710         body: [String: Any],
    711         width: Int,
    712         height: Int
    713     ) -> [GridPosition: Puzzle.Decoration] {
    714         guard let image,
    715               let boardSVG = body["board"] as? String,
    716               let geometry = NYTOverlaySlicer.geometry(boardSVG: boardSVG),
    717               let tiles = NYTOverlaySlicer.tiles(
    718                   imageData: image,
    719                   geometry: geometry,
    720                   width: width,
    721                   height: height
    722               ) else {
    723             return [:]
    724         }
    725 
    726         // NYT sometimes uses overlays to style information already carried by
    727         // playable cells (for example the REDR/REDU rebuses in 2021's "Ruby
    728         // Lips" puzzle). OCR is only useful for otherwise-empty block cells,
    729         // such as the ENERGY letters hidden in the 2026-07-23 grid. Keeping
    730         // this gate in terms of the raw NYT cells also prevents PUZ imports or
    731         // arbitrary decoration images from ever reaching the recognizer.
    732         if phase == .after, overlayTargetsOnlyEmptyBlocks(
    733             Set(tiles.keys),
    734             body: body,
    735             width: width,
    736             height: height
    737         ), let letters = NYTOverlayLetterRecognizer.letters(in: tiles) {
    738             return letters.mapValues { letter in
    739                 Puzzle.Decoration(content: .text(String(letter)), phase: .after)
    740             }
    741         }
    742 
    743         return rasterDecorations(for: tiles, phase: phase)
    744     }
    745 
    746     /// Whether every inked overlay tile lands on a NYT block represented by an
    747     /// empty cell dictionary. One ineligible tile rejects text conversion for
    748     /// the whole asset, so a mixed overlay is never partially guessed.
    749     static func overlayTargetsOnlyEmptyBlocks(
    750         _ positions: Set<GridPosition>,
    751         body: [String: Any],
    752         width: Int,
    753         height: Int
    754     ) -> Bool {
    755         guard !positions.isEmpty,
    756               width > 0,
    757               height > 0,
    758               let cells = body["cells"] as? [Any],
    759               case let (cellCount, false) = width.multipliedReportingOverflow(by: height),
    760               cells.count == cellCount else {
    761             return false
    762         }
    763         for position in positions {
    764             guard position.row >= 0,
    765                   position.row < height,
    766                   position.col >= 0,
    767                   position.col < width,
    768                   let cell = cells[position.row * width + position.col] as? [String: Any],
    769                   cell.isEmpty else {
    770                 return false
    771             }
    772         }
    773         return true
    774     }
    775 
    776     private static func rasterDecorations(
    777         for tiles: [GridPosition: Data],
    778         phase: Puzzle.Decoration.Phase
    779     ) -> [GridPosition: Puzzle.Decoration] {
    780         tiles.mapValues { png in
    781             if let hex = NYTOverlaySlicer.uniformBackgroundHex(imageData: png) {
    782                 return Puzzle.Decoration(
    783                     content: .color(layer: .background, light: hex, dark: nil),
    784                     phase: phase
    785                 )
    786             }
    787             return Puzzle.Decoration(
    788                 content: .data(
    789                     mimeType: "image/png",
    790                     encoding: "base64",
    791                     payload: png.base64EncodedString()
    792                 ),
    793                 phase: phase
    794             )
    795         }
    796     }
    797 
    798     /// Projects the NYT's circled/shaded cell indices onto positions carrying a
    799     /// `mark` decoration. Both are `before`-phase — they're visible from the
    800     /// start, exactly as the `Specials:` header they replace always was.
    801     private static func decorations(
    802         circled: Set<Int>,
    803         shaded: Set<Int>,
    804         width: Int
    805     ) -> [GridPosition: [Puzzle.Decoration]] {
    806         var result: [GridPosition: [Puzzle.Decoration]] = [:]
    807         func add(_ indices: Set<Int>, _ special: Puzzle.Special) {
    808             for index in indices {
    809                 let position = GridPosition(row: index / width, col: index % width)
    810                 result[position, default: []].append(
    811                     Puzzle.Decoration(content: .mark(special), phase: .before)
    812                 )
    813             }
    814         }
    815         add(circled, .circled)
    816         add(shaded, .shaded)
    817         return result
    818     }
    819 
    820     /// NYT image-based clues mirror the image's aria-label in the `plain`
    821     /// field, prefixed with the literal token `[aria-label]`. The prefix is a
    822     /// machine marker, not part of the clue itself, so strip it.
    823     private static func stripAriaLabelPrefix(_ text: String) -> String {
    824         let trimmed = text.drop(while: { $0 == " " })
    825         guard trimmed.lowercased().hasPrefix("[aria-label]") else { return text }
    826         let afterToken = trimmed.dropFirst("[aria-label]".count)
    827         return String(afterToken.drop(while: { $0 == " " }))
    828     }
    829 
    830     /// Extracts an Int from a JSON value that may be NSNumber, Int, or Double.
    831     private static func intValue(_ value: Any?) -> Int? {
    832         if let n = value as? Int { return n }
    833         if let s = value as? String { return Int(s) }
    834         if let n = value as? NSNumber { return n.intValue }
    835         if let n = value as? Double { return Int(n) }
    836         return nil
    837     }
    838 }