From 1ba6bf86e28b6995394e4e3e75ae6059c5ecaa98 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 24 Jun 2026 02:48:50 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 2 ++ Sources/XSLT/XSLTTransform.swift | 45 +++++++++++++++++++++++++------- Tests/XSLTHTMLOutputTests.swift | 11 ++++++++ 3 files changed, 48 insertions(+), 10 deletions(-) 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 = """