PureXML/Sources/HTML/HTMLEntities.swift

109 lines
5.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}