From 12af2fbc24334b8f1195d7f4aa629228d1d1a73a Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 24 Jun 2026 10:57:47 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 2 ++ Sources/XSLT/XSLTParser.swift | 4 +++- Sources/XSLT/XSLTParserDeclarations.swift | 14 ++++++++++++++ Tests/Fixtures/xalan-baseline.txt | 1 - Tests/XSLTWhitespaceTests.swift | 14 ++++++++++++++ 5 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3298964..a30d656 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 +- `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`. diff --git a/Sources/XSLT/XSLTParser.swift b/Sources/XSLT/XSLTParser.swift index ef7f76c..152c11a 100644 --- a/Sources/XSLT/XSLTParser.swift +++ b/Sources/XSLT/XSLTParser.swift @@ -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: " " 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 { diff --git a/Sources/XSLT/XSLTParserDeclarations.swift b/Sources/XSLT/XSLTParserDeclarations.swift index ea717fe..a3239af 100644 --- a/Sources/XSLT/XSLTParserDeclarations.swift +++ b/Sources/XSLT/XSLTParserDeclarations.swift @@ -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). diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt index cfcc397..7fb8525 100644 --- a/Tests/Fixtures/xalan-baseline.txt +++ b/Tests/Fixtures/xalan-baseline.txt @@ -34,4 +34,3 @@ sort/sort27 sort/sort32 sort/sort33 sort/sort34 -whitespace/whitespace20 diff --git a/Tests/XSLTWhitespaceTests.swift b/Tests/XSLTWhitespaceTests.swift index 56c71d7..4678bb9 100644 --- a/Tests/XSLTWhitespaceTests.swift +++ b/Tests/XSLTWhitespaceTests.swift @@ -81,4 +81,18 @@ struct XSLTWhitespaceTests { let source = " " #expect(try transform(style, source) == "0,2") } + + @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 = """ + + + + + """ + #expect(try transform(style, "") == " ") + } }