From 87fd7ba889d48ca90b022c4f4cd687e793c03af2 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Tue, 9 Jun 2026 03:48:20 +0200 Subject: [PATCH] feat(emitting): textEscaping round-trip option to preserve carriage returns (#91) --- CHANGELOG.md | 1 + README.md | 6 +++--- Sources/Emitting/Escaping.swift | 3 ++- Sources/Emitting/Options.swift | 21 +++++++++++++++++++++ Sources/Emitting/Serializer.swift | 4 ++-- Sources/Emitting/Writer.swift | 8 ++++++-- Tests/SerializationEscapeTests.swift | 18 ++++++++++++++++++ 7 files changed, 53 insertions(+), 8 deletions(-) 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")]))])) + } }