diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3d46e71..970a267 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 `` 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 `<` there. The `xml` output method is unchanged (it still escapes both). Closes Apache Xalan conformance cases `output49` and `output74`.
diff --git a/Sources/XSLT/XSLTTransform.swift b/Sources/XSLT/XSLTTransform.swift
index 45f443d..a69fc03 100644
--- a/Sources/XSLT/XSLTTransform.swift
+++ b/Sources/XSLT/XSLTTransform.swift
@@ -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,
diff --git a/Tests/XSLTHTMLOutputTests.swift b/Tests/XSLTHTMLOutputTests.swift
index dd27b8b..ab41342 100644
--- a/Tests/XSLTHTMLOutputTests.swift
+++ b/Tests/XSLTHTMLOutputTests.swift
@@ -57,6 +57,17 @@ struct XSLTHTMLOutputTests {
#expect(try transform(style, "") == "t")
}
+ @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(""), "")
+ #expect(out == "")
+ }
+
@Test("The XML output method still self-closes empty elements")
func test_xmlStillSelfCloses() throws {
let style = """