crossmate

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

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 }