diff --git a/CHANGELOG.md b/CHANGELOG.md
index 71f93b3..87b153f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 (`
` -> `x`), 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 (`` -> ``), content after an out-of-order close is re-wrapped by reconstructing the active formatting elements (`X` -> `X`), and a block inside a formatting element reparents through the furthest-block path, including the canonical `12345
` -> `12345
`.
- 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 ``, ``, ``, and the rest carry their correct SVG names. Applies only in the SVG namespace; same-named HTML elements keep their lowercased name.
diff --git a/README.md b/README.md
index dddbc86..1da9c24 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/Sources/HTML/HTMLDocumentBuilder.swift b/Sources/HTML/HTMLDocumentBuilder.swift
index 48bb561..9257ecf 100644
--- a/Sources/HTML/HTMLDocumentBuilder.swift
+++ b/Sources/HTML/HTMLDocumentBuilder.swift
@@ -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)
}
diff --git a/Sources/HTML/HTMLFosterParenting.swift b/Sources/HTML/HTMLFosterParenting.swift
new file mode 100644
index 0000000..eabc128
--- /dev/null
+++ b/Sources/HTML/HTMLFosterParenting.swift
@@ -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 = [
+ "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 = ["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)
+ }
+}
diff --git a/Tests/HTMLDocumentTests.swift b/Tests/HTMLDocumentTests.swift
index ebab976..5657a5a 100644
--- a/Tests/HTMLDocumentTests.swift
+++ b/Tests/HTMLDocumentTests.swift
@@ -67,4 +67,19 @@ struct HTMLDocumentTests {
func test_consecutiveRows() {
#expect(document("") == bodyDocument(""))
}
+
+ @Test("A stray element inside a table is foster-parented before it")
+ func test_fosterElement() {
+ #expect(document("") == bodyDocument("x"))
+ }
+
+ @Test("Stray text inside a table is foster-parented before it")
+ func test_fosterText() {
+ #expect(document("") == bodyDocument("text"))
+ }
+
+ @Test("Well-formed cell content is not foster-parented")
+ func test_noFosterInCell() {
+ #expect(document("") == bodyDocument(""))
+ }
}