feat(html): full HTML 4.01 named entity set (#84)

This commit is contained in:
Mihaela Mihaljevic 2026-06-09 07:39:32 +02:00
parent 60fc0805bc
commit 7eb33c0d08
5 changed files with 89 additions and 9 deletions

View File

@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- HTML named character references: the full HTML 4.01 entity set (#84), about 250 references across the Latin-1, special, and symbol (Greek, mathematical, arrow, card-suit) groups, replacing the previous 16-entry stopgap. Decoding stays longest-match with an optional trailing semicolon and the existing numeric-reference validation. This matches libxml2's entity coverage; the much larger WHATWG superset of mostly-astral mathematical references is not vendored.
- HTML5 select content model (#83, stage 10): a `<select>` now follows the in-select rules. `<optgroup>` closes an open `<option>`, a nested `<select>` closes the open one rather than nesting, and an `<input>`/`<keygen>`/`<textarea>` closes the select and is placed after it. In the "in select in table" case, a table-structural tag (`caption`/`table`/`tbody`/`tfoot`/`thead`/`tr`/`td`/`th`) closes a select that is inside a table before being processed.
- HTML5 frameset documents (#83, stage 8): a `<frameset>` after the head now produces a frameset document whose `frameset` element replaces the body (`<html><head></head><frameset>...`), with nested framesets and `<noframes>` handled, and `<frame>` is now a void element (it no longer wrongly nests). An ordinary document still produces a body.
- HTML5 integration points (#83, stage 7): HTML content inside an SVG integration point (`foreignObject`, `desc`, `title`) is now parsed in the HTML namespace rather than SVG, so `<svg><foreignObject><div>` puts the `div` (and its descendants) back in HTML; re-entering `<svg>` inside an integration point switches back to the SVG namespace.

View File

@ -89,7 +89,7 @@ flowchart TB
PureXML is a working, dependency-free XML library today: parse, emit, validate,
query, and stream documents on macOS, Linux, Windows, and WASM. The test suite
currently runs **757 tests in 117 suites** (`swift test`).
currently runs **758 tests in 117 suites** (`swift test`).
### Shipped (libxml2-aligned surface)

View File

@ -1,11 +1,4 @@
extension PureXML.HTML.Tokenizer {
private static let named: [String: Character] = [
"amp": "&", "lt": "<", "gt": ">", "quot": "\"", "apos": "'",
"nbsp": "\u{A0}", "copy": "\u{A9}", "reg": "\u{AE}", "trade": "\u{2122}",
"mdash": "\u{2014}", "ndash": "\u{2013}", "hellip": "\u{2026}",
"laquo": "\u{AB}", "raquo": "\u{BB}", "deg": "\u{B0}", "euro": "\u{20AC}",
]
/// 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.
@ -99,7 +92,7 @@ extension PureXML.HTML.Tokenizer {
}
// Longest match wins: shrink the candidate until it names an entity.
while !name.isEmpty {
if let character = named[name] {
if let character = namedEntities[name] {
let end = start + 1 + name.count
let semicolon = end < characters.count && characters[end] == ";"
return (String(character), name.count + 1 + (semicolon ? 1 : 0))

View File

@ -0,0 +1,75 @@
extension PureXML.HTML.Tokenizer {
/// The HTML 4.01 named character references (the `lat1`, `special`, and
/// `symbol` entity sets), keyed by the bare entity name. This is the stable,
/// fully-specified set (the same coverage libxml2 ships); the much larger
/// WHATWG superset of mostly-astral mathematical references is not included.
/// The decoder treats the trailing semicolon as optional (lenient tag soup).
static let namedEntities: [String: Character] = [
// ISO-8859-1 (Latin-1) supplement.
"nbsp": "\u{00A0}", "iexcl": "\u{00A1}", "cent": "\u{00A2}", "pound": "\u{00A3}",
"curren": "\u{00A4}", "yen": "\u{00A5}", "brvbar": "\u{00A6}", "sect": "\u{00A7}",
"uml": "\u{00A8}", "copy": "\u{00A9}", "ordf": "\u{00AA}", "laquo": "\u{00AB}",
"not": "\u{00AC}", "shy": "\u{00AD}", "reg": "\u{00AE}", "macr": "\u{00AF}",
"deg": "\u{00B0}", "plusmn": "\u{00B1}", "sup2": "\u{00B2}", "sup3": "\u{00B3}",
"acute": "\u{00B4}", "micro": "\u{00B5}", "para": "\u{00B6}", "middot": "\u{00B7}",
"cedil": "\u{00B8}", "sup1": "\u{00B9}", "ordm": "\u{00BA}", "raquo": "\u{00BB}",
"frac14": "\u{00BC}", "frac12": "\u{00BD}", "frac34": "\u{00BE}", "iquest": "\u{00BF}",
"Agrave": "\u{00C0}", "Aacute": "\u{00C1}", "Acirc": "\u{00C2}", "Atilde": "\u{00C3}",
"Auml": "\u{00C4}", "Aring": "\u{00C5}", "AElig": "\u{00C6}", "Ccedil": "\u{00C7}",
"Egrave": "\u{00C8}", "Eacute": "\u{00C9}", "Ecirc": "\u{00CA}", "Euml": "\u{00CB}",
"Igrave": "\u{00CC}", "Iacute": "\u{00CD}", "Icirc": "\u{00CE}", "Iuml": "\u{00CF}",
"ETH": "\u{00D0}", "Ntilde": "\u{00D1}", "Ograve": "\u{00D2}", "Oacute": "\u{00D3}",
"Ocirc": "\u{00D4}", "Otilde": "\u{00D5}", "Ouml": "\u{00D6}", "times": "\u{00D7}",
"Oslash": "\u{00D8}", "Ugrave": "\u{00D9}", "Uacute": "\u{00DA}", "Ucirc": "\u{00DB}",
"Uuml": "\u{00DC}", "Yacute": "\u{00DD}", "THORN": "\u{00DE}", "szlig": "\u{00DF}",
"agrave": "\u{00E0}", "aacute": "\u{00E1}", "acirc": "\u{00E2}", "atilde": "\u{00E3}",
"auml": "\u{00E4}", "aring": "\u{00E5}", "aelig": "\u{00E6}", "ccedil": "\u{00E7}",
"egrave": "\u{00E8}", "eacute": "\u{00E9}", "ecirc": "\u{00EA}", "euml": "\u{00EB}",
"igrave": "\u{00EC}", "iacute": "\u{00ED}", "icirc": "\u{00EE}", "iuml": "\u{00EF}",
"eth": "\u{00F0}", "ntilde": "\u{00F1}", "ograve": "\u{00F2}", "oacute": "\u{00F3}",
"ocirc": "\u{00F4}", "otilde": "\u{00F5}", "ouml": "\u{00F6}", "divide": "\u{00F7}",
"oslash": "\u{00F8}", "ugrave": "\u{00F9}", "uacute": "\u{00FA}", "ucirc": "\u{00FB}",
"uuml": "\u{00FC}", "yacute": "\u{00FD}", "thorn": "\u{00FE}", "yuml": "\u{00FF}",
// Special: markup-significant, quotes, dashes, and spacing.
"quot": "\u{0022}", "amp": "\u{0026}", "apos": "\u{0027}", "lt": "\u{003C}", "gt": "\u{003E}",
"OElig": "\u{0152}", "oelig": "\u{0153}", "Scaron": "\u{0160}", "scaron": "\u{0161}",
"Yuml": "\u{0178}", "circ": "\u{02C6}", "tilde": "\u{02DC}", "ensp": "\u{2002}",
"emsp": "\u{2003}", "thinsp": "\u{2009}", "zwnj": "\u{200C}", "zwj": "\u{200D}",
"lrm": "\u{200E}", "rlm": "\u{200F}", "ndash": "\u{2013}", "mdash": "\u{2014}",
"lsquo": "\u{2018}", "rsquo": "\u{2019}", "sbquo": "\u{201A}", "ldquo": "\u{201C}",
"rdquo": "\u{201D}", "bdquo": "\u{201E}", "dagger": "\u{2020}", "Dagger": "\u{2021}",
"permil": "\u{2030}", "lsaquo": "\u{2039}", "rsaquo": "\u{203A}", "euro": "\u{20AC}",
// Symbols: Greek letters, mathematical operators, arrows, and card suits.
"fnof": "\u{0192}", "Alpha": "\u{0391}", "Beta": "\u{0392}", "Gamma": "\u{0393}",
"Delta": "\u{0394}", "Epsilon": "\u{0395}", "Zeta": "\u{0396}", "Eta": "\u{0397}",
"Theta": "\u{0398}", "Iota": "\u{0399}", "Kappa": "\u{039A}", "Lambda": "\u{039B}",
"Mu": "\u{039C}", "Nu": "\u{039D}", "Xi": "\u{039E}", "Omicron": "\u{039F}",
"Pi": "\u{03A0}", "Rho": "\u{03A1}", "Sigma": "\u{03A3}", "Tau": "\u{03A4}",
"Upsilon": "\u{03A5}", "Phi": "\u{03A6}", "Chi": "\u{03A7}", "Psi": "\u{03A8}",
"Omega": "\u{03A9}", "alpha": "\u{03B1}", "beta": "\u{03B2}", "gamma": "\u{03B3}",
"delta": "\u{03B4}", "epsilon": "\u{03B5}", "zeta": "\u{03B6}", "eta": "\u{03B7}",
"theta": "\u{03B8}", "iota": "\u{03B9}", "kappa": "\u{03BA}", "lambda": "\u{03BB}",
"mu": "\u{03BC}", "nu": "\u{03BD}", "xi": "\u{03BE}", "omicron": "\u{03BF}",
"pi": "\u{03C0}", "rho": "\u{03C1}", "sigmaf": "\u{03C2}", "sigma": "\u{03C3}",
"tau": "\u{03C4}", "upsilon": "\u{03C5}", "phi": "\u{03C6}", "chi": "\u{03C7}",
"psi": "\u{03C8}", "omega": "\u{03C9}", "thetasym": "\u{03D1}", "upsih": "\u{03D2}",
"piv": "\u{03D6}", "bull": "\u{2022}", "hellip": "\u{2026}", "prime": "\u{2032}",
"Prime": "\u{2033}", "oline": "\u{203E}", "frasl": "\u{2044}", "weierp": "\u{2118}",
"image": "\u{2111}", "real": "\u{211C}", "trade": "\u{2122}", "alefsym": "\u{2135}",
"larr": "\u{2190}", "uarr": "\u{2191}", "rarr": "\u{2192}", "darr": "\u{2193}",
"harr": "\u{2194}", "crarr": "\u{21B5}", "lArr": "\u{21D0}", "uArr": "\u{21D1}",
"rArr": "\u{21D2}", "dArr": "\u{21D3}", "hArr": "\u{21D4}", "forall": "\u{2200}",
"part": "\u{2202}", "exist": "\u{2203}", "empty": "\u{2205}", "nabla": "\u{2207}",
"isin": "\u{2208}", "notin": "\u{2209}", "ni": "\u{220B}", "prod": "\u{220F}",
"sum": "\u{2211}", "minus": "\u{2212}", "lowast": "\u{2217}", "radic": "\u{221A}",
"prop": "\u{221D}", "infin": "\u{221E}", "ang": "\u{2220}", "and": "\u{2227}",
"or": "\u{2228}", "cap": "\u{2229}", "cup": "\u{222A}", "int": "\u{222B}",
"there4": "\u{2234}", "sim": "\u{223C}", "cong": "\u{2245}", "asymp": "\u{2248}",
"ne": "\u{2260}", "equiv": "\u{2261}", "le": "\u{2264}", "ge": "\u{2265}",
"sub": "\u{2282}", "sup": "\u{2283}", "nsub": "\u{2284}", "sube": "\u{2286}",
"supe": "\u{2287}", "oplus": "\u{2295}", "otimes": "\u{2297}", "perp": "\u{22A5}",
"sdot": "\u{22C5}", "lceil": "\u{2308}", "rceil": "\u{2309}", "lfloor": "\u{230A}",
"rfloor": "\u{230B}", "lang": "\u{2329}", "rang": "\u{232A}", "loz": "\u{25CA}",
"spades": "\u{2660}", "clubs": "\u{2663}", "hearts": "\u{2665}", "diams": "\u{2666}",
]
}

View File

@ -50,4 +50,15 @@ struct HTMLTokenizerTests {
#expect(text("<textarea>\nhello</textarea>") == "hello")
#expect(text("<textarea>\n\nhello</textarea>") == "\nhello")
}
@Test("The HTML4 named entity set decodes across its categories")
func test_namedEntitySet() {
#expect(text("<p>&frac12;&Aacute;&times;</p>") == "\u{BD}\u{C1}\u{D7}") // Latin-1
#expect(text("<p>&OElig;&dagger;&mdash;</p>") == "\u{152}\u{2020}\u{2014}") // special
#expect(text("<p>&alpha;&Omega;&pi;</p>") == "\u{3B1}\u{3A9}\u{3C0}") // Greek
#expect(text("<p>&forall;&sum;&infin;&ne;</p>") == "\u{2200}\u{2211}\u{221E}\u{2260}") // math
#expect(text("<p>&larr;&rarr;&hArr;</p>") == "\u{2190}\u{2192}\u{21D4}") // arrows
#expect(text("<p>&hearts;&diams;&spades;</p>") == "\u{2665}\u{2666}\u{2660}") // suits
#expect(text("<p>&copy &reg</p>") == "\u{A9} \u{AE}") // semicolon-less
}
}