feat(html): table tree construction with implied tbody/tr (#83)

This commit is contained in:
Mihaela Mihaljevic 2026-06-09 05:18:13 +02:00
parent e978da9b41
commit b7f7e15fbc
4 changed files with 56 additions and 1 deletions

View File

@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- 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.
- XInclude range inclusion (#87), completing the XInclude audit. An `xi:include` whose `xpointer` selects a range (`xpointer(range(...))`, `range-to`, or `string-range`) now includes that range's content: when no node-selecting scheme matches, the processor falls back to the XPointer range model and substitutes each range's spanned nodes (or the matched text for a `string-range`).

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

View File

@ -172,6 +172,7 @@ private final class HTMLDocument {
pop(&stack, &roots)
}
}
ensureTableContext(for: name, &stack, &roots)
let modeled = modelAttributes(attributes)
if PureXML.HTML.Elements.void.contains(name) || selfClosing {
attach(.element(PureXML.Model.Element(name: .init(name), attributes: modeled)), &stack, &roots)
@ -180,6 +181,32 @@ private final class HTMLDocument {
}
}
/// 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
/// correctly. Only fires inside an open table.
private func ensureTableContext(for name: String, _ stack: inout [DocFrame], _: inout [Node]) {
guard let context = nearestTableContext(stack) else { return }
switch name {
case "tr" where context == "table":
stack.append(DocFrame(name: "tbody", attributes: []))
case "td", "th":
if context == "table" { stack.append(DocFrame(name: "tbody", attributes: [])) }
if context == "table" || ["tbody", "thead", "tfoot"].contains(context) {
stack.append(DocFrame(name: "tr", attributes: []))
}
default:
break
}
}
/// The nearest open table-structural element (`table`/`tbody`/`thead`/`tfoot`/
/// `tr`), or nil when no table is open.
private func nearestTableContext(_ stack: [DocFrame]) -> String? {
let structural: Set = ["table", "tbody", "thead", "tfoot", "tr"]
return stack.reversed().first { structural.contains($0.name) }?.name
}
private func close(_ name: String, _ stack: inout [DocFrame], _ roots: inout [Node]) {
guard let index = stack.lastIndex(where: { $0.name == name }) else { return }
while stack.count > index {

View File

@ -40,4 +40,31 @@ struct HTMLDocumentTests {
func test_htmlAttributes() {
#expect(document("<html lang=\"en\"><p>x</p></html>") == "<html lang=\"en\"><head></head><body><p>x</p></body></html>")
}
// MARK: Table tree construction
/// Wraps a body fragment in the implied document structure for comparison.
private func bodyDocument(_ inner: String) -> String {
"<html><head></head><body>\(inner)</body></html>"
}
@Test("A bare table row gets an implied tbody")
func test_impliedTbody() {
#expect(document("<table><tr><td>x</td></tr></table>") == bodyDocument("<table><tbody><tr><td>x</td></tr></tbody></table>"))
}
@Test("A bare cell gets an implied tbody and row")
func test_impliedRowAndSection() {
#expect(document("<table><td>x</td></table>") == bodyDocument("<table><tbody><tr><td>x</td></tr></tbody></table>"))
}
@Test("An explicit section is not duplicated")
func test_explicitSection() {
#expect(document("<table><thead><tr><th>h</th></tr></thead></table>") == bodyDocument("<table><thead><tr><th>h</th></tr></thead></table>"))
}
@Test("Consecutive rows share one implied tbody")
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>"))
}
}