feat(html): SVG/MathML foreign-content namespace switching (#83)

This commit is contained in:
Mihaela Mihaljevic 2026-06-09 05:22:37 +02:00
parent acf1efe366
commit d64bcaf2fa
4 changed files with 82 additions and 4 deletions

View File

@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- HTML5 foreign content (#83, stage 2): an `<svg>` or `<math>` element and all of its descendants now carry the SVG (`http://www.w3.org/2000/svg`) or MathML (`http://www.w3.org/1998/Math/MathML`) namespace on their `QualifiedName`, so foreign elements are distinguishable from same-named HTML; ordinary HTML content (including content after a closed `<svg>`) stays in no namespace. (SVG/MathML element- and attribute-name case adjustment, and HTML integration points, are a later refinement.)
- HTML5 table tree construction (#83, stage 1): a `<table>` written without its section and row wrappers now nests correctly. A `<tr>` directly inside a `<table>` gets an implied `<tbody>`, a `<td>`/`<th>` gets an implied `<tr>` (and section), consecutive rows share one implied `<tbody>`, and an explicit `<thead>`/`<tbody>`/`<tfoot>` is not duplicated, all inside an open table only.
- HTML tokenizer correctness (#84, partial): numeric character references are now range/surrogate-validated (zero, surrogates, and values above U+10FFFF yield U+FFFD) with the Windows-1252 C1 fixup table (`&#x80;` -> euro, etc.); named references are decoded by longest match with or without a trailing semicolon; a literal NUL byte becomes U+FFFD; RCDATA elements (`title`, `textarea`) decode character references while raw-text elements (`script`, `style`) take their content verbatim; and a single leading newline after `<textarea>` is stripped. (The full ~2,000-entry named-entity table remains deferred to vendoring from the authoritative WHATWG data, the same policy as the legacy encodings.)
- HTML conformance validator in the composable `Validation` idiom. `PureXML.HTML.validationErrors(in:)` (backed by `PureXML.Validation.HTML`) judges a parsed tree against the intrinsic HTML5 content-model invariants as named, removable, isolation-testable `Validation` rules, the same idiom as the DTD/XSD/structural validators: `voidElementsAreEmpty` (void elements carry no content), `requiredParent` (`li`/`td`/`option`/... appear inside an allowed parent, with the allowed parents named in the finding), and `uniqueIdentifiers` (`id` values are unique). Failures are located `ValidationError`s; this is the validation layer HTML previously lacked, so every parsing/schema subsystem now exposes its conformance checks as composable rules.

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 **721 tests in 113 suites** (`swift test`).
currently runs **725 tests in 114 suites** (`swift test`).
### Shipped (libxml2-aligned surface)

View File

@ -3,6 +3,15 @@ private struct DocFrame {
let name: String
let attributes: [PureXML.Model.Attribute]
var children: [PureXML.Model.Node] = []
/// The foreign-content namespace URI (SVG or MathML) when this element is in
/// foreign content, or nil for ordinary HTML.
var namespace: String?
}
/// The foreign-content namespaces HTML5 switches into. File-scope and private.
private enum ForeignNamespace {
static let svg = "http://www.w3.org/2000/svg"
static let mathml = "http://www.w3.org/1998/Math/MathML"
}
/// The HTML5 tree-construction insertion modes this builder implements: the
@ -174,13 +183,29 @@ private final class HTMLDocument {
}
ensureTableContext(for: name, &stack, &roots)
let modeled = modelAttributes(attributes)
let namespace = foreignNamespace(for: name, in: stack)
if PureXML.HTML.Elements.void.contains(name) || selfClosing {
attach(.element(PureXML.Model.Element(name: .init(name), attributes: modeled)), &stack, &roots)
attach(.element(PureXML.Model.Element(name: qualifiedName(name, namespace), attributes: modeled)), &stack, &roots)
} else {
stack.append(DocFrame(name: name, attributes: modeled))
stack.append(DocFrame(name: name, attributes: modeled, namespace: namespace))
}
}
/// The namespace an element enters: SVG for `<svg>`, MathML for `<math>`, the
/// namespace of the nearest open foreign ancestor for anything inside one, or
/// nil for ordinary HTML content.
private func foreignNamespace(for name: String, in stack: [DocFrame]) -> String? {
if name == "svg" { return ForeignNamespace.svg }
if name == "math" { return ForeignNamespace.mathml }
return stack.reversed().first { $0.namespace != nil }?.namespace
}
/// A qualified name carrying its foreign-content namespace URI, so an SVG or
/// MathML element is distinguishable from same-named HTML.
private func qualifiedName(_ name: String, _ namespace: String?) -> PureXML.Model.QualifiedName {
PureXML.Model.QualifiedName(prefix: nil, localName: name, namespaceURI: namespace)
}
/// HTML table tree construction: a `<tr>` inside a bare `<table>` gets an
/// implied `<tbody>`, and a `<td>`/`<th>` gets an implied `<tr>` (and section),
/// so a table written without its section and row wrappers still nests
@ -216,7 +241,7 @@ private final class HTMLDocument {
private func pop(_ stack: inout [DocFrame], _ roots: inout [Node]) {
guard let frame = stack.popLast() else { return }
let element = PureXML.Model.Element(name: .init(frame.name), attributes: frame.attributes, children: frame.children)
let element = PureXML.Model.Element(name: qualifiedName(frame.name, frame.namespace), attributes: frame.attributes, children: frame.children)
attach(.element(element), &stack, &roots)
}

View File

@ -0,0 +1,52 @@
@testable import PureXML
import Testing
@Suite("HTML5 foreign content (SVG/MathML namespaces)")
struct HTMLForeignContentTests {
private let svg = "http://www.w3.org/2000/svg"
private let mathml = "http://www.w3.org/1998/Math/MathML"
/// The first element with the given local name in the parsed document.
private func find(_ name: String, in html: String) -> PureXML.Model.Element? {
find(name, in: PureXML.HTML.parseDocument(html))
}
private func find(_ name: String, in node: PureXML.Model.Node) -> PureXML.Model.Element? {
switch node {
case let .document(children):
return children.lazy.compactMap { find(name, in: $0) }.first
case let .element(element):
if element.name.localName == name { return element }
return element.children.lazy.compactMap { find(name, in: $0) }.first
default:
return nil
}
}
@Test("An svg element and its descendants carry the SVG namespace")
func test_svgNamespace() {
let html = "<p><svg><g><rect></rect></g></svg></p>"
#expect(find("svg", in: html)?.name.namespaceURI == svg)
#expect(find("g", in: html)?.name.namespaceURI == svg)
#expect(find("rect", in: html)?.name.namespaceURI == svg)
}
@Test("A math element and its descendants carry the MathML namespace")
func test_mathmlNamespace() {
let html = "<math><mi>x</mi></math>"
#expect(find("math", in: html)?.name.namespaceURI == mathml)
#expect(find("mi", in: html)?.name.namespaceURI == mathml)
}
@Test("Ordinary HTML elements have no namespace")
func test_htmlNoNamespace() {
#expect(find("p", in: "<p>text</p>")?.name.namespaceURI == nil)
}
@Test("HTML content after a closed svg is back in no namespace")
func test_namespaceResumes() {
let html = "<svg><rect></rect></svg><p>after</p>"
#expect(find("rect", in: html)?.name.namespaceURI == svg)
#expect(find("p", in: html)?.name.namespaceURI == nil)
}
}