fix(xslt): honor xml:space=preserve when stripping stylesheet whitespace (#271)

XSLT 1.0 3.4: a whitespace-only text node in the stylesheet is preserved when
the nearest ancestor-or-self with an xml:space attribute has value preserve.
The body parser dropped every whitespace-only run unconditionally, so a literal
result element with xml:space="preserve" lost its whitespace content. Consult
the in-scope xml:space before stripping.

Closes Apache Xalan conformance case whitespace20.
This commit is contained in:
Mihaela Mihaljevic 2026-06-24 10:57:47 +02:00 committed by GitHub
parent 42a56abcbf
commit 12af2fbc24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 33 additions and 2 deletions

View File

@ -52,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- `xml:space="preserve"` on a literal result element (or an ancestor) in the stylesheet now keeps that element's whitespace-only text instead of stripping it (XSLT 1.0 3.4). The stylesheet whitespace stripping dropped every whitespace-only run unconditionally, ignoring an in-scope `xml:space="preserve"`; the nearest ancestor-or-self `xml:space` is now consulted, so `preserve` retains the run while `default` (or none) still strips it. Closes Apache Xalan conformance case `whitespace20`.
- `xsl:number level="single"` now counts the element that is the `from` boundary when that element also matches the `count` pattern (XSLT 1.0 7.7). The search for the count node stopped at the nearest `from`-matching ancestor before testing it against `count`, so a pattern like `count="a|b" from="a"` produced an empty number for an element inside an `a` (the count node is that `a`, which is also the boundary). The count pattern is now tested before the boundary stops the walk. Closes Apache Xalan conformance case `numbering63`.
- The `format` attribute of `xsl:number` is now evaluated as an attribute value template (XSLT 1.0 7.7), so `format="{$f}"` uses the value of `$f` rather than the literal string. It was stored and used verbatim, so a computed or passed-in format was never applied. Closes Apache Xalan conformance case `namedtemplate12`.

View File

@ -243,10 +243,12 @@ extension PureXML.XSLT {
// model, so a whitespace-only run is dropped (XSLT 1.0 3.4) only when
// the WHOLE coalesced run is whitespace: " <![CDATA[x]]> " keeps its
// spaces because the run "x" surrounds them with non-whitespace.
// `xml:space="preserve"` in scope keeps a whitespace-only run too.
let preserve = preservesWhitespace(node)
var result: [Instruction] = []
var run = ""
func flush() {
if !run.isEmpty, !run.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isWhitespace) { result.append(.literalText(run)) }
if !run.isEmpty, preserve || run.unicodeScalars.contains(where: { !PureXML.Parsing.XMLCharacter.isWhitespace($0) }) { result.append(.literalText(run)) }
run = ""
}
for child in node.children {

View File

@ -48,6 +48,20 @@ extension PureXML.XSLT.XSLTParser {
XSLTNode.attribute(node, "mode").map { expandedQName($0, at: node) }
}
/// Whether `xml:space="preserve"` is in scope at `node`: the nearest
/// ancestor-or-self carrying an `xml:space` attribute has value `preserve`,
/// so a whitespace-only text run is kept rather than stripped (XSLT 1.0 3.4).
static func preservesWhitespace(_ node: XSLTTree) -> Bool {
var current: XSLTTree? = node
while let element = current {
if let space = element.attributes.first(where: { $0.name.prefix == "xml" && $0.name.localName == "space" })?.value {
return space == "preserve"
}
current = element.parent
}
return false
}
/// 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).

View File

@ -34,4 +34,3 @@ sort/sort27
sort/sort32
sort/sort33
sort/sort34
whitespace/whitespace20

View File

@ -81,4 +81,18 @@ struct XSLTWhitespaceTests {
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>")
}
@Test("xml:space=preserve in the stylesheet keeps a literal element's whitespace (3.4)")
func test_xmlSpacePreserveInStylesheet() throws {
// A literal result element with xml:space="preserve" keeps its
// whitespace-only content; xml:space="default" strips it, as does no
// xml:space at all (Apache Xalan whitespace20).
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:template match="/"><r><a xml:space="preserve"> </a><b xml:space="default"> </b><c> </c></r></xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(style, "<x/>") == "<r><a xml:space=\"preserve\"> </a><b xml:space=\"default\"/><c/></r>")
}
}