crossmate

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

commit b85fe533bc4672c6224399fadb3cfecbd7a3f178
parent 6ebe518427f06133098657a95351652813eccd66
Author: Michael Camilleri <[email protected]>
Date:   Mon, 22 Jun 2026 15:26:29 +0900

Preserve clue emphasis as inline .xd markup

Italic clues now render in italics across the Clue Bar and Clue List
instead of being flattened to plain text.

This commit converts formatted HTML in certain JSON files to the .xd
format's own brace markup, mapping `<i>`/`<em>` and `<b>`/`<strong>` to
emphasis and `<u>` to underline. The brace delimiters keep a literal '*'
or '/' in ordinary prose — a starred theme clue, a fill-in dash — from
being misread as styling. A new XDMarkup type renders the markup into an
AttributedString for display, while a markup-stripped plain form
continues to feed cross-reference parsing and layout measurement, so
neither sees the delimiters.

The same pass tightens theme grouping. A formatted field no longer
signals a themer merely by being present so only genuine italic or bold
emphasis now adds a clue to the Relatives header, and underline,
reserved for highlight gimmicks rather than themers, is excluded. A
revealer that names the set in prose — "the answer to each italicized
clue" — is also folded into that group. The link is drawn only when an
italicized set is actually present, so the mention cannot start a group
on its own.

Because the converter's output has changed, currentCmVersion is raised
to 6 so an already-imported game, stamped at the old version, is
re-fetched and re-converted the next time its owner opens it — the
structural verifier permits the clue-text-only difference — and the
markup reaches puzzles solved before this change rather than only new
imports.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++++++
MCrossmate/Models/Puzzle.swift | 13+++++++++++--
MCrossmate/Models/XD.swift | 2+-
ACrossmate/Models/XDMarkup.swift | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/NYTToXDConverter.swift | 127++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
MCrossmate/Views/Puzzle/ClueBar.swift | 6+++---
MCrossmate/Views/Puzzle/ClueList.swift | 4++--
MTests/Unit/NYTToXDConverterTests.swift | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
ATests/Unit/XDMarkupTests.swift | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 405 insertions(+), 16 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 1016604FBD4D63A0B9AAE503 /* CloudQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */; }; 128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */; }; 13C0F34520828020AD825D07 /* JoiningPuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18FF14E0D73B0D2DB427F08 /* JoiningPuzzleView.swift */; }; + 14749A042380925B7CA902F2 /* XDMarkup.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF18C52558DBD58ECAD4964 /* XDMarkup.swift */; }; 15768439C1783A1780FBB824 /* SessionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7BD8DFDB41BFBA694A0933 /* SessionCoordinator.swift */; }; 17A754692F05B97DBDD645F2 /* PlayerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */; }; 18D5BB584DBF92A2EC580AEA /* NotificationNavigationBrokerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */; }; @@ -96,6 +97,7 @@ 7714B1C2FBCBBAD9BE8FEAF8 /* GameSummaryThumbnailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5EEBF169823C172000FC45B /* GameSummaryThumbnailTests.swift */; }; 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; }; 779D1955F350B507A47B1E5B /* ShareLinkShortener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B50A841D92D1F2B173E7DF /* ShareLinkShortener.swift */; }; + 786813F3418C32EFBF296220 /* XDMarkupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9FE0A9624DB87758F3D1768 /* XDMarkupTests.swift */; }; 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; }; 7BD1A9F69953F9C3288969AF /* PlayerRecordPresenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */; }; 7D4A56FBB1C5D5F89271B77F /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507B4DC893CE8AC4778CBACE /* NotificationService.swift */; }; @@ -427,6 +429,7 @@ E87E28DC9402A4369647DE50 /* PushPayloadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayloadTests.swift; sourceTree = "<group>"; }; E935CE4384F3B67CC22EEBAC /* ClueBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClueBar.swift; sourceTree = "<group>"; }; EAC61E2582D94B1E6EC67136 /* XDFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDFileType.swift; sourceTree = "<group>"; }; + EAF18C52558DBD58ECAD4964 /* XDMarkup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDMarkup.swift; sourceTree = "<group>"; }; ED2D830B9EFAD753C233BEB4 /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; }; ED48AD9C3A7A113D101BBD21 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; }; ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthServiceTests.swift; sourceTree = "<group>"; }; @@ -435,6 +438,7 @@ F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMonitorTests.swift; sourceTree = "<group>"; }; F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; F9B757D86362CD6F0500E9CB /* CustomButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomButtons.swift; sourceTree = "<group>"; }; + F9FE0A9624DB87758F3D1768 /* XDMarkupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDMarkupTests.swift; sourceTree = "<group>"; }; FAF6E3F3558E128E7A482A61 /* PushRequestAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushRequestAuthenticator.swift; sourceTree = "<group>"; }; FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUZToXDConverterTests.swift; sourceTree = "<group>"; }; FED7C2528355BC007E48B7EF /* ParticipantSummaries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantSummaries.swift; sourceTree = "<group>"; }; @@ -568,6 +572,7 @@ 0C190EA5717C291B3F2AE46C /* SyncMonitorTests.swift */, C8D6991C1EBAB2C64D9DF669 /* TipStoreTests.swift */, 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */, + F9FE0A9624DB87758F3D1768 /* XDMarkupTests.swift */, ABB371EF2574E95782CB05FD /* Sync */, ); name = Unit; @@ -600,6 +605,7 @@ 8D4A76B233E16B7C5A248EB7 /* TipStore.swift */, B9031A1574C21866940F6A2C /* XD.swift */, EAC61E2582D94B1E6EC67136 /* XDFileType.swift */, + EAF18C52558DBD58ECAD4964 /* XDMarkup.swift */, F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */, ); path = Models; @@ -1000,6 +1006,7 @@ 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */, 9AD5700398B1C1F29A3A75F6 /* TipStoreTests.swift in Sources */, 31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */, + 786813F3418C32EFBF296220 /* XDMarkupTests.swift in Sources */, 9582AA583F5EA008FFC82B64 /* ZoneOrphaningTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1132,6 +1139,7 @@ 3C54B672A9FCA98C0A304470 /* TipsArchive.swift in Sources */, 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */, 9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */, + 14749A042380925B7CA902F2 /* XDMarkup.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift @@ -97,7 +97,12 @@ struct Puzzle: Sendable { struct Clue: Sendable, Hashable, Identifiable { let number: Int + /// The clue as plain prose, with any `.xd` inline markup stripped. Used + /// wherever a clue is treated as text (cross-reference parsing, + /// measurement, accessibility); `attributedText` carries the rendered + /// form for display. let text: String + let attributedText: AttributedString var id: Int { number } } @@ -149,8 +154,12 @@ struct Puzzle: Sendable { cells.append(rowCells) } self.cells = cells - let acrossClues = xd.acrossClues.map { Clue(number: $0.number, text: $0.text) } - let downClues = xd.downClues.map { Clue(number: $0.number, text: $0.text) } + let acrossClues = xd.acrossClues.map { + Clue(number: $0.number, text: XDMarkup.stripped($0.text), attributedText: XDMarkup.attributed($0.text)) + } + let downClues = xd.downClues.map { + Clue(number: $0.number, text: XDMarkup.stripped($0.text), attributedText: XDMarkup.attributed($0.text)) + } self.acrossClues = acrossClues self.downClues = downClues let groups = Self.buildCrossReferenceGroups( 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 = 5 + static let currentCmVersion = 6 let title: String? let publisher: String? diff --git a/Crossmate/Models/XDMarkup.swift b/Crossmate/Models/XDMarkup.swift @@ -0,0 +1,99 @@ +import SwiftUI + +/// Inline clue markup as defined by the `.xd` format spec. Markup is delimited +/// by braces so literal `*`, `/`, `_`, or `-` in ordinary clue prose — a +/// starred theme clue, a fill-in dash — is never misread: +/// +/// {/italic/} {*bold*} {_underline_} {-strikethrough-} +/// +/// Only clue text carries markup; headers, the grid, and answers are plain. +/// `attributed(_:)` renders it for display; `stripped(_:)` recovers the bare +/// prose for everything that treats a clue as text (cross-reference parsing, +/// measurement, accessibility). +enum XDMarkup { + private enum Style { + case italic, bold, underline, strikethrough + } + + private struct Segment { + let text: String + let style: Style? + } + + /// Renders clue markup into an `AttributedString`, applying emphasis traits + /// and dropping the brace delimiters. Text without markup round-trips to a + /// plain attributed string. + static func attributed(_ source: String) -> AttributedString { + var result = AttributedString() + for segment in segments(source) { + var piece = AttributedString(segment.text) + switch segment.style { + case .italic: piece.inlinePresentationIntent = .emphasized + case .bold: piece.inlinePresentationIntent = .stronglyEmphasized + case .strikethrough: piece.inlinePresentationIntent = .strikethrough + case .underline: piece.underlineStyle = .single + case nil: break + } + result += piece + } + return result + } + + /// Strips clue markup to plain text: delimiters removed, content kept. + static func stripped(_ source: String) -> String { + segments(source).map(\.text).joined() + } + + private static func style(for marker: Character) -> Style? { + switch marker { + case "/": return .italic + case "*": return .bold + case "_": return .underline + case "-": return .strikethrough + default: return nil + } + } + + /// Splits the source into runs of plain and styled text. An opening `{` + /// followed by a recognized marker starts a span that closes at the first + /// matching `marker}`; a `{` with no valid marker, or an unterminated span, + /// stays literal. + private static func segments(_ source: String) -> [Segment] { + let chars = Array(source) + var segments: [Segment] = [] + var plain = "" + var i = 0 + while i < chars.count { + if chars[i] == "{", i + 1 < chars.count, let style = style(for: chars[i + 1]) { + let marker = chars[i + 1] + if let close = closingIndex(chars, after: i + 2, marker: marker) { + if !plain.isEmpty { + segments.append(Segment(text: plain, style: nil)) + plain = "" + } + segments.append(Segment(text: String(chars[(i + 2)..<close]), style: style)) + i = close + 2 + continue + } + } + plain.append(chars[i]) + i += 1 + } + if !plain.isEmpty { + segments.append(Segment(text: plain, style: nil)) + } + return segments + } + + /// First index `j` (≥ `start`) where `chars[j]` is the closing `marker` and + /// is immediately followed by `}`. An internal `marker` not followed by `}` + /// (the hyphen in `{-strike-thru-}`) is skipped. + private static func closingIndex(_ chars: [Character], after start: Int, marker: Character) -> Int? { + var j = start + while j + 1 < chars.count { + if chars[j] == marker, chars[j + 1] == "}" { return j } + j += 1 + } + return nil + } +} diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift @@ -208,12 +208,23 @@ enum NYTToXDConverter { let label = intValue(clue["label"]) ?? 0 // Extract clue text from the nested structure: - // "text": [{"plain": "Clue text"}] + // "text": [{"plain": "Clue text", "formatted": "<i>Clue text</i>"}] + // When NYT supplies emphasis markup in `formatted` (italic themers, + // etc.) convert it to .xd brace markup; otherwise fall back to the + // `plain` reading. `formatted` is *not* always richer than plain — + // image clues carry a bare symbol there ("¥") — so it is only + // preferred when it actually carries markup. let clueText: String if let textArray = clue["text"] as? [[String: Any]], - let firstText = textArray.first, - let plain = firstText["plain"] as? String { - clueText = stripAriaLabelPrefix(plain) + let firstText = textArray.first { + if let formatted = firstText["formatted"] as? String, + let markup = xdMarkup(fromFormatted: formatted) { + clueText = stripAriaLabelPrefix(markup) + } else if let plain = firstText["plain"] as? String { + clueText = stripAriaLabelPrefix(plain) + } else { + clueText = "" + } } else { clueText = "" } @@ -483,22 +494,124 @@ enum NYTToXDConverter { /// 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. + /// + /// A revealer is folded into the same group when its prose names the set — + /// "the answer to each italicized clue", "the five italicized clues". The + /// revealer carries no markup or `relatives` of its own, so this prose + /// reference is the only signal binding it to the themers. The link is only + /// drawn when an italicized set actually exists, which keeps an incidental + /// mention from a clue that isn't a revealer out of the group. private static func buildFormattedClueGroups(clues: [[String: Any]]) -> [[String]] { - let tokens = clues.compactMap { clue -> String? in + var tokens = clues.compactMap { clue -> String? in guard clueHasFormattedText(clue) else { return nil } return clueToken(clue) } - return tokens.isEmpty ? [] : [tokens] + guard !tokens.isEmpty else { return [] } + + let themers = Set(tokens) + for clue in clues where clueReferencesItalicizedSet(clue) { + guard let token = clueToken(clue), !themers.contains(token) else { continue } + tokens.append(token) + } + return [sortedClueTokens(tokens)] + } + + /// Whether a clue's prose points at the italicized themers — the word + /// "italicized" immediately followed by "clue" or "answer" ("each + /// italicized clue", "answers to the italicized clues"). Italic is the only + /// emphasis NYT pairs with a revealer; bold and underline never are. + private static func clueReferencesItalicizedSet(_ clue: [String: Any]) -> Bool { + cluePlainText(clue).lowercased().contains(/italici[sz]ed\s+(clue|answer)/) + } + + private static func cluePlainText(_ clue: [String: Any]) -> String { + guard let textArray = clue["text"] as? [[String: Any]], + let plain = textArray.first?["plain"] as? String else { return "" } + return plain + } + + /// Orders `{number}{A|D}` tokens by number, Across before Down, so a folded + /// revealer lands in sequence rather than at the end. + private static func sortedClueTokens(_ tokens: [String]) -> [String] { + tokens.sorted { a, b in + let na = Int(a.dropLast()) ?? 0 + let nb = Int(b.dropLast()) ?? 0 + if na != nb { return na < nb } + return a.last == "A" && b.last == "D" + } } 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 + // A non-empty `formatted` field alone isn't a theme signal: image + // clues mirror a bare symbol ("¥") or the plain text there. Only + // genuine emphasis markup marks a themer. + return containsEmphasisMarkup(decodeBasicEntities(formatted)) } } + typealias TagMapping = (open: String, close: String, xdOpen: String, xdClose: String) + + /// HTML emphasis tags that mark a *theme* clue. NYT italicizes its themers + /// (`<i>`/`<em>`), the convention this grouping keys on; `<b>`/`<strong>` + /// are included as the same kind of prose emphasis. Underline is handled + /// separately (see `underlineTags`) because NYT uses it for highlight + /// gimmicks — "`<u>John</u> ___`" — not themers, so it must not group. + private static let emphasisTags: [TagMapping] = [ + ("<i>", "</i>", "{/", "/}"), + ("<em>", "</em>", "{/", "/}"), + ("<b>", "</b>", "{*", "*}"), + ("<strong>", "</strong>", "{*", "*}"), + ] + + /// Underline markup. Mapped for display fidelity (it is the second most + /// common clue markup in the NYT archive) but deliberately excluded from + /// the theme-grouping signal above. + private static let underlineTags: [TagMapping] = [ + ("<u>", "</u>", "{_", "_}"), + ] + + private static var markupTags: [TagMapping] { emphasisTags + underlineTags } + + /// Whether `html` carries emphasis NYT uses to flag a themer. Other markup + /// (underline, sub/sup, layout tags) does not count. + private static func containsEmphasisMarkup(_ html: String) -> Bool { + let lower = html.lowercased() + return emphasisTags.contains { lower.contains($0.open) } + } + + private static func containsConvertibleMarkup(_ html: String) -> Bool { + let lower = html.lowercased() + return markupTags.contains { lower.contains($0.open) } + } + + /// Converts NYT `formatted` clue HTML to `.xd` brace markup, or returns nil + /// when it carries no markup we recognize (so the caller falls back to + /// `plain`). Entities are decoded so prose like `Salt &amp; pepper` + /// round-trips, and any unrecognized residual tags — sub/sup, `<span>`, + /// stray layout markup — are dropped, preserving their text content. + private static func xdMarkup(fromFormatted formatted: String) -> String? { + let decoded = decodeBasicEntities(formatted) + guard containsConvertibleMarkup(decoded) else { return nil } + var out = decoded + for tag in markupTags { + out = out.replacingOccurrences(of: tag.open, with: tag.xdOpen, options: .caseInsensitive) + out = out.replacingOccurrences(of: tag.close, with: tag.xdClose, options: .caseInsensitive) + } + return out.replacing(/<[^>]+>/, with: "") + } + + private static func decodeBasicEntities(_ s: String) -> String { + var out = s + for (entity, char) in [("&lt;", "<"), ("&gt;", ">"), ("&quot;", "\""), + ("&#39;", "'"), ("&apos;", "'"), ("&amp;", "&")] { + out = out.replacingOccurrences(of: entity, with: char) + } + return out + } + private static func clueToken(_ clue: [String: Any]) -> String? { let direction = clue["direction"] as? String ?? "" let label = intValue(clue["label"]) ?? 0 diff --git a/Crossmate/Views/Puzzle/ClueBar.swift b/Crossmate/Views/Puzzle/ClueBar.swift @@ -32,7 +32,7 @@ private struct ClueBarReservation: View { var body: some View { ClueBarContent( label: "99 Across", - clueText: "Clue reservation", + clueText: AttributedString("Clue reservation"), reservesClueSpace: true ) .opacity(0) @@ -43,7 +43,7 @@ private struct ClueBarReservation: View { private struct ClueBarContent: View { let label: String - let clueText: String + let clueText: AttributedString var reservesClueSpace = false var currentKey: ClueKey? var slideEdge: Edge = .trailing @@ -147,7 +147,7 @@ private struct ClueBar: View { ClueBarContent( label: label(for: display.clue, direction: display.direction), - clueText: display.clue?.text ?? "—", + clueText: display.clue?.attributedText ?? AttributedString("—"), currentKey: display.currentKey, slideEdge: slideEdge, onPrevious: isShowingReplayClue ? nil : { diff --git a/Crossmate/Views/Puzzle/ClueList.swift b/Crossmate/Views/Puzzle/ClueList.swift @@ -176,7 +176,7 @@ struct ClueList: View { .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) .frame(minWidth: 28, alignment: .trailing) - Text(clue.text) + Text(clue.attributedText) .font(.body) .foregroundStyle(.primary) .frame(maxWidth: .infinity, alignment: .leading) @@ -235,7 +235,7 @@ struct ClueList: View { .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) .frame(minWidth: 28, alignment: .trailing) - Text(clue.text) + Text(clue.attributedText) .font(.body) .foregroundStyle(.primary) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift @@ -16,6 +16,7 @@ struct NYTToXDConverterTests { private func puzzleJSON( relatives: [[Int]?], formattedClueIndices: Set<Int> = [], + formattedOverrides: [Int: String] = [:], clueTexts: [Int: String] = [:], letters: [String] = ["A", "B", "C", "D", "E", "F", "G", "H", "I"], moreAnswersByCell: [Int: [String]] = [:], @@ -34,7 +35,9 @@ struct NYTToXDConverterTests { var clueDicts: [[String: Any]] = [] for (i, def) in defs.enumerated() { var text: [String: Any] = ["plain": clueTexts[i] ?? "clue \(i)"] - if formattedClueIndices.contains(i) { + if let override = formattedOverrides[i] { + text["formatted"] = override + } else if formattedClueIndices.contains(i) { text["formatted"] = "<i>clue \(i)</i>" } @@ -354,6 +357,49 @@ struct NYTToXDConverterTests { #expect(relativesHeader(in: xd) == "1A,5A") } + @Test("Italic formatted clue becomes XD brace markup and round-trips to an emphasized run") + func italicClueBecomesBraceMarkup() throws { + let data = try puzzleJSON( + relatives: [nil, nil, nil, nil, nil, nil], + formattedOverrides: [0: "<i>10th grader critiques swanky boutique?</i>"] + ) + let xd = try NYTToXDConverter.convert(jsonData: data) + #expect(xd.contains("A1. {/10th grader critiques swanky boutique?/} ~ ABC")) + + let puzzle = Puzzle(xd: try XD.parse(xd)) + let clue = try #require(puzzle.acrossClues.first { $0.number == 1 }) + #expect(clue.text == "10th grader critiques swanky boutique?") + let intents = clue.attributedText.runs.map(\.inlinePresentationIntent) + #expect(intents == [.emphasized]) + } + + @Test("Underline formatted clue converts for display but is not a theme group") + func underlineClueConvertsButDoesNotGroup() throws { + let data = try puzzleJSON( + relatives: [nil, nil, nil, nil, nil, nil], + formattedOverrides: [0: "<u>John</u> <u>Philip</u> ___"] + ) + let xd = try NYTToXDConverter.convert(jsonData: data) + #expect(xd.contains("A1. {_John_} {_Philip_} ___ ~ ABC")) + // Underline is a highlight gimmick, not NYT's italic themer marker. + #expect(relativesHeader(in: xd) == nil) + } + + @Test("Formatted field without emphasis tags falls back to plain and isn't a themer") + func symbolFormattedFallsBackToPlain() throws { + // NYT mirrors a bare symbol in `formatted` for image clues; it carries + // no emphasis and must not be treated as markup or as a theme group. + let data = try puzzleJSON( + relatives: [nil, nil, nil, nil, nil, nil], + formattedOverrides: [0: "¥"], + clueTexts: [0: "Eastern currency"] + ) + let xd = try NYTToXDConverter.convert(jsonData: data) + #expect(xd.contains("A1. Eastern currency ~ ABC")) + #expect(!xd.contains("¥")) + #expect(relativesHeader(in: xd) == nil) + } + @Test("Formatted clue text is merged with explicit relatives") func formattedCluesMergeWithRelatives() throws { let data = try puzzleJSON( @@ -367,6 +413,32 @@ struct NYTToXDConverterTests { #expect(header.contains("; ")) } + @Test("A revealer naming the italicized clues is folded into their group") + func italicizedRevealerJoinsThemerGroup() throws { + // 1A and 4A are italic themers; 5A is the plain revealer that points at + // them in prose. NYT supplies no relatives for any of the three, so the + // "italicized clues" phrasing is the only link. + let data = try puzzleJSON( + relatives: [nil, nil, nil, nil, nil, nil], + formattedOverrides: [0: "<i>themer one</i>", 1: "<i>themer two</i>"], + clueTexts: [2: "What the answers to the italicized clues have in common"] + ) + let xd = try NYTToXDConverter.convert(jsonData: data) + #expect(relativesHeader(in: xd) == "1A,4A,5A") + } + + @Test("An italicized-clue mention with no italic set forms no group") + func italicizedMentionWithoutSetDoesNotGroup() throws { + // The revealer phrasing alone is inert: without an actual italicized + // set to bind to, the clue is just prose and must not start a group. + let data = try puzzleJSON( + relatives: [nil, nil, nil, nil, nil, nil], + clueTexts: [2: "What the answers to the italicized clues have in common"] + ) + let xd = try NYTToXDConverter.convert(jsonData: data) + #expect(relativesHeader(in: xd) == nil) + } + @Test("Multiple groups are semicolon-joined on a single Relatives line") func multipleGroups() throws { // Revealer group {1A, 4A, 5A} + mutual pair {1D, 2D}. diff --git a/Tests/Unit/XDMarkupTests.swift b/Tests/Unit/XDMarkupTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("XDMarkup") +struct XDMarkupTests { + + private struct Run: Equatable { + let text: String + let intent: InlinePresentationIntent? + } + + /// The styled substrings of an attributed string, paired with the emphasis + /// intent applied to each, matching `AttributedString.runs`. + private func runs(_ attributed: AttributedString) -> [Run] { + attributed.runs.map { run in + Run(text: String(attributed[run.range].characters), intent: run.inlinePresentationIntent) + } + } + + @Test("Plain clue text round-trips with no emphasis") + func plainTextHasNoEmphasis() { + let attributed = XDMarkup.attributed("Capital of France") + #expect(runs(attributed) == [Run(text: "Capital of France", intent: nil)]) + #expect(XDMarkup.stripped("Capital of France") == "Capital of France") + } + + @Test("Italic span renders emphasized and strips its braces") + func italicSpan() { + let source = "{/Why Keanu is such a generous tipper?/}" + let attributed = XDMarkup.attributed(source) + #expect(runs(attributed) == [Run(text: "Why Keanu is such a generous tipper?", intent: .emphasized)]) + #expect(XDMarkup.stripped(source) == "Why Keanu is such a generous tipper?") + } + + @Test("Mid-clue italics leave surrounding text plain") + func partialItalics() { + let attributed = XDMarkup.attributed("As seen in {/Hamlet/}, e.g.") + #expect(runs(attributed) == [ + Run(text: "As seen in ", intent: nil), + Run(text: "Hamlet", intent: .emphasized), + Run(text: ", e.g.", intent: nil) + ]) + } + + @Test("Bold maps to strong emphasis") + func boldSpan() { + let attributed = XDMarkup.attributed("{*Note*}: read carefully") + #expect(runs(attributed) == [ + Run(text: "Note", intent: .stronglyEmphasized), + Run(text: ": read carefully", intent: nil) + ]) + } + + @Test("Strikethrough span carries the strikethrough intent") + func strikethroughSpan() { + let attributed = XDMarkup.attributed("{-gone-}") + #expect(runs(attributed) == [Run(text: "gone", intent: .strikethrough)]) + } + + @Test("Strikethrough delimiter only closes at a brace, not an internal hyphen") + func strikethroughWithInternalHyphen() { + #expect(XDMarkup.stripped("{-strike-thru-}") == "strike-thru") + } + + @Test("A literal asterisk in plain prose is left untouched") + func literalAsteriskUntouched() { + // Starred theme clues carry a bare '*' that must not be read as markup. + let source = "*Sphere of influence" + #expect(runs(XDMarkup.attributed(source)) == [Run(text: source, intent: nil)]) + #expect(XDMarkup.stripped(source) == source) + } + + @Test("An unterminated span stays literal") + func unterminatedSpanStaysLiteral() { + let source = "Set in {/the past" + #expect(runs(XDMarkup.attributed(source)) == [Run(text: source, intent: nil)]) + #expect(XDMarkup.stripped(source) == source) + } + + @Test("A brace with no valid marker stays literal") + func braceWithoutMarkerStaysLiteral() { + let source = "Score of {0}" + #expect(runs(XDMarkup.attributed(source)) == [Run(text: source, intent: nil)]) + #expect(XDMarkup.stripped(source) == source) + } +}