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 & 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 [("<", "<"), (">", ">"), (""", "\""), 669 ("'", "'"), ("'", "'"), ("&", "&")] { 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 }