diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3228eee..f56dfea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Critic-loop hardening of the xalan burn-down (#130): raw-output marker handling is now gated on the stylesheet actually mentioning `disable-output-escaping`, so a source document that happens to contain the private-use marker characters U+E000/U+E001 passes through every transform untouched (previously they were stripped, and text between them unescaped, unconditionally), and the text output method only removes markers without "unescaping" content that was never escaped; the pattern-matching function table is built once per transform instead of once per (template, node) membership test; and the d-o-e, CDATA "]]>" splitting, simplified-syntax, html default-method/META, per-document key(), and attribute-axes behaviors gain focused unit tests that run in CI without the opt-in corpus (eight new tests).
- Following/preceding from attribute nodes, twelfth xalan burn-down (#130): the following and preceding axes now work from attribute and namespace starts, anchored at the owner element per the XPath 1.0 document order (an attribute follows its element and precedes its children, and has no descendants of its own). Baseline 182 to 174 (axes 16 to 8).
- Keys per document, in document order, eleventh xalan burn-down (#130): `key()` resolves its index against the current node's own document (built lazily per document root, so keys work inside document() trees), a node-set `use` expression indexes the matched node under every member's string value, and key() results return in document order regardless of the lookup value order. Baseline 187 to 182.
- Sort keys in context and cross-document template matching, tenth xalan burn-down (#130): xsl:sort keys are computed once per node with the node's own evaluation context (its position and size in the selection, the caller's variables, functions, and namespace bindings), so position()-based and variable-referencing keys work, and NaN keys order before every number under data-type="number" (the Xalan order); match patterns evaluate over the matched node's own document rather than the transform source, so templates match nodes loaded through document() (the match cache keys include the document identity). Baseline 204 to 187 (sort 18 to 11, mdocs 14 to 6).
diff --git a/README.md b/README.md
index 16cd5ef..104e742 100644
--- a/README.md
+++ b/README.md
@@ -125,7 +125,7 @@ flowchart TB
PureXML is a working, dependency-free XML library today: parse, emit, validate,
query, and stream documents on macOS, Linux, Windows, and WASM. The test suite
-currently runs **955 tests in 165 suites** (`swift test`).
+currently runs **963 tests in 166 suites** (`swift test`).
### Shipped (libxml2-aligned surface)
diff --git a/Sources/XSLT/XSLTModel.swift b/Sources/XSLT/XSLTModel.swift
index 6872da0..9a9f951 100644
--- a/Sources/XSLT/XSLTModel.swift
+++ b/Sources/XSLT/XSLTModel.swift
@@ -254,6 +254,11 @@ public extension PureXML.XSLT {
/// `xsl:decimal-format` declarations for `format-number`, keyed by name; the
/// empty key is the default (unnamed) format.
public var decimalFormats: [String: DecimalFormat]
+ /// Whether the stylesheet mentions `disable-output-escaping` anywhere,
+ /// so the serializer only post-processes raw-text markers when the
+ /// feature can actually be in play (a source document that happens to
+ /// contain the private-use marker characters is otherwise untouched).
+ public var usesRawText: Bool = false
/// `xsl:namespace-alias` rewrites, keyed by the stylesheet namespace URI
/// (the empty key is the no-namespace/default case).
public var namespaceAliases: [String: NamespaceAlias]
diff --git a/Sources/XSLT/XSLTNumbering.swift b/Sources/XSLT/XSLTNumbering.swift
index e45995c..1d914a8 100644
--- a/Sources/XSLT/XSLTNumbering.swift
+++ b/Sources/XSLT/XSLTNumbering.swift
@@ -34,16 +34,11 @@ enum XSLTNumbering {
return counts.reversed()
case "any":
var total = 0
- var stopped = false
walkPreceding(node) { candidate in
- if matchesFrom(candidate) {
- stopped = true
- return false
- }
+ if matchesFrom(candidate) { return false }
if matchesCount(candidate) { total += 1 }
return true
}
- _ = stopped
return [total]
default: // single
var current: Tree? = node
diff --git a/Sources/XSLT/XSLTParserEntry.swift b/Sources/XSLT/XSLTParserEntry.swift
index cbd1850..e4465ac 100644
--- a/Sources/XSLT/XSLTParserEntry.swift
+++ b/Sources/XSLT/XSLTParserEntry.swift
@@ -43,8 +43,11 @@ struct Parts {
extension PureXML.XSLT.XSLTParser {
static func parse(_ xsl: String, loader: (String) -> String? = { _ in nil }) throws -> PureXML.XSLT.Stylesheet {
let root = try PureXML.parseTree(xsl, limits: .init(allowDoctype: true))
+ let usesRawText = containsSubstring(xsl, "disable-output-escaping")
if let top = stylesheetElement(root) {
- return compile(top, loader: loader, precedence: 0)
+ var sheet = compile(top, loader: loader, precedence: 0)
+ sheet.usesRawText = usesRawText
+ return sheet
}
// Simplified syntax (2.3): a literal result element carrying
// xsl:version becomes the body of a match="/" template.
@@ -61,11 +64,25 @@ extension PureXML.XSLT.XSLTParser {
parameters: [],
body: [body],
))
- return parts.stylesheet
+ var sheet = parts.stylesheet
+ sheet.usesRawText = usesRawText
+ return sheet
}
throw PureXML.XSLT.XSLTError.notAStylesheet
}
+ /// Substring search without Foundation (the stdlib String-argument
+ /// `contains` needs a newer platform floor).
+ private static func containsSubstring(_ text: String, _ needle: String) -> Bool {
+ guard let head = needle.first else { return true }
+ var search = text[...]
+ while let start = search.firstIndex(of: head) {
+ if search[start...].hasPrefix(needle) { return true }
+ search = search[search.index(after: start)...]
+ }
+ return false
+ }
+
static func stylesheetElement(_ root: XSLTTree) -> XSLTTree? {
guard let top = XSLTNode.elementChildren(root).first, XSLTNode.isXSL(top),
XSLTNode.localName(top) == "stylesheet" || XSLTNode.localName(top) == "transform"
diff --git a/Sources/XSLT/XSLTTransform.swift b/Sources/XSLT/XSLTTransform.swift
index b2e5915..5d90c7d 100644
--- a/Sources/XSLT/XSLTTransform.swift
+++ b/Sources/XSLT/XSLTTransform.swift
@@ -19,7 +19,11 @@ public extension PureXML.XSLT {
let result = transformer.run()
if let message = transformer.terminationMessage { throw XSLTError.terminated(message) }
let method = sheet.output.method ?? defaultMethod(for: result)
- if method == "text" { return RawText.resolve(textValue(of: result)) }
+ if method == "text" {
+ // Text output is never escaped, so raw markers are only removed,
+ // never "unescaped".
+ return sheet.usesRawText ? RawText.stripped(textValue(of: result)) : textValue(of: result)
+ }
let body: String
if method == "html" {
body = PureXML.HTML.serialize(withContentTypeMeta(result, encoding: sheet.output.encoding ?? "UTF-8"))
@@ -31,7 +35,7 @@ public extension PureXML.XSLT {
body = PureXML.serialize(prepared, options: emitOptions)
}
// The XML declaration stays first; the doctype follows it.
- let resolved = RawText.resolve(body)
+ let resolved = sheet.usesRawText ? RawText.resolve(body) : body
let documentType = doctype(for: result, sheet.output, method: method)
guard !documentType.isEmpty else { return resolved }
if resolved.hasPrefix("", in: resolved[...]) {
diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift
index ddad704..d8c3cb1 100644
--- a/Sources/XSLT/XSLTTransformer.swift
+++ b/Sources/XSLT/XSLTTransformer.swift
@@ -28,6 +28,23 @@ extension PureXML.XSLT {
self.stylesheet = stylesheet
self.root = root
self.documentLoader = documentLoader
+ // One table for all pattern matching: matches() runs per
+ // (template, node) during template selection, so a fresh table
+ // per call would be allocated millions of times on large inputs.
+ let caches = keyIndexes
+ patternTable = PureXML.XSLT.Library.table(
+ current: .tree(root),
+ keys: { documentRoot in
+ let identity = ObjectIdentifier(documentRoot)
+ if let cached = caches.indexes[identity] { return cached }
+ let built = PureXML.XSLT.Library.buildKeyIndex(stylesheet: stylesheet, root: documentRoot)
+ caches.indexes[identity] = built
+ return built
+ },
+ loader: documentLoader,
+ decimalFormats: stylesheet.decimalFormats,
+ documents: documentCache,
+ )
}
/// The key index for `documentRoot`, built on first use (keys apply
@@ -86,15 +103,11 @@ extension PureXML.XSLT {
}
/// The function table match patterns evaluate with (`key()`/`id()`
- /// patterns), rooted at the document.
+ /// patterns), rooted at the document and built once in init.
+ private let patternTable: PureXML.XPath.FunctionTable
+
private func patternFunctions() -> PureXML.XPath.FunctionTable {
- PureXML.XSLT.Library.table(
- current: .tree(root),
- keys: { keyIndex(for: $0) },
- loader: documentLoader,
- decimalFormats: stylesheet.decimalFormats,
- documents: documentCache,
- )
+ patternTable
}
func applyTemplates(
@@ -209,7 +222,10 @@ extension PureXML.XSLT {
// Raw-output markers do not survive string extraction (16.4:
// escaping is disabled only for text written directly to the
// result tree), so a fragment round-trip re-enables escaping.
- PureXML.XSLT.RawText.stripped(value(expression, context)?.string ?? "")
+ // Gated so source text in a stylesheet without
+ // disable-output-escaping passes through untouched.
+ let extracted = value(expression, context)?.string ?? ""
+ return stylesheet.usesRawText ? PureXML.XSLT.RawText.stripped(extracted) : extracted
}
func boolean(_ expression: String, _ context: XSLTContext) -> Bool {
diff --git a/Tests/XSLTOutputMethodTests.swift b/Tests/XSLTOutputMethodTests.swift
new file mode 100644
index 0000000..a6c41df
--- /dev/null
+++ b/Tests/XSLTOutputMethodTests.swift
@@ -0,0 +1,125 @@
+@testable import PureXML
+import Testing
+
+/// The output-method behaviors of XSLT 1.0 section 16 added in the xalan
+/// burn-down (#130): disable-output-escaping (including its 16.4 fragment
+/// rule and the marker-safety gate), CDATA "]]>" splitting, the simplified
+/// stylesheet syntax, the html default method with META injection, key()
+/// inside document() trees, and the attribute-start following/preceding axes.
+@Suite("XSLT output methods and runtime corners")
+struct XSLTOutputMethodTests {
+ private let xsl = "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\""
+
+ @Test("disable-output-escaping writes text through unescaped")
+ func test_disableOutputEscaping() throws {
+ let style = """
+
a