在Swift中编写轻量标记解析器

这篇文章的完整代码可以在这里找到 。

最近,我不得不编写一个轻量级的标记解析器,以在我们的iOS应用程序中提供文本格式。 要求与您在其他富通讯应用程序中可以找到的要求类似:

  • 为了强调单词或句子,用户可以用*号包围文本以创建粗体文本,或用_underscores_表示斜体文本。
  • 要在消息中显示更正,用户可以用〜波浪号〜包围文本以删除文本。
  • 用户可以组合格式选项。

因此,以下文本:

  那个*快速*,〜红褐色的狐狸跳过了_ *懒狗* _。 

应采用以下格式:

  敏捷的棕狐狸跳过了一条懒狗

另一个附加要求是字内格式化不应该被允许。 例如,以下文本:

  计算_6 * 4 * 8_。  _Quick_zephyrs_blow_。 

应采用以下格式:

  计算6 * 4 * 8Quick_zephyrs_blow

我考虑了实现解析器的几种方法,包括Parser Combinators ,但是最后,我决定从头开始编写它。 这样做可以让我完全控制性能。

我们可以将标记文本格式分解为以下步骤:

  • 令牌化,这是将输入字符串分解为令牌(格式定界符和文本)的过程。
  • 解析 ,这是解释标记以生成格式化文本的抽象表示的过程。
  • 渲染 ,包括将抽象表示形式转换为NSAttributedString

让我们详细了解实现每个步骤的细节。

实施分词器

我们的标记格式具有三种不同的标记: textleft delimiterright delimiter 。 每个令牌应携带上下文信息,例如定界符或实际文本。 让我们创建一个enum来建模:

  枚举MarkupToken { 
    案例文本 (字符串) 
    case leftDelimiter (UnicodeScalar) 
    大小写正确的分隔符 (UnicodeScalar) 
  } 

标记器的工作是为给定的字符串返回一系列MarkupToken实例。 例如,以下输入:

  _你好,世界*_ 

产生以下令牌序列:

  leftDelimiter(“ _”) 
  文字(“ Hello”) 
  leftDelimiter(“ *”) 
  文字(“世界”) 
  rightDelimiter(“ *”) 
  rightDelimiter(“ _”) 

分词器将不检查左定界符是否具有相应的右定界符 。 这种调整将通过避免回溯来帮助我们实现线性时间。 因此,解析器可以预期以下令牌序列:

  文字(“ Hello”) 
  leftDelimiter(“ *”) 
  文字(“世界”) 

但是,令牌生成器可以通过维护到目前为止已处理的左定界符的堆栈来验证右定界符是否具有相应的左定界符,而不会产生任何性能成本。

标记器接口很简单:

  struct MarkupTokenizer { 
    初始化 (字符串:字符串) 
    变异func nextToken ()-> MarkupToken吗? 
  } 

它由一个接收输入字符串的初始化程序和一个变异函数组成,该函数返回下一个标记 ,如果没有更多标记,则返回nil 。 以下代码打印在"_Hello *world*_"找到的所有令牌:

  var tokenizer = MarkupTokenizer (字符串:“ _ Hello * world * _”) 
  而让令牌=令牌生成器。  nextToken (){ 
    打印(令牌) 
  } 

让我们仔细看看nextToken()的实现:

  变异func nextToken ()-> MarkupToken吗?  { 
    守卫让c = 当前其他{ 
      返回零 
    } 
  
    var令牌:MarkupToken? 
  
    如果是CharacterSet。  分隔符 .contains(c){ 
      令牌= 扫描分隔符 :c) 
    }其他{ 
      令牌= scanText () 
    } 
  
    如果令牌==无{ 
      令牌= .text(String(c)) 
      前进 () 
    } 
  
    返回令牌 
  } 

该方法首先获取当前字符,如果没有更多字符要处理,则退出。

然后它检查当前字符是否是定界符 。 请注意, delimiters是通过扩展添加的静态属性,其值是CharacterSet(charactersIn: "*_~")

考虑以下规则,对scan(delimiter:)的调用将scan(delimiter:)左定界符或右定界符:

  • 左定界符必须在空格或标点符号之前,并且不得在空格或换行符之后。
  • 右定界符不得在空格之前,并且必须在空格或标点之后。

如果调用成功,它将返回相应的令牌并前进到下一个字符,否则将返回nil

scanText()的调用从当前位置开始扫描字符串,直到找到分隔符为止。

最后,如果尚未找到有效的令牌,则该方法将返回包含当前字符的text令牌,并前进到字符串中的下一个字符。 这将处理发现分隔符但不能将其视为分隔符的情况(即"Hello * world" )。

您可以在此处查看 MarkupTokenizer的完整实现。 还有一个游乐场 ,您可以在其中通过向令牌生成器输入不同的输入字符串进行实验。

实现解析器

解析器的工作是根据令牌生成器生成的令牌序列生成语法树。 在我们的例子中,树的每个节点表示文本特定部分的格式。

例如,以下文本:

  *快速*,〜红〜棕狐狸跳过了_ *懒狗* _ 

结果显示在以下语法树中:

具有关联值的Swift enums非常适合于树结构。 让我们为标记节点创建一个enum

  枚举MarkupNode { 
    案例文本 (字符串) 
    区分大小写([MarkupNode]) 
    区分大小写([MarkupNode]) 
    案例删除 ([MarkupNode]) 
  } 

有了这种新类型,我们可以说解析器将为先前的文本生成以下元素数组:

  var元素:[MarkupNode] = [ 
    .text( “ The” ), 
    。强大([ 
      .text( “快速” ) 
    ]), 
    .text( “,” ), 
    。删除([ 
      .text( “红色” ) 
    ]), 
    .text( “棕色的狐狸跳过一个” ), 
    。重点([ 
      。强大([ 
         .text( “懒狗” ) 
      ]) 
    ]) 
  ] 

让我们看看如何将令牌序列转换为MarkupNode实例数组。

我们首先使用单个公共类方法创建一个MarkupParser结构,该结构返回输入文本的节点数组。 它使用内部标记器对标记进行迭代,并维护迄今为止发现的开放格式定界符的堆栈:

  公共结构MarkupParser { 
    公共静态函数解析 (文本:字符串)-> [MarkupNode] { 
        var parser = MarkupParser(text:text) 
        返回parser.parse() 
    } 
  
    私人var 标记器 :MarkupTokenizer 
    私人var OpeningDelimiters :[UnicodeScalar] = [] 
  
    私人初始化 (文字:字串){ 
        tokenizer = MarkupTokenizer(字符串:文本) 
    } 私有变异函数解析 ()-> [MarkupNode] { 
      ... 
    } 
  } 

私有的parse()方法遍历每个令牌并对其进行检查。 文本标记将转换为text节点,并附加到elements数组中:

  变异func parse()-> [MarkupNode] { 
    var元素:[MarkupNode] = [], 而让令牌= tokenizer.nextToken() { 
      切换令牌{ 
  大小写.text(let值): 
        elements.append(.text(value)) 
      ... 
      } 
    } 
    ... 
    返回元素 
  } 

如果令牌是左定界符,则将其添加到堆栈中 ,然后递归解析以下令牌:

  情况.leftDelimiter(让定界符): 
    //递归解析定界符之后的所有标记 
    openingDelimiters.append(定界符) 
    elements.append(contentsOf:parse()) 

当令牌是一个右定界符且在堆栈中具有匹配的对应定界符时,我们需要调用close(delimiter:elements:) 。 此方法为指定的定界符创建一个节点,将提供的元素添加为子元素,并从堆栈中删除该定界符。 它还会将那之后的所有打开的定界符转换为纯文本,并将其从堆栈中删除。 需要使用此逻辑来正确处理输入文本,例如"Hello *_world*"

  case .rightDelimiter(让定界符) 
  其中openingDelimiters.contains(定界符): 
    让节点= 关闭(分隔符:分隔符,元素:元素) 
    返回[节点] 

默认情况下,在没有匹配左定界符的情况下处理右定界符,将它们转换为文本节点。

  默认: 
    elements.append(.text(token.description)) 

浏览完所有标记后,我们需要将其余的打开定界符转换为文本节点,并将它们放在结果元素数组的前面。 此逻辑将正确处理输入文本,例如"Hello *world"

  //将孤立的开头分隔符转换为纯文本 
  让textElements:[MarkupNode] = OpeningDelimiters.map { 
    .text(字符串($ 0)) 
  } elements.insert(contentsOf:textElements,at:0) 
  openingDelimiters.removeAll() 返回元素 

MarkupParser有一个测试用例 ,它涵盖了大多数边缘情况,并且还用于记录格式的细节。

渲染格式化文本

获得格式化文本的最后一步是将节点数组转换为属性字符串。

我们首先创建一个为单个MarkupNode生成NSAttributedString的方法。 render方法接收具有至少一个字体属性的属性字典:

  扩展MarkupNode { 
    func render (属性:[NSAttributedStringKey:任意])-> NSAttributedString { 
      守护让currentFont = 
        属性[NSAttributedStringKey.font]如?  UIFont其他{ 
        fatalError(“ \(属性)中缺少字体属性”) 
      } 
      ... 
    } 
  } 

然后,它打开节点的类型。 对于text情况,我们只需要返回一个NSAttributedString并使用关联的文本和提供的属性进行初始化:

  切换自我 { 
  大小写.text(let值): 
    返回NSAttributedString(字符串:值,属性:属性) 
  ... 
  } 

strongemphasis案件需要更多的工作。 我们需要通过添加适当的符号特征(分别为粗体和斜体)来基于当前字体创建一种新字体。 为了渲染该节点,我们在子节点上进行映射,使用新属性调用render方法,最后将得到的属性字符串连接到单个字符串中。

  案例.strong(让孩子): 
    var newAttributes =属性 
    newAttributes [NSAttributedStringKey.font] = 
      currentFont.addingSymbolicTraits( .traitBold返回children.map { 
      $ 0.render(withAttributes:newAttributes) 
    } .joined() case .emphasis(让孩子们): 
    var newAttributes =属性 
    newAttributes [NSAttributedStringKey.font] = 
      currentFont.addingSymbolicTraits( .traitItalic返回children.map { 
      $ 0.render(withAttributes:newAttributes) 
    } .joined() 

对于delete情况,我们使用类似的方法,但是我们没有添加新的字体,而是添加了删除线样式来达到预期的效果:

  案例.delete(让孩子们): 
    var newAttributes =属性 
    newAttributes [NSAttributedStringKey.strikethroughStyle] = 
      NSUnderlineStyle.styleSingle.rawValue 
    newAttributes [NSAttributedStringKey.baselineOffset] = 0 
    返回children.map { 
      $ 0.render(withAttributes:newAttributes) 
    } .joined() 

现在我们可以将节点转换为属性字符串,我们可以创建一个MarkupRenderer类,该类接受一个标记字符串并生成一个属性字符串:

  公共最终课程MarkupRenderer { 
    私人让baseFont :UIFont 公共初始化 (baseFont:UIFont){ 
      self.baseFont = baseFont 
    } 公共功能渲染 (文本:字符串)-> NSAttributedString { 
      让元素= MarkupParser。  解析 (文本:文本) 
      让attributes = [NSAttributedStringKey.font: baseFont ] 返回elements.map { 
        $ 0.render(withAttributes:属性) 
      } .joined() 
    } 
  } 

有了所有这些之后,我们终于有了使用格式化文本的好方法。 例如,代码段:

  let renderer = MarkupRenderer (baseFont:UIFont.systemFont(ofSize:16)) let label = UILabel() 
  label.numberOfLines = 0 
  label.backgroundColor = UIColor.white 
  label.textColor = UIColor.black 
  label.attributedText = renderer.render(text: 
  “” 
   那个*快速*,〜红褐色的狐狸跳过了_ *懒狗* _。 
   计算_6 * 4 * 8_。  _Quick_zephyrs_blow_。 
   “” 

产生以下输出:

参考文献

  • Swift中的字符串,字符和性能-深入探讨
  • Swift Talk#2渲染CommonMark