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 = """