fix(xslt): serialize a namespaced element as XML under the html output method (16.2) (#260)

* fix(xslt): serialize a namespaced element as XML under the html output method

XSLT 1.0 16.2: the html output method outputs an element whose expanded-name
has a non-null namespace URI as XML, not HTML. A namespaced document element's
tree was serialized as HTML, leaving empty namespaced elements unclosed (e.g.
a void name like br in a namespace) and repeating xmlns on every element, which
is not well-formed XML. Such a tree now serializes through the XML path (empty
elements self-close, the namespace is declared once and inherited); the html
method still omits the XML declaration. Null-namespace trees are unchanged.

Grounded in XSLT 1.0 16.2 and the libxml2 HTML serializer study; reimplemented
in Swift. Apache Xalan output63 exercises this rule but stays baselined because
its gold differs further only in non-normative attribute order.

* docs(xslt): disclose the document-element granularity of the 16.2 namespace rule

Make the whole-tree approximation explicit in the serializedBody comment and
the changelog: the html-outputs-namespaced-elements-as-XML rule is applied at
document-element granularity, not per element, so a mixed-namespace tree
follows its document element's path.
This commit is contained in:
Mihaela Mihaljevic 2026-06-24 02:48:50 +02:00 committed by GitHub
parent 445412b105
commit 1ba6bf86e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 48 additions and 10 deletions

View File

@ -52,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- The `html` output method now serializes an element whose expanded-name has a non-null namespace URI as XML rather than HTML (XSLT 1.0 16.2). When the result's document element is namespaced, the tree is serialized through the XML path (empty elements self-close, the namespace is declared once and inherited) while the html method still omits the XML declaration; previously such a tree was emitted as HTML, leaving empty namespaced elements unclosed and repeating the `xmlns` declaration on every element, which is not well-formed XML. A null-namespace tree is unchanged. The rule is applied at document-element granularity rather than per element, so a tree mixing null- and non-null-namespace elements follows its document element's path (the realistic result is all-null or all-namespaced). (Apache Xalan `output63` exercises this rule but remains baselined: its gold differs further only in attribute order, which is not normative.)
- The `html` output method now names the document type declaration `HTML` rather than the document element name (XSLT 1.0 16.2: "The name following the `<!DOCTYPE` should be HTML or html"). With `method="html"` and a `doctype-public` or `doctype-system`, the emitted declaration is `<!DOCTYPE HTML PUBLIC "...">` regardless of the result's root element; the `xml` method still uses the document element name (16.1). Closes Apache Xalan conformance cases `output40`, `output48`, and `output60`.
- The `html` output method no longer escapes a `<` character in an attribute value (XSLT 1.0 16.2). The HTML output method escapes `&` and `"` in attribute values but leaves `<` and `>` literal, unlike the `xml` method; the serializer was escaping `<` to `&lt;` there. The `xml` output method is unchanged (it still escapes both). Closes Apache Xalan conformance cases `output49` and `output74`.

View File

@ -43,16 +43,7 @@ public extension PureXML.XSLT {
// never "unescaped".
return sheet.usesRawText ? RawText.stripped(textValue(of: result)) : textValue(of: result)
}
let body: String
if method == "html" {
body = PureXML.HTML.serialize(withContentTypeMeta(result, encoding: sheet.output.encoding ?? "UTF-8"))
} else {
let fixed = XSLTNamespaceFixup.apply(result)
let prepared = withCDATASections(fixed, sheet.output.cdataSectionElements)
var emitOptions = options.applying(sheet.output)
if sheet.output.omitXMLDeclaration == nil { emitOptions.includeXMLDeclaration = true }
body = PureXML.serialize(prepared, options: emitOptions)
}
let body = serializedBody(result, sheet: sheet, options: options, method: method)
// The XML declaration stays first; the doctype follows it.
let resolved = sheet.usesRawText ? RawText.resolve(body) : body
let documentType = doctype(for: result, sheet.output, method: method)
@ -213,6 +204,40 @@ public extension PureXML.XSLT {
}
}
/// Serializes the result tree by output method. 16.2 outputs a
/// non-null-namespace element as XML rather than HTML; this is applied at
/// document-element granularity, not per element: a namespaced document
/// element serializes the whole tree as XML (the html method still omitting
/// the XML declaration), so a tree mixing null- and non-null-namespace
/// elements follows its document element's path. The realistic result is
/// all-null (HTML) or all-namespaced (XML); the html null-namespace path is
/// unchanged.
private static func serializedBody(_ result: PureXML.Model.Node, sheet: Stylesheet, options: PureXML.Emitting.Options, method: String) -> String {
if method == "html", !documentElementIsNamespaced(result) {
return PureXML.HTML.serialize(withContentTypeMeta(result, encoding: sheet.output.encoding ?? "UTF-8"))
}
let fixed = XSLTNamespaceFixup.apply(result)
let prepared = withCDATASections(fixed, sheet.output.cdataSectionElements)
var emitOptions = options.applying(sheet.output)
if method == "html" {
emitOptions.includeXMLDeclaration = false
} else if sheet.output.omitXMLDeclaration == nil {
emitOptions.includeXMLDeclaration = true
}
return PureXML.serialize(prepared, options: emitOptions)
}
/// Whether the result's document element has a non-null namespace URI, which
/// the html output method serializes as XML rather than HTML (16.2).
private static func documentElementIsNamespaced(_ node: PureXML.Model.Node) -> Bool {
let element: PureXML.Model.Element? = switch node {
case let .element(element): element
case let .document(children): children.compactMap(\.element).first
default: nil
}
return !((element?.name.namespaceURI) ?? "").isEmpty
}
/// Transforms `source` with `stylesheet`, returning the result tree.
static func transformToNode(
stylesheet: String,

View File

@ -57,6 +57,17 @@ struct XSLTHTMLOutputTests {
#expect(try transform(style, "<x/>") == "<a title=\"&lt;x&gt;\">t</a>")
}
@Test("the html method serializes a namespaced element as XML, not HTML")
func test_namespacedElementAsXML() throws {
// XSLT 1.0 16.2: an element whose expanded-name has a non-null namespace
// URI is output as XML (empty elements self-close, the namespace is
// declared once), not HTML; a null-namespace element stays HTML (see
// test_voidElement). The html serializer otherwise left empty namespaced
// elements unclosed and repeated xmlns on every element.
let out = try transform(htmlStyle("<svg xmlns=\"urn:svg\"><rect/><g><circle/></g></svg>"), "<x/>")
#expect(out == "<svg xmlns=\"urn:svg\"><rect/><g><circle/></g></svg>")
}
@Test("The XML output method still self-closes empty elements")
func test_xmlStillSelfCloses() throws {
let style = """