109 lines
5.0 KiB
Swift
109 lines
5.0 KiB
Swift
extension PureXML.HTML.Tokenizer {
|
||
/// The HTML numeric-character-reference fixups for the Windows-1252 C1 range
|
||
/// (`0x80`–`0x9F`): a reference to one of these code points yields the mapped
|
||
/// character rather than the C1 control, per the HTML standard.
|
||
private static let c1Replacements: [UInt32: UInt32] = [
|
||
0x80: 0x20AC, 0x82: 0x201A, 0x83: 0x0192, 0x84: 0x201E, 0x85: 0x2026,
|
||
0x86: 0x2020, 0x87: 0x2021, 0x88: 0x02C6, 0x89: 0x2030, 0x8A: 0x0160,
|
||
0x8B: 0x2039, 0x8C: 0x0152, 0x8E: 0x017D, 0x91: 0x2018, 0x92: 0x2019,
|
||
0x93: 0x201C, 0x94: 0x201D, 0x95: 0x2022, 0x96: 0x2013, 0x97: 0x2014,
|
||
0x98: 0x02DC, 0x99: 0x2122, 0x9A: 0x0161, 0x9B: 0x203A, 0x9C: 0x0153,
|
||
0x9E: 0x017E, 0x9F: 0x0178,
|
||
]
|
||
|
||
private static let replacementCharacter: Character = "\u{FFFD}"
|
||
|
||
/// Replaces NUL with U+FFFD, the HTML rule for the literal null byte in
|
||
/// character data (it never reaches the document as U+0000).
|
||
static func replacingNull(_ value: String) -> String {
|
||
guard value.contains("\u{0}") else { return value }
|
||
return String(value.map { $0 == "\u{0}" ? replacementCharacter : $0 })
|
||
}
|
||
|
||
/// Decodes the HTML character references in `value`: named references (with or
|
||
/// without a trailing semicolon, longest match wins) and numeric references
|
||
/// (decimal `&#...;` or hex `&#x...;`) with range, surrogate, and C1 fixups.
|
||
/// An unrecognized `&...` sequence is left as written (the lenient behavior).
|
||
static func decodeEntities(_ value: String) -> String {
|
||
guard value.contains("&") else { return value }
|
||
let characters = Array(value)
|
||
var result = ""
|
||
var index = 0
|
||
while index < characters.count {
|
||
guard characters[index] == "&" else {
|
||
result.append(characters[index])
|
||
index += 1
|
||
continue
|
||
}
|
||
if let (text, consumed) = decodeReference(characters, at: index) {
|
||
result += text
|
||
index += consumed
|
||
} else {
|
||
result.append("&")
|
||
index += 1
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
/// Decodes a single reference starting at the `&` in `characters[start]`,
|
||
/// returning its replacement text and how many characters it consumed, or nil
|
||
/// when there is no valid reference there.
|
||
private static func decodeReference(_ characters: [Character], at start: Int) -> (String, Int)? {
|
||
if start + 1 < characters.count, characters[start + 1] == "#" {
|
||
return decodeNumeric(characters, at: start)
|
||
}
|
||
return decodeNamed(characters, at: start)
|
||
}
|
||
|
||
private static func decodeNumeric(_ characters: [Character], at start: Int) -> (String, Int)? {
|
||
var cursor = start + 2 // past "&#"
|
||
let isHex = cursor < characters.count && (characters[cursor] == "x" || characters[cursor] == "X")
|
||
if isHex { cursor += 1 }
|
||
let digitsStart = cursor
|
||
while cursor < characters.count, isDigit(characters[cursor], hex: isHex) {
|
||
cursor += 1
|
||
}
|
||
guard cursor > digitsStart, let number = UInt32(String(characters[digitsStart ..< cursor]), radix: isHex ? 16 : 10) else { return nil }
|
||
if cursor < characters.count, characters[cursor] == ";" { cursor += 1 }
|
||
return (String(numericCharacter(number)), cursor - start)
|
||
}
|
||
|
||
/// The character a numeric reference resolves to: U+FFFD for zero, surrogates,
|
||
/// and out-of-range values; the C1 fixup for `0x80`–`0x9F`; otherwise the code
|
||
/// point itself.
|
||
private static func numericCharacter(_ number: UInt32) -> Character {
|
||
if let replacement = c1Replacements[number] {
|
||
return Unicode.Scalar(replacement).map(Character.init) ?? replacementCharacter
|
||
}
|
||
if number == 0 || number > 0x10FFFF || (0xD800 ... 0xDFFF).contains(number) {
|
||
return replacementCharacter
|
||
}
|
||
return Unicode.Scalar(number).map(Character.init) ?? replacementCharacter
|
||
}
|
||
|
||
private static func decodeNamed(_ characters: [Character], at start: Int) -> (String, Int)? {
|
||
var cursor = start + 1
|
||
var name = ""
|
||
// The longest reference name is 31 characters; cap the scan a little above.
|
||
while cursor < characters.count, characters[cursor].isLetter || characters[cursor].isNumber, name.count < 32 {
|
||
name.append(characters[cursor])
|
||
cursor += 1
|
||
}
|
||
// Longest match wins: shrink the candidate until it names an entity.
|
||
while !name.isEmpty {
|
||
if let replacement = namedEntities[name] {
|
||
let end = start + 1 + name.count
|
||
let semicolon = end < characters.count && characters[end] == ";"
|
||
return (replacement, name.count + 1 + (semicolon ? 1 : 0))
|
||
}
|
||
name.removeLast()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private static func isDigit(_ character: Character, hex: Bool) -> Bool {
|
||
hex ? character.isHexDigit : character.isNumber
|
||
}
|
||
}
|