Merge #83 (stage 6): table foster-parenting

This commit is contained in:
Mihaela Mihaljevic 2026-06-09 06:04:02 +02:00
commit 6dab33a89f
5 changed files with 66 additions and 3 deletions

View File

@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- HTML5 table foster-parenting (#83, stage 6): stray flow content inside a table is now moved out rather than nested in the table structure, matching the HTML standard. A non-table element or character data appearing while a `table`/`tbody`/`thead`/`tfoot`/`tr` is the open node is inserted immediately before the table (`<table><b>x</table>` -> `<b>x</b><table></table>`), while well-formed cell content is untouched.
- HTML5 SVG attribute-name case adjustment (#83, stage 5): an SVG attribute written in mixed case is restored to its canonical camel case in the tree (`viewbox` -> `viewBox`, `gradienttransform` -> `gradientTransform`, and the rest), applying only to elements in the SVG namespace. HTML attribute names are left as the tokenizer lowercased them. With this, SVG/MathML foreign content carries correct element and attribute names.
- HTML5 adoption agency algorithm and active formatting elements (#83, stage 4): misnested formatting tags are now recovered exactly as the HTML standard prescribes. Overlapping tags nest (`<b><i></b></i>` -> `<b><i></i></b>`), content after an out-of-order close is re-wrapped by reconstructing the active formatting elements (`<b><i></b>X</i>` -> `<b><i></i></b><i>X</i>`), and a block inside a formatting element reparents through the furthest-block path, including the canonical `<p>1<b>2<i>3</b>4</i>5</p>` -> `<p>1<b>2<i>3</i></b><i>4</i>5</p>`.
- 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.

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

View File

@ -172,7 +172,7 @@ final class HTMLDocument {
bodyClose(name)
case let .text(value):
reconstructActiveFormatting()
bodyInsert(.text(value))
placeText(.text(value))
case let .comment(value):
bodyInsert(.comment(value))
default:
@ -204,7 +204,7 @@ final class HTMLDocument {
bodyEnsureTableContext(for: name)
let namespace = bodyForeignNamespace(for: name)
let element = PureXML.Model.TreeNode.element(qualifiedName(name, namespace), attributes: adjustedAttributes(modelAttributes(attributes), namespace: namespace))
openBody.last?.append(element)
placeElement(element, name: name)
if !(PureXML.HTML.Elements.void.contains(name) || selfClosing) {
openBody.append(element)
}

View File

@ -0,0 +1,47 @@
extension HTMLDocument {
/// The elements the table insertion modes place directly (so they are never
/// foster-parented out of a table).
private static let tableScope: Set<String> = [
"table", "caption", "colgroup", "col", "tbody", "thead", "tfoot", "tr", "td", "th",
]
/// The table-structural elements that, when current, mean a stray non-table
/// node must be foster-parented rather than nested.
private static let tableContexts: Set<String> = ["table", "tbody", "thead", "tfoot", "tr"]
/// Inserts an opened element, foster-parenting it before the table when it is a
/// non-table element appearing in table context (the HTML5 rule for stray
/// flow content inside a table).
func placeElement(_ element: PureXML.Model.TreeNode, name: String) {
if !Self.tableScope.contains(name), inTableContext() {
fosterParent(element)
} else {
openBody.last?.append(element)
}
}
/// Inserts a text node, foster-parenting it out of a table when in table
/// context (character data is not allowed directly inside table structure).
func placeText(_ node: PureXML.Model.TreeNode) {
if inTableContext() {
fosterParent(node)
} else {
openBody.last?.append(node)
}
}
private func inTableContext() -> Bool {
guard let current = openBody.last else { return false }
return Self.tableContexts.contains(tagName(current))
}
/// Inserts `node` immediately before the nearest open table in that table's
/// parent; if the table has no parent yet, falls back to ordinary insertion.
private func fosterParent(_ node: PureXML.Model.TreeNode) {
guard let table = openBody.last(where: { tagName($0) == "table" }), let parent = table.parent else {
openBody.last?.append(node)
return
}
parent.insert(node, before: table)
}
}

View File

@ -67,4 +67,19 @@ struct HTMLDocumentTests {
func test_consecutiveRows() {
#expect(document("<table><tr><td>a</td><tr><td>b</td></table>") == bodyDocument("<table><tbody><tr><td>a</td></tr><tr><td>b</td></tr></tbody></table>"))
}
@Test("A stray element inside a table is foster-parented before it")
func test_fosterElement() {
#expect(document("<table><b>x</table>") == bodyDocument("<b>x</b><table></table>"))
}
@Test("Stray text inside a table is foster-parented before it")
func test_fosterText() {
#expect(document("<table>text<tr><td>c</td></tr></table>") == bodyDocument("text<table><tbody><tr><td>c</td></tr></tbody></table>"))
}
@Test("Well-formed cell content is not foster-parented")
func test_noFosterInCell() {
#expect(document("<table><tr><td><b>x</b></td></tr></table>") == bodyDocument("<table><tbody><tr><td><b>x</b></td></tr></tbody></table>"))
}
}