diff --git a/CHANGELOG.md b/CHANGELOG.md
index db08ddb..27c86cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Serialization `textEscaping` round-trip option (#91), completing the serialization audit. `Options.textEscaping` defaults to `.standard` (escape only `&`, `<`, `>`); `.roundTrip` also escapes a carriage return as `
` in text and CDATA-as-text output, so a literal `\r` survives the parser's end-of-line normalization and the document round-trips byte for byte. (The CDATA-section vs escaped-text toggle, `cdataAsText`, was already present.)
- DOM tree: first-class doctype, entity-reference, and namespace nodes, plus `ownerDocument`, `adopt`, and `importNode` (#90), completing the tree-model audit. `TreeNodeKind` gains `.doctype`, `.entityReference`, and `.namespace`, each with a factory (`TreeNode.doctype(name:publicID:systemID:internalSubset:)`, `.entityReference(_:children:)`, `.namespace(prefix:uri:)`) and typed accessors; doctype and namespace nodes drop out of the content value-`Node` projection while an entity reference splices its replacement into place. `ownerDocument` derives the owning document from the parent chain; `adopt` and `importNode` move or copy a subtree into another document, re-declaring every namespace it relied on from an outer scope so the result is self-contained.
- Schematron `key()`, `document()`, and `current()` in tests (#80), completing the Schematron audit. Test, let, and message expressions can now resolve nodes through `xsl:key` declarations, load an external document for cross-document checks (via an injected `documentLoader`), and refer to the rule context node with `current()`.
- RELAX NG compact `>>` follow-annotations (#79), completing the RNC syntax. A `>>` annotation after a pattern (a foreign element name with optional `[ … ]` content) is skipped, chaining and combining with leading `[ … ]` annotations; both carry no schema semantics. (Namespace declarations inside annotations are already dropped by the annotation skipper.)
diff --git a/README.md b/README.md
index 7c0a66a..df5ec4c 100644
--- a/README.md
+++ b/README.md
@@ -77,9 +77,9 @@ flowchart TB
classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
Epic71["#71 Beyond-parity libxml2 audit"]:::epic
- AuditDone["Closed in main: DTD #72 #73, XSD #75 #76 #77 #78, parsing #95 #96 #98 #100, regex #89, catalog #86, XSLT #81 #82, RELAX NG #79, Schematron #80, DOM #90"]:::done
+ AuditDone["Closed in main: DTD #72 #73, XSD #75 #76 #77 #78, parsing #95 #96 #98 #100, regex #89, catalog #86, XSLT #81 #82, RELAX NG #79, Schematron #80, DOM #90, serialization #91"]:::done
Epic74["#74 Parser-breadth sub-epic: standalone/decl #95, XML 1.1 #96, reader #98, recovery #100 done; encodings #97 #99 open"]:::epic
- Partial["Partial in main: XInclude #87, XPath/XPointer #88, serialization #91"]:::todo
+ Partial["Partial in main: XInclude #87, XPath/XPointer #88"]:::todo
Open["Open: HTML5 #83 #84, C14N #85, encoding tables #97 #99, validator decomposition #101"]:::todo
Epic71 --> AuditDone
AuditDone --> Epic74
@@ -91,7 +91,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 **673 tests in 107 suites** (`swift test`).
+currently runs **675 tests in 107 suites** (`swift test`).
### Shipped (libxml2-aligned surface)
diff --git a/Sources/Emitting/Escaping.swift b/Sources/Emitting/Escaping.swift
index 9a60b8f..c989949 100644
--- a/Sources/Emitting/Escaping.swift
+++ b/Sources/Emitting/Escaping.swift
@@ -5,13 +5,14 @@ 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, asciiOnly: Bool = false) -> String {
+ static func text(_ value: String, asciiOnly: Bool = false, escapeCarriageReturn: Bool = false) -> String {
var result = ""
for character in value {
switch character {
case "&": result += "&"
case "<": result += "<"
case ">": result += ">"
+ case "\r" where escapeCarriageReturn: result += "
"
default: result += plain(character, asciiOnly: asciiOnly)
}
}
diff --git a/Sources/Emitting/Options.swift b/Sources/Emitting/Options.swift
index 18797ce..c8c0f00 100644
--- a/Sources/Emitting/Options.swift
+++ b/Sources/Emitting/Options.swift
@@ -12,6 +12,21 @@ public extension PureXML.Emitting {
}
}
+ /// How much to escape in text content beyond the markup-significant
+ /// characters `&`, `<`, and `>`.
+ enum TextEscaping: Equatable, Hashable, Sendable {
+ /// Escape only `&`, `<`, and `>` (the safe default).
+ case standard
+ /// Additionally escape a carriage return as `
`, so it survives the
+ /// parser's end-of-line normalization (which folds a literal `\r` or
+ /// `\r\n` to `\n`) and the text round-trips byte for byte.
+ case roundTrip
+
+ var escapesCarriageReturn: Bool {
+ self == .roundTrip
+ }
+ }
+
struct Options: Equatable, Hashable, Sendable {
/// Whether to indent nested elements onto their own lines.
public var prettyPrint: Bool
@@ -27,6 +42,10 @@ public extension PureXML.Emitting {
/// Whether to emit a CDATA section's content as escaped text rather than as
/// a `` section.
public var cdataAsText: Bool
+ /// How much to escape in text content beyond `&`, `<`, and `>`. The default
+ /// is the minimal-safe set; ``TextEscaping/roundTrip`` also escapes a
+ /// carriage return so it is preserved across a parse.
+ public var textEscaping: TextEscaping
/// Whether to escape every non-ASCII character as a numeric character
/// reference, so the output is pure ASCII (libxml2's character-reference
/// encoding).
@@ -48,6 +67,7 @@ public extension PureXML.Emitting {
lineEnding: String = "\n",
selfCloseEmptyElements: Bool = true,
cdataAsText: Bool = false,
+ textEscaping: TextEscaping = .standard,
asciiOnly: Bool = false,
includeXMLDeclaration: Bool = false,
xmlVersion: String = "1.0",
@@ -60,6 +80,7 @@ public extension PureXML.Emitting {
self.lineEnding = lineEnding
self.selfCloseEmptyElements = selfCloseEmptyElements
self.cdataAsText = cdataAsText
+ self.textEscaping = textEscaping
self.asciiOnly = asciiOnly
self.includeXMLDeclaration = includeXMLDeclaration
self.xmlVersion = xmlVersion
diff --git a/Sources/Emitting/Serializer.swift b/Sources/Emitting/Serializer.swift
index d12d241..2f3c70d 100644
--- a/Sources/Emitting/Serializer.swift
+++ b/Sources/Emitting/Serializer.swift
@@ -65,10 +65,10 @@ 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, asciiOnly: options.asciiOnly)
+ output += Escaping.text(value, asciiOnly: options.asciiOnly, escapeCarriageReturn: options.textEscaping.escapesCarriageReturn)
case let .cdata(value):
output += options.cdataAsText
- ? Escaping.text(value, asciiOnly: options.asciiOnly)
+ ? Escaping.text(value, asciiOnly: options.asciiOnly, escapeCarriageReturn: options.textEscaping.escapesCarriageReturn)
: ""
case let .comment(value):
output += ""
diff --git a/Sources/Emitting/Writer.swift b/Sources/Emitting/Writer.swift
index 58c4434..b6dda82 100644
--- a/Sources/Emitting/Writer.swift
+++ b/Sources/Emitting/Writer.swift
@@ -77,7 +77,7 @@ public extension PureXML.Emitting {
/// Writes escaped character data into the current element.
public mutating func writeString(_ text: String) {
closeStartTag()
- output += Escaping.text(text, asciiOnly: options.asciiOnly)
+ output += Escaping.text(text, asciiOnly: options.asciiOnly, escapeCarriageReturn: options.textEscaping.escapesCarriageReturn)
setTopContent(.text)
}
@@ -85,7 +85,11 @@ public extension PureXML.Emitting {
/// `cdataAsText` is set.
public mutating func writeCData(_ text: String) {
closeStartTag()
- output += options.cdataAsText ? Escaping.text(text, asciiOnly: options.asciiOnly) : ""
+ if options.cdataAsText {
+ output += Escaping.text(text, asciiOnly: options.asciiOnly, escapeCarriageReturn: options.textEscaping.escapesCarriageReturn)
+ } else {
+ output += ""
+ }
setTopContent(.text)
}
diff --git a/Tests/SerializationEscapeTests.swift b/Tests/SerializationEscapeTests.swift
index 89ee98e..9800af2 100644
--- a/Tests/SerializationEscapeTests.swift
+++ b/Tests/SerializationEscapeTests.swift
@@ -35,4 +35,22 @@ struct SerializationEscapeTests {
let element = PureXML.Model.Element("a", children: [.text("café")])
#expect(serialize(.element(element), PureXML.Emitting.Options(prettyPrint: false)) == "café")
}
+
+ @Test("textEscaping .standard leaves a carriage return verbatim")
+ func test_standardKeepsCarriageReturn() {
+ let element = PureXML.Model.Element("a", children: [.text("x\ry")])
+ #expect(serialize(.element(element), PureXML.Emitting.Options(prettyPrint: false)) == "x\ry")
+ }
+
+ @Test("textEscaping .roundTrip escapes a carriage return so it survives a parse")
+ func test_roundTripEscapesCarriageReturn() throws {
+ let element = PureXML.Model.Element("a", children: [.text("x\ry")])
+ let options = PureXML.Emitting.Options(prettyPrint: false, textEscaping: .roundTrip)
+ let serialized = serialize(.element(element), options)
+ #expect(serialized == "x
y")
+ // The escaped form survives end-of-line normalization; the verbatim form does not.
+ #expect(try PureXML.parse(serialized) == .document([.element(element)]))
+ let lossy = serialize(.element(element), PureXML.Emitting.Options(prettyPrint: false))
+ #expect(try PureXML.parse(lossy) == .document([.element(.init("a", children: [.text("x\ny")]))]))
+ }
}