fix(xslt): match strip-space/preserve-space element tests by namespace (#265)

XSLT 1.0 3.4: an xsl:strip-space/xsl:preserve-space elements name test is a
namespace-qualified NameTest, matched by namespace, not by prefix. A specifier
prefix:* or prefix:local now resolves its prefix to a URI at parse time, and a
source element is matched on its expanded name by specificity (exact {uri}local
over namespace wildcard {uri}* over *). The tests compared raw prefixed strings,
so ns1:* did not strip a source element bound to the same namespace under a
different prefix.

Closes Apache Xalan conformance cases whitespace06 and whitespace07.
This commit is contained in:
Mihaela Mihaljevic 2026-06-24 03:52:45 +02:00 committed by GitHub
parent 3ab8637c14
commit 2fe8632bb2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 48 additions and 11 deletions

View File

@ -52,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- `xsl:strip-space` and `xsl:preserve-space` now match their `elements` name tests by namespace rather than by prefix (XSLT 1.0 3.4). A specifier `prefix:*` or `prefix:local` resolves the prefix to its namespace URI at parse time, and a source element is matched on its expanded name in order of specificity (an exact `{uri}local` over a namespace wildcard `{uri}*` over `*`), so a stylesheet that strips `ns1:*` strips a source element in that namespace whatever prefix the source bound it to, while leaving another namespace untouched. The tests previously compared the raw prefixed name strings, so a differing prefix did not match. Closes Apache Xalan conformance cases `whitespace06` and `whitespace07`.
- `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`.

View File

@ -139,9 +139,12 @@ extension PureXML.XSLT {
}
/// The whitespace-separated element name tests of an `xsl:strip-space` or
/// `xsl:preserve-space` element's `elements` attribute.
/// `xsl:preserve-space` element's `elements` attribute, each resolved to
/// namespace form so matching is by namespace, not prefix.
private static func elementNames(_ node: XSLTTree) -> Set<String> {
Set((XSLTNode.attribute(node, "elements") ?? "").split(whereSeparator: \.isWhitespace).map(String.init))
Set((XSLTNode.attribute(node, "elements") ?? "").split(whereSeparator: \.isWhitespace).map {
expandedSpecifier(String($0), at: node)
})
}
/// Loads an include/import target's stylesheet element: the href

View File

@ -48,6 +48,17 @@ extension PureXML.XSLT.XSLTParser {
XSLTNode.attribute(node, "mode").map { expandedQName($0, at: node) }
}
/// A strip-space/preserve-space NameTest resolved to namespace form: `*` and
/// an unprefixed name (the null namespace) stay as is, `prefix:*` becomes
/// `{uri}*`, and `prefix:local` becomes `{uri}local` (XSLT 1.0 3.4).
static func expandedSpecifier(_ token: String, at node: XSLTTree) -> String {
guard let colon = token.firstIndex(of: ":") else { return token }
let prefix = String(token[..<colon])
let local = String(token[token.index(after: colon)...])
guard let uri = resolvePrefix(prefix, at: node) else { return token }
return "{\(uri)}\(local)"
}
/// A variable or parameter `name` as an expanded name `{uri}local` when it
/// carries a prefix, or the bare name when unprefixed. A variable is named by
/// expanded QName (XSLT 1.0 11.1), so two prefixes bound to one namespace name

View File

@ -28,14 +28,20 @@ extension PureXML.XSLT {
}
}
/// Whether an element with this name has its whitespace stripped: a name
/// test wins over `*`, and `preserve-space` beats `strip-space` on a tie.
/// Whether an element has its whitespace stripped. Specifiers are matched
/// by namespace in order of specificity (an expanded `{uri}local` name
/// over a namespace wildcard `{uri}*` over `*`), and `preserve-space`
/// beats `strip-space` on a tie (XSLT 1.0 3.4).
private static func shouldStrip(_ node: PureXML.Model.TreeNode, _ stripSet: Set<String>, _ preserveSet: Set<String>) -> Bool {
let names = [node.name?.localName, node.name?.description].compactMap(\.self)
if names.contains(where: preserveSet.contains) { return false }
if names.contains(where: stripSet.contains) { return true }
if preserveSet.contains("*") { return false }
return stripSet.contains("*")
let uri = node.name?.namespaceURI ?? ""
let local = node.name?.localName ?? ""
let expanded = uri.isEmpty ? local : "{\(uri)}\(local)"
let namespaceWildcard = uri.isEmpty ? nil : "{\(uri)}*"
for test in [expanded, namespaceWildcard, "*"].compactMap(\.self) {
if preserveSet.contains(test) { return false }
if stripSet.contains(test) { return true }
}
return false
}
private static func xmlSpace(_ node: PureXML.Model.TreeNode) -> String? {

View File

@ -41,6 +41,4 @@ sort/sort32
sort/sort33
sort/sort34
string/string132
whitespace/whitespace06
whitespace/whitespace07
whitespace/whitespace20

View File

@ -64,4 +64,21 @@ struct XSLTWhitespaceTests {
let source = "<a xml:space=\"preserve\">\n <b/>\n</a>"
#expect(try transform(style, source).contains("\n "))
}
@Test("strip-space matches a namespace wildcard by namespace, not prefix")
func test_namespaceWildcard() throws {
// strip-space `p:*` strips elements in p's namespace. The source binds the
// same namespace to a different prefix (q), which must still match, while
// an element in another namespace is preserved (Apache Xalan whitespace06,
// whitespace07).
let style = """
<xsl:stylesheet version="1.0" \(xsl) xmlns:p="urn:n1" xmlns:s="urn:n2" exclude-result-prefixes="p s">
<xsl:output omit-xml-declaration="yes"/>
<xsl:strip-space elements="p:*"/>
<xsl:template match="/"><out><xsl:value-of select="string-length(doc/p:a)"/>,<xsl:value-of select="string-length(doc/s:b)"/></out></xsl:template>
</xsl:stylesheet>
"""
let source = "<doc xmlns:q=\"urn:n1\" xmlns:r=\"urn:n2\"><q:a> </q:a><r:b> </r:b></doc>"
#expect(try transform(style, source) == "<out>0,2</out>")
}
}