Merge: XSLT strip-space/preserve-space (#81)
This commit is contained in:
commit
83fdeb9bfa
@ -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 <x> is not allowed in
|
||||
<r>`), and reports a pure order/count violation once with the allowed elements
|
||||
|
||||
@ -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<String>
|
||||
/// Element name tests that keep their whitespace, overriding `stripSpace`.
|
||||
public var preserveSpace: Set<String>
|
||||
|
||||
public init(
|
||||
templates: [Template],
|
||||
globals: [Instruction],
|
||||
keys: [Key],
|
||||
output: Output,
|
||||
stripSpace: Set<String> = [],
|
||||
preserveSpace: Set<String> = [],
|
||||
) {
|
||||
self.templates = templates
|
||||
self.globals = globals
|
||||
self.keys = keys
|
||||
self.output = output
|
||||
self.stripSpace = stripSpace
|
||||
self.preserveSpace = preserveSpace
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String> = []
|
||||
var preserveSpace: Set<String> = []
|
||||
|
||||
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<String> {
|
||||
Set((XSLTNode.attribute(node, "elements") ?? "").split(whereSeparator: \.isWhitespace).map(String.init))
|
||||
}
|
||||
|
||||
private static func load(_ node: Tree, loader: (String) -> String?, precedence: Int) -> Stylesheet? {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
49
Sources/XSLT/XSLTWhitespace.swift
Normal file
49
Sources/XSLT/XSLTWhitespace.swift
Normal file
@ -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<String>,
|
||||
_ preserveSet: Set<String>,
|
||||
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<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("*")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
67
Tests/XSLTWhitespaceTests.swift
Normal file
67
Tests/XSLTWhitespaceTests.swift
Normal file
@ -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 = """
|
||||
<xsl:stylesheet version="1.0" \(xsl)>
|
||||
<xsl:strip-space elements="a"/>
|
||||
<xsl:output method="xml" omit-xml-declaration="yes"/>
|
||||
<xsl:template match="/"><xsl:copy-of select="a"/></xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
let source = "<a>\n <b/>\n <b/>\n</a>"
|
||||
// The indentation whitespace between <b/> elements is stripped.
|
||||
#expect(try transform(style, source) == "<a><b/><b/></a>")
|
||||
}
|
||||
|
||||
@Test("Without strip-space the whitespace is preserved")
|
||||
func test_noStrip() throws {
|
||||
let style = """
|
||||
<xsl:stylesheet version="1.0" \(xsl)>
|
||||
<xsl:output method="xml" omit-xml-declaration="yes"/>
|
||||
<xsl:template match="/"><xsl:copy-of select="a"/></xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
let source = "<a>\n <b/>\n</a>"
|
||||
#expect(try transform(style, source).contains("\n "))
|
||||
}
|
||||
|
||||
@Test("preserve-space overrides strip-space for a named element")
|
||||
func test_preserveOverrides() throws {
|
||||
let style = """
|
||||
<xsl:stylesheet version="1.0" \(xsl)>
|
||||
<xsl:strip-space elements="*"/>
|
||||
<xsl:preserve-space elements="keep"/>
|
||||
<xsl:output method="xml" omit-xml-declaration="yes"/>
|
||||
<xsl:template match="/"><xsl:copy-of select="*"/></xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
// strip-space="*" strips <drop>'s whitespace, but <keep> preserves its own.
|
||||
let stripped = try transform(style, "<drop>\n <x/>\n</drop>")
|
||||
#expect(stripped == "<drop><x/></drop>")
|
||||
let kept = try transform(style, "<keep>\n <x/>\n</keep>")
|
||||
#expect(kept.contains("\n "))
|
||||
}
|
||||
|
||||
@Test("xml:space=preserve on a source element keeps its whitespace despite strip-space")
|
||||
func test_xmlSpacePreserve() throws {
|
||||
let style = """
|
||||
<xsl:stylesheet version="1.0" \(xsl)>
|
||||
<xsl:strip-space elements="a"/>
|
||||
<xsl:output method="xml" omit-xml-declaration="yes"/>
|
||||
<xsl:template match="/"><xsl:copy-of select="a"/></xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
let source = "<a xml:space=\"preserve\">\n <b/>\n</a>"
|
||||
#expect(try transform(style, source).contains("\n "))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user