XDMarkup.swift (3797B)
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 while i < chars.count { 67 if chars[i] == "{", i + 1 < chars.count, let style = style(for: chars[i + 1]) { 68 let marker = chars[i + 1] 69 if let close = closingIndex(chars, after: i + 2, marker: marker) { 70 if !plain.isEmpty { 71 segments.append(Segment(text: plain, style: nil)) 72 plain = "" 73 } 74 segments.append(Segment(text: String(chars[(i + 2)..<close]), style: style)) 75 i = close + 2 76 continue 77 } 78 } 79 plain.append(chars[i]) 80 i += 1 81 } 82 if !plain.isEmpty { 83 segments.append(Segment(text: plain, style: nil)) 84 } 85 return segments 86 } 87 88 /// First index `j` (≥ `start`) where `chars[j]` is the closing `marker` and 89 /// is immediately followed by `}`. An internal `marker` not followed by `}` 90 /// (the hyphen in `{-strike-thru-}`) is skipped. 91 private static func closingIndex(_ chars: [Character], after start: Int, marker: Character) -> Int? { 92 var j = start 93 while j + 1 < chars.count { 94 if chars[j] == marker, chars[j + 1] == "}" { return j } 95 j += 1 96 } 97 return nil 98 } 99 }