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 `...;`) 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
}
}