diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf43e9..6189944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- XSLT `xsl:strip-space` / `xsl:preserve-space` (#81). Whitespace-only text nodes + are removed from the source tree for elements named by `strip-space` before + transformation, with `preserve-space` and a more specific name test winning over + `*`, and `xml:space="preserve"` on a source element or ancestor overriding the + stripping. Folded across `xsl:include`/`xsl:import`. - DTD located content-model errors. An element whose children break its content model now names each stray child individually (`element is not allowed in `), and reports a pure order/count violation once with the allowed elements diff --git a/Sources/XSLT/XSLTModel.swift b/Sources/XSLT/XSLTModel.swift index 7ab3600..d13c479 100644 --- a/Sources/XSLT/XSLTModel.swift +++ b/Sources/XSLT/XSLTModel.swift @@ -136,12 +136,34 @@ public extension PureXML.XSLT { public var body: [Instruction] } - /// A compiled stylesheet: its template rules, global variables, keys, and - /// output controls (with `xsl:include`/`xsl:import` already folded in). + /// A compiled stylesheet: its template rules, global variables, keys, output + /// controls, and the `xsl:strip-space`/`xsl:preserve-space` name tests (with + /// `xsl:include`/`xsl:import` already folded in). struct Stylesheet: Sendable { public var templates: [Template] public var globals: [Instruction] public var keys: [Key] public var output: Output + /// Element name tests whose whitespace-only text children are stripped from + /// the source (an NCName, a qualified name, or `*`). + public var stripSpace: Set + /// Element name tests that keep their whitespace, overriding `stripSpace`. + public var preserveSpace: Set + + public init( + templates: [Template], + globals: [Instruction], + keys: [Key], + output: Output, + stripSpace: Set = [], + preserveSpace: Set = [], + ) { + self.templates = templates + self.globals = globals + self.keys = keys + self.output = output + self.stripSpace = stripSpace + self.preserveSpace = preserveSpace + } } } diff --git a/Sources/XSLT/XSLTParser.swift b/Sources/XSLT/XSLTParser.swift index 9b37f9b..a800449 100644 --- a/Sources/XSLT/XSLTParser.swift +++ b/Sources/XSLT/XSLTParser.swift @@ -1,5 +1,33 @@ private typealias Tree = PureXML.Model.TreeNode +/// The accumulating declarations of a stylesheet as its top-level elements are +/// compiled, plus the folding of an included or imported sub-stylesheet. File +/// scope and private. +private struct Parts { + var templates: [PureXML.XSLT.Template] = [] + var globals: [PureXML.XSLT.Instruction] = [] + var keys: [PureXML.XSLT.Key] = [] + var output = PureXML.XSLT.Output() + var stripSpace: Set = [] + var preserveSpace: Set = [] + + var stylesheet: PureXML.XSLT.Stylesheet { + PureXML.XSLT.Stylesheet(templates: templates, globals: globals, keys: keys, output: output, stripSpace: stripSpace, preserveSpace: preserveSpace) + } + + /// Folds a sub-stylesheet in: an import has lower precedence, so its globals + /// come before and its output is overridden by this stylesheet's. + mutating func fold(_ sub: PureXML.XSLT.Stylesheet?, isImport: Bool) { + guard let sub else { return } + templates += sub.templates + keys += sub.keys + stripSpace.formUnion(sub.stripSpace) + preserveSpace.formUnion(sub.preserveSpace) + globals = isImport ? sub.globals + globals : globals + sub.globals + output = isImport ? sub.output.merged(with: output) : output.merged(with: sub.output) + } +} + /// Tree helpers for the XSLT parser. File-scope and private. private enum XSLTNode { static let namespace = "http://www.w3.org/1999/XSL/Transform" @@ -63,32 +91,31 @@ extension PureXML.XSLT { /// (same import precedence) and `xsl:import` (one lower) resolved through /// `loader`. private static func compile(_ top: Tree, loader: (String) -> String?, precedence: Int) -> Stylesheet { - var templates: [Template] = [] - var globals: [Instruction] = [] - var keys: [Key] = [] - var output = Output() + var parts = Parts() for child in XSLTNode.elementChildren(top) where XSLTNode.isXSL(child) { - switch XSLTNode.localName(child) { - case "template": templates.append(template(child, precedence: precedence)) - case "variable", "param": globals.append(variable(child)) - case "key": keys.append(key(child)) - case "output": output = output.merged(with: parseOutput(child)) - case "include": - guard let sub = load(child, loader: loader, precedence: precedence) else { break } - templates += sub.templates - globals += sub.globals - keys += sub.keys - output = output.merged(with: sub.output) - case "import": - guard let sub = load(child, loader: loader, precedence: precedence - 1) else { break } - templates += sub.templates - globals = sub.globals + globals - keys += sub.keys - output = sub.output.merged(with: output) - default: break - } + absorb(child, into: &parts, loader: loader, precedence: precedence) } - return Stylesheet(templates: templates, globals: globals, keys: keys, output: output) + return parts.stylesheet + } + + private static func absorb(_ child: Tree, into parts: inout Parts, loader: (String) -> String?, precedence: Int) { + switch XSLTNode.localName(child) { + case "template": parts.templates.append(template(child, precedence: precedence)) + case "variable", "param": parts.globals.append(variable(child)) + case "key": parts.keys.append(key(child)) + case "output": parts.output = parts.output.merged(with: parseOutput(child)) + case "strip-space": parts.stripSpace.formUnion(elementNames(child)) + case "preserve-space": parts.preserveSpace.formUnion(elementNames(child)) + case "include": parts.fold(load(child, loader: loader, precedence: precedence), isImport: false) + case "import": parts.fold(load(child, loader: loader, precedence: precedence - 1), isImport: true) + default: break + } + } + + /// The whitespace-separated element name tests of an `xsl:strip-space` or + /// `xsl:preserve-space` element's `elements` attribute. + private static func elementNames(_ node: Tree) -> Set { + Set((XSLTNode.attribute(node, "elements") ?? "").split(whereSeparator: \.isWhitespace).map(String.init)) } private static func load(_ node: Tree, loader: (String) -> String?, precedence: Int) -> Stylesheet? { diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift index d8d31a0..1e1ce21 100644 --- a/Sources/XSLT/XSLTTransformer.swift +++ b/Sources/XSLT/XSLTTransformer.swift @@ -365,6 +365,7 @@ public extension PureXML.XSLT { ) throws -> String { let sheet = try XSLTParser.parse(stylesheet, loader: documentLoader) let root = try PureXML.parseTree(source) + Whitespace.strip(root, stylesheet: sheet) let result = Transformer(stylesheet: sheet, root: root, documentLoader: documentLoader).run() if sheet.output.method == "text" { return textValue(of: result) } return PureXML.serialize(result, options: options.applying(sheet.output)) @@ -378,6 +379,7 @@ public extension PureXML.XSLT { ) throws -> PureXML.Model.Node { let sheet = try XSLTParser.parse(stylesheet, loader: documentLoader) let root = try PureXML.parseTree(source) + Whitespace.strip(root, stylesheet: sheet) return Transformer(stylesheet: sheet, root: root, documentLoader: documentLoader).run() } diff --git a/Sources/XSLT/XSLTWhitespace.swift b/Sources/XSLT/XSLTWhitespace.swift new file mode 100644 index 0000000..c995bec --- /dev/null +++ b/Sources/XSLT/XSLTWhitespace.swift @@ -0,0 +1,49 @@ +extension PureXML.XSLT { + /// Applies a stylesheet's `xsl:strip-space` / `xsl:preserve-space` to the + /// source tree, removing whitespace-only text nodes from stripped elements + /// before transformation. `xml:space="preserve"` on an element or an ancestor + /// (until an `xml:space="default"` resets it) forces preservation regardless + /// of `strip-space`, per the XSLT 1.0 whitespace-stripping rules. CDATA is + /// significant and never stripped. + enum Whitespace { + static func strip(_ root: PureXML.Model.TreeNode, stylesheet: Stylesheet) { + guard !stylesheet.stripSpace.isEmpty else { return } + strip(root, stylesheet.stripSpace, stylesheet.preserveSpace, inScopePreserve: false) + } + + private static func strip( + _ node: PureXML.Model.TreeNode, + _ stripSet: Set, + _ preserveSet: Set, + inScopePreserve: Bool, + ) { + let preserveHere = xmlSpace(node).map { $0 == "preserve" } ?? inScopePreserve + if node.kind == .element, !preserveHere, shouldStrip(node, stripSet, preserveSet) { + for child in node.children where child.kind == .text && isWhitespace(child.value) { + node.removeChild(child) + } + } + for child in node.children { + strip(child, stripSet, preserveSet, inScopePreserve: preserveHere) + } + } + + /// Whether an element with this name has its whitespace stripped: a name + /// test wins over `*`, and `preserve-space` beats `strip-space` on a tie. + private static func shouldStrip(_ node: PureXML.Model.TreeNode, _ stripSet: Set, _ preserveSet: Set) -> 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("*") + } + + private static func xmlSpace(_ node: PureXML.Model.TreeNode) -> String? { + node.attributes.first { $0.name.localName == "space" && $0.name.prefix == "xml" }?.value + } + + private static func isWhitespace(_ value: String) -> Bool { + value.allSatisfy(\.isWhitespace) + } + } +} diff --git a/Tests/XSLTWhitespaceTests.swift b/Tests/XSLTWhitespaceTests.swift new file mode 100644 index 0000000..b626a2f --- /dev/null +++ b/Tests/XSLTWhitespaceTests.swift @@ -0,0 +1,67 @@ +@testable import PureXML +import Testing + +@Suite("XSLT strip-space / preserve-space") +struct XSLTWhitespaceTests { + private let xsl = "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\"" + + private func transform(_ stylesheet: String, _ source: String) throws -> String { + try PureXML.XSLT.transform(stylesheet: stylesheet, source: source) + } + + @Test("strip-space removes whitespace-only text nodes; a copy reflects it") + func test_stripSpace() throws { + let style = """ + + + + + + """ + let source = "\n \n \n" + // The indentation whitespace between elements is stripped. + #expect(try transform(style, source) == "") + } + + @Test("Without strip-space the whitespace is preserved") + func test_noStrip() throws { + let style = """ + + + + + """ + let source = "\n \n" + #expect(try transform(style, source).contains("\n ")) + } + + @Test("preserve-space overrides strip-space for a named element") + func test_preserveOverrides() throws { + let style = """ + + + + + + + """ + // strip-space="*" strips 's whitespace, but preserves its own. + let stripped = try transform(style, "\n \n") + #expect(stripped == "") + let kept = try transform(style, "\n \n") + #expect(kept.contains("\n ")) + } + + @Test("xml:space=preserve on a source element keeps its whitespace despite strip-space") + func test_xmlSpacePreserve() throws { + let style = """ + + + + + + """ + let source = "\n \n" + #expect(try transform(style, source).contains("\n ")) + } +}