feat(emitting): cdataAsText and asciiOnly save options
- cdataAsText emits a CDATA section's content as escaped text instead of a <![CDATA[ ... ]]> section. - asciiOnly escapes every non-ASCII character as a numeric character reference, so the output is pure ASCII (libxml2's character-reference encoding). Both apply to the Serializer and the incremental Writer through the shared Escaping rules. Closes #91
This commit is contained in:
parent
85c7bded45
commit
e2ba6a7359
@ -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 ? "&#x\(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
|
||||
|
||||
@ -24,6 +24,13 @@ public extension PureXML.Emitting {
|
||||
public var lineEnding: String
|
||||
/// Whether childless elements collapse to `<name/>`.
|
||||
public var selfCloseEmptyElements: Bool
|
||||
/// Whether to emit a CDATA section's content as escaped text rather than as
|
||||
/// a `<![CDATA[ ... ]]>` 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 `<?xml ...?>` 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
|
||||
|
||||
@ -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 += "<![CDATA[\(value)]]>"
|
||||
output += options.cdataAsText
|
||||
? Escaping.text(value, asciiOnly: options.asciiOnly)
|
||||
: "<![CDATA[\(value)]]>"
|
||||
case let .comment(value):
|
||||
output += "<!--\(value)-->"
|
||||
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 {
|
||||
|
||||
@ -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 `<![CDATA[ ... ]]>` section.
|
||||
/// Writes a `<![CDATA[ ... ]]>` section, or its escaped text when
|
||||
/// `cdataAsText` is set.
|
||||
public mutating func writeCData(_ text: String) {
|
||||
closeStartTag()
|
||||
output += "<![CDATA[\(text)]]>"
|
||||
output += options.cdataAsText ? Escaping.text(text, asciiOnly: options.asciiOnly) : "<![CDATA[\(text)]]>"
|
||||
setTopContent(.text)
|
||||
}
|
||||
|
||||
|
||||
38
Tests/SerializationEscapeTests.swift
Normal file
38
Tests/SerializationEscapeTests.swift
Normal file
@ -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 == "<a><![CDATA[x < y & z]]></a>")
|
||||
let asText = serialize(.element(element), PureXML.Emitting.Options(prettyPrint: false, cdataAsText: true))
|
||||
#expect(asText == "<a>x < y & z</a>")
|
||||
}
|
||||
|
||||
@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) == "<a>café</a>")
|
||||
}
|
||||
|
||||
@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) == "<a t=\"ñ\"/>")
|
||||
}
|
||||
|
||||
@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)) == "<a>café</a>")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user