초기 커밋: 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:
Msystech
2026-08-11 17:20:22 +09:00
co-authored by Claude Fable 5
commit 16c07f48dc
102 changed files with 14210 additions and 0 deletions
@@ -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 을 &#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
}