From cff26b255d44535c91566939c9b4e09e78c42fdc Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 10 Jun 2026 16:11:02 +0200 Subject: [PATCH] feat(xslt): attribute-set merging, copied namespaces, html booleans, loader entities Same-name xsl:attribute-set declarations merge as ordered definitions per 7.1.4, each expanding its used sets before its own attributes with later definitions winning; xsl:copy carries the source element's in-scope namespace nodes per 7.5; the namespace fixup lets a carried attribute prefix shadow an inherited binding unless the element's own name relies on it; the html method minimizes boolean attributes whose value repeats their name; and external DTDs and entities in the stylesheet or source resolve through the transform's document loader. Four unit tests pin the behaviors. Xalan baseline 174 -> 159 (attribset 13 -> 4, copy 12 -> 7). --- CHANGELOG.md | 4 ++ README.md | 2 +- Sources/HTML/HTMLSerializer.swift | 8 +++- Sources/HTML/HTMLToken.swift | 7 +++ Sources/XSLT/XSLTDocumentCache.swift | 18 +++++++ Sources/XSLT/XSLTElements.swift | 32 ++++++++++++- Sources/XSLT/XSLTModel.swift | 4 +- Sources/XSLT/XSLTNamespaceFixup.swift | 28 +++++++---- Sources/XSLT/XSLTParser.swift | 6 ++- Sources/XSLT/XSLTParserEntry.swift | 6 +-- Sources/XSLT/XSLTSorting.swift | 7 +++ Sources/XSLT/XSLTTransform.swift | 2 +- Sources/XSLT/XSLTTransformer.swift | 21 ++++----- Tests/Fixtures/xalan-baseline.txt | 15 ------ Tests/XSLTOutputMethodTests.swift | 67 +++++++++++++++++++++++++++ 15 files changed, 181 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4f7f97..b38e613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Scanner-side validity findings are now located structured values (validation-framework audit follow-up): `DocumentType.validityFindings` carries `ValidityFinding` values (reason plus the declared name they are about) instead of strings, `DTDSchema.declarationErrors` holds ready `ValidationError`s, and every declaration-level finding renders at its declaration's coding path (`x`, `r/@a`) instead of "at root of document", matching the XSD consistency-errors precedent. Finding texts are unchanged. +### Added + +- Attribute sets, copied namespaces, html booleans, loader entities, thirteenth xalan burn-down (#130): same-name `xsl:attribute-set` declarations merge as ordered definitions per 7.1.4 (each expands its used sets before its own attributes, a later definition's attributes winning); `xsl:copy` of an element carries the source element's in-scope namespace nodes per 7.5 (the fixup pass drops result-redundant ones); the namespace fixup lets a carried attribute prefix shadow an inherited binding locally (matching the source document's shape) unless the element's own name relies on it; the html method minimizes boolean attributes whose value repeats their name (CHECKED="CHECKED" serializes as CHECKED); and external DTDs and entities referenced by the stylesheet or source now resolve through the transform's document loader, the same channel as document(). Four unit tests pin the new behaviors. Baseline 174 to 159 (attribset 13 to 4, copy 12 to 7). + ### Fixed - format-number and xsl:number no longer trap on extreme values (#130 critic loop): format-number of a value at or beyond 2^53 rendered through an unguarded Double-to-Int conversion, a fatal trap reachable from user data (format-number(99999999999999999999 * 99999999999999999999, '#,##0') crashed the host); a fraction picture with more than 15 places overflowed the scale conversion the same way; and xsl:number value= beyond 2^53 trapped identically. Digit extraction now clamps fraction places to 15 (the 2^53 exactness bound), pads back to the picture's requested minimum, renders out-of-range integers through the exponent expander (digits stay exact), and xsl:number falls back to plain XPath number rendering outside [1, 2^53). Regression tests cover all three paths. diff --git a/README.md b/README.md index 9fdf83a..f5a0c01 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 **966 tests in 166 suites** (`swift test`). +currently runs **970 tests in 166 suites** (`swift test`). ### Shipped (libxml2-aligned surface) diff --git a/Sources/HTML/HTMLSerializer.swift b/Sources/HTML/HTMLSerializer.swift index 9372bd8..96ee3db 100644 --- a/Sources/HTML/HTMLSerializer.swift +++ b/Sources/HTML/HTMLSerializer.swift @@ -49,7 +49,13 @@ extension PureXML.HTML { private static func attributeText(_ attribute: PureXML.Model.Attribute) -> String { let name = attribute.name.description - return attribute.value.isEmpty ? " \(name)" : " \(name)=\"\(escapeAttribute(attribute.value))\"" + if attribute.value.isEmpty { return " \(name)" } + // Boolean attributes minimize when the value repeats the name + // (CHECKED="CHECKED" serializes as CHECKED), the html form. + if Elements.booleanAttributes.contains(name.lowercased()), attribute.value.lowercased() == name.lowercased() { + return " \(name)" + } + return " \(name)=\"\(escapeAttribute(attribute.value))\"" } private static func escapeText(_ value: String) -> String { diff --git a/Sources/HTML/HTMLToken.swift b/Sources/HTML/HTMLToken.swift index d46cf3e..712f1e0 100644 --- a/Sources/HTML/HTMLToken.swift +++ b/Sources/HTML/HTMLToken.swift @@ -35,6 +35,13 @@ extension PureXML.HTML { /// end tag, not parsed as markup. static let rawText: Set = ["script", "style", "textarea", "title"] + /// Boolean attributes, serialized minimized when their value repeats + /// their name (the html output form). + static let booleanAttributes: Set = [ + "checked", "selected", "disabled", "readonly", "multiple", "ismap", + "defer", "declare", "noresize", "nowrap", "noshade", "compact", + ] + /// RCDATA elements: like raw-text, but character references in their /// content are decoded (`title`, `textarea`). The remaining raw-text /// elements (`script`, `style`) take their content with no decoding. diff --git a/Sources/XSLT/XSLTDocumentCache.swift b/Sources/XSLT/XSLTDocumentCache.swift index dec8b04..e4d8cf1 100644 --- a/Sources/XSLT/XSLTDocumentCache.swift +++ b/Sources/XSLT/XSLTDocumentCache.swift @@ -1,4 +1,22 @@ extension PureXML.XSLT { + /// Wraps a transform's document loader as an entity resolver, so external + /// DTDs and entities referenced by the stylesheet or source resolve + /// through the same channel as document(). The loader is used from a + /// single thread for the duration of one parse. + static func loaderResolver(_ loader: @escaping (String) -> String?) -> PureXML.Parsing.EntityResolver { + let box = LoaderBox(load: loader) + return PureXML.Parsing.EntityResolver( + resolveEntity: { _, id in box.load(id.resolvedSystemID) }, + resolveExternalSubset: { id in box.load(id.resolvedSystemID) }, + ) + } + + /// A single-threaded loader carried into the resolver's Sendable + /// closures; XSLT transforms are confined to one thread. + private struct LoaderBox: @unchecked Sendable { + let load: (String) -> String? + } + /// The key indexes a transform has built, one per document root. final class KeyIndexCache { var indexes: [ObjectIdentifier: KeyIndex] = [:] diff --git a/Sources/XSLT/XSLTElements.swift b/Sources/XSLT/XSLTElements.swift index b86a70c..6b82598 100644 --- a/Sources/XSLT/XSLTElements.swift +++ b/Sources/XSLT/XSLTElements.swift @@ -108,7 +108,14 @@ extension PureXML.XSLT.Transformer { } switch context.node.kind { case .element: - return [buildElement(name: context.node.name ?? .init(""), literalAttributes: [], useAttributeSets: useAttributeSets, body: body, context)] + let copied = buildElement( + name: context.node.name ?? .init(""), + literalAttributes: Self.namespaceDeclarations(inScopeAt: context.node), + useAttributeSets: useAttributeSets, + body: body, + context, + ) + return [copied] case .text, .cdata: return [.node(.text(context.node.value))] case .comment: @@ -122,6 +129,29 @@ extension PureXML.XSLT.Transformer { } extension PureXML.XSLT.Transformer { + /// The source element's in-scope namespace declarations as literal + /// xmlns attributes (xsl:copy copies namespace nodes, 7.5); the fixup + /// pass drops the ones already in scope in the result. + static func namespaceDeclarations(inScopeAt node: PureXML.Model.TreeNode) -> [PureXML.XSLT.LiteralAttribute] { + var bindings: [String: String] = [:] + var current: PureXML.Model.TreeNode? = node + while let candidate = current { + for attribute in candidate.attributes { + if attribute.name.prefix == "xmlns", bindings[attribute.name.localName] == nil { + bindings[attribute.name.localName] = attribute.value + } else if attribute.name.prefix == nil, attribute.name.localName == "xmlns", bindings[""] == nil { + bindings[""] = attribute.value + } + } + current = candidate.parent + } + return bindings.sorted(by: { $0.key < $1.key }).compactMap { prefix, uri in + if prefix.isEmpty, uri.isEmpty { return nil } + let name = prefix.isEmpty ? "xmlns" : "xmlns:" + prefix + return PureXML.XSLT.LiteralAttribute(name: PureXML.Model.QualifiedName(name), value: [.literal(uri)]) + } + } + /// The topmost ancestor of `node` (its document node). static func documentRoot(of node: PureXML.Model.TreeNode) -> PureXML.Model.TreeNode { var current = node diff --git a/Sources/XSLT/XSLTModel.swift b/Sources/XSLT/XSLTModel.swift index 9a9f951..b22265c 100644 --- a/Sources/XSLT/XSLTModel.swift +++ b/Sources/XSLT/XSLTModel.swift @@ -250,7 +250,7 @@ public extension PureXML.XSLT { /// Element name tests that keep their whitespace, overriding `stripSpace`. public var preserveSpace: Set /// Named `xsl:attribute-set` declarations, keyed by name. - public var attributeSets: [String: AttributeSet] + public var attributeSets: [String: [AttributeSet]] /// `xsl:decimal-format` declarations for `format-number`, keyed by name; the /// empty key is the default (unnamed) format. public var decimalFormats: [String: DecimalFormat] @@ -270,7 +270,7 @@ public extension PureXML.XSLT { output: Output, stripSpace: Set = [], preserveSpace: Set = [], - attributeSets: [String: AttributeSet] = [:], + attributeSets: [String: [AttributeSet]] = [:], decimalFormats: [String: DecimalFormat] = [:], namespaceAliases: [String: NamespaceAlias] = [:], ) { diff --git a/Sources/XSLT/XSLTNamespaceFixup.swift b/Sources/XSLT/XSLTNamespaceFixup.swift index 568a8a9..f37bb7a 100644 --- a/Sources/XSLT/XSLTNamespaceFixup.swift +++ b/Sources/XSLT/XSLTNamespaceFixup.swift @@ -93,12 +93,11 @@ enum XSLTNamespaceFixup { } } - private static func fix(_ element: PureXML.Model.Element, inScope: [String: String], counter: inout Int) -> PureXML.Model.Element { - var scope = inScope - // A declaration that repeats an inherited binding is dropped: copied - // 7.1.1 namespace nodes travel on every literal element and would - // otherwise redeclare on each descendant. - var attributes = element.attributes.filter { attribute in + /// Drops declarations that repeat an inherited binding: copied 7.1.1 + /// namespace nodes travel on every literal element and would otherwise + /// redeclare on each descendant. + private static func withoutRedundantDeclarations(_ attributes: [PureXML.Model.Attribute], inScope: [String: String]) -> [PureXML.Model.Attribute] { + attributes.filter { attribute in if attribute.name.prefix == "xmlns" { return inScope[attribute.name.localName] != attribute.value } @@ -107,7 +106,15 @@ enum XSLTNamespaceFixup { } return true } + } + + private static func fix(_ element: PureXML.Model.Element, inScope: [String: String], counter: inout Int) -> PureXML.Model.Element { + var scope = inScope + var attributes = withoutRedundantDeclarations(element.attributes, inScope: inScope) enterDeclarations(attributes, into: &scope) + // The prefixes this element itself declares: a carried prefix may + // shadow an inherited binding, but not one declared locally. + var localPrefixes = Set(attributes.filter { $0.name.prefix == "xmlns" }.map(\.name.localName)) fixElementName(element.name, scope: &scope, attributes: &attributes) // Attribute names: a namespaced attribute always needs a prefix. for index in attributes.indices { @@ -119,8 +126,13 @@ enum XSLTNamespaceFixup { if let candidate = prefix, scope[candidate] == uri { continue // Already bound correctly. } - if prefix == nil || scope[prefix ?? ""] != nil { - // Generate a fresh prefix when absent or taken by another URI. + if let candidate = prefix, !localPrefixes.contains(candidate), element.name.prefix != candidate { + // Redeclare the carried prefix locally, shadowing any + // inherited binding (the source document's own shape). The + // element's own prefix is never shadowed. + localPrefixes.insert(candidate) + } else if prefix == nil || scope[prefix ?? ""] != nil { + // Generate a fresh prefix when absent or taken locally. if let existing = scope.first(where: { $0.value == uri && !$0.key.isEmpty })?.key { prefix = existing } else { diff --git a/Sources/XSLT/XSLTParser.swift b/Sources/XSLT/XSLTParser.swift index d359f1f..6523456 100644 --- a/Sources/XSLT/XSLTParser.swift +++ b/Sources/XSLT/XSLTParser.swift @@ -308,7 +308,11 @@ private extension PureXML.XSLT.XSLTParser { static func addAttributeSet(_ child: XSLTTree, into parts: inout Parts) { guard let name = XSLTNode.attribute(child, "name") else { return } - parts.attributeSets[name] = PureXML.XSLT.AttributeSet(attributes: body(child), use: useAttributeSets(child)) + // Same-name attribute sets merge (7.1.4) as ordered definitions: + // each expands its used sets before its own attributes, and a later + // definition's attributes override earlier same-named ones. + let addition = PureXML.XSLT.AttributeSet(attributes: body(child), use: useAttributeSets(child)) + parts.attributeSets[name, default: []].append(addition) } /// Reads an `xsl:decimal-format`'s symbol overrides; each unset attribute diff --git a/Sources/XSLT/XSLTParserEntry.swift b/Sources/XSLT/XSLTParserEntry.swift index e4465ac..f631d6c 100644 --- a/Sources/XSLT/XSLTParserEntry.swift +++ b/Sources/XSLT/XSLTParserEntry.swift @@ -6,7 +6,7 @@ struct Parts { var output = PureXML.XSLT.Output() var stripSpace: Set = [] var preserveSpace: Set = [] - var attributeSets: [String: PureXML.XSLT.AttributeSet] = [:] + var attributeSets: [String: [PureXML.XSLT.AttributeSet]] = [:] var decimalFormats: [String: PureXML.XSLT.DecimalFormat] = [:] var namespaceAliases: [String: PureXML.XSLT.NamespaceAlias] = [:] @@ -41,8 +41,8 @@ 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)) + static func parse(_ xsl: String, loader: @escaping (String) -> String? = { _ in nil }) throws -> PureXML.XSLT.Stylesheet { + let root = try PureXML.parseTree(xsl, limits: .init(allowDoctype: true), resolver: PureXML.XSLT.loaderResolver(loader)) let usesRawText = containsSubstring(xsl, "disable-output-escaping") if let top = stylesheetElement(root) { var sheet = compile(top, loader: loader, precedence: 0) diff --git a/Sources/XSLT/XSLTSorting.swift b/Sources/XSLT/XSLTSorting.swift index 1555145..f07a165 100644 --- a/Sources/XSLT/XSLTSorting.swift +++ b/Sources/XSLT/XSLTSorting.swift @@ -102,4 +102,11 @@ extension PureXML.XSLT.Transformer { [.node(.text(uri))] } } + + func message(_ terminate: Bool, _ body: [PureXML.XSLT.Instruction], _ context: XSLTContext) -> [ResultItem] { + if terminate, termination.message == nil { + termination.message = Self.text(of: instantiate(body, context)) + } + return [] + } } diff --git a/Sources/XSLT/XSLTTransform.swift b/Sources/XSLT/XSLTTransform.swift index 5d90c7d..3e34224 100644 --- a/Sources/XSLT/XSLTTransform.swift +++ b/Sources/XSLT/XSLTTransform.swift @@ -13,7 +13,7 @@ public extension PureXML.XSLT { ) throws -> String { let documentLoader = resolvingLoader(documentLoader, baseURI: baseURI) let sheet = try XSLTParser.parse(stylesheet, loader: documentLoader) - let root = try PureXML.parseTree(source, limits: .init(allowDoctype: true)) + let root = try PureXML.parseTree(source, limits: .init(allowDoctype: true), resolver: PureXML.XSLT.loaderResolver(documentLoader)) Whitespace.strip(root, stylesheet: sheet) let transformer = Transformer(stylesheet: sheet, root: root, documentLoader: documentLoader) let result = transformer.run() diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift index d8c3cb1..2c6bbbd 100644 --- a/Sources/XSLT/XSLTTransformer.swift +++ b/Sources/XSLT/XSLTTransformer.swift @@ -15,7 +15,7 @@ extension PureXML.XSLT { let root: PureXML.Model.TreeNode let documentLoader: (String) -> String? private let keyIndexes = PureXML.XSLT.KeyIndexCache() - private let termination = Termination() + let termination = Termination() private let matchCache = MatchCache() private let documentCache = PureXML.XSLT.DocumentCache() @@ -281,14 +281,8 @@ extension PureXML.XSLT.Transformer { } } - /// Instantiates an `xsl:message` body as its text; `terminate` records the - /// signal so the transform aborts with it. Produces no result-tree output. - private func message(_ terminate: Bool, _ body: [PureXML.XSLT.Instruction], _ context: XSLTContext) -> [ResultItem] { - if terminate, termination.message == nil { - termination.message = Self.text(of: instantiate(body, context)) - } - return [] - } + // Instantiates an `xsl:message` body as its text; `terminate` records the + // signal so the transform aborts with it. Produces no result-tree output. // Builds a literal result element, rewriting its name and attribute names // through any `xsl:namespace-alias` in effect. @@ -380,10 +374,11 @@ extension PureXML.XSLT.Transformer { private func attributeSetAttributes(_ names: [String], _ context: XSLTContext, visiting: Set) -> [PureXML.Model.Attribute] { var result: [PureXML.Model.Attribute] = [] for name in names where !visiting.contains(name) { - guard let set = stylesheet.attributeSets[name] else { continue } - result += attributeSetAttributes(set.use, context, visiting: visiting.union([name])) - for item in instantiate(set.attributes, context) { - if case let .attribute(attribute) = item { result.append(attribute) } + for definition in stylesheet.attributeSets[name] ?? [] { + result += attributeSetAttributes(definition.use, context, visiting: visiting.union([name])) + for item in instantiate(definition.attributes, context) { + if case let .attribute(attribute) = item { result.append(attribute) } + } } } return result diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt index 57396f7..e62981e 100644 --- a/Tests/Fixtures/xalan-baseline.txt +++ b/Tests/Fixtures/xalan-baseline.txt @@ -1,15 +1,8 @@ # The xalan-test conf cases this implementation does not yet pass, # one category/name per line, exact in both directions: a fixed case # must leave this list and a regression shows up as a new failure. -attribset/attribset10 -attribset/attribset17 -attribset/attribset24 attribset/attribset27 attribset/attribset29 -attribset/attribset31 -attribset/attribset32 -attribset/attribset41 -attribset/attribset43 attribset/attribset44 attribset/attribset45 attribset/attribset46 @@ -34,13 +27,7 @@ conflictres/conflictres30 conflictres/conflictres34 conflictres/conflictres37 copy/copy09 -copy/copy19 -copy/copy20 -copy/copy21 -copy/copy22 copy/copy27 -copy/copy31 -copy/copy36 copy/copy48 copy/copy56 copy/copy58 @@ -109,7 +96,6 @@ output/output26 output/output32 output/output33 output/output34 -output/output35 output/output37 output/output40 output/output48 @@ -159,7 +145,6 @@ sort/sort27 sort/sort32 sort/sort33 sort/sort34 -string/string130 string/string132 string/string143 variable/variable29 diff --git a/Tests/XSLTOutputMethodTests.swift b/Tests/XSLTOutputMethodTests.swift index a6c41df..1b21ebc 100644 --- a/Tests/XSLTOutputMethodTests.swift +++ b/Tests/XSLTOutputMethodTests.swift @@ -122,4 +122,71 @@ struct XSLTOutputMethodTests { // ancestor). #expect(try PureXML.XSLT.transform(stylesheet: style, source: source) == "inner c

a

") } + + @Test("Same-name attribute sets merge as ordered definitions (7.1.4)") + func test_attributeSetMerge() throws { + let style = """ + + + + yellowbrickroad + shallow + + + theleader + + deep + ondrugs + + + """ + // The later definition expands after the earlier one (its used set + // included), so its hole=deep and follow=theleader win. + #expect(try PureXML.XSLT.transform(stylesheet: style, source: "") + == "") + } + + @Test("xsl:copy carries the source element's in-scope namespace nodes (7.5)") + func test_copyNamespaceNodes() throws { + let style = """ + + + + + + """ + let source = "" + #expect(try PureXML.XSLT.transform(stylesheet: style, source: source) + == "") + } + + @Test("html boolean attributes minimize when the value repeats the name") + func test_htmlBooleanAttributes() throws { + let style = """ + + +
+
+ """ + #expect(try PureXML.XSLT.transform(stylesheet: style, source: "") + == "
") + } + + @Test("Stylesheet and source entities resolve through the document loader") + func test_loaderEntityResolution() throws { + let style = """ + + + + + + + """ + let result = try PureXML.XSLT.transform( + stylesheet: style, + source: "", + documentLoader: { $0 == "ents.dtd" ? "" : nil }, + ) + #expect(result == "a\u{E6}b") + } }