초기 커밋: SheetMe 서식생성기 (P0~P5 완료 상태)
레거시 서식생성기(VB.NET WinForms) 대체용 C#/.NET 10 WPF 디자이너. 기준선: 실DB 활성 디자인 1,271건 왕복 의미론 diff 0 / 예외 0, 단위 테스트 49/49. 이 커밋에 함께 포함된 자격증명 분리: - appsettings.json 을 __HOST__/__PASSWORD__ 플레이스홀더로 전환 - 실접속 정보는 appsettings.Development.json 으로 분리(.gitignore 제외, csproj Debug 조건부 복사라 Release 산출물에 실리지 않음) - ConfigLoader 를 환경변수 > Development > appsettings 순 레이어링으로 변경, 미치환 플레이스홀더는 '미설정'으로 간주 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
using System.Xml;
|
||||
|
||||
namespace SheetMe.Core.Serialization;
|
||||
|
||||
/// <summary>Spread 셀 1개(텍스트/스팬)</summary>
|
||||
public sealed class SpreadCellInfo
|
||||
{
|
||||
/// <summary>행 인덱스(0-base)</summary>
|
||||
public int Row { get; init; }
|
||||
|
||||
/// <summary>열 인덱스(0-base)</summary>
|
||||
public int Col { get; init; }
|
||||
|
||||
/// <summary>행 스팬(기본 1)</summary>
|
||||
public int RowSpan { get; init; } = 1;
|
||||
|
||||
/// <summary>열 스팬(기본 1)</summary>
|
||||
public int ColSpan { get; init; } = 1;
|
||||
|
||||
/// <summary>셀 텍스트</summary>
|
||||
public string Text { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Spread 격자 파싱 결과 — 렌더에 필요한 최소 정보</summary>
|
||||
public sealed class SpreadGridInfo
|
||||
{
|
||||
/// <summary>기본 행 높이(px) — FarPoint 기본 20</summary>
|
||||
public double DefaultRowHeight { get; set; } = 20;
|
||||
|
||||
/// <summary>기본 열 너비(px) — FarPoint 기본 62</summary>
|
||||
public double DefaultColWidth { get; set; } = 62;
|
||||
|
||||
/// <summary>행별 명시 높이(index → px)</summary>
|
||||
public Dictionary<int, double> RowHeights { get; } = new();
|
||||
|
||||
/// <summary>열별 명시 너비(index → px)</summary>
|
||||
public Dictionary<int, double> ColWidths { get; } = new();
|
||||
|
||||
/// <summary>텍스트 있는 셀 목록</summary>
|
||||
public List<SpreadCellInfo> Cells { get; } = new();
|
||||
|
||||
/// <summary>셀 스팬 목록(텍스트 없어도 병합 표시)</summary>
|
||||
public List<SpreadCellInfo> Spans { get; } = new();
|
||||
|
||||
/// <summary>행 높이 조회</summary>
|
||||
public double RowHeightOf(int row) => RowHeights.TryGetValue(row, out var h) ? h : DefaultRowHeight;
|
||||
|
||||
/// <summary>열 너비 조회</summary>
|
||||
public double ColWidthOf(int col) => ColWidths.TryGetValue(col, out var w) ? w : DefaultColWidth;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FarPoint Spread 직렬화 XML(E_SpdMst.SpdDesign) 파서 — 렌더용 최소 추출.
|
||||
/// 근거: 실DB S999/P062 샘플(기본 골격) + FarPoint 표준 직렬화 구조.
|
||||
/// 셀 데이터(<Cell Row Column>)와 스팬(CellRange)은 실물 부재로 합성 샘플 검증 —
|
||||
/// 셀 있는 실서식 확보 시 재검증 필요(주석 유지).
|
||||
/// </summary>
|
||||
public static class FarPointSpreadParser
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>SpdDesign XML 파싱 — 실패 시 null(호출부가 자리표시 렌더 유지)</summary>
|
||||
public static SpreadGridInfo? Parse(string spdDesignXml)
|
||||
{
|
||||
try
|
||||
{
|
||||
var xml = LegacyXmlSerializer.LoadLenient(spdDesignXml);
|
||||
var info = new SpreadGridInfo();
|
||||
|
||||
// 축 크기 — Presentation//AxisModels 의 Row(세로 축=행 높이)/Column(가로 축=열 너비)
|
||||
foreach (XmlElement axis in xml.SelectNodes("//AxisModels/Row")!)
|
||||
{
|
||||
ParseAxis(axis, info.RowHeights, size => info.DefaultRowHeight = size);
|
||||
}
|
||||
foreach (XmlElement axis in xml.SelectNodes("//AxisModels/Column")!)
|
||||
{
|
||||
ParseAxis(axis, info.ColWidths, size => info.DefaultColWidth = size);
|
||||
}
|
||||
|
||||
// 셀 스팬 — SpanModel 하위 CellRange(Row/Column/RowCount/ColumnCount)
|
||||
foreach (XmlElement range in xml.SelectNodes("//SpanModel//CellRange")!)
|
||||
{
|
||||
info.Spans.Add(new SpreadCellInfo
|
||||
{
|
||||
Row = IntAttr(range, "Row"),
|
||||
Col = IntAttr(range, "Column"),
|
||||
RowSpan = Math.Max(1, IntAttr(range, "RowCount", 1)),
|
||||
ColSpan = Math.Max(1, IntAttr(range, "ColumnCount", 1)),
|
||||
});
|
||||
}
|
||||
|
||||
// 셀 텍스트 — Data 섹션 DataArea 하위 <Cell Row= Column=> 의 텍스트
|
||||
foreach (XmlElement cell in xml.SelectNodes("//Data//DataArea//Cell")!)
|
||||
{
|
||||
var text = cell.InnerText.Trim();
|
||||
if (text.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
info.Cells.Add(new SpreadCellInfo
|
||||
{
|
||||
Row = IntAttr(cell, "Row"),
|
||||
Col = IntAttr(cell, "Column"),
|
||||
Text = text,
|
||||
});
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>축 Items 파싱 — Item(index, Size). index=-1 은 기본 크기 지정</summary>
|
||||
private static void ParseAxis(XmlElement axis, Dictionary<int, double> sizes, Action<double> setDefault)
|
||||
{
|
||||
var defaultAttr = axis.GetAttribute("defaultSize");
|
||||
if (double.TryParse(defaultAttr, out var defaultSize) && defaultSize > 0)
|
||||
{
|
||||
setDefault(defaultSize);
|
||||
}
|
||||
foreach (XmlElement item in axis.SelectNodes("Items/Item")!)
|
||||
{
|
||||
var index = IntAttr(item, "index", -1);
|
||||
var sizeNode = item.SelectSingleNode("Size");
|
||||
if (sizeNode is null || !double.TryParse(sizeNode.InnerText, out var size) || size <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (index < 0)
|
||||
{
|
||||
setDefault(size);
|
||||
}
|
||||
else
|
||||
{
|
||||
sizes[index] = size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int IntAttr(XmlElement element, string name, int fallback = 0)
|
||||
=> int.TryParse(element.GetAttribute(name), out var value) ? value : fallback;
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using SheetMe.Core.Models;
|
||||
|
||||
namespace SheetMe.Core.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// JSON 직렬화기 — 부 포맷(파일 내보내기/백업, 추후 주 포맷 승격 대비).
|
||||
/// 모델 트리를 있는 그대로 담아 레거시 XML 과 동일한 정보량을 유지한다.
|
||||
/// </summary>
|
||||
public sealed class FormJsonSerializer : IDesignSerializer
|
||||
{
|
||||
#region Member Fields
|
||||
private static readonly JsonSerializerOptions options = CreateOptions();
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>JSON 을 서식 문서로 역직렬화</summary>
|
||||
public FormDocument Read(string content)
|
||||
=> JsonSerializer.Deserialize<FormDocument>(content, options)
|
||||
?? throw new InvalidDataException("JSON 서식 문서를 해석할 수 없습니다.");
|
||||
|
||||
/// <summary>서식 문서를 JSON 으로 직렬화</summary>
|
||||
public string Write(FormDocument document)
|
||||
=> JsonSerializer.Serialize(document, options);
|
||||
|
||||
private static JsonSerializerOptions CreateOptions()
|
||||
{
|
||||
var opts = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
opts.Converters.Add(new PropBagJsonConverter());
|
||||
opts.Converters.Add(new LegacyPropValueJsonConverter());
|
||||
return opts;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>PropBag JSON 컨버터 — 순서 보존을 위해 [{ "n": 이름, "v": 값 }] 배열로 직렬화</summary>
|
||||
public sealed class PropBagJsonConverter : JsonConverter<PropBag>
|
||||
{
|
||||
public override PropBag Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var bag = new PropBag();
|
||||
if (reader.TokenType != JsonTokenType.StartArray)
|
||||
{
|
||||
throw new JsonException("PropBag 은 배열이어야 합니다.");
|
||||
}
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
|
||||
{
|
||||
if (reader.TokenType != JsonTokenType.StartObject)
|
||||
{
|
||||
throw new JsonException("PropBag 항목은 객체여야 합니다.");
|
||||
}
|
||||
string? name = null;
|
||||
LegacyPropValue? value = null;
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
|
||||
{
|
||||
var propName = reader.GetString();
|
||||
reader.Read();
|
||||
switch (propName)
|
||||
{
|
||||
case "n":
|
||||
name = reader.GetString();
|
||||
break;
|
||||
case "v":
|
||||
value = JsonSerializer.Deserialize<LegacyPropValue>(ref reader, options);
|
||||
break;
|
||||
default:
|
||||
reader.Skip();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (name is not null)
|
||||
{
|
||||
bag.Set(name, value ?? new LegacyPropValue.NullValue());
|
||||
}
|
||||
}
|
||||
return bag;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, PropBag value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
foreach (var (name, propValue) in value)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("n", name);
|
||||
writer.WritePropertyName("v");
|
||||
JsonSerializer.Serialize(writer, propValue, options);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>LegacyPropValue 다형 JSON 컨버터 — "k" 판별자 기반</summary>
|
||||
public sealed class LegacyPropValueJsonConverter : JsonConverter<LegacyPropValue>
|
||||
{
|
||||
public override LegacyPropValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Null)
|
||||
{
|
||||
return new LegacyPropValue.NullValue();
|
||||
}
|
||||
if (reader.TokenType == JsonTokenType.String)
|
||||
{
|
||||
// 축약형: 문자열이면 TextValue
|
||||
return new LegacyPropValue.TextValue(reader.GetString() ?? string.Empty);
|
||||
}
|
||||
if (reader.TokenType != JsonTokenType.StartObject)
|
||||
{
|
||||
throw new JsonException("LegacyPropValue 는 문자열 또는 객체여야 합니다.");
|
||||
}
|
||||
|
||||
string? kind = null;
|
||||
string? text = null;
|
||||
string? base64 = null;
|
||||
string? refName = null;
|
||||
PropBag? nested = null;
|
||||
List<LegacyItem>? items = null;
|
||||
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
|
||||
{
|
||||
var propName = reader.GetString();
|
||||
reader.Read();
|
||||
switch (propName)
|
||||
{
|
||||
case "k": kind = reader.GetString(); break;
|
||||
case "text": text = reader.GetString(); break;
|
||||
case "b64": base64 = reader.GetString(); break;
|
||||
case "ref": refName = reader.GetString(); break;
|
||||
case "props": nested = JsonSerializer.Deserialize<PropBag>(ref reader, options); break;
|
||||
case "items":
|
||||
items = new List<LegacyItem>();
|
||||
if (reader.TokenType != JsonTokenType.StartArray)
|
||||
{
|
||||
throw new JsonException("items 는 배열이어야 합니다.");
|
||||
}
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
|
||||
{
|
||||
string? aqn = null;
|
||||
LegacyPropValue? itemValue = null;
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
|
||||
{
|
||||
var itemProp = reader.GetString();
|
||||
reader.Read();
|
||||
switch (itemProp)
|
||||
{
|
||||
case "aqn": aqn = reader.GetString(); break;
|
||||
case "v": itemValue = JsonSerializer.Deserialize<LegacyPropValue>(ref reader, options); break;
|
||||
default: reader.Skip(); break;
|
||||
}
|
||||
}
|
||||
items.Add(new LegacyItem { Aqn = aqn ?? string.Empty, Value = itemValue ?? new LegacyPropValue.NullValue() });
|
||||
}
|
||||
break;
|
||||
default: reader.Skip(); break;
|
||||
}
|
||||
}
|
||||
|
||||
return kind switch
|
||||
{
|
||||
"text" => new LegacyPropValue.TextValue(text ?? string.Empty),
|
||||
"null" => new LegacyPropValue.NullValue(),
|
||||
"nested" => new LegacyPropValue.NestedValue { Children = nested ?? new PropBag() },
|
||||
"items" => new LegacyPropValue.ItemsValue { Items = items ?? new List<LegacyItem>() },
|
||||
"binary" => new LegacyPropValue.BinaryValue { Base64 = base64 ?? string.Empty },
|
||||
"ref" => new LegacyPropValue.ReferenceValue { Name = refName ?? string.Empty },
|
||||
_ => throw new JsonException($"알 수 없는 LegacyPropValue 종류: {kind}"),
|
||||
};
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, LegacyPropValue value, JsonSerializerOptions options)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case LegacyPropValue.TextValue text:
|
||||
// 가장 흔한 형태는 축약(문자열)으로 — JSON 가독성
|
||||
writer.WriteStringValue(text.Value);
|
||||
break;
|
||||
|
||||
case LegacyPropValue.NullValue:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("k", "null");
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
|
||||
case LegacyPropValue.NestedValue nested:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("k", "nested");
|
||||
writer.WritePropertyName("props");
|
||||
JsonSerializer.Serialize(writer, nested.Children, options);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
|
||||
case LegacyPropValue.ItemsValue items:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("k", "items");
|
||||
writer.WritePropertyName("items");
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in items.Items)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("aqn", item.Aqn);
|
||||
writer.WritePropertyName("v");
|
||||
JsonSerializer.Serialize(writer, item.Value, options);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
|
||||
case LegacyPropValue.BinaryValue binary:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("k", "binary");
|
||||
writer.WriteString("b64", binary.Base64);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
|
||||
case LegacyPropValue.ReferenceValue reference:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("k", "ref");
|
||||
writer.WriteString("ref", reference.Name);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using SheetMe.Core.Models;
|
||||
|
||||
namespace SheetMe.Core.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// 서식 문서 직렬화 계약 — v1 주 포맷은 레거시 XML(<see cref="LegacyXmlSerializer"/>),
|
||||
/// 부 포맷은 JSON(<see cref="FormJsonSerializer"/>). 추후 JSON 주 포맷 승격 시 교체 지점.
|
||||
/// </summary>
|
||||
public interface IDesignSerializer
|
||||
{
|
||||
/// <summary>문자열 콘텐츠를 서식 문서로 역직렬화</summary>
|
||||
FormDocument Read(string content);
|
||||
|
||||
/// <summary>서식 문서를 문자열로 직렬화</summary>
|
||||
string Write(FormDocument document);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace SheetMe.Core.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// 레거시 TypeConverter invariant 문자열 포맷 도우미.
|
||||
/// 읽기는 관용적으로, 쓰기는 레거시 원문 형식("X, Y" / "이름, 11.25pt, style=Bold")과 동일하게.
|
||||
/// </summary>
|
||||
public static class LegacyFormat
|
||||
{
|
||||
#region Point / Size
|
||||
/// <summary>"336, 1064" 형식 파싱 — 실패 시 (0,0)</summary>
|
||||
public static (double X, double Y) ParsePoint(string? text)
|
||||
{
|
||||
if (TryParsePair(text, out var x, out var y))
|
||||
{
|
||||
return (x, y);
|
||||
}
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
/// <summary>"174, 18" 형식 파싱 — 실패 시 (0,0)</summary>
|
||||
public static (double W, double H) ParseSize(string? text) => ParsePoint(text);
|
||||
|
||||
/// <summary>Point/Size invariant 문자열 생성 — 레거시와 동일한 "X, Y" (정수 반올림)</summary>
|
||||
public static string FormatPair(double a, double b)
|
||||
=> string.Create(CultureInfo.InvariantCulture, $"{(int)Math.Round(a)}, {(int)Math.Round(b)}");
|
||||
|
||||
private static bool TryParsePair(string? text, out double a, out double b)
|
||||
{
|
||||
a = 0;
|
||||
b = 0;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var parts = text.Split(',');
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return double.TryParse(parts[0].Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out a)
|
||||
&& double.TryParse(parts[1].Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out b);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Font
|
||||
/// <summary>
|
||||
/// 레거시 FontConverter invariant 문자열 파싱 — "굴림, 11.25pt, style=Bold, Underline".
|
||||
/// 실패 시 기본(굴림 9pt) 반환.
|
||||
/// </summary>
|
||||
public static LegacyFont ParseFont(string? text)
|
||||
{
|
||||
var font = new LegacyFont();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return font;
|
||||
}
|
||||
|
||||
var parts = text.Split(',');
|
||||
if (parts.Length > 0)
|
||||
{
|
||||
font.Family = parts[0].Trim();
|
||||
}
|
||||
|
||||
for (var i = 1; i < parts.Length; i++)
|
||||
{
|
||||
var part = parts[i].Trim();
|
||||
if (part.EndsWith("pt", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (double.TryParse(part[..^2], NumberStyles.Number, CultureInfo.InvariantCulture, out var size))
|
||||
{
|
||||
font.SizePt = size;
|
||||
}
|
||||
}
|
||||
else if (part.StartsWith("style=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ApplyStyle(font, part[6..]);
|
||||
}
|
||||
else
|
||||
{
|
||||
// "style=Bold, Italic" 처럼 콤마로 이어진 후속 스타일 토큰
|
||||
ApplyStyle(font, part);
|
||||
}
|
||||
}
|
||||
return font;
|
||||
}
|
||||
|
||||
/// <summary>레거시 FontConverter invariant 형식으로 생성 — "이름, 11.25pt[, style=Bold, Italic]"</summary>
|
||||
public static string FormatFont(LegacyFont font)
|
||||
{
|
||||
var size = font.SizePt.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
var styles = new List<string>(4);
|
||||
if (font.Bold) { styles.Add("Bold"); }
|
||||
if (font.Italic) { styles.Add("Italic"); }
|
||||
if (font.Underline) { styles.Add("Underline"); }
|
||||
if (font.Strikeout) { styles.Add("Strikeout"); }
|
||||
|
||||
return styles.Count == 0
|
||||
? $"{font.Family}, {size}pt"
|
||||
: $"{font.Family}, {size}pt, style={string.Join(", ", styles)}";
|
||||
}
|
||||
|
||||
private static void ApplyStyle(LegacyFont font, string token)
|
||||
{
|
||||
switch (token.Trim())
|
||||
{
|
||||
case "Bold": font.Bold = true; break;
|
||||
case "Italic": font.Italic = true; break;
|
||||
case "Underline": font.Underline = true; break;
|
||||
case "Strikeout": font.Strikeout = true; break;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Color
|
||||
/// <summary>
|
||||
/// 레거시 ColorConverter invariant 문자열 파싱 — "224, 224, 224" / "255, 0, 0, 0"(ARGB) / "White" / "ControlDarkDark".
|
||||
/// 반환: (A,R,G,B). 이름 색은 System.Drawing 으로 해석, 실패 시 검정.
|
||||
/// </summary>
|
||||
public static (byte A, byte R, byte G, byte B) ParseColor(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return (255, 0, 0, 0);
|
||||
}
|
||||
|
||||
var parts = text.Split(',');
|
||||
if (parts.Length is 3 or 4 && byte.TryParse(parts[0].Trim(), out _))
|
||||
{
|
||||
var values = new byte[parts.Length];
|
||||
for (var i = 0; i < parts.Length; i++)
|
||||
{
|
||||
if (!byte.TryParse(parts[i].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out values[i]))
|
||||
{
|
||||
return (255, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
return parts.Length == 3
|
||||
? ((byte)255, values[0], values[1], values[2])
|
||||
: (values[0], values[1], values[2], values[3]);
|
||||
}
|
||||
|
||||
// 명명색/시스템색 — System.Drawing 의 KnownColor 해석 사용(레거시와 동일 의미론)
|
||||
var named = System.Drawing.Color.FromName(text.Trim());
|
||||
if (named.IsKnownColor || named.A != 0 || named.R != 0 || named.G != 0 || named.B != 0)
|
||||
{
|
||||
return (named.A, named.R, named.G, named.B);
|
||||
}
|
||||
return (255, 0, 0, 0);
|
||||
}
|
||||
|
||||
/// <summary>ColorConverter invariant 형식으로 생성 — 불투명 색은 "R, G, B", 반투명은 "A, R, G, B"</summary>
|
||||
public static string FormatColor(byte a, byte r, byte g, byte b)
|
||||
=> a == 255
|
||||
? string.Create(CultureInfo.InvariantCulture, $"{r}, {g}, {b}")
|
||||
: string.Create(CultureInfo.InvariantCulture, $"{a}, {r}, {g}, {b}");
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>레거시 폰트 표현 — WinForms Font invariant 문자열과 1:1</summary>
|
||||
public sealed class LegacyFont
|
||||
{
|
||||
/// <summary>글꼴 패밀리명</summary>
|
||||
public string Family { get; set; } = "굴림";
|
||||
|
||||
/// <summary>크기(pt)</summary>
|
||||
public double SizePt { get; set; } = 9;
|
||||
|
||||
/// <summary>굵게</summary>
|
||||
public bool Bold { get; set; }
|
||||
|
||||
/// <summary>기울임</summary>
|
||||
public bool Italic { get; set; }
|
||||
|
||||
/// <summary>밑줄</summary>
|
||||
public bool Underline { get; set; }
|
||||
|
||||
/// <summary>취소선</summary>
|
||||
public bool Strikeout { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
namespace SheetMe.Core.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// 레거시 컨트롤 타입(AQN) ↔ 중립 타입명 매핑의 단일 출처.
|
||||
/// 읽기: AQN 의 클래스 단축명을 정확 일치로 매핑(미지 타입은 "Placeholder").
|
||||
/// 쓰기: 신규 생성 컨트롤의 AQN 을 실DB 샘플에서 확인된 원문 형식으로 생성.
|
||||
/// </summary>
|
||||
public static class LegacyTypeCatalog
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>미지원/미지 타입의 중립 타입명</summary>
|
||||
public const string PlaceholderType = "Placeholder";
|
||||
|
||||
/// <summary>페이지 루트 클래스 단축명</summary>
|
||||
public const string HostClassName = "MDesignerHost";
|
||||
|
||||
/// <summary>페이지 루트 AQN (실샘플 sheetdesign.xml 원문 확인)</summary>
|
||||
public const string HostAqn =
|
||||
"M.EMR.UserControl.MDesignerHost, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
|
||||
// 레거시 클래스 단축명 → 중립 타입명 (정확 일치 — contains 순서 버그 원천 차단)
|
||||
private static readonly Dictionary<string, string> legacyToNeutral = new(StringComparer.Ordinal)
|
||||
{
|
||||
["TextBox"] = "TextBox",
|
||||
["Label"] = "Label",
|
||||
["MFormatLabel"] = "Label",
|
||||
["MSequence"] = "Label",
|
||||
["CheckBox"] = "CheckBox",
|
||||
["CheckLabel"] = "CheckBox",
|
||||
["RadioButton"] = "RadioButton",
|
||||
["ComboBox"] = "ComboBox",
|
||||
["ListBox"] = "ListBox",
|
||||
["MCheckedListBox"] = "CheckList",
|
||||
["DateTimePicker"] = "DateTimePicker",
|
||||
["MaskedTextBox"] = "MaskedTextBox",
|
||||
["Panel"] = "Panel",
|
||||
["Panel2"] = "Panel",
|
||||
["MLayerPanel"] = "Panel",
|
||||
["MExpandablePanel"] = "Panel",
|
||||
["GroupBox"] = "GroupBox",
|
||||
["MLine"] = "Line",
|
||||
["MPictureBox"] = "PictureBox",
|
||||
["MCalcBox"] = "CalcBox",
|
||||
["MButton"] = "Button",
|
||||
["MDataTable"] = "DataTable",
|
||||
// Spread 는 읽기 매핑만(렌더) — E_SpdMst 디자인 없이는 신규 생성 불가하므로 쓰기 매핑/팔레트 미등록
|
||||
["Spread"] = "Spread",
|
||||
};
|
||||
|
||||
// 중립 타입명 → 신규 생성 시 사용할 레거시 클래스 단축명
|
||||
private static readonly Dictionary<string, string> neutralToLegacyClass = new(StringComparer.Ordinal)
|
||||
{
|
||||
["TextBox"] = "TextBox",
|
||||
["Label"] = "Label",
|
||||
["CheckBox"] = "CheckBox",
|
||||
["RadioButton"] = "RadioButton",
|
||||
["ComboBox"] = "ComboBox",
|
||||
["ListBox"] = "ListBox",
|
||||
["CheckList"] = "MCheckedListBox",
|
||||
["DateTimePicker"] = "DateTimePicker",
|
||||
["MaskedTextBox"] = "MaskedTextBox",
|
||||
["Panel"] = "Panel",
|
||||
["GroupBox"] = "GroupBox",
|
||||
["Line"] = "MLine",
|
||||
["PictureBox"] = "MPictureBox",
|
||||
["CalcBox"] = "MCalcBox",
|
||||
["Button"] = "MButton",
|
||||
["DataTable"] = "MDataTable",
|
||||
};
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>AQN 에서 클래스 단축명 추출 (예: "M.EMR.UserControl.TextBox, ..." → "TextBox")</summary>
|
||||
public static string ShortClassName(string aqn)
|
||||
{
|
||||
var comma = aqn.IndexOf(',');
|
||||
var fullName = comma >= 0 ? aqn[..comma] : aqn;
|
||||
var dot = fullName.LastIndexOf('.');
|
||||
return (dot >= 0 ? fullName[(dot + 1)..] : fullName).Trim();
|
||||
}
|
||||
|
||||
/// <summary>AQN 을 중립 타입명으로 해석 — 미지 타입은 Placeholder</summary>
|
||||
public static string Resolve(string aqn)
|
||||
{
|
||||
var shortName = ShortClassName(aqn);
|
||||
return legacyToNeutral.TryGetValue(shortName, out var neutral) ? neutral : PlaceholderType;
|
||||
}
|
||||
|
||||
/// <summary>페이지 루트(MDesignerHost) 타입인지 판정</summary>
|
||||
public static bool IsHost(string aqn) => ShortClassName(aqn) == HostClassName;
|
||||
|
||||
/// <summary>중립 타입명의 신규 생성용 AQN — 미지원 타입이면 null</summary>
|
||||
public static string? AqnOf(string neutralType)
|
||||
{
|
||||
if (!neutralToLegacyClass.TryGetValue(neutralType, out var legacyClass))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return $"M.EMR.UserControl.{legacyClass}, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using SheetMe.Core.Models;
|
||||
|
||||
namespace SheetMe.Core.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// 레거시 서식 XML 직렬화기 — v1 주 포맷.
|
||||
/// 포맷 원본: [003]DesignerHosting\clsBasicDesignerLoader.vb (읽기 LoadObject/ReadProperty·쓰기 WriteObjectDesignInfo),
|
||||
/// 병합 규약: clsCommonLib.Xml_MergeXmlDocument (Sheet 루트 1개에 페이지당 MDesignerHost Object N개).
|
||||
/// 모든 Property 를 PropBag 으로 무손실 왕복하며, 쓰기 시 Bounds 를 Location/Size/LocationOnBase 로 동기화한다.
|
||||
/// </summary>
|
||||
public sealed class LegacyXmlSerializer : IDesignSerializer
|
||||
{
|
||||
#region Methods - Read
|
||||
/// <summary>레거시 XML 을 서식 문서로 역직렬화 — 파싱 경고는 document.Meta.ReadWarnings 에 수집</summary>
|
||||
public FormDocument Read(string content)
|
||||
{
|
||||
var document = new FormDocument();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
document.Pages.Add(CreateEmptyPage(1));
|
||||
return document;
|
||||
}
|
||||
|
||||
// 레거시 로더(clsBasicDesignerLoader)와 동일하게 PreserveWhitespace=false 로 파싱.
|
||||
// CheckCharacters=false: 레거시 데이터에 제어문자(예: Spread 구분자 0x05)가 원문 포함된 사례가 있어
|
||||
// 구식 XmlTextReader(무검사)와 동일하게 관용 처리한다.
|
||||
var xml = LoadLenient(content);
|
||||
|
||||
var root = xml.DocumentElement
|
||||
?? throw new InvalidDataException("서식 XML 에 루트 요소가 없습니다.");
|
||||
|
||||
foreach (XmlNode node in root.ChildNodes)
|
||||
{
|
||||
if (node.NodeType != XmlNodeType.Element || node.Name != "Object")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var pageRoot = ParseObject((XmlElement)node, document.Meta.ReadWarnings);
|
||||
document.Pages.Add(new FormPage { Root = pageRoot });
|
||||
}
|
||||
|
||||
if (document.Pages.Count == 0)
|
||||
{
|
||||
document.Meta.ReadWarnings.Add("페이지(Object) 노드를 찾지 못해 빈 페이지를 생성했습니다.");
|
||||
document.Pages.Add(CreateEmptyPage(1));
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>빈 페이지 생성 — 레거시 기본 신규 서식(MDesignerHost 720×856)과 동일</summary>
|
||||
public static FormPage CreateEmptyPage(int pageNumber)
|
||||
{
|
||||
var root = new ControlElement
|
||||
{
|
||||
Type = LegacyTypeCatalog.HostClassName,
|
||||
Id = $"MDesignerHost{pageNumber}",
|
||||
LegacyAqn = LegacyTypeCatalog.HostAqn,
|
||||
Bounds = new LayoutRect { W = 720, H = 856 },
|
||||
HasSize = true,
|
||||
};
|
||||
root.Props.SetText("BackColor", "White");
|
||||
root.Props.SetText("Name", root.Id);
|
||||
root.Props.SetText("Size", "720, 856");
|
||||
return new FormPage { Root = root };
|
||||
}
|
||||
|
||||
private ControlElement ParseObject(XmlElement node, List<string> warnings)
|
||||
{
|
||||
var aqn = node.GetAttribute("type");
|
||||
var nameAttr = node.Attributes["name"];
|
||||
var displaySequenceAttr = node.GetAttribute("DisplaySequence");
|
||||
|
||||
var element = new ControlElement
|
||||
{
|
||||
LegacyAqn = string.IsNullOrEmpty(aqn) ? null : aqn,
|
||||
Type = LegacyTypeCatalog.IsHost(aqn) ? LegacyTypeCatalog.HostClassName : LegacyTypeCatalog.Resolve(aqn),
|
||||
Id = nameAttr?.Value ?? string.Empty,
|
||||
HasNameAttr = nameAttr is not null,
|
||||
HasChildrenAttr = node.Attributes["children"] is not null,
|
||||
DisplaySequence = int.TryParse(displaySequenceAttr, out var seq) ? seq : 0,
|
||||
};
|
||||
|
||||
if (element.Type == LegacyTypeCatalog.PlaceholderType)
|
||||
{
|
||||
warnings.Add($"미지원 컨트롤 타입 보존: {element.Id} ({LegacyTypeCatalog.ShortClassName(aqn)})");
|
||||
}
|
||||
|
||||
foreach (XmlNode child in node.ChildNodes)
|
||||
{
|
||||
if (child.NodeType != XmlNodeType.Element)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
switch (child.Name)
|
||||
{
|
||||
case "Object":
|
||||
element.Children.Add(ParseObject((XmlElement)child, warnings));
|
||||
break;
|
||||
case "Property":
|
||||
var propName = ((XmlElement)child).GetAttribute("name");
|
||||
if (propName.Length == 0)
|
||||
{
|
||||
warnings.Add($"이름 없는 Property 무시: {element.Id}");
|
||||
break;
|
||||
}
|
||||
element.Props.Set(propName, ParseValue((XmlElement)child, warnings));
|
||||
break;
|
||||
default:
|
||||
warnings.Add($"알 수 없는 노드 무시: <{child.Name}> in {element.Id}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
LiftBounds(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
private LegacyPropValue ParseValue(XmlElement propNode, List<string> warnings)
|
||||
{
|
||||
var elementChildren = propNode.ChildNodes.OfType<XmlElement>().ToList();
|
||||
|
||||
if (elementChildren.Count == 0)
|
||||
{
|
||||
var hasText = propNode.ChildNodes
|
||||
.Cast<XmlNode>()
|
||||
.Any(n => n.NodeType is XmlNodeType.Text or XmlNodeType.CDATA);
|
||||
return hasText
|
||||
? new LegacyPropValue.TextValue(propNode.InnerText)
|
||||
: new LegacyPropValue.NullValue();
|
||||
}
|
||||
|
||||
if (elementChildren.All(e => e.Name == "Property"))
|
||||
{
|
||||
var nested = new LegacyPropValue.NestedValue();
|
||||
foreach (var child in elementChildren)
|
||||
{
|
||||
var childName = child.GetAttribute("name");
|
||||
if (childName.Length == 0)
|
||||
{
|
||||
warnings.Add("이름 없는 중첩 Property 무시");
|
||||
continue;
|
||||
}
|
||||
nested.Children.Set(childName, ParseValue(child, warnings));
|
||||
}
|
||||
return nested;
|
||||
}
|
||||
|
||||
if (elementChildren.All(e => e.Name == "Item"))
|
||||
{
|
||||
var items = new LegacyPropValue.ItemsValue();
|
||||
foreach (var child in elementChildren)
|
||||
{
|
||||
items.Items.Add(new LegacyItem
|
||||
{
|
||||
Aqn = child.GetAttribute("type"),
|
||||
Value = ParseValue(child, warnings),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
if (elementChildren.Count == 1 && elementChildren[0].Name == "Binary")
|
||||
{
|
||||
return new LegacyPropValue.BinaryValue { Base64 = elementChildren[0].InnerText };
|
||||
}
|
||||
|
||||
if (elementChildren.Count == 1 && elementChildren[0].Name == "Reference")
|
||||
{
|
||||
return new LegacyPropValue.ReferenceValue { Name = elementChildren[0].GetAttribute("name") };
|
||||
}
|
||||
|
||||
warnings.Add($"해석 불가 Property 형태(자식: {string.Join(",", elementChildren.Select(e => e.Name).Distinct())}) — 중첩으로 보존 시도");
|
||||
var fallback = new LegacyPropValue.NestedValue();
|
||||
foreach (var child in elementChildren.Where(e => e.Name == "Property"))
|
||||
{
|
||||
var childName = child.GetAttribute("name");
|
||||
if (childName.Length > 0)
|
||||
{
|
||||
fallback.Children.Set(childName, ParseValue(child, warnings));
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static void LiftBounds(ControlElement element)
|
||||
{
|
||||
var location = element.Props.GetText("Location");
|
||||
if (location is not null)
|
||||
{
|
||||
var (x, y) = LegacyFormat.ParsePoint(location);
|
||||
element.Bounds.X = x;
|
||||
element.Bounds.Y = y;
|
||||
element.HasLocation = true;
|
||||
}
|
||||
|
||||
var size = element.Props.GetText("Size");
|
||||
if (size is not null)
|
||||
{
|
||||
var (w, h) = LegacyFormat.ParseSize(size);
|
||||
element.Bounds.W = w;
|
||||
element.Bounds.H = h;
|
||||
element.HasSize = true;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - Write
|
||||
/// <summary>서식 문서를 레거시 XML(병합형 — Sheet 루트에 페이지당 Object)로 직렬화</summary>
|
||||
public string Write(FormDocument document)
|
||||
{
|
||||
var xml = new XmlDocument();
|
||||
var sheet = xml.CreateElement("Sheet");
|
||||
var nameAttr = xml.CreateAttribute("name");
|
||||
nameAttr.Value = "SheetDesignInfo";
|
||||
sheet.Attributes.Append(nameAttr);
|
||||
xml.AppendChild(sheet);
|
||||
|
||||
foreach (var page in document.Pages)
|
||||
{
|
||||
sheet.AppendChild(WriteObject(xml, page.Root, 0, 0, isRoot: true));
|
||||
}
|
||||
|
||||
var builder = new StringBuilder();
|
||||
using (var writer = XmlWriter.Create(builder, new XmlWriterSettings
|
||||
{
|
||||
OmitXmlDeclaration = true,
|
||||
Indent = false,
|
||||
// Entitize: 텍스트 내 CR 을 
 로 보존(레거시 XmlDocument 출력과 동일) — 재파싱 정규화 손실 방지
|
||||
NewLineHandling = NewLineHandling.Entitize,
|
||||
// 레거시 데이터의 제어문자(0x05 등) verbatim 재방출 허용
|
||||
CheckCharacters = false,
|
||||
}))
|
||||
{
|
||||
xml.Save(writer);
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>관용 파서 — 문자 검사 없이 XmlDocument 로드(레거시 XmlTextReader 동등)</summary>
|
||||
internal static XmlDocument LoadLenient(string content)
|
||||
{
|
||||
var xml = new XmlDocument();
|
||||
using var reader = XmlReader.Create(new StringReader(content), new XmlReaderSettings
|
||||
{
|
||||
CheckCharacters = false,
|
||||
});
|
||||
xml.Load(reader);
|
||||
return xml;
|
||||
}
|
||||
|
||||
private XmlElement WriteObject(XmlDocument xml, ControlElement element, double absX, double absY, bool isRoot)
|
||||
{
|
||||
SyncBoundsToProps(element, absX, absY, isRoot);
|
||||
|
||||
var node = xml.CreateElement("Object");
|
||||
|
||||
// 어트리뷰트 순서는 레거시 writer 와 동일: type → name → children → DisplaySequence
|
||||
var aqn = element.LegacyAqn ?? LegacyTypeCatalog.AqnOf(element.Type)
|
||||
?? throw new InvalidOperationException($"타입 '{element.Type}' 의 레거시 AQN 을 결정할 수 없습니다: {element.Id}");
|
||||
AppendAttr(xml, node, "type", aqn);
|
||||
|
||||
if (element.HasNameAttr && element.Id.Length > 0)
|
||||
{
|
||||
AppendAttr(xml, node, "name", element.Id);
|
||||
}
|
||||
if (element.HasChildrenAttr)
|
||||
{
|
||||
AppendAttr(xml, node, "children", "Controls");
|
||||
}
|
||||
AppendAttr(xml, node, "DisplaySequence", element.DisplaySequence.ToString());
|
||||
|
||||
// 레거시 writer 규약: 자식 Object 먼저, Property 나중
|
||||
foreach (var child in element.Children)
|
||||
{
|
||||
node.AppendChild(WriteObject(xml, child, absX + child.Bounds.X, absY + child.Bounds.Y, isRoot: false));
|
||||
}
|
||||
|
||||
foreach (var (name, value) in element.Props)
|
||||
{
|
||||
node.AppendChild(WriteProperty(xml, name, value));
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounds → Props(Location/Size/LocationOnBase/Name) 동기화 — 레거시 writer 의 재계산 동작과 동일.
|
||||
/// XML 에서 읽어온 컨트롤(LegacyAqn 보유)은 있던 키만 갱신(순수 보존),
|
||||
/// 신규 생성 컨트롤(LegacyAqn 없음)에는 레거시 필수 속성(LocationOnBase/Name — 실샘플 117/117 보유)을 추가한다.
|
||||
/// </summary>
|
||||
private static void SyncBoundsToProps(ControlElement element, double absX, double absY, bool isRoot)
|
||||
{
|
||||
var isNew = element.LegacyAqn is null;
|
||||
|
||||
if (!isRoot)
|
||||
{
|
||||
if (element.HasLocation || element.Bounds.X != 0 || element.Bounds.Y != 0)
|
||||
{
|
||||
element.Props.SetText("Location", LegacyFormat.FormatPair(element.Bounds.X, element.Bounds.Y));
|
||||
element.HasLocation = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (element.HasSize || element.Bounds.W != 0 || element.Bounds.H != 0)
|
||||
{
|
||||
element.Props.SetText("Size", LegacyFormat.FormatPair(element.Bounds.W, element.Bounds.H));
|
||||
element.HasSize = true;
|
||||
}
|
||||
|
||||
// LocationOnBase 는 레거시 writer 가 저장 시마다 재계산(부모 절대좌표 누적)
|
||||
if (element.Props.Contains("LocationOnBase") || (!isRoot && isNew))
|
||||
{
|
||||
element.Props.SetText("LocationOnBase", LegacyFormat.FormatPair(absX, absY));
|
||||
}
|
||||
|
||||
// Name Property 는 name 어트리뷰트와 동일 값 유지(개명 동기화)
|
||||
if (element.Id.Length > 0 && (element.Props.Contains("Name") || isNew))
|
||||
{
|
||||
element.Props.SetText("Name", element.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private XmlElement WriteProperty(XmlDocument xml, string name, LegacyPropValue value)
|
||||
{
|
||||
var node = xml.CreateElement("Property");
|
||||
AppendAttr(xml, node, "name", name);
|
||||
WriteValueInto(xml, node, value);
|
||||
return node;
|
||||
}
|
||||
|
||||
private void WriteValueInto(XmlDocument xml, XmlElement node, LegacyPropValue value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case LegacyPropValue.TextValue text:
|
||||
node.InnerText = text.Value;
|
||||
break;
|
||||
|
||||
case LegacyPropValue.NullValue:
|
||||
break;
|
||||
|
||||
case LegacyPropValue.NestedValue nested:
|
||||
foreach (var (childName, childValue) in nested.Children)
|
||||
{
|
||||
node.AppendChild(WriteProperty(xml, childName, childValue));
|
||||
}
|
||||
break;
|
||||
|
||||
case LegacyPropValue.ItemsValue items:
|
||||
foreach (var item in items.Items)
|
||||
{
|
||||
var itemNode = xml.CreateElement("Item");
|
||||
AppendAttr(xml, itemNode, "type", item.Aqn);
|
||||
WriteValueInto(xml, itemNode, item.Value);
|
||||
node.AppendChild(itemNode);
|
||||
}
|
||||
break;
|
||||
|
||||
case LegacyPropValue.BinaryValue binary:
|
||||
var binaryNode = xml.CreateElement("Binary");
|
||||
binaryNode.InnerText = binary.Base64;
|
||||
node.AppendChild(binaryNode);
|
||||
break;
|
||||
|
||||
case LegacyPropValue.ReferenceValue reference:
|
||||
var referenceNode = xml.CreateElement("Reference");
|
||||
AppendAttr(xml, referenceNode, "name", reference.Name);
|
||||
node.AppendChild(referenceNode);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendAttr(XmlDocument xml, XmlElement node, string name, string value)
|
||||
{
|
||||
var attr = xml.CreateAttribute(name);
|
||||
attr.Value = value;
|
||||
node.Attributes.Append(attr);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
|
||||
namespace SheetMe.Core.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// XML 의미론 비교 — 레거시 로더가 보는 의미가 같은지 검사.
|
||||
/// 요소명·어트리뷰트·자식 요소 순서·텍스트를 비교하고, 빈 요소의 self-closing 여부와 요소 사이 공백은 무시한다.
|
||||
/// (왕복 테스트와 실DB 일괄 스모크가 공유)
|
||||
/// </summary>
|
||||
public static class XmlSemanticDiff
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>두 XML 문자열의 의미 차이 목록 — 완전 동일하면 빈 목록 (제어문자 관용 파싱)</summary>
|
||||
public static List<string> Compare(string expectedXml, string actualXml, int maxDiffs = 30)
|
||||
{
|
||||
var expected = LegacyXmlSerializer.LoadLenient(expectedXml);
|
||||
var actual = LegacyXmlSerializer.LoadLenient(actualXml);
|
||||
|
||||
var diffs = new List<string>();
|
||||
CompareElements(expected.DocumentElement!, actual.DocumentElement!, "/", diffs, maxDiffs);
|
||||
return diffs;
|
||||
}
|
||||
|
||||
private static void CompareElements(XmlElement expected, XmlElement actual, string path, List<string> diffs, int maxDiffs)
|
||||
{
|
||||
if (diffs.Count >= maxDiffs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentPath = $"{path}{expected.Name}[{Describe(expected)}]";
|
||||
|
||||
if (expected.Name != actual.Name)
|
||||
{
|
||||
diffs.Add($"{currentPath}: 요소명 다름 — 기대 <{expected.Name}> 실제 <{actual.Name}>");
|
||||
return;
|
||||
}
|
||||
|
||||
var expectedAttrs = expected.Attributes.Cast<XmlAttribute>().ToDictionary(a => a.Name, a => a.Value);
|
||||
var actualAttrs = actual.Attributes.Cast<XmlAttribute>().ToDictionary(a => a.Name, a => a.Value);
|
||||
foreach (var (name, value) in expectedAttrs)
|
||||
{
|
||||
if (!actualAttrs.TryGetValue(name, out var actualValue))
|
||||
{
|
||||
diffs.Add($"{currentPath}: 어트리뷰트 누락 — {name}=\"{value}\"");
|
||||
}
|
||||
else if (actualValue != value)
|
||||
{
|
||||
diffs.Add($"{currentPath}: 어트리뷰트 값 다름 — {name} 기대 \"{value}\" 실제 \"{actualValue}\"");
|
||||
}
|
||||
}
|
||||
foreach (var name in actualAttrs.Keys.Where(k => !expectedAttrs.ContainsKey(k)))
|
||||
{
|
||||
diffs.Add($"{currentPath}: 어트리뷰트 추가됨 — {name}=\"{actualAttrs[name]}\"");
|
||||
}
|
||||
|
||||
var expectedElements = expected.ChildNodes.OfType<XmlElement>().ToList();
|
||||
var actualElements = actual.ChildNodes.OfType<XmlElement>().ToList();
|
||||
|
||||
if (expectedElements.Count != actualElements.Count)
|
||||
{
|
||||
diffs.Add($"{currentPath}: 자식 요소 수 다름 — 기대 {expectedElements.Count} 실제 {actualElements.Count} " +
|
||||
$"(기대: {Summarize(expectedElements)} / 실제: {Summarize(actualElements)})");
|
||||
}
|
||||
|
||||
var count = Math.Min(expectedElements.Count, actualElements.Count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
CompareElements(expectedElements[i], actualElements[i], currentPath + "/", diffs, maxDiffs);
|
||||
if (diffs.Count >= maxDiffs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedElements.Count == 0 && actualElements.Count == 0)
|
||||
{
|
||||
var expectedText = GetMeaningfulText(expected);
|
||||
var actualText = GetMeaningfulText(actual);
|
||||
if (expectedText != actualText)
|
||||
{
|
||||
diffs.Add($"{currentPath}: 텍스트 다름 — 기대 \"{Truncate(expectedText)}\" 실제 \"{Truncate(actualText)}\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 전용 텍스트는 레거시 로더에서 null 로 취급 — "" 와 동일시
|
||||
private static string GetMeaningfulText(XmlElement element)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
foreach (XmlNode node in element.ChildNodes)
|
||||
{
|
||||
if (node.NodeType is XmlNodeType.Text or XmlNodeType.CDATA)
|
||||
{
|
||||
text.Append(node.Value);
|
||||
}
|
||||
}
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
private static string Describe(XmlElement element)
|
||||
=> element.GetAttribute("name") is { Length: > 0 } name ? name : element.Name;
|
||||
|
||||
private static string Summarize(List<XmlElement> elements)
|
||||
=> string.Join(",", elements.Take(8).Select(Describe)) + (elements.Count > 8 ? ",..." : string.Empty);
|
||||
|
||||
private static string Truncate(string value)
|
||||
=> value.Length > 60 ? value[..60] + "..." : value;
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user