XDMarkup.swift (4330B)
1 import SwiftUI 2 3 /// Inline clue markup as defined by the `.xd` format spec. Markup is delimited 4 /// by braces so literal `*`, `/`, `_`, or `-` in ordinary clue prose — a 5 /// starred theme clue, a fill-in dash — is never misread: 6 /// 7 /// {/italic/} {*bold*} {_underline_} {-strikethrough-} 8 /// 9 /// Only clue text carries markup; headers, the grid, and answers are plain. 10 /// `attributed(_:)` renders it for display; `stripped(_:)` recovers the bare 11 /// prose for everything that treats a clue as text (cross-reference parsing, 12 /// measurement, accessibility). 13 enum XDMarkup { 14 private enum Style { 15 case italic, bold, underline, strikethrough 16 } 17 18 private struct Segment { 19 let text: String 20 let style: Style? 21 } 22 23 /// Renders clue markup into an `AttributedString`, applying emphasis traits 24 /// and dropping the brace delimiters. Text without markup round-trips to a 25 /// plain attributed string. 26 static func attributed(_ source: String) -> AttributedString { 27 var result = AttributedString() 28 for segment in segments(source) { 29 var piece = AttributedString(segment.text) 30 switch segment.style { 31 case .italic: piece.inlinePresentationIntent = .emphasized 32 case .bold: piece.inlinePresentationIntent = .stronglyEmphasized 33 case .strikethrough: piece.inlinePresentationIntent = .strikethrough 34 case .underline: piece.underlineStyle = .single 35 case nil: break 36 } 37 result += piece 38 } 39 return result 40 } 41 42 /// Strips clue markup to plain text: delimiters removed, content kept. 43 static func stripped(_ source: String) -> String { 44 segments(source).map(\.text).joined() 45 } 46 47 private static func style(for marker: Character) -> Style? { 48 switch marker { 49 case "/": return .italic 50 case "*": return .bold 51 case "_": return .underline 52 case "-": return .strikethrough 53 default: return nil 54 } 55 } 56 57 /// Splits the source into runs of plain and styled text. An opening `{` 58 /// followed by a recognized marker starts a span that closes at the first 59 /// matching `marker}`; a `{` with no valid marker, or an unterminated span, 60 /// stays literal. 61 private static func segments(_ source: String) -> [Segment] { 62 let chars = Array(source) 63 var segments: [Segment] = [] 64 var plain = "" 65 var i = 0 66 // Markers whose close-scan already reached the end of the source: no 67 // later opener of that marker can close either, so skip its scan. 68 // Without this, every unterminated opener rescans to the end — a clue 69 // packed with `{/` openers costs O(n²), and hundreds of such clues in 70 // one hostile puzzle add up to real main-actor stalls. 71 var exhaustedMarkers: Set<Character> = [] 72 while i < chars.count { 73 if chars[i] == "{", i + 1 < chars.count, let style = style(for: chars[i + 1]), 74 !exhaustedMarkers.contains(chars[i + 1]) { 75 let marker = chars[i + 1] 76 if let close = closingIndex(chars, after: i + 2, marker: marker) { 77 if !plain.isEmpty { 78 segments.append(Segment(text: plain, style: nil)) 79 plain = "" 80 } 81 segments.append(Segment(text: String(chars[(i + 2)..<close]), style: style)) 82 i = close + 2 83 continue 84 } 85 exhaustedMarkers.insert(marker) 86 } 87 plain.append(chars[i]) 88 i += 1 89 } 90 if !plain.isEmpty { 91 segments.append(Segment(text: plain, style: nil)) 92 } 93 return segments 94 } 95 96 /// First index `j` (≥ `start`) where `chars[j]` is the closing `marker` and 97 /// is immediately followed by `}`. An internal `marker` not followed by `}` 98 /// (the hyphen in `{-strike-thru-}`) is skipped. 99 private static func closingIndex(_ chars: [Character], after start: Int, marker: Character) -> Int? { 100 var j = start 101 while j + 1 < chars.count { 102 if chars[j] == marker, chars[j + 1] == "}" { return j } 103 j += 1 104 } 105 return nil 106 } 107 }