PureXML/Sources/XSLT/XSLTFunctions.swift
Mihaela Mihaljevic 23db1289ed feat(xslt): add xsl:include/import, xsl:output, comment, and processing-instruction
Round out the XSLT 1.0 stylesheet and output controls:

- xsl:include folds another stylesheet's templates, keys, globals, and output
  at the same import precedence; xsl:import folds them one precedence lower,
  so the importing stylesheet's rules win conflicts. Both resolve their href
  through the injected document loader, never the filesystem.
- xsl:output drives serialization: method (xml or text), indent,
  omit-xml-declaration, encoding, version, and standalone, layered over the
  caller's emitter options. The text method emits character data only.
- xsl:comment and xsl:processing-instruction produce comment and PI nodes
  from their instantiated bodies.

Templates now carry an import precedence that the matcher ranks ahead of
priority and document order.

Closes #64
2026-06-08 08:08:38 +02:00

201 lines
8.6 KiB
Swift

extension PureXML.XSLT {
/// The XSLT `format-number` function over the default decimal format. Supports
/// `0`/`#` digit places, `.` for the decimal point, `,` grouping, and a `%`
/// percent suffix. Pure Swift, no Foundation.
enum FormatNumber {
static func format(_ value: Double, _ picture: String) -> String {
guard !value.isNaN else { return "NaN" }
guard !picture.isEmpty else { return PureXML.XPath.Value.format(value) }
let percent = picture.contains("%")
let pattern = picture.filter { $0 != "%" }
let parts = pattern.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: false)
let integerPattern = String(parts.first ?? "")
let fractionPattern = parts.count > 1 ? String(parts[1]) : ""
let minInteger = integerPattern.count(where: { $0 == "0" })
let minFraction = fractionPattern.count(where: { $0 == "0" })
let maxFraction = fractionPattern.count(where: { $0 == "0" || $0 == "#" })
let grouping = integerPattern.contains(",")
var magnitude = Swift.abs(percent ? value * 100 : value)
magnitude = round(magnitude, places: maxFraction)
let rendered = render(
magnitude,
minInteger: minInteger,
minFraction: minFraction,
maxFraction: maxFraction,
grouping: grouping,
)
return (value < 0 ? "-" : "") + rendered + (percent ? "%" : "")
}
private static func round(_ value: Double, places: Int) -> Double {
var scale = 1.0
for _ in 0 ..< places {
scale *= 10
}
return (value * scale).rounded() / scale
}
private static func render(
_ value: Double,
minInteger: Int,
minFraction: Int,
maxFraction: Int,
grouping: Bool,
) -> String {
let integerValue = Int(value)
var integerText = String(integerValue)
while integerText.count < Swift.max(1, minInteger) {
integerText = "0" + integerText
}
if grouping { integerText = group(integerText) }
var fractionValue = value - Double(integerValue)
var fractionText = ""
for _ in 0 ..< maxFraction {
fractionValue *= 10
let digit = Int(fractionValue)
fractionText += String(digit)
fractionValue -= Double(digit)
}
while fractionText.count > minFraction, fractionText.hasSuffix("0") {
fractionText.removeLast()
}
return fractionText.isEmpty ? integerText : integerText + "." + fractionText
}
private static func group(_ digits: String) -> String {
var result = ""
for (offset, character) in digits.reversed().enumerated() {
if offset > 0, offset.isMultiple(of: 3) { result.append(",") }
result.append(character)
}
return String(result.reversed())
}
}
/// The `xsl:key` index: each key name maps a key value to the nodes indexed
/// under it.
typealias KeyIndex = [String: [String: [PureXML.Model.TreeNode]]]
/// The XSLT extensions to the XPath function library: `current`, `key`,
/// `format-number`, and `document`, plus the `xsl:key` index they read.
enum Library {
/// The XSLT functions for an evaluation whose current node is `current`,
/// reading the prebuilt `keys` index and the injected `document` loader.
static func table(
current: PureXML.Model.TreeNode,
keys: KeyIndex,
loader: @escaping (String) -> String?,
) -> PureXML.XPath.FunctionTable {
PureXML.XPath.FunctionTable()
.adding("current") { _, _ in .nodeSet([.tree(current)]) }
.adding("format-number") { arguments, _ in
.string(FormatNumber.format(
arguments.first?.number ?? .nan,
arguments.count > 1 ? arguments[1].string : "",
))
}
.adding("key") { arguments, _ in
let name = arguments.first?.string ?? ""
let value = arguments.count > 1 ? arguments[1].string : ""
return .nodeSet((keys[name]?[value] ?? []).map { .tree($0) })
}
.adding("document") { arguments, _ in
guard let uri = arguments.first?.string, let text = loader(uri),
let parsed = try? PureXML.parseTree(text) else { return .nodeSet([]) }
return .nodeSet([.tree(parsed)])
}
}
/// Builds the `xsl:key` index: each declared key maps every node matching
/// its pattern to its `use` value.
static func buildKeyIndex(stylesheet: Stylesheet, root: PureXML.Model.TreeNode) -> KeyIndex {
var index: KeyIndex = [:]
for key in stylesheet.keys {
let path = key.match.hasPrefix("/") ? key.match : "//" + key.match
guard let matchQuery = try? PureXML.XPath.Query(path),
let useQuery = try? PureXML.XPath.Query(key.use) else { continue }
for node in matchQuery.nodes(over: root) {
let value = (try? useQuery.value(at: node).string) ?? ""
index[key.name, default: [:]][value, default: []].append(node)
}
}
return index
}
}
/// The `xsl:number` numbering machinery: a node's position rendered per a
/// format token (`1` arabic, `A`/`a` alphabetic, `I`/`i` roman).
enum Numbering {
/// The number for `node`: its 1-based position among preceding siblings of
/// the same name (or the `count` name), rendered per `format`.
static func value(of node: PureXML.Model.TreeNode, count: String?, format: String) -> String {
let target = count ?? node.name?.localName
var position = 1
if let parent = node.parent {
for sibling in parent.children {
if sibling === node { break }
if sibling.kind == .element, sibling.name?.localName == target { position += 1 }
}
}
return render(position, format)
}
private static func render(_ number: Int, _ format: String) -> String {
switch format.first {
case "A": alphabetic(number, base: 65)
case "a": alphabetic(number, base: 97)
case "I": roman(number).uppercased()
case "i": roman(number)
default: String(number)
}
}
private static func alphabetic(_ number: Int, base: UInt32) -> String {
var value = number
var result = ""
while value > 0 {
let remainder = UInt32((value - 1) % 26)
if let scalar = Unicode.Scalar(base + remainder) {
result = String(Character(scalar)) + result
}
value = (value - 1) / 26
}
return result
}
private static func roman(_ number: Int) -> String {
let table: [(Int, String)] = [
(1000, "m"), (900, "cm"), (500, "d"), (400, "cd"), (100, "c"), (90, "xc"),
(50, "l"), (40, "xl"), (10, "x"), (9, "ix"), (5, "v"), (4, "iv"), (1, "i"),
]
var value = number
var result = ""
for (amount, numeral) in table {
while value >= amount {
result += numeral
value -= amount
}
}
return result
}
}
}
extension PureXML.Emitting.Options {
/// These options with an `xsl:output`'s settings layered over them. Only an
/// explicit setting overrides; unspecified `xsl:output` attributes leave the
/// caller's corresponding option untouched.
func applying(_ output: PureXML.XSLT.Output) -> Self {
var copy = self
if let indent = output.indent { copy.prettyPrint = indent }
if let omit = output.omitXMLDeclaration { copy.includeXMLDeclaration = !omit }
if let encoding = output.encoding { copy.encodingName = encoding }
if let version = output.version { copy.xmlVersion = version }
if let standalone = output.standalone { copy.standalone = standalone }
return copy
}
}