crossmate

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

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 }