using System; using System.IO; using System.Text; using System.Text.RegularExpressions; using System.Xml; namespace XNeedle.Formats { /// /// An XmlWriter that round-trips XML as faithfully as possible: /// - Preserves the source encoding (detected from BOM or XML declaration). /// - Preserves line endings (they live in the document's whitespace text nodes). /// - Optionally strips the space before self-closing tags (<Tag/> vs <Tag />), /// auto-detected from whether the input file uses compact style. /// public sealed class XmlRoundtripWriter : XmlWriter { private readonly XmlTextWriter _inner; public override WriteState WriteState => _inner.WriteState; private XmlRoundtripWriter(XmlTextWriter inner) { _inner = inner; } /// /// Creates an XmlRoundtripWriter by inspecting the input file for encoding /// and self-closing tag style. /// public static XmlRoundtripWriter Create(string inputPath, string outputPath) { var rawBytes = File.ReadAllBytes(inputPath); var encoding = DetectEncoding(rawBytes); var rawText = encoding.GetString(rawBytes); // Compact self-closing: has a non-whitespace char immediately before '/>' var compact = Regex.IsMatch(rawText, @"\S/>"); // Detect DOS line endings var dosLineEndings = rawText.Contains("\r\n"); // Extract verbatim encoding name from the XML declaration to preserve its exact casing string? verbatimEncodingName = null; var encMatch = Regex.Match(rawText, @"encoding\s*=\s*[""']([^""']+)[""']", RegexOptions.IgnoreCase); if (encMatch.Success) verbatimEncodingName = encMatch.Groups[1].Value; var streamWriter = new StreamWriter(outputPath, false, encoding); var filterWriter = new FilteringTextWriter(streamWriter, compact, dosLineEndings, verbatimEncodingName); var xmlWriter = new XmlTextWriter(filterWriter) { Formatting = Formatting.None }; return new XmlRoundtripWriter(xmlWriter); } /// /// Creates an XmlRoundtripWriter that writes to a StringBuilder for in-memory rendering. /// Style detection (compact self-closing tags, DOS line endings) is read from the source file. /// public static (XmlRoundtripWriter writer, StringBuilder buffer) CreateToString(string filePath, Encoding encoding) { var rawBytes = File.ReadAllBytes(filePath); var rawText = encoding.GetString(rawBytes); var compact = Regex.IsMatch(rawText, @"\S/>"); var dosLineEndings = rawText.Contains("\r\n"); var sb = new StringBuilder(); var stringWriter = new StringWriter(sb); var filterWriter = new FilteringTextWriter(stringWriter, compact, dosLineEndings, verbatimEncodingName: null); var xmlWriter = new XmlTextWriter(filterWriter) { Formatting = Formatting.None }; return (new XmlRoundtripWriter(xmlWriter), sb); } /// /// Detects encoding from BOM first, then the XML declaration, then defaults to UTF-8. /// internal static Encoding DetectEncoding(byte[] raw) { if (raw.Length >= 3 && raw[0] == 0xEF && raw[1] == 0xBB && raw[2] == 0xBF) return new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); if (raw.Length >= 2 && raw[0] == 0xFF && raw[1] == 0xFE) return Encoding.Unicode; // UTF-16 LE if (raw.Length >= 2 && raw[0] == 0xFE && raw[1] == 0xFF) return Encoding.BigEndianUnicode; // UTF-16 BE // No BOM — probe the XML declaration for an encoding= attribute var probe = Encoding.ASCII.GetString(raw, 0, Math.Min(raw.Length, 256)); var match = Regex.Match(probe, @"encoding\s*=\s*[""']([^""']+)[""']", RegexOptions.IgnoreCase); if (match.Success) { try { return Encoding.GetEncoding(match.Groups[1].Value); } catch (ArgumentException) { /* unknown name, fall through */ } } return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); } // ── XmlWriter abstract member delegation ────────────────────────────── public override void WriteStartDocument() => _inner.WriteStartDocument(); public override void WriteStartDocument(bool standalone) => _inner.WriteStartDocument(standalone); public override void WriteEndDocument() => _inner.WriteEndDocument(); public override void WriteDocType(string name, string? pubid, string? sysid, string? subset) => _inner.WriteDocType(name, pubid, sysid, subset); public override void WriteStartElement(string? prefix, string localName, string? ns) => _inner.WriteStartElement(prefix, localName, ns); public override void WriteEndElement() => _inner.WriteEndElement(); public override void WriteFullEndElement() => _inner.WriteFullEndElement(); public override void WriteStartAttribute(string? prefix, string localName, string? ns) => _inner.WriteStartAttribute(prefix, localName, ns); public override void WriteEndAttribute() => _inner.WriteEndAttribute(); public override void WriteCData(string? text) => _inner.WriteCData(text); public override void WriteComment(string? text) => _inner.WriteComment(text); public override void WriteProcessingInstruction(string name, string? text) => _inner.WriteProcessingInstruction(name, text); public override void WriteEntityRef(string name) => _inner.WriteEntityRef(name); public override void WriteCharEntity(char ch) => _inner.WriteCharEntity(ch); public override void WriteWhitespace(string? ws) => _inner.WriteWhitespace(ws); public override void WriteString(string? text) => _inner.WriteString(text); public override void WriteSurrogateCharEntity(char lowChar, char highChar) => _inner.WriteSurrogateCharEntity(lowChar, highChar); public override void WriteChars(char[] buffer, int index, int count) => _inner.WriteChars(buffer, index, count); public override void WriteRaw(string data) => _inner.WriteRaw(data); public override void WriteRaw(char[] buffer, int index, int count) => _inner.WriteRaw(buffer, index, count); public override void WriteBase64(byte[] buffer, int index, int count) => _inner.WriteBase64(buffer, index, count); public override string? LookupPrefix(string ns) => _inner.LookupPrefix(ns); public override void Flush() => _inner.Flush(); public override void Close() => _inner.Close(); protected override void Dispose(bool disposing) { if (disposing) _inner.Close(); base.Dispose(disposing); } // ── FilteringTextWriter ─────────────────────────────────────────────── /// /// Wraps a TextWriter and optionally converts " />" to "/>" via a state machine, /// correctly spanning across multiple Write() calls. /// private sealed class FilteringTextWriter : TextWriter { private readonly TextWriter _inner; private readonly bool _filter; private readonly bool _dosLineEndings; private readonly string? _verbatimEncodingName; private enum State { Normal, SeenSpace, SeenSpaceSlash } private State _state = State.Normal; private bool _lastWasCR = false; public FilteringTextWriter(TextWriter inner, bool filter, bool dosLineEndings, string? verbatimEncodingName) { _inner = inner; _filter = filter; _dosLineEndings = dosLineEndings; _verbatimEncodingName = verbatimEncodingName; } public override Encoding Encoding => _inner.Encoding; public override void Write(char c) { ProcessChar(c); } public override void Write(string? value) { if (value is null) return; // XmlTextWriter writes the declaration in pieces; the encoding attribute // arrives as a standalone " encoding="..." " string — match either form. if (_verbatimEncodingName != null && value.Contains("encoding=", StringComparison.OrdinalIgnoreCase)) value = Regex.Replace(value, @"encoding=[""']([^""']+)[""']", $"encoding=\"{_verbatimEncodingName}\"", RegexOptions.IgnoreCase); foreach (var c in value) ProcessChar(c); } private void ProcessChar(char c) { // Restore DOS line endings stripped by XmlTextWriter's normalisation if (_dosLineEndings && c == '\n' && !_lastWasCR) { Emit('\r'); } _lastWasCR = (c == '\r'); if (!_filter) { _inner.Write(c); return; } switch (_state) { case State.Normal: if (c == ' ') _state = State.SeenSpace; else Emit(c); break; case State.SeenSpace: if (c == '/') _state = State.SeenSpaceSlash; else { Emit(' '); _state = State.Normal; ProcessChar(c); } break; case State.SeenSpaceSlash: if (c == '>') { _inner.Write("/>"); _state = State.Normal; } else { Emit(' '); Emit('/'); _state = State.Normal; ProcessChar(c); } break; } } private void Emit(char c) => _inner.Write(c); public override void Flush() { // Drain any partially-matched state if (_filter) { if (_state == State.SeenSpace) { _inner.Write(' '); _state = State.Normal; } else if (_state == State.SeenSpaceSlash) { _inner.Write(" /"); _state = State.Normal; } } _inner.Flush(); } protected override void Dispose(bool disposing) { if (disposing) { Flush(); _inner.Dispose(); } base.Dispose(disposing); } } } }