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.
This commit is contained in:
Mihaela Mihaljevic 2026-06-24 03:41:56 +02:00 committed by GitHub
parent 74bf622275
commit 3ab8637c14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 33 additions and 4 deletions

View File

@ -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`.

View File

@ -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.

View File

@ -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:

View File

@ -30,7 +30,6 @@ output/output26
output/output63
output/output70
output/output73
output/output75
output/output86
position/position106
processorinfo/processorinfo03

View File

@ -37,6 +37,23 @@ struct XSLTOutputMethodTests {
#expect(try PureXML.XSLT.transform(stylesheet: style, source: "<x/>") == "<out>a &lt;b&gt; c</out>")
}
@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 = "<e><xsl:attribute name=\"a\">"
+ "<xsl:text disable-output-escaping=\"yes\">&lt;x&gt;</xsl:text></xsl:attribute>"
+ "<xsl:text disable-output-escaping=\"yes\">&lt;t&gt;</xsl:text></e>"
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:template match="/">\(body)</xsl:template>
</xsl:stylesheet>
"""
#expect(try PureXML.XSLT.transform(stylesheet: style, source: "<x/>") == "<e a=\"&lt;x&gt;\"><t></e>")
}
@Test("Source text holding the private-use marker characters is untouched")
func test_markerCharactersInSource() throws {
let style = """