258 lines
11 KiB
C#
258 lines
11 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using System.Xml;
|
|
|
|
namespace XNeedle.Formats
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class XmlRoundtripWriter : XmlWriter
|
|
{
|
|
private readonly XmlTextWriter _inner;
|
|
|
|
public override WriteState WriteState => _inner.WriteState;
|
|
|
|
private XmlRoundtripWriter(XmlTextWriter inner)
|
|
{
|
|
_inner = inner;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates an XmlRoundtripWriter by inspecting the input file for encoding
|
|
/// and self-closing tag style.
|
|
/// </summary>
|
|
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: <Tag/> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Detects encoding from BOM first, then the XML declaration, then defaults to UTF-8.
|
|
/// </summary>
|
|
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 ───────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Wraps a TextWriter and optionally converts " />" to "/>" via a state machine,
|
|
/// correctly spanning across multiple Write() calls.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
} |