feat(emitting): textEscaping round-trip option to preserve carriage returns (#91)
This commit is contained in:
parent
a8cf4d2d76
commit
87fd7ba889
@ -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.)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 `<![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
|
||||
|
||||
@ -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)-->"
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
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")]))]))
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user