diff --git a/Sources/Emitting/Escaping.swift b/Sources/Emitting/Escaping.swift
index eb04289..9a60b8f 100644
--- a/Sources/Emitting/Escaping.swift
+++ b/Sources/Emitting/Escaping.swift
@@ -5,20 +5,31 @@ extension PureXML.Emitting {
/// quote and the whitespace characters as numeric references (matching
/// libxml2), so attributes survive attribute-value normalization.
enum Escaping {
- static func text(_ value: String) -> String {
+ static func text(_ value: String, asciiOnly: Bool = false) -> String {
var result = ""
for character in value {
switch character {
case "&": result += "&"
case "<": result += "<"
case ">": result += ">"
- default: result.append(character)
+ default: result += plain(character, asciiOnly: asciiOnly)
}
}
return result
}
- static func attribute(_ value: String, quote: Character = "\"") -> String {
+ /// A character verbatim, or, in ASCII-only mode, each of its non-ASCII
+ /// scalars as a numeric character reference so the output is pure ASCII.
+ private static func plain(_ character: Character, asciiOnly: Bool) -> String {
+ guard asciiOnly else { return String(character) }
+ var result = ""
+ for scalar in character.unicodeScalars {
+ result += scalar.value > 0x7F ? "\(String(scalar.value, radix: 16, uppercase: true));" : String(scalar)
+ }
+ return result
+ }
+
+ static func attribute(_ value: String, quote: Character = "\"", asciiOnly: Bool = false) -> String {
var result = ""
for character in value {
if character == quote {
@@ -32,7 +43,7 @@ extension PureXML.Emitting {
case "\t": result += " "
case "\n": result += "
"
case "\r": result += "
"
- default: result.append(character)
+ default: result += plain(character, asciiOnly: asciiOnly)
}
}
return result
diff --git a/Sources/Emitting/Options.swift b/Sources/Emitting/Options.swift
index ee94c79..18797ce 100644
--- a/Sources/Emitting/Options.swift
+++ b/Sources/Emitting/Options.swift
@@ -24,6 +24,13 @@ public extension PureXML.Emitting {
public var lineEnding: String
/// Whether childless elements collapse to ``.
public var selfCloseEmptyElements: Bool
+ /// Whether to emit a CDATA section's content as escaped text rather than as
+ /// a `` section.
+ public var cdataAsText: Bool
+ /// Whether to escape every non-ASCII character as a numeric character
+ /// reference, so the output is pure ASCII (libxml2's character-reference
+ /// encoding).
+ public var asciiOnly: Bool
/// Whether to emit an `` declaration (libxml2 emits one by
/// default when saving a document; PureXML defaults to off for fragments).
public var includeXMLDeclaration: Bool
@@ -40,6 +47,8 @@ public extension PureXML.Emitting {
attributeQuote: QuoteStyle = .double,
lineEnding: String = "\n",
selfCloseEmptyElements: Bool = true,
+ cdataAsText: Bool = false,
+ asciiOnly: Bool = false,
includeXMLDeclaration: Bool = false,
xmlVersion: String = "1.0",
encodingName: String? = "UTF-8",
@@ -50,6 +59,8 @@ public extension PureXML.Emitting {
self.attributeQuote = attributeQuote
self.lineEnding = lineEnding
self.selfCloseEmptyElements = selfCloseEmptyElements
+ self.cdataAsText = cdataAsText
+ self.asciiOnly = asciiOnly
self.includeXMLDeclaration = includeXMLDeclaration
self.xmlVersion = xmlVersion
self.encodingName = encodingName
diff --git a/Sources/Emitting/Serializer.swift b/Sources/Emitting/Serializer.swift
index 36f2740..d12d241 100644
--- a/Sources/Emitting/Serializer.swift
+++ b/Sources/Emitting/Serializer.swift
@@ -65,9 +65,11 @@ public extension PureXML.Emitting {
case let .element(element):
write(element, depth: depth, formatted: formatted, output: &output, stack: &stack)
case let .text(value):
- output += Escaping.text(value)
+ output += Escaping.text(value, asciiOnly: options.asciiOnly)
case let .cdata(value):
- output += ""
+ output += options.cdataAsText
+ ? Escaping.text(value, asciiOnly: options.asciiOnly)
+ : ""
case let .comment(value):
output += ""
case let .processingInstruction(target, data):
@@ -88,7 +90,8 @@ public extension PureXML.Emitting {
output += "<" + element.name.description
let quote = options.attributeQuote.character
for attribute in element.attributes {
- output += " \(attribute.name.description)=\(quote)\(Escaping.attribute(attribute.value, quote: quote))\(quote)"
+ let value = Escaping.attribute(attribute.value, quote: quote, asciiOnly: options.asciiOnly)
+ output += " \(attribute.name.description)=\(quote)\(value)\(quote)"
}
if element.children.isEmpty, options.selfCloseEmptyElements {
diff --git a/Sources/Emitting/Writer.swift b/Sources/Emitting/Writer.swift
index c4c9ed0..58c4434 100644
--- a/Sources/Emitting/Writer.swift
+++ b/Sources/Emitting/Writer.swift
@@ -71,20 +71,21 @@ public extension PureXML.Emitting {
public mutating func writeAttribute(_ name: String, _ value: String) {
guard startTagOpen else { return }
let quote = options.attributeQuote.character
- output += " \(name)=\(quote)\(Escaping.attribute(value, quote: quote))\(quote)"
+ output += " \(name)=\(quote)\(Escaping.attribute(value, quote: quote, asciiOnly: options.asciiOnly))\(quote)"
}
/// Writes escaped character data into the current element.
public mutating func writeString(_ text: String) {
closeStartTag()
- output += Escaping.text(text)
+ output += Escaping.text(text, asciiOnly: options.asciiOnly)
setTopContent(.text)
}
- /// Writes a `` section.
+ /// Writes a `` section, or its escaped text when
+ /// `cdataAsText` is set.
public mutating func writeCData(_ text: String) {
closeStartTag()
- output += ""
+ output += options.cdataAsText ? Escaping.text(text, asciiOnly: options.asciiOnly) : ""
setTopContent(.text)
}
diff --git a/Tests/SerializationEscapeTests.swift b/Tests/SerializationEscapeTests.swift
new file mode 100644
index 0000000..89ee98e
--- /dev/null
+++ b/Tests/SerializationEscapeTests.swift
@@ -0,0 +1,38 @@
+@testable import PureXML
+import Testing
+
+@Suite("Serialization: CDATA-as-text and ASCII-only")
+struct SerializationEscapeTests {
+ private func serialize(_ node: PureXML.Model.Node, _ options: PureXML.Emitting.Options) -> String {
+ PureXML.serialize(node, options: options)
+ }
+
+ @Test("cdataAsText emits a CDATA section as escaped text")
+ func test_cdataAsText() {
+ let element = PureXML.Model.Element("a", children: [.cdata("x < y & z")])
+ let asSection = serialize(.element(element), PureXML.Emitting.Options(prettyPrint: false))
+ #expect(asSection == "")
+ let asText = serialize(.element(element), PureXML.Emitting.Options(prettyPrint: false, cdataAsText: true))
+ #expect(asText == "x < y & z")
+ }
+
+ @Test("asciiOnly escapes non-ASCII characters as numeric references")
+ func test_asciiOnlyText() {
+ let element = PureXML.Model.Element("a", children: [.text("café")])
+ let options = PureXML.Emitting.Options(prettyPrint: false, asciiOnly: true)
+ #expect(serialize(.element(element), options) == "café")
+ }
+
+ @Test("asciiOnly escapes non-ASCII in attribute values too")
+ func test_asciiOnlyAttribute() {
+ let element = PureXML.Model.Element("a", attributes: [.init("t", "ñ")])
+ let options = PureXML.Emitting.Options(prettyPrint: false, asciiOnly: true)
+ #expect(serialize(.element(element), options) == "")
+ }
+
+ @Test("Without asciiOnly, non-ASCII is left verbatim")
+ func test_nonAsciiVerbatim() {
+ let element = PureXML.Model.Element("a", children: [.text("café")])
+ #expect(serialize(.element(element), PureXML.Emitting.Options(prettyPrint: false)) == "café")
+ }
+}