feat(emitting): textEscaping round-trip option to preserve carriage returns (#91)

This commit is contained in:
Mihaela Mihaljevic 2026-06-09 03:48:20 +02:00
parent a8cf4d2d76
commit 87fd7ba889
7 changed files with 53 additions and 8 deletions

View File

@ -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 `&#xD;` 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.)

View File

@ -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)

View File

@ -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 += "&amp;"
case "<": result += "&lt;"
case ">": result += "&gt;"
case "\r" where escapeCarriageReturn: result += "&#xD;"
default: result += plain(character, asciiOnly: asciiOnly)
}
}

View File

@ -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 `&#xD;`, 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 `<![CDATA[ ... ]]>` 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

View File

@ -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)
: "<![CDATA[\(value)]]>"
case let .comment(value):
output += "<!--\(value)-->"

View File

@ -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) : "<![CDATA[\(text)]]>"
if options.cdataAsText {
output += Escaping.text(text, asciiOnly: options.asciiOnly, escapeCarriageReturn: options.textEscaping.escapesCarriageReturn)
} else {
output += "<![CDATA[\(text)]]>"
}
setTopContent(.text)
}

View File

@ -35,4 +35,22 @@ struct SerializationEscapeTests {
let element = PureXML.Model.Element("a", children: [.text("café")])
#expect(serialize(.element(element), PureXML.Emitting.Options(prettyPrint: false)) == "<a>café</a>")
}
@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)) == "<a>x\ry</a>")
}
@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 == "<a>x&#xD;y</a>")
// 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")]))]))
}
}