Actually fix block element rendering

This commit is contained in:
Shadowfacts 2024-02-14 21:07:19 -05:00
parent 1bfacb8fe9
commit fa03efedbb
4 changed files with 92 additions and 126 deletions

View File

@ -17,7 +17,7 @@ private typealias PlatformFont = UIFont
private typealias PlatformFont = NSFont private typealias PlatformFont = NSFont
#endif #endif
public struct 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,7 +26,7 @@ public struct AttributedStringConverter<Callbacks: HTMLConversionCallbacks> {
private var actionStack: [ElementAction] = [] private var actionStack: [ElementAction] = []
private var styleStack: [Style] = [] private var styleStack: [Style] = []
private var blockState = BlockState.unstarted 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
@ -46,7 +46,7 @@ public struct AttributedStringConverter<Callbacks: HTMLConversionCallbacks> {
actionStack = [] actionStack = []
styleStack = [] styleStack = []
blockState = .unstarted blockState = .start
currentElementIsEmpty = true currentElementIsEmpty = true
previouslyFinishedListItem = false previouslyFinishedListItem = false
currentRun = "" currentRun = ""
@ -126,26 +126,28 @@ public struct AttributedStringConverter<Callbacks: HTMLConversionCallbacks> {
finishRun() finishRun()
styleStack.append(.monospace) styleStack.append(.monospace)
case "pre": case "pre":
startBlockIfNecessary() startOrFinishBlock()
finishRun() finishRun()
styleStack.append(.monospace) styleStack.append(.monospace)
case "blockquote": case "blockquote":
startBlockIfNecessary() startOrFinishBlock()
finishRun() finishRun()
styleStack.append(.blockquote) styleStack.append(.blockquote)
case "p": case "p":
startBlockIfNecessary() startOrFinishBlock()
case "ol": case "ol":
startBlockIfNecessary() startOrFinishBlock()
finishRun() finishRun()
styleStack.append(.orderedList(nextElementOrdinal: 1)) styleStack.append(.orderedList(nextElementOrdinal: 1))
case "ul": case "ul":
startBlockIfNecessary() startOrFinishBlock()
finishRun() finishRun()
styleStack.append(.unorderedList) styleStack.append(.unorderedList)
case "li": case "li":
if previouslyFinishedListItem { if previouslyFinishedListItem {
currentRun.append("\n") 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 {
@ -184,22 +186,22 @@ public struct AttributedStringConverter<Callbacks: HTMLConversionCallbacks> {
case "pre": case "pre":
finishRun() finishRun()
removeLastStyle(.monospace) removeLastStyle(.monospace)
finishBlockElement() startOrFinishBlock()
case "blockquote": case "blockquote":
finishRun() finishRun()
removeLastStyle(.blockquote) removeLastStyle(.blockquote)
finishBlockElement() startOrFinishBlock()
case "p": case "p":
finishBlockElement() startOrFinishBlock()
case "ol": case "ol":
finishRun() finishRun()
removeLastStyle(.orderedList) removeLastStyle(.orderedList)
finishBlockElement() startOrFinishBlock()
previouslyFinishedListItem = false previouslyFinishedListItem = false
case "ul": case "ul":
finishRun() finishRun()
removeLastStyle(.unorderedList) removeLastStyle(.unorderedList)
finishBlockElement() startOrFinishBlock()
previouslyFinishedListItem = false previouslyFinishedListItem = false
case "li": case "li":
finishRun() finishRun()
@ -209,42 +211,8 @@ public struct AttributedStringConverter<Callbacks: HTMLConversionCallbacks> {
} }
} }
private mutating func startBlockIfNecessary() { mutating func insertBlockBreak() {
switch blockState { currentRun.append("\n\n")
case .unstarted:
blockState = .started(false)
case .started:
break
case .ongoing:
currentRun.append("\n\n")
blockState = .started(true)
case .finished(let nonEmpty):
if nonEmpty {
currentRun.append("\n\n")
}
blockState = .started(nonEmpty)
}
}
private mutating func continueBlock() {
switch blockState {
case .unstarted, .started(_):
blockState = .ongoing
case .ongoing:
break
case .finished(let nonEmpty):
if nonEmpty {
currentRun.append("\n\n")
}
blockState = .ongoing
}
}
private mutating func finishBlockElement() {
if blockState == .started(true) && currentElementIsEmpty {
currentRun.removeLast(2)
}
blockState = .finished(blockState == .ongoing)
} }
// Finds the last currently-open style of the given type. // Finds the last currently-open style of the given type.
@ -452,13 +420,6 @@ private enum Style {
} }
} }
enum BlockState: Equatable {
case unstarted
case started(Bool)
case ongoing
case finished(Bool)
}
extension Collection where Element == Attribute { extension Collection where Element == Attribute {
public func attributeValue(for name: String) -> String? { public func attributeValue(for name: String) -> String? {
first(where: { $0.name == name })?.value first(where: { $0.name == name })?.value

View File

@ -0,0 +1,45 @@
//
// BlockState.swift
// HTMLStreamer
//
// Created by Shadowfacts on 2/14/24.
//
import Foundation
protocol BlockRenderer {
var blockState: BlockState { get set }
mutating func insertBlockBreak()
}
extension BlockRenderer {
mutating func startOrFinishBlock() {
switch blockState {
case .start:
break
case .nonEmptyBlock:
blockState = .emptyBlock
case .emptyBlock:
break
}
}
mutating func continueBlock() {
switch blockState {
case .start:
blockState = .nonEmptyBlock
case .nonEmptyBlock:
break
case .emptyBlock:
insertBlockBreak()
blockState = .nonEmptyBlock
}
}
}
enum BlockState: Equatable {
case start
case nonEmptyBlock
case emptyBlock
}

View File

@ -7,7 +7,7 @@
import Foundation import Foundation
public struct TextConverter<Callbacks: HTMLConversionCallbacks> { public struct TextConverter<Callbacks: HTMLConversionCallbacks>: BlockRenderer {
private let configuration: TextConverterConfiguration private let configuration: TextConverterConfiguration
@ -15,7 +15,7 @@ public struct TextConverter<Callbacks: HTMLConversionCallbacks> {
private var str: String! private var str: String!
private var actionStack: [ElementAction] = [] private var actionStack: [ElementAction] = []
private var blockState = BlockState.unstarted var blockState = BlockState.start
private var currentElementIsEmpty = true private var currentElementIsEmpty = true
private var currentRun = "" private var currentRun = ""
@ -31,7 +31,7 @@ public struct TextConverter<Callbacks: HTMLConversionCallbacks> {
tokenizer = Tokenizer(chars: html.unicodeScalars.makeIterator()) tokenizer = Tokenizer(chars: html.unicodeScalars.makeIterator())
str = "" str = ""
blockState = .unstarted blockState = .start
currentElementIsEmpty = true currentElementIsEmpty = true
currentRun = "" currentRun = ""
@ -80,7 +80,7 @@ public struct TextConverter<Callbacks: HTMLConversionCallbacks> {
currentRun.append(" ") currentRun.append(" ")
} }
case "pre", "blockquote", "p", "ol", "ul": case "pre", "blockquote", "p", "ol", "ul":
startBlockIfNecessary() startOrFinishBlock()
default: default:
break break
} }
@ -89,66 +89,20 @@ public struct TextConverter<Callbacks: HTMLConversionCallbacks> {
private mutating 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":
finishBlockElement() startOrFinishBlock()
finishRun() finishRun()
default: default:
break break
} }
} }
private mutating func startBlockIfNecessary() { mutating func insertBlockBreak() {
switch blockState { if configuration.insertNewlines {
case .unstarted: currentRun.append("\n\n")
blockState = .started(false) } else {
case .started: currentRun.append(" ")
break
case .ongoing:
if configuration.insertNewlines {
currentRun.append("\n\n")
} else {
currentRun.append(" ")
}
blockState = .started(true)
case .finished(let nonEmpty):
if nonEmpty {
if configuration.insertNewlines {
currentRun.append("\n\n")
} else {
currentRun.append(" ")
}
}
blockState = .started(nonEmpty)
} }
} }
private mutating func continueBlock() {
switch blockState {
case .unstarted, .started(_):
blockState = .ongoing
case .ongoing:
break
case .finished(let nonEmpty):
if nonEmpty {
if configuration.insertNewlines {
currentRun.append("\n\n")
} else {
currentRun.append(" ")
}
}
blockState = .ongoing
}
}
private mutating func finishBlockElement() {
if blockState == .started(true) && currentElementIsEmpty {
if configuration.insertNewlines {
currentRun.removeLast(2)
} else {
currentRun.removeLast(1)
}
}
blockState = .finished(blockState == .ongoing)
}
private mutating func finishRun() { private mutating func finishRun() {
if actionStack.contains(.skip) { if actionStack.contains(.skip) {

View File

@ -206,20 +206,11 @@ final class AttributedStringConverterTests: XCTestCase {
} }
func testMultipleBlockElements() { func testMultipleBlockElements() {
let result = NSMutableAttributedString() let result = NSAttributedString(string: "a\n\nb", attributes: [
result.append(NSAttributedString(string: "a", attributes: [
.font: italicFont, .font: italicFont,
.paragraphStyle: blockquoteParagraphStyle, .paragraphStyle: blockquoteParagraphStyle,
])) .foregroundColor: color,
result.append(NSAttributedString(string: "\n\n", attributes: [ ])
.font: font,
.paragraphStyle: NSParagraphStyle.default,
]))
result.append(NSAttributedString(string: "b", attributes: [
.font: italicFont,
.paragraphStyle: blockquoteParagraphStyle,
]))
result.addAttribute(.foregroundColor, value: color, range: NSRange(location: 0, length: result.length))
XCTAssertEqual(convert("<blockquote>a</blockquote><blockquote>b</blockquote>"), result) XCTAssertEqual(convert("<blockquote>a</blockquote><blockquote>b</blockquote>"), result)
} }
@ -321,12 +312,12 @@ final class AttributedStringConverterTests: XCTestCase {
func testFollowedByList() { func testFollowedByList() {
let result = NSMutableAttributedString() let result = NSMutableAttributedString()
result.append(NSAttributedString(string: "a\n\n", attributes: [ result.append(NSAttributedString(string: "a", attributes: [
.font: font, .font: font,
.paragraphStyle: NSParagraphStyle.default, .paragraphStyle: NSParagraphStyle.default,
.foregroundColor: color, .foregroundColor: color,
])) ]))
result.append(NSAttributedString(string: "\t1.\tb\n\t2.\tc", attributes: [ result.append(NSAttributedString(string: "\n\n\t1.\tb\n\t2.\tc", attributes: [
.font: font, .font: font,
.paragraphStyle: listParagraphStyle, .paragraphStyle: listParagraphStyle,
.foregroundColor: color, .foregroundColor: color,
@ -356,4 +347,19 @@ final class AttributedStringConverterTests: XCTestCase {
XCTAssertEqual(convert("</span>"), .init()) XCTAssertEqual(convert("</span>"), .init())
} }
func testWTF() {
let result = NSMutableAttributedString()
result.append(NSAttributedString(string: "a", attributes: [
.font: italicFont,
.paragraphStyle: blockquoteParagraphStyle,
.foregroundColor: color,
]))
result.append(NSAttributedString(string: "\n\nb", attributes: [
.font: font,
.paragraphStyle: NSParagraphStyle.default,
.foregroundColor: color,
]))
XCTAssertEqual(convert(#"<blockquote><p>a</p></blockquote><p>b</p>"#), result)
}
} }