From e2ba6a735996a6bf77c2afd8c8a2466425d89967 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 16:34:57 +0200 Subject: [PATCH] feat(emitting): cdataAsText and asciiOnly save options - cdataAsText emits a CDATA section's content as escaped text instead of a 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 --- Sources/Emitting/Escaping.swift | 19 +++++++++++--- Sources/Emitting/Options.swift | 11 ++++++++ Sources/Emitting/Serializer.swift | 9 ++++--- Sources/Emitting/Writer.swift | 9 ++++--- Tests/SerializationEscapeTests.swift | 38 ++++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 11 deletions(-) create mode 100644 Tests/SerializationEscapeTests.swift 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 ? "&#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 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é") + } +}