Compare commits
No commits in common. "a2ca8fd65053872f37114d8bf769bc7fb6807ab5" and "fa03efedbbfba123946548cdd08b211a2e220742" have entirely different histories.
a2ca8fd650
...
fa03efedbb
126
BlockState.dot
126
BlockState.dot
|
@ -1,126 +0,0 @@
|
||||||
digraph blockstate {
|
|
||||||
/* rankdir=LR; */
|
|
||||||
node [shape = doublecircle, fontsize = 18]; end;
|
|
||||||
node [shape = circle, fontsize = 18];
|
|
||||||
edge [fontsize = 18];
|
|
||||||
init [label = "", shape=none, height = .0, width = .0];
|
|
||||||
start;
|
|
||||||
emptyBlock [label = "empty block"];
|
|
||||||
nonEmptyBlock [label = "non-empty block"];
|
|
||||||
emittedSpace [label = "emitted space"];
|
|
||||||
lineBreakTag [label = "line break tag"];
|
|
||||||
atLeastTwoLineBreakTags [label = ">=2 line break tags"];
|
|
||||||
emptyBlockWithAtLeastTwoPreviousLineBreakTags [label = "empty block w/ >=2 prev line break tags"];
|
|
||||||
beginListItem [label = "begin list item"];
|
|
||||||
endListItem [label = "end list item"];
|
|
||||||
listItemContent [label = "list item content"];
|
|
||||||
emittedSpaceInListItemContent [label = "emitted space in text in list item content"];
|
|
||||||
lineBreakTagInListItemContent [label = "line break tag in list item content"];
|
|
||||||
atLeastTwoLineBreakTagsInListItemContent [label = ">= 2 line break tags in list item content"];
|
|
||||||
preformattedStart [label = "preformatted start"];
|
|
||||||
preformattedEmptyBlock [label = "preformatted empty block"];
|
|
||||||
preformattedNonEmptyBlock [label = "preformatted non-empty block"];
|
|
||||||
preformattedLineBreak [label = "preformatted line break"];
|
|
||||||
preformattedAtLeastTwoLineBreaks [label = "preformatted >=2 line breaks"];
|
|
||||||
afterPreStartTag [label = "after <pre> start tag"];
|
|
||||||
afterPreStartTagWithLeadingWhitespace [label = "after <pre> start tag w/ leading whitespace"];
|
|
||||||
preformattedNonEmptyBlockWithTrailingWhitespace [label = "preformatted non-empty block w/ trailing whitespace"];
|
|
||||||
preformattedEmptyBlockWithLeadingWhitespace [label = "preformatted empty block w/ leading whitespace"];
|
|
||||||
|
|
||||||
init -> start;
|
|
||||||
start -> start [label = "whitespace (skip)\n<br> (skip)\n</pre>\nstart/end block"];
|
|
||||||
start -> nonEmptyBlock [label = "non-whitespace"];
|
|
||||||
start -> preformattedStart [label = "<pre> (depth = 1)"];
|
|
||||||
start -> beginListItem [label = "<li>"];
|
|
||||||
nonEmptyBlock -> nonEmptyBlock [label = "non-whitespace"];
|
|
||||||
nonEmptyBlock -> emptyBlock [label = "start/end block"];
|
|
||||||
nonEmptyBlock -> emittedSpace [label = "whitespace (emit space)"];
|
|
||||||
nonEmptyBlock -> lineBreakTag [label = "<br> (append to tmp)"];
|
|
||||||
nonEmptyBlock -> beginListItem [label = "<li>"];
|
|
||||||
nonEmptyBlock -> endListItem [label = "</li>"];
|
|
||||||
emittedSpace -> nonEmptyBlock [label = "non-whitespace"];
|
|
||||||
emittedSpace -> emittedSpace [label = "whitespace (skip)"];
|
|
||||||
emittedSpace -> emptyBlock [label = "start/end block (remove 1)"];
|
|
||||||
emittedSpace -> lineBreakTag [label = "<br> (append to tmp)"];
|
|
||||||
emittedSpace -> end [label = "EOF (remove 1)"];
|
|
||||||
emptyBlock -> nonEmptyBlock [label = "non-whitespace (block break)"];
|
|
||||||
emptyBlock -> emptyBlock [label = "whitespace (skip)\n<br>\n</pre>\nstart/end block"];
|
|
||||||
emptyBlock -> afterPreStartTag [label = "<pre> (depth = 1)"];
|
|
||||||
emptyBlock -> beginListItem [label = "<li>"];
|
|
||||||
emptyBlock -> endListItem [label = "</li>"];
|
|
||||||
lineBreakTag -> lineBreakTag [label = "whitespace (skip)"];
|
|
||||||
lineBreakTag -> atLeastTwoLineBreakTags [label = "<br> (append to tmp)"];
|
|
||||||
lineBreakTag -> emptyBlock [label = "start/end block (clear tmp)"];
|
|
||||||
lineBreakTag -> nonEmptyBlock [label = "non-whitespace (emit tmp)"];
|
|
||||||
atLeastTwoLineBreakTags -> atLeastTwoLineBreakTags [label = "whitespace (skip)\n<br> (append to tmp)"];
|
|
||||||
atLeastTwoLineBreakTags -> nonEmptyBlock [label = "non-whitespace (emit tmp)"];
|
|
||||||
atLeastTwoLineBreakTags -> emptyBlockWithAtLeastTwoPreviousLineBreakTags [label = "start/end block"];
|
|
||||||
emptyBlockWithAtLeastTwoPreviousLineBreakTags -> emptyBlockWithAtLeastTwoPreviousLineBreakTags [label = "whitespace (skip)\n<br>\n</pre>\nstart/end block"];
|
|
||||||
emptyBlockWithAtLeastTwoPreviousLineBreakTags -> nonEmptyBlock [label = "non-whitespace (emit tmp)"];
|
|
||||||
emptyBlockWithAtLeastTwoPreviousLineBreakTags -> afterPreStartTagWithLeadingWhitespace [label = "<pre> (depth = 1)"];
|
|
||||||
beginListItem -> beginListItem [label = "<li>\nwhitespace (skip)\n<br>\nstart/end block"];
|
|
||||||
beginListItem -> listItemContent [label = "non-whitespace"];
|
|
||||||
beginListItem -> endListItem [label = "</li>"];
|
|
||||||
beginListItem -> afterPreStartTagWithLeadingWhitespace [label = "<pre>"];
|
|
||||||
endListItem -> endListItem [label = "whitespace (skip)\n</li>"];
|
|
||||||
endListItem -> beginListItem [label = "<li> (line break)"];
|
|
||||||
endListItem -> emptyBlock [label = "start/end block"];
|
|
||||||
endListItem -> listItemContent [label = "non-whitespace (line break, indent)"];
|
|
||||||
endListItem -> lineBreakTagInListItemContent [label = "<br> (append to tmp)"];
|
|
||||||
listItemContent -> listItemContent [label = "non-whitespace"];
|
|
||||||
listItemContent -> beginListItem [label = "<li> (line break)"];
|
|
||||||
listItemContent -> lineBreakTagInListItemContent [label = "<br> (append to tmp)"];
|
|
||||||
listItemContent -> emittedSpaceInListItemContent [label = "whitespace (emit space)"];
|
|
||||||
listItemContent -> emptyBlock [label = "start/end block"];
|
|
||||||
listItemContent -> endListItem [label = "</li>"];
|
|
||||||
emittedSpaceInListItemContent -> emittedSpaceInListItemContent [label = "whitespace (skip)"];
|
|
||||||
emittedSpaceInListItemContent -> listItemContent [label = "non-whitespace"];
|
|
||||||
emittedSpaceInListItemContent -> end [label = "EOF (remove 1)"];
|
|
||||||
emittedSpaceInListItemContent -> emptyBlock [label = "start/end block (remove 1)"];
|
|
||||||
emittedSpaceInListItemContent -> beginListItem [label = "<li> (remove 1, line break)"];
|
|
||||||
emittedSpaceInListItemContent -> lineBreakTagInListItemContent [label = "<br> (append to tmp)"];
|
|
||||||
emittedSpaceInListItemContent -> endListItem [label = "</li> (remove 1)"];
|
|
||||||
lineBreakTagInListItemContent -> lineBreakTagInListItemContent [label = "whitespace (skip)"];
|
|
||||||
lineBreakTagInListItemContent -> emptyBlock [label = "start/end block (clear tmp)"];
|
|
||||||
lineBreakTagInListItemContent -> beginListItem [label = "<li> (emit tmp, line break)"];
|
|
||||||
lineBreakTagInListItemContent -> listItemContent [label = "non-whitespace (emit tmp)"];
|
|
||||||
lineBreakTagInListItemContent -> atLeastTwoLineBreakTagsInListItemContent [label = "<br> (append to tmp)"];
|
|
||||||
lineBreakTagInListItemContent -> endListItem [label = "</li> (clear tmp)"];
|
|
||||||
atLeastTwoLineBreakTagsInListItemContent -> atLeastTwoLineBreakTagsInListItemContent [label = "<br> (append to tmp)\nwhitespace (skip)"];
|
|
||||||
atLeastTwoLineBreakTagsInListItemContent -> beginListItem [label = "<li> (emit tmp, line break)"];
|
|
||||||
atLeastTwoLineBreakTagsInListItemContent -> emptyBlockWithAtLeastTwoPreviousLineBreakTags [label = "start/end block"];
|
|
||||||
atLeastTwoLineBreakTagsInListItemContent -> listItemContent [label = "non-whitespace (emit tmp)"];
|
|
||||||
atLeastTwoLineBreakTagsInListItemContent -> endListItem [label = "</li> (clear tmp)"];
|
|
||||||
afterPreStartTag -> preformattedLineBreak [label = "<br> (append to tmp, append block break to tmp)"];
|
|
||||||
afterPreStartTag -> preformattedNonEmptyBlock [label = "non \\n (block break)"];
|
|
||||||
afterPreStartTag -> preformattedEmptyBlock [label = "\\n (skip)\nstart/end block"];
|
|
||||||
preformattedLineBreak -> preformattedNonEmptyBlock [label = "non-whitespace (emit tmp)"];
|
|
||||||
preformattedLineBreak -> preformattedNonEmptyBlockWithTrailingWhitespace [label = "other whitespace (append to tmp)"];
|
|
||||||
preformattedLineBreak -> preformattedAtLeastTwoLineBreaks [label = "\\n or <br> (append to tmp)"];
|
|
||||||
preformattedAtLeastTwoLineBreaks -> preformattedAtLeastTwoLineBreaks [label = "\\n or <br> (append to tmp)"];
|
|
||||||
preformattedAtLeastTwoLineBreaks -> preformattedNonEmptyBlock [label = "non \\n or <br> (emit tmp)"];
|
|
||||||
preformattedAtLeastTwoLineBreaks -> preformattedEmptyBlockWithLeadingWhitespace [label = "start/end block"];
|
|
||||||
preformattedEmptyBlockWithLeadingWhitespace -> preformattedEmptyBlockWithLeadingWhitespace [label = "whitespace (append to tmp)\nstart/end block\n</pre> if depth>1&&tmp.count>=2 (depth - 1, remove 1 from tmp)"];
|
|
||||||
preformattedEmptyBlockWithLeadingWhitespace -> preformattedLineBreak [label = "\\n or <br> (append to tmp)"];
|
|
||||||
preformattedEmptyBlockWithLeadingWhitespace -> afterPreStartTagWithLeadingWhitespace [label = "<pre> (depth + 1)"];
|
|
||||||
preformattedEmptyBlockWithLeadingWhitespace -> preformattedEmptyBlock [label = "</pre> if depth>1&&tmp.count<2 (depth - 1, remove 1 from tmp)"];
|
|
||||||
preformattedEmptyBlockWithLeadingWhitespace -> emptyBlock [label = "</pre> if depth<=1 (clear tmp)"];
|
|
||||||
preformattedEmptyBlock -> preformattedEmptyBlock [label = "start/end block\n</pre>if depth>1 (depth - 1)"];
|
|
||||||
preformattedEmptyBlock -> afterPreStartTag [label = "<pre> (depth + 1"];
|
|
||||||
preformattedEmptyBlock -> preformattedNonEmptyBlock [label = "non-whitespace (block break)"];
|
|
||||||
preformattedEmptyBlock -> preformattedEmptyBlockWithLeadingWhitespace [label = "whitespace (append to tmp)"];
|
|
||||||
preformattedEmptyBlock -> preformattedLineBreak [label = "<br> (append to tmp)"];
|
|
||||||
preformattedNonEmptyBlock -> preformattedNonEmptyBlock [label = "non-whitespace"];
|
|
||||||
preformattedNonEmptyBlock -> preformattedLineBreak [label = "\\n or <br> (append to tmp)"];
|
|
||||||
preformattedNonEmptyBlock -> preformattedNonEmptyBlockWithTrailingWhitespace [label = "other whitespace (append to tmp)"];
|
|
||||||
preformattedNonEmptyBlock -> preformattedEmptyBlock [label = "start/end block"];
|
|
||||||
preformattedNonEmptyBlockWithTrailingWhitespace -> preformattedNonEmptyBlockWithTrailingWhitespace [label = "whitespace (append to tmp)"];
|
|
||||||
preformattedNonEmptyBlockWithTrailingWhitespace -> preformattedNonEmptyBlock [label = "non-whitespace (emit tmp)"];
|
|
||||||
preformattedNonEmptyBlockWithTrailingWhitespace -> preformattedLineBreak [label = "\\n or <br> (append to tmp)"];
|
|
||||||
preformattedNonEmptyBlockWithTrailingWhitespace -> preformattedEmptyBlockWithLeadingWhitespace [label = "start/end block (append block break to tmp)"];
|
|
||||||
afterPreStartTagWithLeadingWhitespace -> preformattedNonEmptyBlock [label = "non-whitespace (emit tmp)"];
|
|
||||||
afterPreStartTagWithLeadingWhitespace -> preformattedEmptyBlockWithLeadingWhitespace [label = "\\n (skip)\nother whitespace (append to tmp)\n<br> (append to tmp)\nstart/end block"];
|
|
||||||
preformattedStart -> preformattedStart [label = "<pre> (depth + 1)\n</pre> if depth>1 (depth - 1)\n\\n or <br> (skip)\nstart/end block"];
|
|
||||||
preformattedStart -> start [label = "</pre> if depth<=1"];
|
|
||||||
preformattedStart -> preformattedNonEmptyBlock [label = "non \\n"];
|
|
||||||
}
|
|
|
@ -17,7 +17,7 @@ private typealias PlatformFont = UIFont
|
||||||
private typealias PlatformFont = NSFont
|
private typealias PlatformFont = NSFont
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
public class AttributedStringConverter<Callbacks: HTMLConversionCallbacks> {
|
public struct AttributedStringConverter<Callbacks: HTMLConversionCallbacks>: BlockRenderer {
|
||||||
private let configuration: AttributedStringConverterConfiguration
|
private let configuration: AttributedStringConverterConfiguration
|
||||||
private var fontCache: [FontTrait: PlatformFont] = [:]
|
private var fontCache: [FontTrait: PlatformFont] = [:]
|
||||||
|
|
||||||
|
@ -26,13 +26,13 @@ public class AttributedStringConverter<Callbacks: HTMLConversionCallbacks> {
|
||||||
|
|
||||||
private var actionStack: [ElementAction] = []
|
private var actionStack: [ElementAction] = []
|
||||||
private var styleStack: [Style] = []
|
private var styleStack: [Style] = []
|
||||||
private var blockStateMachine = BlockStateMachine(blockBreak: "", lineBreak: "", listIndentForContentOutsideItem: "", append: { _ in }, removeChar: {})
|
var blockState = BlockState.start
|
||||||
private var currentElementIsEmpty = true
|
private var currentElementIsEmpty = true
|
||||||
private var previouslyFinishedListItem = false
|
private var previouslyFinishedListItem = false
|
||||||
// The current run of text w/o styles changing
|
// The current run of text w/o styles changing
|
||||||
private var currentRun: String = ""
|
private var currentRun: String = ""
|
||||||
|
|
||||||
public convenience init(configuration: AttributedStringConverterConfiguration) where Callbacks == DefaultCallbacks {
|
public init(configuration: AttributedStringConverterConfiguration) where Callbacks == DefaultCallbacks {
|
||||||
self.init(configuration: configuration, callbacks: DefaultCallbacks.self)
|
self.init(configuration: configuration, callbacks: DefaultCallbacks.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,17 +40,13 @@ public class AttributedStringConverter<Callbacks: HTMLConversionCallbacks> {
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
public func convert(html: String) -> NSAttributedString {
|
public mutating func convert(html: String) -> NSAttributedString {
|
||||||
tokenizer = Tokenizer(chars: html.unicodeScalars.makeIterator())
|
tokenizer = Tokenizer(chars: html.unicodeScalars.makeIterator())
|
||||||
str = NSMutableAttributedString()
|
str = NSMutableAttributedString()
|
||||||
|
|
||||||
actionStack = []
|
actionStack = []
|
||||||
styleStack = []
|
styleStack = []
|
||||||
blockStateMachine = BlockStateMachine(blockBreak: "\n\n", lineBreak: "\n", listIndentForContentOutsideItem: "\t\t", append: { [unowned self] in
|
blockState = .start
|
||||||
self.append($0)
|
|
||||||
}, removeChar: { [unowned self] in
|
|
||||||
self.removeChar()
|
|
||||||
})
|
|
||||||
currentElementIsEmpty = true
|
currentElementIsEmpty = true
|
||||||
previouslyFinishedListItem = false
|
previouslyFinishedListItem = false
|
||||||
currentRun = ""
|
currentRun = ""
|
||||||
|
@ -59,16 +55,12 @@ public class AttributedStringConverter<Callbacks: HTMLConversionCallbacks> {
|
||||||
switch token {
|
switch token {
|
||||||
case .character(let c):
|
case .character(let c):
|
||||||
currentElementIsEmpty = false
|
currentElementIsEmpty = false
|
||||||
if blockStateMachine.continueBlock(char: c) {
|
continueBlock()
|
||||||
currentRun.unicodeScalars.append(c)
|
currentRun.unicodeScalars.append(c)
|
||||||
}
|
|
||||||
case .characterSequence(let s):
|
case .characterSequence(let s):
|
||||||
currentElementIsEmpty = false
|
currentElementIsEmpty = false
|
||||||
for c in s.unicodeScalars {
|
continueBlock()
|
||||||
if blockStateMachine.continueBlock(char: c) {
|
currentRun.append(s)
|
||||||
currentRun.unicodeScalars.append(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .comment:
|
case .comment:
|
||||||
// ignored
|
// ignored
|
||||||
continue
|
continue
|
||||||
|
@ -95,15 +87,14 @@ public class AttributedStringConverter<Callbacks: HTMLConversionCallbacks> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
blockStateMachine.endBlocks()
|
|
||||||
finishRun()
|
finishRun()
|
||||||
|
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleStartTag(_ name: String, selfClosing: Bool, attributes: [Attribute]) {
|
private mutating func handleStartTag(_ name: String, selfClosing: Bool, attributes: [Attribute]) {
|
||||||
if name == "br" {
|
if name == "br" {
|
||||||
blockStateMachine.breakTag()
|
currentRun.append("\n")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// self closing tags are ignored since they have no content
|
// self closing tags are ignored since they have no content
|
||||||
|
@ -135,25 +126,29 @@ public class AttributedStringConverter<Callbacks: HTMLConversionCallbacks> {
|
||||||
finishRun()
|
finishRun()
|
||||||
styleStack.append(.monospace)
|
styleStack.append(.monospace)
|
||||||
case "pre":
|
case "pre":
|
||||||
blockStateMachine.startOrEndBlock()
|
startOrFinishBlock()
|
||||||
blockStateMachine.startPreformatted()
|
|
||||||
finishRun()
|
finishRun()
|
||||||
styleStack.append(.monospace)
|
styleStack.append(.monospace)
|
||||||
case "blockquote":
|
case "blockquote":
|
||||||
blockStateMachine.startOrEndBlock()
|
startOrFinishBlock()
|
||||||
finishRun()
|
finishRun()
|
||||||
styleStack.append(.blockquote)
|
styleStack.append(.blockquote)
|
||||||
case "p":
|
case "p":
|
||||||
blockStateMachine.startOrEndBlock()
|
startOrFinishBlock()
|
||||||
case "ol":
|
case "ol":
|
||||||
blockStateMachine.startOrEndBlock()
|
startOrFinishBlock()
|
||||||
finishRun()
|
finishRun()
|
||||||
styleStack.append(.orderedList(nextElementOrdinal: 1))
|
styleStack.append(.orderedList(nextElementOrdinal: 1))
|
||||||
case "ul":
|
case "ul":
|
||||||
blockStateMachine.startOrEndBlock()
|
startOrFinishBlock()
|
||||||
finishRun()
|
finishRun()
|
||||||
styleStack.append(.unorderedList)
|
styleStack.append(.unorderedList)
|
||||||
case "li":
|
case "li":
|
||||||
|
if previouslyFinishedListItem {
|
||||||
|
currentRun.append("\n")
|
||||||
|
} else {
|
||||||
|
continueBlock()
|
||||||
|
}
|
||||||
let marker: String
|
let marker: String
|
||||||
if case .orderedList(let nextElementOrdinal) = styleStack.last {
|
if case .orderedList(let nextElementOrdinal) = styleStack.last {
|
||||||
marker = orderedTextList.marker(forItemNumber: nextElementOrdinal)
|
marker = orderedTextList.marker(forItemNumber: nextElementOrdinal)
|
||||||
|
@ -163,14 +158,13 @@ public class AttributedStringConverter<Callbacks: HTMLConversionCallbacks> {
|
||||||
} else {
|
} else {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
blockStateMachine.startListItem()
|
|
||||||
currentRun.append("\t\(marker)\t")
|
currentRun.append("\t\(marker)\t")
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleEndTag(_ name: String) {
|
private mutating func handleEndTag(_ name: String) {
|
||||||
switch name {
|
switch name {
|
||||||
case "a":
|
case "a":
|
||||||
if case .link(.some(_)) = lastStyle(.link) {
|
if case .link(.some(_)) = lastStyle(.link) {
|
||||||
|
@ -192,60 +186,38 @@ public class AttributedStringConverter<Callbacks: HTMLConversionCallbacks> {
|
||||||
case "pre":
|
case "pre":
|
||||||
finishRun()
|
finishRun()
|
||||||
removeLastStyle(.monospace)
|
removeLastStyle(.monospace)
|
||||||
blockStateMachine.startOrEndBlock()
|
startOrFinishBlock()
|
||||||
blockStateMachine.endPreformatted()
|
|
||||||
case "blockquote":
|
case "blockquote":
|
||||||
finishRun()
|
finishRun()
|
||||||
removeLastStyle(.blockquote)
|
removeLastStyle(.blockquote)
|
||||||
blockStateMachine.startOrEndBlock()
|
startOrFinishBlock()
|
||||||
case "p":
|
case "p":
|
||||||
blockStateMachine.startOrEndBlock()
|
startOrFinishBlock()
|
||||||
case "ol":
|
case "ol":
|
||||||
finishRun()
|
finishRun()
|
||||||
removeLastStyle(.orderedList)
|
removeLastStyle(.orderedList)
|
||||||
blockStateMachine.startOrEndBlock()
|
startOrFinishBlock()
|
||||||
previouslyFinishedListItem = false
|
previouslyFinishedListItem = false
|
||||||
case "ul":
|
case "ul":
|
||||||
finishRun()
|
finishRun()
|
||||||
removeLastStyle(.unorderedList)
|
removeLastStyle(.unorderedList)
|
||||||
blockStateMachine.startOrEndBlock()
|
startOrFinishBlock()
|
||||||
previouslyFinishedListItem = false
|
previouslyFinishedListItem = false
|
||||||
case "li":
|
case "li":
|
||||||
finishRun()
|
finishRun()
|
||||||
previouslyFinishedListItem = true
|
previouslyFinishedListItem = true
|
||||||
blockStateMachine.endListItem()
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var blockBreak: String {
|
mutating func insertBlockBreak() {
|
||||||
"\n\n"
|
currentRun.append("\n\n")
|
||||||
}
|
|
||||||
|
|
||||||
var lineBreak: String {
|
|
||||||
"\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
var listIndentForContentOutsideItem: String {
|
|
||||||
"\t\t"
|
|
||||||
}
|
|
||||||
|
|
||||||
func append(_ s: String) {
|
|
||||||
currentRun.append(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeChar() {
|
|
||||||
if currentRun.isEmpty {
|
|
||||||
str.deleteCharacters(in: NSRange(location: str.length - 1, length: 1))
|
|
||||||
} else {
|
|
||||||
currentRun.removeLast()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finds the last currently-open style of the given type.
|
// Finds the last currently-open style of the given type.
|
||||||
// We can't just use the last one because we need to handle mis-nested tags.
|
// We can't just use the last one because we need to handle mis-nested tags.
|
||||||
private func removeLastStyle(_ type: Style.StyleType) {
|
private mutating func removeLastStyle(_ type: Style.StyleType) {
|
||||||
var i = styleStack.index(before: styleStack.endIndex)
|
var i = styleStack.index(before: styleStack.endIndex)
|
||||||
while i >= styleStack.startIndex {
|
while i >= styleStack.startIndex {
|
||||||
if styleStack[i].type == type {
|
if styleStack[i].type == type {
|
||||||
|
@ -280,7 +252,7 @@ public class AttributedStringConverter<Callbacks: HTMLConversionCallbacks> {
|
||||||
return style
|
return style
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private func finishRun() {
|
private mutating func finishRun() {
|
||||||
if actionStack.contains(.skip) {
|
if actionStack.contains(.skip) {
|
||||||
currentRun = ""
|
currentRun = ""
|
||||||
return
|
return
|
||||||
|
@ -328,7 +300,7 @@ public class AttributedStringConverter<Callbacks: HTMLConversionCallbacks> {
|
||||||
currentRun = ""
|
currentRun = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getFont(traits: FontTrait) -> PlatformFont? {
|
private mutating func getFont(traits: FontTrait) -> PlatformFont? {
|
||||||
if let cached = fontCache[traits] {
|
if let cached = fontCache[traits] {
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,581 +7,39 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/*
|
protocol BlockRenderer {
|
||||||
|
var blockState: BlockState { get set }
|
||||||
This gnarly mess of a state machine is responsible for:
|
mutating func insertBlockBreak()
|
||||||
1) Inserting line breaks in the right places corresponding to boundaries between block elements
|
|
||||||
2) Preventing leading/trailing whitespace from being emitted
|
|
||||||
3) Collapsing whitespace within the string like https://www.w3.org/TR/css-text-3/#white-space-phase-1
|
|
||||||
4) Handling whitespace inside <pre> elements
|
|
||||||
|
|
||||||
DO NOT TOUCH THE CODE WITHOUT CHECKING/UPDATING THE DIAGRAM.
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
struct BlockStateMachine {
|
|
||||||
var blockState: BlockState = .start
|
|
||||||
let blockBreak: String
|
|
||||||
let lineBreak: String
|
|
||||||
let listIndentForContentOutsideItem: String
|
|
||||||
var temporaryBuffer: String = ""
|
|
||||||
let append: (String) -> Void
|
|
||||||
let removeChar: () -> Void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension BlockStateMachine {
|
extension BlockRenderer {
|
||||||
mutating func startOrEndBlock() {
|
mutating func startOrFinishBlock() {
|
||||||
switch blockState {
|
switch blockState {
|
||||||
case .start:
|
case .start:
|
||||||
break
|
break
|
||||||
case .emptyBlock:
|
|
||||||
break
|
|
||||||
case .nonEmptyBlock:
|
case .nonEmptyBlock:
|
||||||
blockState = .emptyBlock
|
blockState = .emptyBlock
|
||||||
case .emittedSpace:
|
case .emptyBlock:
|
||||||
blockState = .emptyBlock
|
|
||||||
removeChar()
|
|
||||||
case .lineBreakTag:
|
|
||||||
blockState = .emptyBlock
|
|
||||||
temporaryBuffer = ""
|
|
||||||
case .atLeastTwoLineBreakTags:
|
|
||||||
blockState = .emptyBlockWithAtLeastTwoPreviousLineBreakTags
|
|
||||||
case .emptyBlockWithAtLeastTwoPreviousLineBreakTags:
|
|
||||||
break
|
|
||||||
case .beginListItem:
|
|
||||||
break
|
|
||||||
case .endListItem:
|
|
||||||
blockState = .emptyBlock
|
|
||||||
case .listItemContent:
|
|
||||||
blockState = .emptyBlock
|
|
||||||
case .emittedSpaceInListItemContent:
|
|
||||||
blockState = .emptyBlock
|
|
||||||
removeChar()
|
|
||||||
case .lineBreakTagInListItemContent:
|
|
||||||
blockState = .emptyBlock
|
|
||||||
temporaryBuffer = ""
|
|
||||||
case .atLeastTwoLineBreakTagsInListItemContent:
|
|
||||||
blockState = .emptyBlockWithAtLeastTwoPreviousLineBreakTags
|
|
||||||
case .preformattedStart(depth: _):
|
|
||||||
break
|
|
||||||
case .preformattedEmptyBlock(depth: _):
|
|
||||||
break
|
|
||||||
case .preformattedNonEmptyBlock(let depth):
|
|
||||||
blockState = .preformattedEmptyBlock(depth: depth)
|
|
||||||
case .preformattedLineBreak(depth: let depth):
|
|
||||||
blockState = .preformattedEmptyBlockWithLeadingWhitespace(depth: depth)
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
case .preformattedAtLeastTwoLineBreaks(let depth):
|
|
||||||
blockState = .preformattedEmptyBlockWithLeadingWhitespace(depth: depth)
|
|
||||||
case .afterPreStartTag(let depth):
|
|
||||||
blockState = .preformattedEmptyBlock(depth: depth)
|
|
||||||
case .afterPreStartTagWithLeadingWhitespace(let depth):
|
|
||||||
blockState = .preformattedEmptyBlockWithLeadingWhitespace(depth: depth)
|
|
||||||
case .preformattedNonEmptyBlockWithTrailingWhitespace(depth: _):
|
|
||||||
temporaryBuffer.append(blockBreak)
|
|
||||||
case .preformattedEmptyBlockWithLeadingWhitespace(depth: _):
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func continueBlock(char: UnicodeScalar) -> Bool {
|
mutating func continueBlock() {
|
||||||
let isNewline = char == "\n"
|
|
||||||
let isWhitespace = isNewline || isWhitespace(char)
|
|
||||||
switch blockState {
|
switch blockState {
|
||||||
case .start:
|
case .start:
|
||||||
if isWhitespace {
|
blockState = .nonEmptyBlock
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .nonEmptyBlock
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .emptyBlock:
|
|
||||||
if isWhitespace {
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .nonEmptyBlock
|
|
||||||
append(blockBreak)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .nonEmptyBlock:
|
case .nonEmptyBlock:
|
||||||
if isWhitespace {
|
break
|
||||||
blockState = .emittedSpace
|
case .emptyBlock:
|
||||||
append(" ")
|
insertBlockBreak()
|
||||||
return false
|
blockState = .nonEmptyBlock
|
||||||
} else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .emittedSpace:
|
|
||||||
if isWhitespace {
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .nonEmptyBlock
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .lineBreakTag:
|
|
||||||
if isWhitespace {
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .nonEmptyBlock
|
|
||||||
append(temporaryBuffer)
|
|
||||||
temporaryBuffer = ""
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .atLeastTwoLineBreakTags:
|
|
||||||
if isWhitespace {
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .nonEmptyBlock
|
|
||||||
append(temporaryBuffer)
|
|
||||||
temporaryBuffer = ""
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .emptyBlockWithAtLeastTwoPreviousLineBreakTags:
|
|
||||||
if isWhitespace {
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .nonEmptyBlock
|
|
||||||
append(temporaryBuffer)
|
|
||||||
temporaryBuffer = ""
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .beginListItem:
|
|
||||||
if isWhitespace {
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .listItemContent
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .endListItem:
|
|
||||||
if isWhitespace {
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .listItemContent
|
|
||||||
append(lineBreak)
|
|
||||||
append(listIndentForContentOutsideItem)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .listItemContent:
|
|
||||||
if isWhitespace {
|
|
||||||
blockState = .emittedSpaceInListItemContent
|
|
||||||
append(" ")
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .emittedSpaceInListItemContent:
|
|
||||||
if isWhitespace {
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .listItemContent
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .lineBreakTagInListItemContent:
|
|
||||||
if isWhitespace {
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .listItemContent
|
|
||||||
append(temporaryBuffer)
|
|
||||||
temporaryBuffer = ""
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .atLeastTwoLineBreakTagsInListItemContent:
|
|
||||||
if isWhitespace {
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .listItemContent
|
|
||||||
append(temporaryBuffer)
|
|
||||||
temporaryBuffer = ""
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .preformattedStart(let depth):
|
|
||||||
if isNewline {
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .preformattedNonEmptyBlock(depth: depth)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .preformattedEmptyBlock(depth: let depth):
|
|
||||||
if isWhitespace {
|
|
||||||
blockState = .preformattedEmptyBlockWithLeadingWhitespace(depth: depth)
|
|
||||||
temporaryBuffer.unicodeScalars.append(char)
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .preformattedNonEmptyBlock(depth: depth)
|
|
||||||
append(blockBreak)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .preformattedNonEmptyBlock(let depth):
|
|
||||||
if isNewline {
|
|
||||||
blockState = .preformattedLineBreak(depth: depth)
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
return false
|
|
||||||
} else if isWhitespace {
|
|
||||||
blockState = .preformattedNonEmptyBlockWithTrailingWhitespace(depth: depth)
|
|
||||||
temporaryBuffer.unicodeScalars.append(char)
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .preformattedLineBreak(let depth):
|
|
||||||
if isNewline {
|
|
||||||
blockState = .preformattedAtLeastTwoLineBreaks(depth: depth)
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
return false
|
|
||||||
} else if isWhitespace {
|
|
||||||
blockState = .preformattedNonEmptyBlockWithTrailingWhitespace(depth: depth)
|
|
||||||
temporaryBuffer.unicodeScalars.append(char)
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .preformattedNonEmptyBlock(depth: depth)
|
|
||||||
append(temporaryBuffer)
|
|
||||||
temporaryBuffer = ""
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .preformattedAtLeastTwoLineBreaks(let depth):
|
|
||||||
if isWhitespace {
|
|
||||||
temporaryBuffer.unicodeScalars.append(char)
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .preformattedNonEmptyBlock(depth: depth)
|
|
||||||
append(temporaryBuffer)
|
|
||||||
temporaryBuffer = ""
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .afterPreStartTag(let depth):
|
|
||||||
if isNewline {
|
|
||||||
blockState = .preformattedEmptyBlock(depth: depth)
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .preformattedNonEmptyBlock(depth: depth)
|
|
||||||
append(blockBreak)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .afterPreStartTagWithLeadingWhitespace(let depth):
|
|
||||||
if isNewline {
|
|
||||||
blockState = .preformattedEmptyBlockWithLeadingWhitespace(depth: depth)
|
|
||||||
return false
|
|
||||||
} else if isWhitespace {
|
|
||||||
blockState = .preformattedEmptyBlockWithLeadingWhitespace(depth: depth)
|
|
||||||
temporaryBuffer.unicodeScalars.append(char)
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .preformattedNonEmptyBlock(depth: depth)
|
|
||||||
append(temporaryBuffer)
|
|
||||||
temporaryBuffer = ""
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .preformattedNonEmptyBlockWithTrailingWhitespace(let depth):
|
|
||||||
if isNewline {
|
|
||||||
blockState = .preformattedLineBreak(depth: depth)
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
return false
|
|
||||||
} else if isWhitespace {
|
|
||||||
temporaryBuffer.unicodeScalars.append(char)
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .preformattedNonEmptyBlock(depth: depth)
|
|
||||||
append(temporaryBuffer)
|
|
||||||
temporaryBuffer = ""
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case .preformattedEmptyBlockWithLeadingWhitespace(let depth):
|
|
||||||
if isNewline {
|
|
||||||
blockState = .preformattedLineBreak(depth: depth)
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
return false
|
|
||||||
} else if isWhitespace {
|
|
||||||
temporaryBuffer.unicodeScalars.append(char)
|
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
blockState = .preformattedNonEmptyBlock(depth: depth)
|
|
||||||
append(temporaryBuffer)
|
|
||||||
temporaryBuffer = ""
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func breakTag() {
|
|
||||||
switch blockState {
|
|
||||||
case .start:
|
|
||||||
break
|
|
||||||
case .emptyBlock:
|
|
||||||
append(lineBreak)
|
|
||||||
case .nonEmptyBlock:
|
|
||||||
blockState = .lineBreakTag
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
case .emittedSpace:
|
|
||||||
blockState = .lineBreakTag
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
case .lineBreakTag:
|
|
||||||
blockState = .atLeastTwoLineBreakTags
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
case .atLeastTwoLineBreakTags:
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
case .emptyBlockWithAtLeastTwoPreviousLineBreakTags:
|
|
||||||
append(lineBreak)
|
|
||||||
case .beginListItem:
|
|
||||||
append(lineBreak)
|
|
||||||
case .endListItem:
|
|
||||||
blockState = .lineBreakTagInListItemContent
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
case .listItemContent:
|
|
||||||
blockState = .lineBreakTagInListItemContent
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
case .emittedSpaceInListItemContent:
|
|
||||||
blockState = .lineBreakTagInListItemContent
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
case .lineBreakTagInListItemContent:
|
|
||||||
blockState = .atLeastTwoLineBreakTagsInListItemContent
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
case .atLeastTwoLineBreakTagsInListItemContent:
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
case .preformattedStart(depth: _):
|
|
||||||
break
|
|
||||||
case .preformattedEmptyBlock(let depth):
|
|
||||||
blockState = .preformattedLineBreak(depth: depth)
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
case .preformattedNonEmptyBlock(let depth):
|
|
||||||
blockState = .preformattedLineBreak(depth: depth)
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
case .preformattedLineBreak(let depth):
|
|
||||||
blockState = .preformattedAtLeastTwoLineBreaks(depth: depth)
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
case .preformattedAtLeastTwoLineBreaks(depth: _):
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
case .afterPreStartTag(let depth):
|
|
||||||
blockState = .preformattedLineBreak(depth: depth)
|
|
||||||
temporaryBuffer.append(blockBreak)
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
case .afterPreStartTagWithLeadingWhitespace(let depth):
|
|
||||||
blockState = .preformattedEmptyBlockWithLeadingWhitespace(depth: depth)
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
case .preformattedNonEmptyBlockWithTrailingWhitespace(let depth):
|
|
||||||
blockState = .preformattedLineBreak(depth: depth)
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
case .preformattedEmptyBlockWithLeadingWhitespace(let depth):
|
|
||||||
blockState = .preformattedLineBreak(depth: depth)
|
|
||||||
temporaryBuffer.append(lineBreak)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func startPreformatted() {
|
|
||||||
switch blockState {
|
|
||||||
case .start:
|
|
||||||
blockState = .preformattedStart(depth: 1)
|
|
||||||
case .emptyBlock:
|
|
||||||
blockState = .afterPreStartTag(depth: 1)
|
|
||||||
case .nonEmptyBlock:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .emittedSpace:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .lineBreakTag:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .atLeastTwoLineBreakTags:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .emptyBlockWithAtLeastTwoPreviousLineBreakTags:
|
|
||||||
blockState = .afterPreStartTagWithLeadingWhitespace(depth: 1)
|
|
||||||
case .beginListItem:
|
|
||||||
blockState = .afterPreStartTagWithLeadingWhitespace(depth: 1)
|
|
||||||
case .endListItem:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .listItemContent:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .emittedSpaceInListItemContent:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .lineBreakTagInListItemContent:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .atLeastTwoLineBreakTagsInListItemContent:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .preformattedStart(let depth):
|
|
||||||
blockState = .preformattedStart(depth: depth + 1)
|
|
||||||
case .preformattedEmptyBlock(let depth):
|
|
||||||
blockState = .afterPreStartTag(depth: depth + 1)
|
|
||||||
case .preformattedNonEmptyBlock(depth: _):
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .preformattedLineBreak(depth: _):
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .preformattedAtLeastTwoLineBreaks(depth: _):
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .afterPreStartTag(depth: _):
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .afterPreStartTagWithLeadingWhitespace(depth: _):
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .preformattedNonEmptyBlockWithTrailingWhitespace(depth: _):
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .preformattedEmptyBlockWithLeadingWhitespace(let depth):
|
|
||||||
blockState = .afterPreStartTagWithLeadingWhitespace(depth: depth + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func endPreformatted() {
|
|
||||||
switch blockState {
|
|
||||||
case .start:
|
|
||||||
break
|
|
||||||
case .emptyBlock:
|
|
||||||
break
|
|
||||||
case .nonEmptyBlock:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .emittedSpace:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .lineBreakTag:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .atLeastTwoLineBreakTags:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .emptyBlockWithAtLeastTwoPreviousLineBreakTags:
|
|
||||||
break
|
|
||||||
case .beginListItem:
|
|
||||||
break
|
|
||||||
case .endListItem:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .listItemContent:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .emittedSpaceInListItemContent:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .lineBreakTagInListItemContent:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .atLeastTwoLineBreakTagsInListItemContent:
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .preformattedStart(let depth):
|
|
||||||
if depth <= 1 {
|
|
||||||
blockState = .start
|
|
||||||
} else {
|
|
||||||
blockState = .preformattedStart(depth: depth - 1)
|
|
||||||
}
|
|
||||||
case .preformattedEmptyBlock(let depth):
|
|
||||||
if depth <= 1 {
|
|
||||||
blockState = .emptyBlock
|
|
||||||
} else {
|
|
||||||
blockState = .preformattedEmptyBlock(depth: depth - 1)
|
|
||||||
}
|
|
||||||
case .preformattedNonEmptyBlock(depth: _):
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .preformattedLineBreak(depth: _):
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .preformattedAtLeastTwoLineBreaks(depth: _):
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .afterPreStartTag(depth: _):
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .afterPreStartTagWithLeadingWhitespace(depth: _):
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .preformattedNonEmptyBlockWithTrailingWhitespace(depth: _):
|
|
||||||
fatalError("unreachable")
|
|
||||||
case .preformattedEmptyBlockWithLeadingWhitespace(let depth):
|
|
||||||
if depth <= 1 {
|
|
||||||
blockState = .emptyBlock
|
|
||||||
temporaryBuffer = ""
|
|
||||||
} else {
|
|
||||||
if temporaryBuffer.count >= 2 {
|
|
||||||
temporaryBuffer.removeLast()
|
|
||||||
blockState = .preformattedEmptyBlockWithLeadingWhitespace(depth: depth - 1)
|
|
||||||
} else {
|
|
||||||
temporaryBuffer.removeLast()
|
|
||||||
blockState = .preformattedEmptyBlock(depth: depth - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func startListItem() {
|
|
||||||
switch blockState {
|
|
||||||
case .start:
|
|
||||||
blockState = .beginListItem
|
|
||||||
case .emptyBlock:
|
|
||||||
blockState = .beginListItem
|
|
||||||
append(blockBreak)
|
|
||||||
case .nonEmptyBlock:
|
|
||||||
blockState = .beginListItem
|
|
||||||
append(blockBreak)
|
|
||||||
case .beginListItem:
|
|
||||||
break
|
|
||||||
case .endListItem:
|
|
||||||
blockState = .beginListItem
|
|
||||||
append(lineBreak)
|
|
||||||
case .listItemContent:
|
|
||||||
blockState = .beginListItem
|
|
||||||
append(lineBreak)
|
|
||||||
case .emittedSpaceInListItemContent:
|
|
||||||
blockState = .beginListItem
|
|
||||||
removeChar()
|
|
||||||
append(lineBreak)
|
|
||||||
case .lineBreakTagInListItemContent:
|
|
||||||
blockState = .beginListItem
|
|
||||||
append(temporaryBuffer)
|
|
||||||
temporaryBuffer = ""
|
|
||||||
append(lineBreak)
|
|
||||||
case .atLeastTwoLineBreakTagsInListItemContent:
|
|
||||||
blockState = .beginListItem
|
|
||||||
append(temporaryBuffer)
|
|
||||||
temporaryBuffer = ""
|
|
||||||
append(lineBreak)
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func endListItem() {
|
|
||||||
switch blockState {
|
|
||||||
case .emptyBlock:
|
|
||||||
blockState = .endListItem
|
|
||||||
case .nonEmptyBlock:
|
|
||||||
blockState = .endListItem
|
|
||||||
case .listItemContent:
|
|
||||||
blockState = .endListItem
|
|
||||||
case .emittedSpaceInListItemContent:
|
|
||||||
blockState = .endListItem
|
|
||||||
removeChar()
|
|
||||||
case .lineBreakTagInListItemContent:
|
|
||||||
blockState = .endListItem
|
|
||||||
temporaryBuffer = ""
|
|
||||||
case .atLeastTwoLineBreakTagsInListItemContent:
|
|
||||||
blockState = .endListItem
|
|
||||||
temporaryBuffer = ""
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func endBlocks() {
|
|
||||||
switch blockState {
|
|
||||||
case .emittedSpace:
|
|
||||||
removeChar()
|
|
||||||
case .emittedSpaceInListItemContent:
|
|
||||||
removeChar()
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BlockState: Equatable {
|
enum BlockState: Equatable {
|
||||||
case start
|
case start
|
||||||
case emptyBlock
|
|
||||||
case nonEmptyBlock
|
case nonEmptyBlock
|
||||||
case emittedSpace
|
case emptyBlock
|
||||||
case lineBreakTag
|
|
||||||
case atLeastTwoLineBreakTags
|
|
||||||
case emptyBlockWithAtLeastTwoPreviousLineBreakTags
|
|
||||||
case beginListItem
|
|
||||||
case endListItem
|
|
||||||
case listItemContent
|
|
||||||
case emittedSpaceInListItemContent
|
|
||||||
case lineBreakTagInListItemContent
|
|
||||||
case atLeastTwoLineBreakTagsInListItemContent
|
|
||||||
case preformattedStart(depth: Int32)
|
|
||||||
case preformattedEmptyBlock(depth: Int32)
|
|
||||||
case preformattedNonEmptyBlock(depth: Int32)
|
|
||||||
case preformattedLineBreak(depth: Int32)
|
|
||||||
case preformattedAtLeastTwoLineBreaks(depth: Int32)
|
|
||||||
case afterPreStartTag(depth: Int32)
|
|
||||||
case afterPreStartTagWithLeadingWhitespace(depth: Int32)
|
|
||||||
case preformattedNonEmptyBlockWithTrailingWhitespace(depth: Int32)
|
|
||||||
case preformattedEmptyBlockWithLeadingWhitespace(depth: Int32)
|
|
||||||
}
|
|
||||||
|
|
||||||
@inline(__always)
|
|
||||||
private func isWhitespace(_ c: UnicodeScalar) -> Bool {
|
|
||||||
// this is not strictly correct, but checking the actual unicode properties is slow
|
|
||||||
// and this should cover the vast majority of actual use
|
|
||||||
c == " " || c == "\n" || c == "\t" || c == "\u{A0}" /* NO-BREAK SPACE */
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,18 +7,19 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class TextConverter<Callbacks: HTMLConversionCallbacks> {
|
public struct TextConverter<Callbacks: HTMLConversionCallbacks>: BlockRenderer {
|
||||||
|
|
||||||
private let configuration: TextConverterConfiguration
|
private let configuration: TextConverterConfiguration
|
||||||
|
|
||||||
private var tokenizer: Tokenizer<String.UnicodeScalarView.Iterator>!
|
private var tokenizer: Tokenizer<String.UnicodeScalarView.Iterator>!
|
||||||
private var str: String!
|
private var str: String!
|
||||||
|
|
||||||
private var actionStack: [ElementAction] = []
|
private var actionStack: [ElementAction] = []
|
||||||
var blockStateMachine = BlockStateMachine(blockBreak: "", lineBreak: "", listIndentForContentOutsideItem: "", append: { _ in }, removeChar: {})
|
var blockState = BlockState.start
|
||||||
private var currentElementIsEmpty = true
|
private var currentElementIsEmpty = true
|
||||||
private var currentRun = ""
|
private var currentRun = ""
|
||||||
|
|
||||||
public convenience init(configuration: TextConverterConfiguration = .init()) where Callbacks == DefaultCallbacks {
|
public init(configuration: TextConverterConfiguration = .init()) where Callbacks == DefaultCallbacks {
|
||||||
self.init(configuration: configuration, callbacks: DefaultCallbacks.self)
|
self.init(configuration: configuration, callbacks: DefaultCallbacks.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,19 +27,11 @@ public class TextConverter<Callbacks: HTMLConversionCallbacks> {
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
public func convert(html: String) -> String {
|
public mutating func convert(html: String) -> String {
|
||||||
tokenizer = Tokenizer(chars: html.unicodeScalars.makeIterator())
|
tokenizer = Tokenizer(chars: html.unicodeScalars.makeIterator())
|
||||||
str = ""
|
str = ""
|
||||||
|
|
||||||
blockStateMachine = BlockStateMachine(
|
blockState = .start
|
||||||
blockBreak: configuration.insertNewlines ? "\n\n" : " " ,
|
|
||||||
lineBreak: configuration.insertNewlines ? "\n" : " " ,
|
|
||||||
listIndentForContentOutsideItem: "",
|
|
||||||
append: { [unowned self] in
|
|
||||||
self.append($0)
|
|
||||||
}, removeChar: { [unowned self] in
|
|
||||||
self.removeChar()
|
|
||||||
})
|
|
||||||
currentElementIsEmpty = true
|
currentElementIsEmpty = true
|
||||||
currentRun = ""
|
currentRun = ""
|
||||||
|
|
||||||
|
@ -46,16 +39,12 @@ public class TextConverter<Callbacks: HTMLConversionCallbacks> {
|
||||||
switch token {
|
switch token {
|
||||||
case .character(let scalar):
|
case .character(let scalar):
|
||||||
currentElementIsEmpty = false
|
currentElementIsEmpty = false
|
||||||
if blockStateMachine.continueBlock(char: scalar) {
|
continueBlock()
|
||||||
currentRun.unicodeScalars.append(scalar)
|
currentRun.unicodeScalars.append(scalar)
|
||||||
}
|
|
||||||
case .characterSequence(let string):
|
case .characterSequence(let string):
|
||||||
currentElementIsEmpty = false
|
currentElementIsEmpty = false
|
||||||
for c in string.unicodeScalars {
|
continueBlock()
|
||||||
if blockStateMachine.continueBlock(char: c) {
|
currentRun.append(string)
|
||||||
currentRun.unicodeScalars.append(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .startTag(let name, let selfClosing, let attributes):
|
case .startTag(let name, let selfClosing, let attributes):
|
||||||
currentElementIsEmpty = true
|
currentElementIsEmpty = true
|
||||||
let action = Callbacks.elementAction(name: name, attributes: attributes)
|
let action = Callbacks.elementAction(name: name, attributes: attributes)
|
||||||
|
@ -77,66 +66,45 @@ public class TextConverter<Callbacks: HTMLConversionCallbacks> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
blockStateMachine.endBlocks()
|
|
||||||
finishRun()
|
finishRun()
|
||||||
|
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleStartTag(_ name: String, selfClosing: Bool, attributes: [Attribute]) {
|
private mutating func handleStartTag(_ name: String, selfClosing: Bool, attributes: [Attribute]) {
|
||||||
switch name {
|
switch name {
|
||||||
case "br":
|
case "br":
|
||||||
blockStateMachine.breakTag()
|
if configuration.insertNewlines {
|
||||||
|
currentRun.append("\n")
|
||||||
|
} else {
|
||||||
|
currentRun.append(" ")
|
||||||
|
}
|
||||||
case "pre", "blockquote", "p", "ol", "ul":
|
case "pre", "blockquote", "p", "ol", "ul":
|
||||||
blockStateMachine.startOrEndBlock()
|
startOrFinishBlock()
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleEndTag(_ name: String) {
|
private mutating func handleEndTag(_ name: String) {
|
||||||
switch name {
|
switch name {
|
||||||
case "pre", "blockquote", "p", "ol", "ul":
|
case "pre", "blockquote", "p", "ol", "ul":
|
||||||
blockStateMachine.startOrEndBlock()
|
startOrFinishBlock()
|
||||||
finishRun()
|
finishRun()
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var blockBreak: String {
|
mutating func insertBlockBreak() {
|
||||||
if configuration.insertNewlines {
|
if configuration.insertNewlines {
|
||||||
"\n\n"
|
currentRun.append("\n\n")
|
||||||
} else {
|
} else {
|
||||||
" "
|
currentRun.append(" ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var lineBreak: String {
|
private mutating func finishRun() {
|
||||||
if configuration.insertNewlines {
|
|
||||||
"\n"
|
|
||||||
} else {
|
|
||||||
" "
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var listIndentForContentOutsideItem: String {
|
|
||||||
" "
|
|
||||||
}
|
|
||||||
|
|
||||||
func append(_ s: String) {
|
|
||||||
currentRun.append(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeChar() {
|
|
||||||
if currentRun.isEmpty {
|
|
||||||
str.removeLast()
|
|
||||||
} else {
|
|
||||||
currentRun.removeLast()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func finishRun() {
|
|
||||||
if actionStack.contains(.skip) {
|
if actionStack.contains(.skip) {
|
||||||
currentRun = ""
|
currentRun = ""
|
||||||
return
|
return
|
||||||
|
|
|
@ -327,15 +327,6 @@ final class AttributedStringConverterTests: XCTestCase {
|
||||||
XCTAssertEqual(convert("a<ol><li>b</li><li>c</li></ol>"), result)
|
XCTAssertEqual(convert("a<ol><li>b</li><li>c</li></ol>"), result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testListItemOutsideList() {
|
|
||||||
let result = NSAttributedString(string: "a", attributes: [
|
|
||||||
.font: font,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<li>a</li>"), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSkipElementActionFollowingUnfinishedRun() {
|
func testSkipElementActionFollowingUnfinishedRun() {
|
||||||
struct Callbacks: HTMLConversionCallbacks {
|
struct Callbacks: HTMLConversionCallbacks {
|
||||||
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {
|
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {
|
||||||
|
@ -356,7 +347,7 @@ final class AttributedStringConverterTests: XCTestCase {
|
||||||
XCTAssertEqual(convert("</span>"), .init())
|
XCTAssertEqual(convert("</span>"), .init())
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMultipleClosingBlockTagsBeforeOpeningBlockTag() {
|
func testWTF() {
|
||||||
let result = NSMutableAttributedString()
|
let result = NSMutableAttributedString()
|
||||||
result.append(NSAttributedString(string: "a", attributes: [
|
result.append(NSAttributedString(string: "a", attributes: [
|
||||||
.font: italicFont,
|
.font: italicFont,
|
||||||
|
@ -371,234 +362,4 @@ final class AttributedStringConverterTests: XCTestCase {
|
||||||
XCTAssertEqual(convert(#"<blockquote><p>a</p></blockquote><p>b</p>"#), result)
|
XCTAssertEqual(convert(#"<blockquote><p>a</p></blockquote><p>b</p>"#), result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNewlineBetweenClosingAndOpeningBlockTag() {
|
|
||||||
let result = NSAttributedString(string: "a\n\nb", attributes: [
|
|
||||||
.font: font,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<p>a</p>\n<p>b</p>"), result)
|
|
||||||
XCTAssertEqual(convert("<p>a</p><p>\nb</p>"), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testEndAfterNewlineInBlockContent() {
|
|
||||||
let result = NSAttributedString(string: "a", attributes: [
|
|
||||||
.font: font,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<p>a\n\n</p>"), result)
|
|
||||||
XCTAssertEqual(convert("<p>a\n\n</p>\n"), result)
|
|
||||||
XCTAssertEqual(convert("<p>\n\na</p>"), result)
|
|
||||||
XCTAssertEqual(convert("<p>\n\na</p>\n"), result)
|
|
||||||
let result2 = NSAttributedString(string: "a b", attributes: [
|
|
||||||
.font: font,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<p>a\n\n\nb</p>"), result2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testBRAtBlockElementBoundary() {
|
|
||||||
let two = NSAttributedString(string: "a\n\nb", attributes: [
|
|
||||||
.font: font,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<p>a<br></p><p>b</p>"), two)
|
|
||||||
let three = NSAttributedString(string: "a\n\n\nb", attributes: [
|
|
||||||
.font: font,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<p>a</p><p><br>b</p>"), three)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPreFollowedByP() {
|
|
||||||
let result = NSMutableAttributedString()
|
|
||||||
result.append(NSAttributedString(string: "a", attributes: [
|
|
||||||
.font: monospaceFont,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
]))
|
|
||||||
result.append(NSAttributedString(string: "\n\nb", attributes: [
|
|
||||||
.font: font,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
]))
|
|
||||||
XCTAssertEqual(convert("<pre>a<br></pre><p>b</p>"), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPreFollowedByPre() {
|
|
||||||
let result = NSAttributedString(string: "a\n\nb", attributes: [
|
|
||||||
.font: monospaceFont,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<pre>a</pre><pre>b</pre>"), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testBRAtPreBoundary() {
|
|
||||||
let two = NSAttributedString(string: "a\n\nb", attributes: [
|
|
||||||
.font: monospaceFont,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<pre>a<br></pre><pre>b</pre>"), two)
|
|
||||||
let three = NSAttributedString(string: "a\n\n\nb", attributes: [
|
|
||||||
.font: monospaceFont,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<pre>a</pre><pre><br>b</pre>"), three)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testNestedPre() {
|
|
||||||
let one = NSAttributedString(string: "a", attributes: [
|
|
||||||
.font: monospaceFont,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<pre><pre>a</pre></pre>"), one)
|
|
||||||
let two = NSAttributedString(string: "a\n\nb", attributes: [
|
|
||||||
.font: monospaceFont,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<pre>a<pre>b</pre></pre>"), two)
|
|
||||||
XCTAssertEqual(convert("<pre>a<br><pre>b</pre></pre>"), two)
|
|
||||||
let three = NSAttributedString(string: "a\n\n\nb", attributes: [
|
|
||||||
.font: monospaceFont,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<pre>a<pre><br>b</pre></pre>"), three)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testIgnoreLeadingNewlineInPre() {
|
|
||||||
let one = NSAttributedString(string: "a", attributes: [
|
|
||||||
.font: monospaceFont,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<pre>\na</pre>"), one)
|
|
||||||
let two = NSMutableAttributedString()
|
|
||||||
two.append(NSAttributedString(string: "a", attributes: [
|
|
||||||
.font: font,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
]))
|
|
||||||
two.append(NSAttributedString(string: "\n\nb", attributes: [
|
|
||||||
.font: monospaceFont,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
]))
|
|
||||||
XCTAssertEqual(convert("a<pre>\nb</pre>"), two)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPreFollowingChar() {
|
|
||||||
let result = NSMutableAttributedString()
|
|
||||||
result.append(NSAttributedString(string: "a", attributes: [
|
|
||||||
.font: font,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
]))
|
|
||||||
result.append(NSAttributedString(string: "\n\nb", attributes: [
|
|
||||||
.font: monospaceFont,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
]))
|
|
||||||
XCTAssertEqual(convert("a<pre>b</pre>"), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testSkipLeadingTrailingWhitespace() {
|
|
||||||
let result = NSAttributedString(string: "a", attributes: [
|
|
||||||
.font: font,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert(" \n\ta"), result)
|
|
||||||
XCTAssertEqual(convert(" \n\t<p>a</p>"), result)
|
|
||||||
XCTAssertEqual(convert("a \n\t"), result)
|
|
||||||
XCTAssertEqual(convert("<p>a</p> \n\t"), result)
|
|
||||||
|
|
||||||
let pre = NSAttributedString(string: "a", attributes: [
|
|
||||||
.font: monospaceFont,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert(" \n\t<pre>a</pre>"), pre)
|
|
||||||
XCTAssertEqual(convert("<pre>a</pre> \n\t"), pre)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testWhitespaceCollapsing() {
|
|
||||||
let result = NSAttributedString(string: "a b", attributes: [
|
|
||||||
.font: font,
|
|
||||||
.paragraphStyle: NSParagraphStyle.default,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<p>a \t\nb</p>"), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testParagraphInsideListItem() {
|
|
||||||
let result = NSAttributedString(string: "\t1.\ta\n\t2.\tb", attributes: [
|
|
||||||
.font: font,
|
|
||||||
.paragraphStyle: listParagraphStyle,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<ol><li><p>a</p></li><li><p>b</p></li></ol>"), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testBreakBetweenListItems() {
|
|
||||||
let result = NSAttributedString(string: "\t1.\ta\n\n\t2.\tb", attributes: [
|
|
||||||
.font: font,
|
|
||||||
.paragraphStyle: listParagraphStyle,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<ol><li>a</li><br><li>b</li></ol>"), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCharacterBetweenListItems() {
|
|
||||||
let result = NSAttributedString(string: "\t1.\ta\n\t\tc\n\t2.\tb", attributes: [
|
|
||||||
.font: font,
|
|
||||||
.paragraphStyle: listParagraphStyle,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<ol><li>a</li>c<li>b</li></ol>"), result)
|
|
||||||
XCTAssertEqual(convert("<ol><li>a</li>c <li>b</li></ol>"), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testWhitespaceCollapsingInTextBetweenListItems() {
|
|
||||||
let result = NSAttributedString(string: "\t1.\ta\n\t\tc d\n\t2.\tb", attributes: [
|
|
||||||
.font: font,
|
|
||||||
.paragraphStyle: listParagraphStyle,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<ol><li>a</li>c d<li>b</li></ol>"), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testImplicitlyClosedListItem() {
|
|
||||||
let result = NSAttributedString(string: "\t1.\ta\n\t2.\tb", attributes: [
|
|
||||||
.font: font,
|
|
||||||
.paragraphStyle: listParagraphStyle,
|
|
||||||
.foregroundColor: color,
|
|
||||||
])
|
|
||||||
XCTAssertEqual(convert("<ol><li>a<li>b</ol>"), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPreInsideListItem() {
|
|
||||||
let result = NSMutableAttributedString()
|
|
||||||
result.append(NSAttributedString(string: "\t1.\t", attributes: [
|
|
||||||
.font: font,
|
|
||||||
.paragraphStyle: listParagraphStyle,
|
|
||||||
.foregroundColor: color,
|
|
||||||
]))
|
|
||||||
result.append(NSAttributedString(string: "a", attributes: [
|
|
||||||
.font: monospaceFont,
|
|
||||||
.paragraphStyle: listParagraphStyle,
|
|
||||||
.foregroundColor: color,
|
|
||||||
]))
|
|
||||||
XCTAssertEqual(convert("<ol><li><pre>a</pre></li></ol>"), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue