From 3ab8637c14ccbe98e6ae6e7738a406da8430d6ac Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 24 Jun 2026 03:41:56 +0200 Subject: [PATCH] fix(xslt): ignore disable-output-escaping for attribute, comment, and PI values (#264) XSLT 1.0 16.4: disabling output escaping for a value used as something other than a text node (an attribute, comment, or processing-instruction string value) is an error; the recovery is to ignore it. The attribute value built from such content kept its raw-output markers, so the serializer emitted < and > unescaped in the attribute. Strip the markers for these three node kinds, via a shared helper, gated on the stylesheet actually using the feature so a private-use character in ordinary data is never removed. Closes Apache Xalan conformance case output75. --- CHANGELOG.md | 2 ++ Sources/XSLT/XSLTElements.swift | 13 ++++++++++++- Sources/XSLT/XSLTTransformer.swift | 4 ++-- Tests/Fixtures/xalan-baseline.txt | 1 - Tests/XSLTOutputMethodTests.swift | 17 +++++++++++++++++ 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51b744c..b1b97ba 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 +- `disable-output-escaping` is now ignored when an `xsl:text` or `xsl:value-of` produces the string value of an attribute, comment, or processing-instruction node (XSLT 1.0 16.4: disabling escaping for a value used as something other than a text node is an error, with the recovery being to ignore it). The attribute value built from such content kept its raw-output markers, so the serializer left `<` and `>` unescaped in the attribute; the markers are now stripped for these three node kinds (only when the stylesheet uses the feature, so a private-use character in ordinary data is untouched). Closes Apache Xalan conformance case `output75`. + - The `html` output method now percent-escapes non-ASCII and control characters in URI-valued attributes (XSLT 1.0 16.2, HTML 4.01 appendix B.2.1). For the HTML 4 attributes of type %URI (`href`, `src`, `cite`, `action`, and the rest), each non-ASCII or control/space character in the value is written as the uppercase hex of its UTF-8 bytes (so `cite="bë.xml"` becomes `cite="b%C3%AB.xml"`), while `"` and `&` keep their entity form; other attributes are unaffected. Closes Apache Xalan conformance case `output32`. - Serializing a processing instruction whose data contains `?>` now inserts a space (`? >`) so the instruction is not terminated early (XSLT 1.0 7.3: a processor recovers from a generated `?>` by inserting a space after the `?`). The serializer emitted the `?>` raw, producing a malformed instruction. Closes Apache Xalan conformance case `output72`. diff --git a/Sources/XSLT/XSLTElements.swift b/Sources/XSLT/XSLTElements.swift index f2d6e98..bd02a38 100644 --- a/Sources/XSLT/XSLTElements.swift +++ b/Sources/XSLT/XSLTElements.swift @@ -99,7 +99,7 @@ extension PureXML.XSLT.Transformer { let name = createdName(nameTemplate, namespaceTemplate, namespaces, context, isAttribute: true) // xsl:attribute content that creates a non-text node ignores it with its // content (XSLT 1.0 errata E27), like xsl:comment/processing-instruction. - return [.attribute(.init(name: name, value: Self.textNodesOnly(of: instantiate(body, context))))] + return [.attribute(.init(name: name, value: escapedTextValue(of: instantiate(body, context))))] } func copyInstruction(_ useAttributeSets: [String], _ body: [PureXML.XSLT.Instruction], _ context: XSLTContext) -> [PureXML.XSLT.ResultItem] { @@ -160,6 +160,17 @@ extension PureXML.XSLT.Transformer { } } + /// The text-node content of `items` as the string value of an attribute, + /// comment, or processing-instruction node, with disable-output-escaping + /// ignored (XSLT 1.0 16.4: disabling escaping for a value used as something + /// other than a text node is an error; the recovery is to ignore it). The + /// raw markers are only stripped when the stylesheet uses the feature, so a + /// private-use character in ordinary data is never removed. + func escapedTextValue(of items: [PureXML.XSLT.ResultItem]) -> String { + let text = Self.textNodesOnly(of: items) + return stylesheet.usesRawText ? PureXML.XSLT.RawText.stripped(text) : text + } + /// The source element's in-scope namespace declarations as literal /// xmlns attributes (xsl:copy copies namespace nodes, 7.5); the fixup /// pass drops the ones already in scope in the result. diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift index d45b7b3..4916961 100644 --- a/Sources/XSLT/XSLTTransformer.swift +++ b/Sources/XSLT/XSLTTransformer.swift @@ -302,9 +302,9 @@ extension PureXML.XSLT.Transformer { case .number: [.node(.text(numberInstruction(instruction, context)))] case let .comment(body): - [.node(.comment(Self.textNodesOnly(of: instantiate(body, context))))] + [.node(.comment(escapedTextValue(of: instantiate(body, context))))] case let .processingInstruction(name, body): - [.node(.processingInstruction(target: avt(name, context), data: Self.textNodesOnly(of: instantiate(body, context))))] + [.node(.processingInstruction(target: avt(name, context), data: escapedTextValue(of: instantiate(body, context))))] case let .message(terminate, body): message(terminate, body, context) case .applyImports: diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt index f69954c..3abc6ed 100644 --- a/Tests/Fixtures/xalan-baseline.txt +++ b/Tests/Fixtures/xalan-baseline.txt @@ -30,7 +30,6 @@ output/output26 output/output63 output/output70 output/output73 -output/output75 output/output86 position/position106 processorinfo/processorinfo03 diff --git a/Tests/XSLTOutputMethodTests.swift b/Tests/XSLTOutputMethodTests.swift index 31b19c5..225140f 100644 --- a/Tests/XSLTOutputMethodTests.swift +++ b/Tests/XSLTOutputMethodTests.swift @@ -37,6 +37,23 @@ struct XSLTOutputMethodTests { #expect(try PureXML.XSLT.transform(stylesheet: style, source: "") == "a <b> c") } + @Test("disable-output-escaping is ignored for an attribute value (16.4)") + func test_disableOutputEscapingIgnoredOffTextNode() throws { + // 16.4: disabling escaping for a value used as an attribute (not a text + // node) is an error; the recovery ignores it, so the attribute escapes + // `<`/`>` while a sibling text node keeps it raw (Apache Xalan output75). + let body = "" + + "<x>" + + "<t>" + let style = """ + + + \(body) + + """ + #expect(try PureXML.XSLT.transform(stylesheet: style, source: "") == "") + } + @Test("Source text holding the private-use marker characters is untouched") func test_markerCharactersInSource() throws { let style = """