Files
SheetMe/src/SheetMe.Core/Serialization/LegacyXmlSerializer.cs
T
MsystechandClaude Fable 5 16c07f48dc 초기 커밋: 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>
2026-08-11 17:20:22 +09:00

385 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 을 &#xD; 로 보존(레거시 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
}