feat(html): SVG element-name case adjustment in foreign content (#83)

This commit is contained in:
Mihaela Mihaljevic 2026-06-09 05:29:14 +02:00
parent d084e3521f
commit 40c5973d5a
5 changed files with 46 additions and 3 deletions

View File

@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- HTML5 SVG element-name case adjustment (#83, stage 3): an SVG element written in mixed case (which the tokenizer lowercases) is restored to its canonical camel case in the tree, so `<foreignObject>`, `<linearGradient>`, `<clipPath>`, and the rest carry their correct SVG names. Applies only in the SVG namespace; same-named HTML elements keep their lowercased name.
- 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.)

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

View File

@ -201,9 +201,12 @@ private final class HTMLDocument {
}
/// A qualified name carrying its foreign-content namespace URI, so an SVG or
/// MathML element is distinguishable from same-named HTML.
/// MathML element is distinguishable from same-named HTML. SVG element names
/// are restored to their canonical camel case (`foreignObject`, not the
/// tokenizer's lowercased `foreignobject`).
private func qualifiedName(_ name: String, _ namespace: String?) -> PureXML.Model.QualifiedName {
PureXML.Model.QualifiedName(prefix: nil, localName: name, namespaceURI: namespace)
let local = namespace == ForeignNamespace.svg ? (PureXML.HTML.ForeignNames.svgElements[name] ?? name) : name
return PureXML.Model.QualifiedName(prefix: nil, localName: local, namespaceURI: namespace)
}
/// HTML table tree construction: a `<tr>` inside a bare `<table>` gets an

View File

@ -0,0 +1,23 @@
extension PureXML.HTML {
/// The HTML5 "adjust SVG element names" table: the SVG elements whose correct
/// names are camel-cased, keyed by the lowercased form the tokenizer produces.
/// Applied when materializing an element in the SVG namespace so a parsed
/// `<foreignObject>` or `<lineargradient>` carries its canonical SVG name.
enum ForeignNames {
static let svgElements: [String: String] = [
"altglyph": "altGlyph", "altglyphdef": "altGlyphDef", "altglyphitem": "altGlyphItem",
"animatecolor": "animateColor", "animatemotion": "animateMotion", "animatetransform": "animateTransform",
"clippath": "clipPath", "feblend": "feBlend", "fecolormatrix": "feColorMatrix",
"fecomponenttransfer": "feComponentTransfer", "fecomposite": "feComposite", "feconvolvematrix": "feConvolveMatrix",
"fediffuselighting": "feDiffuseLighting", "fedisplacementmap": "feDisplacementMap", "fedistantlight": "feDistantLight",
"fedropshadow": "feDropShadow", "feflood": "feFlood", "fefunca": "feFuncA",
"fefuncb": "feFuncB", "fefuncg": "feFuncG", "fefuncr": "feFuncR",
"fegaussianblur": "feGaussianBlur", "feimage": "feImage", "femerge": "feMerge",
"femergenode": "feMergeNode", "femorphology": "feMorphology", "feoffset": "feOffset",
"fepointlight": "fePointLight", "fespecularlighting": "feSpecularLighting", "fespotlight": "feSpotLight",
"fetile": "feTile", "feturbulence": "feTurbulence", "foreignobject": "foreignObject",
"glyphref": "glyphRef", "lineargradient": "linearGradient", "radialgradient": "radialGradient",
"textpath": "textPath",
]
}
}

View File

@ -49,4 +49,20 @@ struct HTMLForeignContentTests {
#expect(find("rect", in: html)?.name.namespaceURI == svg)
#expect(find("p", in: html)?.name.namespaceURI == nil)
}
@Test("SVG element names are restored to their canonical camel case")
func test_svgNameCaseAdjustment() {
let html = "<svg><foreignObject></foreignObject><lineargradient></lineargradient></svg>"
#expect(find("foreignObject", in: html)?.name.namespaceURI == svg)
#expect(find("linearGradient", in: html)?.name.namespaceURI == svg)
// The lowercased forms the tokenizer produced are not what landed in the tree.
#expect(find("foreignobject", in: html) == nil)
}
@Test("A same-named HTML element keeps its lowercased name")
func test_htmlNameNotAdjusted() {
// Outside SVG, no case adjustment applies.
#expect(find("clippath", in: "<clippath></clippath>")?.name.namespaceURI == nil)
#expect(find("clipPath", in: "<clippath></clippath>") == nil)
}
}