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).
This commit is contained in:
parent
b84fdd00ef
commit
cff26b255d
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -35,6 +35,13 @@ extension PureXML.HTML {
|
||||
/// end tag, not parsed as markup.
|
||||
static let rawText: Set<String> = ["script", "style", "textarea", "title"]
|
||||
|
||||
/// Boolean attributes, serialized minimized when their value repeats
|
||||
/// their name (the html output form).
|
||||
static let booleanAttributes: Set<String> = [
|
||||
"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.
|
||||
|
||||
@ -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] = [:]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -250,7 +250,7 @@ public extension PureXML.XSLT {
|
||||
/// Element name tests that keep their whitespace, overriding `stripSpace`.
|
||||
public var preserveSpace: Set<String>
|
||||
/// 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<String> = [],
|
||||
preserveSpace: Set<String> = [],
|
||||
attributeSets: [String: AttributeSet] = [:],
|
||||
attributeSets: [String: [AttributeSet]] = [:],
|
||||
decimalFormats: [String: DecimalFormat] = [:],
|
||||
namespaceAliases: [String: NamespaceAlias] = [:],
|
||||
) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -6,7 +6,7 @@ struct Parts {
|
||||
var output = PureXML.XSLT.Output()
|
||||
var stripSpace: Set<String> = []
|
||||
var preserveSpace: Set<String> = []
|
||||
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)
|
||||
|
||||
@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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<String>) -> [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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -122,4 +122,71 @@ struct XSLTOutputMethodTests {
|
||||
// ancestor).
|
||||
#expect(try PureXML.XSLT.transform(stylesheet: style, source: source) == "<out><f>inner c </f><p>a </p></out>")
|
||||
}
|
||||
|
||||
@Test("Same-name attribute sets merge as ordered definitions (7.1.4)")
|
||||
func test_attributeSetMerge() throws {
|
||||
let style = """
|
||||
<xsl:stylesheet version="1.0" \(xsl)>
|
||||
<xsl:output omit-xml-declaration="yes"/>
|
||||
<xsl:attribute-set name="child" use-attribute-sets="alice">
|
||||
<xsl:attribute name="follow">yellowbrickroad</xsl:attribute>
|
||||
<xsl:attribute name="hole">shallow</xsl:attribute>
|
||||
</xsl:attribute-set>
|
||||
<xsl:attribute-set name="child" use-attribute-sets="rabbit">
|
||||
<xsl:attribute name="follow">theleader</xsl:attribute>
|
||||
</xsl:attribute-set>
|
||||
<xsl:attribute-set name="rabbit"><xsl:attribute name="hole">deep</xsl:attribute></xsl:attribute-set>
|
||||
<xsl:attribute-set name="alice"><xsl:attribute name="alice">ondrugs</xsl:attribute></xsl:attribute-set>
|
||||
<xsl:template match="/"><out xsl:use-attribute-sets="child"/></xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
// 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: "<x/>")
|
||||
== "<out alice=\"ondrugs\" follow=\"theleader\" hole=\"deep\"/>")
|
||||
}
|
||||
|
||||
@Test("xsl:copy carries the source element's in-scope namespace nodes (7.5)")
|
||||
func test_copyNamespaceNodes() throws {
|
||||
let style = """
|
||||
<xsl:stylesheet version="1.0" \(xsl)>
|
||||
<xsl:output omit-xml-declaration="yes"/>
|
||||
<xsl:template match="inner"><xsl:copy/></xsl:template>
|
||||
<xsl:template match="/"><out><xsl:apply-templates select="//inner"/></out></xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
let source = "<r xmlns:a=\"urn:a\"><inner xmlns:b=\"urn:b\"/></r>"
|
||||
#expect(try PureXML.XSLT.transform(stylesheet: style, source: source)
|
||||
== "<out><inner xmlns:a=\"urn:a\" xmlns:b=\"urn:b\"/></out>")
|
||||
}
|
||||
|
||||
@Test("html boolean attributes minimize when the value repeats the name")
|
||||
func test_htmlBooleanAttributes() throws {
|
||||
let style = """
|
||||
<xsl:stylesheet version="1.0" \(xsl)>
|
||||
<xsl:output method="html"/>
|
||||
<xsl:template match="/"><Form><Input Type="checkbox" CHECKED="CHECKED"/><Input Type="text" Value="CHECKED"/></Form></xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
#expect(try PureXML.XSLT.transform(stylesheet: style, source: "<x/>")
|
||||
== "<Form><Input Type=\"checkbox\" CHECKED><Input Type=\"text\" Value=\"CHECKED\"></Form>")
|
||||
}
|
||||
|
||||
@Test("Stylesheet and source entities resolve through the document loader")
|
||||
func test_loaderEntityResolution() throws {
|
||||
let style = """
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE xsl:stylesheet SYSTEM "ents.dtd">
|
||||
<xsl:stylesheet version="1.0" \(xsl)>
|
||||
<xsl:output omit-xml-declaration="yes"/>
|
||||
<xsl:template match="/"><out><xsl:copy-of select="'aæb'"/></out></xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
let result = try PureXML.XSLT.transform(
|
||||
stylesheet: style,
|
||||
source: "<x/>",
|
||||
documentLoader: { $0 == "ents.dtd" ? "<!ENTITY aelig \"æ\">" : nil },
|
||||
)
|
||||
#expect(result == "<out>a\u{E6}b</out>")
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user