using System.Text; using System.Xml; using SheetMe.Core.Models; namespace SheetMe.Core.Serialization; /// /// 레거시 서식 XML 직렬화기 — v1 주 포맷. /// 포맷 원본: [003]DesignerHosting\clsBasicDesignerLoader.vb (읽기 LoadObject/ReadProperty·쓰기 WriteObjectDesignInfo), /// 병합 규약: clsCommonLib.Xml_MergeXmlDocument (Sheet 루트 1개에 페이지당 MDesignerHost Object N개). /// 모든 Property 를 PropBag 으로 무손실 왕복하며, 쓰기 시 Bounds 를 Location/Size/LocationOnBase 로 동기화한다. /// public sealed class LegacyXmlSerializer : IDesignSerializer { #region Methods - Read /// 레거시 XML 을 서식 문서로 역직렬화 — 파싱 경고는 document.Meta.ReadWarnings 에 수집 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; } /// 빈 페이지 생성 — 레거시 기본 신규 서식(MDesignerHost 720×856)과 동일 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 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 warnings) { var elementChildren = propNode.ChildNodes.OfType().ToList(); if (elementChildren.Count == 0) { var hasText = propNode.ChildNodes .Cast() .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 /// 서식 문서를 레거시 XML(병합형 — Sheet 루트에 페이지당 Object)로 직렬화 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(); } /// 관용 파서 — 문자 검사 없이 XmlDocument 로드(레거시 XmlTextReader 동등) 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; } /// /// Bounds → Props(Location/Size/LocationOnBase/Name) 동기화 — 레거시 writer 의 재계산 동작과 동일. /// XML 에서 읽어온 컨트롤(LegacyAqn 보유)은 있던 키만 갱신(순수 보존), /// 신규 생성 컨트롤(LegacyAqn 없음)에는 레거시 필수 속성(LocationOnBase/Name — 실샘플 117/117 보유)을 추가한다. /// 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 }