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
` -> `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("
a
b
") == bodyDocument("
a
b
")) } + + @Test("A stray element inside a table is foster-parented before it") + func test_fosterElement() { + #expect(document("x
") == bodyDocument("x
")) + } + + @Test("Stray text inside a table is foster-parented before it") + func test_fosterText() { + #expect(document("text
c
") == bodyDocument("text
c
")) + } + + @Test("Well-formed cell content is not foster-parented") + func test_noFosterInCell() { + #expect(document("
x
") == bodyDocument("
x
")) + } }