초기 커밋: 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,258 @@
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// 팔레트 컨트롤 카탈로그 — 팔레트 목록·기본 크기·필드 생성 규칙의 단일 출처.
|
||||
/// 속성 편집 스키마(PropertyDef)는 P3(인스펙터)에서 확장한다.
|
||||
/// </summary>
|
||||
public static class ControlRegistry
|
||||
{
|
||||
#region Member Fields
|
||||
private static readonly List<ControlDescriptor> all = new()
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = "Label", DisplayName = "라벨", DefaultW = 120, DefaultH = 22,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.MultilineText },
|
||||
new() { Key = "TextAlign", Label = "정렬", Editor = PropEditorKind.Choice, Choices = new[] { "TopLeft", "TopCenter", "TopRight", "MiddleLeft", "MiddleCenter", "MiddleRight" } },
|
||||
new() { Key = "ForeColor", Label = "글자색", Editor = PropEditorKind.Color },
|
||||
new() { Key = "BackColor", Label = "배경색", Editor = PropEditorKind.Color },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "TextBox", DisplayName = "텍스트박스", DefaultW = 160, DefaultH = 22, CreatesField = true,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
|
||||
new() { Key = "Multiline", Label = "여러 줄", Editor = PropEditorKind.Toggle },
|
||||
new() { Key = "TextAlign", Label = "정렬", Editor = PropEditorKind.Choice, Choices = new[] { "Left", "Center", "Right" } },
|
||||
new() { Key = "IsRequiredValue", Label = "필수 입력", Editor = PropEditorKind.Choice, Choices = new[] { "No", "Yes" } },
|
||||
new() { Key = "InitialValue", Label = "초기값", Editor = PropEditorKind.Text },
|
||||
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
|
||||
new() { Key = "DataActionTag", Label = "액션 태그", Editor = PropEditorKind.DataActionTag },
|
||||
new() { Key = "DataActionTagControl", Label = "액션 대상 컨트롤", Editor = PropEditorKind.Text },
|
||||
new() { Key = "RwdRsvWrdYon", Label = "상용구 사용", Editor = PropEditorKind.Toggle },
|
||||
new() { Key = "RwdOrderAutYon", Label = "처방연동 상용구", Editor = PropEditorKind.Toggle },
|
||||
new() { Key = "BackColor", Label = "배경색", Editor = PropEditorKind.Color },
|
||||
new() { Key = "BorderStyle", Label = "테두리", Editor = PropEditorKind.Choice, Choices = new[] { "None", "FixedSingle", "Fixed3D" } },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "MaskedTextBox", DisplayName = "마스크입력", DefaultW = 160, DefaultH = 22, CreatesField = true,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Mask", Label = "마스크", Editor = PropEditorKind.Text },
|
||||
new() { Key = "IsRequiredValue", Label = "필수 입력", Editor = PropEditorKind.Choice, Choices = new[] { "No", "Yes" } },
|
||||
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "CheckBox", DisplayName = "체크박스", DefaultW = 110, DefaultH = 20, CreatesField = true, DefaultDataType = "bool",
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
|
||||
new() { Key = "Checked", Label = "기본 체크", Editor = PropEditorKind.Toggle },
|
||||
new() { Key = "Score", Label = "점수", Editor = PropEditorKind.Number },
|
||||
new() { Key = "IsRequiredValue", Label = "필수 입력", Editor = PropEditorKind.Choice, Choices = new[] { "No", "Yes" } },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "RadioButton", DisplayName = "라디오버튼", DefaultW = 110, DefaultH = 20, CreatesField = true,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
|
||||
new() { Key = "Checked", Label = "기본 선택", Editor = PropEditorKind.Toggle },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "ComboBox", DisplayName = "콤보박스", DefaultW = 140, DefaultH = 22, CreatesField = true,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Items", Label = "선택지(줄바꿈 구분)", Editor = PropEditorKind.StringList },
|
||||
new() { Key = "IsRequiredValue", Label = "필수 입력", Editor = PropEditorKind.Choice, Choices = new[] { "No", "Yes" } },
|
||||
new() { Key = "DataTableField", Label = "데이터 필드", Editor = PropEditorKind.Text },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "ListBox", DisplayName = "리스트박스", DefaultW = 140, DefaultH = 90, CreatesField = true,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Items", Label = "항목(줄바꿈 구분)", Editor = PropEditorKind.StringList },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "CheckList", DisplayName = "체크리스트", DefaultW = 160, DefaultH = 90, CreatesField = true,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Items", Label = "항목(줄바꿈 구분)", Editor = PropEditorKind.StringList },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "DateTimePicker", DisplayName = "날짜선택", DefaultW = 140, DefaultH = 22, CreatesField = true, DefaultDataType = "datetime",
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Format", Label = "형식", Editor = PropEditorKind.Choice, Choices = new[] { "Long", "Short", "Time", "Custom" } },
|
||||
new() { Key = "CustomFormat", Label = "사용자 형식", Editor = PropEditorKind.Text },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "Panel", DisplayName = "패널", DefaultW = 240, DefaultH = 140, IsContainer = true,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "BackColor", Label = "배경색", Editor = PropEditorKind.Color },
|
||||
new() { Key = "BorderStyle", Label = "테두리", Editor = PropEditorKind.Choice, Choices = new[] { "None", "FixedSingle", "Fixed3D" } },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "GroupBox", DisplayName = "그룹박스", DefaultW = 240, DefaultH = 140, IsContainer = true,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Text", Label = "제목", Editor = PropEditorKind.Text },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "Line", DisplayName = "선", DefaultW = 200, DefaultH = 1,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "LineColor", Label = "선 색", Editor = PropEditorKind.Color },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "PictureBox", DisplayName = "이미지", DefaultW = 120, DefaultH = 90,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
|
||||
new() { Key = "SizeMode", Label = "크기 모드", Editor = PropEditorKind.Choice, Choices = new[] { "Normal", "StretchImage", "AutoSize", "CenterImage", "Zoom" } },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "CalcBox", DisplayName = "계산박스", DefaultW = 100, DefaultH = 22, CreatesField = true, DefaultDataType = "decimal",
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Formula", Label = "수식", Editor = PropEditorKind.MultilineText },
|
||||
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "Button", DisplayName = "버튼", DefaultW = 90, DefaultH = 26,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
|
||||
new() { Key = "DataActionTag", Label = "액션 태그", Editor = PropEditorKind.DataActionTag },
|
||||
new() { Key = "DataActionTagControl", Label = "액션 대상 컨트롤", Editor = PropEditorKind.Text },
|
||||
new() { Key = "GetDataActionTagControl", Label = "입력 파라미터 컨트롤", Editor = PropEditorKind.Text },
|
||||
new() { Key = "KeyboardShortcut", Label = "단축키", Editor = PropEditorKind.Text },
|
||||
new() { Key = "BackColor", Label = "배경색", Editor = PropEditorKind.Color },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "DataTable", DisplayName = "데이터소스", DefaultW = 34, DefaultH = 34,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Query", Label = "쿼리(SQL)", Editor = PropEditorKind.SqlQuery },
|
||||
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
|
||||
new() { Key = "ExcuteQuery", Label = "쿼리 실행", Editor = PropEditorKind.Toggle },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, ControlDescriptor> byType =
|
||||
all.ToDictionary(d => d.Type, StringComparer.Ordinal);
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>팔레트에 노출할 전체 컨트롤 목록</summary>
|
||||
public static IReadOnlyList<ControlDescriptor> All => all;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>타입명으로 기술자 조회 — 미지원 타입(Placeholder 포함)은 null</summary>
|
||||
public static ControlDescriptor? Find(string type)
|
||||
=> byType.TryGetValue(type, out var descriptor) ? descriptor : null;
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>팔레트 컨트롤 1종의 기술자</summary>
|
||||
public sealed class ControlDescriptor
|
||||
{
|
||||
/// <summary>중립 타입명(LegacyTypeCatalog 와 공유하는 키)</summary>
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>팔레트 표시명(한글)</summary>
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>드롭 시 기본 너비(px)</summary>
|
||||
public double DefaultW { get; init; }
|
||||
|
||||
/// <summary>드롭 시 기본 높이(px)</summary>
|
||||
public double DefaultH { get; init; }
|
||||
|
||||
/// <summary>컨테이너 여부(Panel/GroupBox — 자식 배치 가능)</summary>
|
||||
public bool IsContainer { get; init; }
|
||||
|
||||
/// <summary>배치 시 데이터 필드를 만드는 입력 컨트롤인지</summary>
|
||||
public bool CreatesField { get; init; }
|
||||
|
||||
/// <summary>필드 기본 데이터 타입</summary>
|
||||
public string DefaultDataType { get; init; } = "string";
|
||||
|
||||
/// <summary>인스펙터 속성 편집 스키마(타입 전용 — 공통 X/Y/W/H/Id/폰트는 인스펙터가 별도 제공)</summary>
|
||||
public IReadOnlyList<PropertyDef> Properties { get; init; } = Array.Empty<PropertyDef>();
|
||||
}
|
||||
|
||||
/// <summary>속성 편집기 종류</summary>
|
||||
public enum PropEditorKind
|
||||
{
|
||||
/// <summary>문자열</summary>
|
||||
Text,
|
||||
/// <summary>여러 줄 문자열</summary>
|
||||
MultilineText,
|
||||
/// <summary>숫자</summary>
|
||||
Number,
|
||||
/// <summary>참/거짓</summary>
|
||||
Toggle,
|
||||
/// <summary>선택지</summary>
|
||||
Choice,
|
||||
/// <summary>색(레거시 invariant 문자열)</summary>
|
||||
Color,
|
||||
/// <summary>문자열 목록(콤보 items 등 — 줄바꿈 구분 편집)</summary>
|
||||
StringList,
|
||||
/// <summary>데이터 태그 피커(bzDataInterface 메서드 — 자동 채움 원천)</summary>
|
||||
DataInterfaceTag,
|
||||
/// <summary>액션 태그 피커(EN_DataActionTyp — 더블클릭/버튼 액션)</summary>
|
||||
DataActionTag,
|
||||
/// <summary>SQL 쿼리 — 전용 편집기 창(치환 변수 삽입 지원)</summary>
|
||||
SqlQuery,
|
||||
}
|
||||
|
||||
/// <summary>인스펙터 속성 정의 1건 — PropBag 의 레거시 Property 를 편집</summary>
|
||||
public sealed class PropertyDef
|
||||
{
|
||||
/// <summary>PropBag 키(레거시 Property 이름)</summary>
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>표시 라벨(한글)</summary>
|
||||
public string Label { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>편집기 종류</summary>
|
||||
public PropEditorKind Editor { get; init; }
|
||||
|
||||
/// <summary>Choice 편집기의 선택지</summary>
|
||||
public string[]? Choices { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// 레거시 태그 값 카탈로그 — 인스펙터 피커의 선택지.
|
||||
/// 원본: [003]EMR\[002]UserControl\clsBaseUserControl.vb 의 EN_DataActionTyp(선언 순서 보존 — 값 의미상 순서 변경 금지),
|
||||
/// [001]Common\[012]DataTag\M.CMM.DataTag\bzDataInterface.vb 의 Public Function 목록 (2026-07-16 추출).
|
||||
/// 사이트 커스텀 태그는 텍스트 직접 입력으로 지원(피커는 선택 보조).
|
||||
/// </summary>
|
||||
public static class LegacyTagCatalog
|
||||
{
|
||||
/// <summary>액션 태그(EN_DataActionTyp) — 더블클릭/버튼 액션 종류</summary>
|
||||
public static readonly string[] DataActionTags =
|
||||
{
|
||||
"None",
|
||||
"DtrLicList",
|
||||
"OkdInfList",
|
||||
"ComPidList",
|
||||
"YondoList",
|
||||
"ImgeList",
|
||||
"ChoiceData",
|
||||
"AllCheck",
|
||||
"ReturnUidNam",
|
||||
"OprInfList",
|
||||
"TPR",
|
||||
"VisibleYon",
|
||||
"DateVisibleYon",
|
||||
"ReturnEmrData",
|
||||
"ReturnAllEmrData",
|
||||
"EmgMnsList",
|
||||
"EmgLevTim",
|
||||
"CodeFInd",
|
||||
"EmgKTAS",
|
||||
"NdsMst",
|
||||
"UmlsMst",
|
||||
"ComTrsDtm",
|
||||
"PikPs1",
|
||||
"ButtonData",
|
||||
"OkdInfData",
|
||||
"EmgTotalEmr",
|
||||
"LabOdrList",
|
||||
"PdeOdrList",
|
||||
"ComOkdList",
|
||||
"MakeDataList",
|
||||
"CodList",
|
||||
"PrgRcdList",
|
||||
"PanelVisible",
|
||||
"PanelUnVisible",
|
||||
"MinusToPlus",
|
||||
"TPRList",
|
||||
"OrderList",
|
||||
"BldOutList",
|
||||
"BlmInfList",
|
||||
"ReturnLabData",
|
||||
"ReturnLevOrderList",
|
||||
"SetVisibleStatus",
|
||||
"HiraSearch",
|
||||
"DataCopy_Btn",
|
||||
"DataCopy_MtM",
|
||||
"EndoOdrList",
|
||||
"EndoOdrList2",
|
||||
"LabOdrCodList",
|
||||
"상병발생진단일",
|
||||
"UnAllCheck",
|
||||
"MaterialList",
|
||||
"IcpMstList",
|
||||
"UserLicList",
|
||||
"SgaSearch",
|
||||
"OkdInfEngData",
|
||||
"OrderEngList",
|
||||
"DataCopy_Chk",
|
||||
"DataCopy_Chk_UidNam",
|
||||
"DataCopy_Opt",
|
||||
"PanelVisibility_Opt",
|
||||
"ComAcpLev",
|
||||
"ComAcpLev_O",
|
||||
"ComAcpLev_O_Ver2",
|
||||
"ComAcpLev_I",
|
||||
"ComAcpLev_I_Ver2",
|
||||
"ComAcpLev_I_Ver3",
|
||||
"CowInfData",
|
||||
"AllCheck_Opt",
|
||||
"EmgOkdInfData",
|
||||
"ChildIBWCalc",
|
||||
"EmgDetList",
|
||||
"OrderNameList",
|
||||
"NurseList",
|
||||
"EmrDataCopy",
|
||||
"TextYorN",
|
||||
"TextPlusMinus",
|
||||
"SMSWebLink",
|
||||
"RadCheckControl",
|
||||
"NonOdrCodList",
|
||||
"ZipFinder",
|
||||
"OprInfList_Cht",
|
||||
"ChkPatData",
|
||||
"PatInfList",
|
||||
"HiPassPatList",
|
||||
"ChildIBWValue",
|
||||
"DataTableRefresh",
|
||||
"PathologyInfo",
|
||||
"UserListTile_Doctor",
|
||||
"UserListTile_Nurse_OPR",
|
||||
"UserListTile_Nurse_ANE",
|
||||
"UserListTile_Nurse_ASS",
|
||||
"UserListTile_Nurse_CIR",
|
||||
"UserListTile_nurse_SCR",
|
||||
"UserListTile_Nurse_WRD",
|
||||
"UserListTile_LAB",
|
||||
"ReturnProgressRecord",
|
||||
"RstInfList",
|
||||
"TextConnect",
|
||||
"DssOkdOpnLst",
|
||||
"OprInfList_Multi",
|
||||
"IndAccDetail",
|
||||
"IndAccDetail_Chart",
|
||||
"ChkDataCopy",
|
||||
"ChkDataCopy_MtM",
|
||||
"AneDtrLicList",
|
||||
"PreOprInfList",
|
||||
"PresentTime",
|
||||
"SingleOprInfo",
|
||||
"GetTerminology",
|
||||
"ComTrsLev",
|
||||
"Expanded",
|
||||
"UnExpanded",
|
||||
"EyeTestResultView",
|
||||
"OkdInf_MainSub",
|
||||
"UserAllLicList",
|
||||
"DateTime_Calculation",
|
||||
"EmrSelectData",
|
||||
"DateTime_DateDiff_Day",
|
||||
"ComPidListDate",
|
||||
"AllCheckUnCheck",
|
||||
"NonOdrCodList_WithDtl",
|
||||
"EmrSelectData_Program",
|
||||
"PdeOdrList_OdrNam",
|
||||
"TPRList_VS_HW",
|
||||
"ProgressRecord",
|
||||
"XrayOdrCodList",
|
||||
"OprOrderList",
|
||||
"MakeNurseRecord_S077",
|
||||
"OprOkdList",
|
||||
"GCSCheck",
|
||||
"ComboBox_COPY_ATOB",
|
||||
"NonOdrCodList_Dep",
|
||||
"EvalutaionList",
|
||||
"DepMstList",
|
||||
"NurUidList",
|
||||
"PhaLicList",
|
||||
"OnsetInsert",
|
||||
"BldInfoDetail",
|
||||
"GetEmrData",
|
||||
"RefreshDataInterfaceTag",
|
||||
"ICUInfList",
|
||||
"OprUidList",
|
||||
"QueryResultViewer",
|
||||
"ComAcpLev_O_Ver3",
|
||||
"OpDtrLicList",
|
||||
"OrderCPTList",
|
||||
"GroupResultCheck",
|
||||
"OrderList_Usg",
|
||||
"UnAllCheck_Opt",
|
||||
};
|
||||
|
||||
/// <summary>데이터 태그(bzDataInterface 메서드) — 자동 채움 데이터 원천</summary>
|
||||
public static readonly string[] DataInterfaceTags =
|
||||
{
|
||||
"ECT_로그인사용자싸인",
|
||||
"ETC_건강보험증번호",
|
||||
"ETC_건강보험증번호_Refer",
|
||||
"ETC_로그인_근무부서",
|
||||
"ETC_로그인_근무부서_사용자명",
|
||||
"ETC_로그인_사용자ID",
|
||||
"ETC_로그인_사용자ID_사용자명",
|
||||
"ETC_로그인_사용자명",
|
||||
"ETC_로그인_사용자연락처",
|
||||
"ETC_로그인_원내연락처",
|
||||
"ETC_로그인_의사면허번호",
|
||||
"ETC_로그인_전문의번호",
|
||||
"ETC_로그인_전문의번호_Refer",
|
||||
"ETC_로그인_직급",
|
||||
"ETC_로그인사용자_병동_수간호사",
|
||||
"ETC_로그인사용자_병동_수간호사_싸인",
|
||||
"ETC_로그인사용자_병동간호사목록_InitalDatatable",
|
||||
"ETC_로그인사용자_서명",
|
||||
"ETC_마취간호사List_InitialDatatable",
|
||||
"ETC_마취과의사List_InitialDatatable",
|
||||
"ETC_마취및소독간호사List_InitialDatatable",
|
||||
"ETC_병리판독의사명",
|
||||
"ETC_병리판독의사싸인",
|
||||
"ETC_병리판독의사전문의번호",
|
||||
"ETC_산재지정번호",
|
||||
"ETC_소독간호사List_InitialDatatable",
|
||||
"ETC_수술_내시경간호사목록_InitalDatatable",
|
||||
"ETC_수술간호사목록",
|
||||
"ETC_수술간호사목록_InitalDatatable",
|
||||
"ETC_수술실간호사",
|
||||
"ETC_수술일자_1_몇년",
|
||||
"ETC_수술일자_1_몇년2자리",
|
||||
"ETC_수술일자_2_몇월",
|
||||
"ETC_수술일자_3_몇일",
|
||||
"ETC_순환간호사List_InitialDatatable",
|
||||
"ETC_승인의사",
|
||||
"ETC_어시스트간호사List_InitialDatatable",
|
||||
"ETC_영상의학과의사싸인",
|
||||
"ETC_외출외박_확인간호사싸인",
|
||||
"ETC_외출외박_확인원무과싸인",
|
||||
"ETC_요양기관기호",
|
||||
"ETC_요양기관명칭_병원대표자",
|
||||
"ETC_요양기관명칭_병원로고",
|
||||
"ETC_요양기관명칭_병원명",
|
||||
"ETC_요양기관명칭_병원명_영문",
|
||||
"ETC_요양기관명칭_병원장",
|
||||
"ETC_요양기관명칭_병원장귀하",
|
||||
"ETC_요양기관명칭_병원전화_팩스번호",
|
||||
"ETC_요양기관명칭_병원전화번호",
|
||||
"ETC_요양기관명칭_병원주소",
|
||||
"ETC_요양기관명칭_병원주소_영문",
|
||||
"ETC_요양기관명칭_병원직인",
|
||||
"ETC_요양기관명칭_병원팩스번호",
|
||||
"ETC_요양기관명칭_사업자등록번호",
|
||||
"ETC_요양기관명칭_영문_병원전화_팩스번호",
|
||||
"ETC_의사List_이름순",
|
||||
"ETC_재해발생일",
|
||||
"ETC_전체진료과_영문명",
|
||||
"ETC_전체진료과_한글명",
|
||||
"ETC_접속유저_부서번호",
|
||||
"ETC_제한향균제_InitialDatatable",
|
||||
"ETC_진단검사의사명",
|
||||
"ETC_진단검사의사싸인",
|
||||
"ETC_진단검사의사전문의번호",
|
||||
"ETC_진료과별의사List_이름순",
|
||||
"ETC_현재년도",
|
||||
"ETC_현재시간",
|
||||
"ETC_현재일시",
|
||||
"ETC_현재일시_1_년",
|
||||
"ETC_현재일시_2_월",
|
||||
"ETC_현재일시_3_일",
|
||||
"ETC_현재일시_4_몇시",
|
||||
"ETC_현재일시_5_몇분",
|
||||
"ETC_현재일시_MaskBox",
|
||||
"ETC_현재일자",
|
||||
"ETC_현재일자_영문",
|
||||
"GetNurseEmrDT",
|
||||
"GetNurseShtCod",
|
||||
"GetNurseShtCod_GCRCH",
|
||||
"GetNurseShtCod_GNBEDRO",
|
||||
"GetNurseShtCod_SRH",
|
||||
"GetOprInfDT",
|
||||
"GetOprInfDT_OKDMain",
|
||||
"OCM_BMI",
|
||||
"OCM_BST",
|
||||
"OCM_BST_LAST",
|
||||
"OCM_Cosign_싸인",
|
||||
"OCM_Cosign_의사명",
|
||||
"OCM_DX",
|
||||
"OCM_FollowUp",
|
||||
"OCM_LMP",
|
||||
"OCM_OPNAME",
|
||||
"OCM_OPRCODNAM",
|
||||
"OCM_SPO2",
|
||||
"OCM_SPO2_LAST",
|
||||
"OCM_STFDTR",
|
||||
"OCM_User진료과",
|
||||
"OCM_User진료과_Refer",
|
||||
"OCM_User진료과_한글명칭",
|
||||
"OCM_User진료의사명",
|
||||
"OCM_User진료의사명_Refer",
|
||||
"OCM_VITAL접수일시_LAST",
|
||||
"OCM_간호정보조사지_과거력",
|
||||
"OCM_감염분류_감염정보",
|
||||
"OCM_감염정보",
|
||||
"OCM_계산유형",
|
||||
"OCM_내원유형",
|
||||
"OCM_담당의사",
|
||||
"OCM_담당의사_Refer",
|
||||
"OCM_담당의사_담당의사코드",
|
||||
"OCM_담당의사_연락처",
|
||||
"OCM_담당의사_영문",
|
||||
"OCM_담당의사_영문_Refer",
|
||||
"OCM_담당의사면허번호",
|
||||
"OCM_담당의사면허번호_Refer",
|
||||
"OCM_담당의사싸인",
|
||||
"OCM_담당의사싸인_Refer",
|
||||
"OCM_담당의사전문의번호",
|
||||
"OCM_담당의사전문의번호_Refer",
|
||||
"OCM_당뇨식칼로리_List",
|
||||
"OCM_당일식이",
|
||||
"OCM_로그인_사용자싸인",
|
||||
"OCM_맥박",
|
||||
"OCM_맥박_LAST",
|
||||
"OCM_머리둘레",
|
||||
"OCM_머리둘레_LAST",
|
||||
"OCM_몸무게",
|
||||
"OCM_몸무게_LAST",
|
||||
"OCM_발병일",
|
||||
"OCM_병동",
|
||||
"OCM_병동_Refer",
|
||||
"OCM_병동_병실",
|
||||
"OCM_병동_병실_베드",
|
||||
"OCM_병실",
|
||||
"OCM_병실_Refer",
|
||||
"OCM_병실베드",
|
||||
"OCM_병실인실",
|
||||
"OCM_병실차액",
|
||||
"OCM_보험유형",
|
||||
"OCM_보험유형_Refer",
|
||||
"OCM_부상병POA",
|
||||
"OCM_부상병POA_Refer",
|
||||
"OCM_부상병명",
|
||||
"OCM_부상병명_Refer",
|
||||
"OCM_부상병코드",
|
||||
"OCM_부상병코드_Refer",
|
||||
"OCM_비급여처방",
|
||||
"OCM_비만도",
|
||||
"OCM_상병명_가로",
|
||||
"OCM_상병명_가로_Refer",
|
||||
"OCM_상병명_상병코드",
|
||||
"OCM_상병명_상병코드_Refer",
|
||||
"OCM_상병명_세로",
|
||||
"OCM_상병명_세로_Refer",
|
||||
"OCM_상병명_영어_가로",
|
||||
"OCM_상병명_영어_가로_Refer",
|
||||
"OCM_상병명_영어_세로",
|
||||
"OCM_상병명_영어_세로_Refer",
|
||||
"OCM_상병코드_가로",
|
||||
"OCM_상병코드_가로_Refer",
|
||||
"OCM_상병코드_상병명",
|
||||
"OCM_상병코드_상병명_Refer",
|
||||
"OCM_상병코드_상병명_부상병",
|
||||
"OCM_상병코드_상병명_부상병_Refer",
|
||||
"OCM_상병코드_상병명_주상병",
|
||||
"OCM_상병코드_상병명_주상병_Refer",
|
||||
"OCM_상병코드_세로",
|
||||
"OCM_상병코드_세로_Refer",
|
||||
"OCM_수납일시",
|
||||
"OCM_수납일시_Refer",
|
||||
"OCM_수술_OprPatETC",
|
||||
"OCM_수술마취의사",
|
||||
"OCM_수술마취의사싸인",
|
||||
"OCM_수술마취의사싸인_수술접수연동",
|
||||
"OCM_수술명칭",
|
||||
"OCM_수술부위",
|
||||
"OCM_수술상병",
|
||||
"OCM_수술소독간호사",
|
||||
"OCM_수술일자",
|
||||
"OCM_수술일자_Refer",
|
||||
"OCM_수술일자_마지막수술",
|
||||
"OCM_수술주상병",
|
||||
"OCM_수술진단명",
|
||||
"OCM_수술집도의",
|
||||
"OCM_수술집도의_그룹진료과",
|
||||
"OCM_수술집도의_싸인",
|
||||
"OCM_수술집도의_진료과",
|
||||
"OCM_수술처치",
|
||||
"OCM_신환_초진일",
|
||||
"OCM_신환_초진일_Refer",
|
||||
"OCM_심사_부상병명",
|
||||
"OCM_심사_부상병코드",
|
||||
"OCM_심사_주상병명",
|
||||
"OCM_심사_주상병코드",
|
||||
"OCM_알러지",
|
||||
"OCM_알러지_List",
|
||||
"OCM_알러지_기타",
|
||||
"OCM_알러지_약물",
|
||||
"OCM_알러지_음식",
|
||||
"OCM_알러지_조영제",
|
||||
"OCM_영상촬영이력",
|
||||
"OCM_영상촬영이력_Refer",
|
||||
"OCM_외래내원일시",
|
||||
"OCM_외래내원일시_Refer",
|
||||
"OCM_외래내원일자",
|
||||
"OCM_외래내원일자_Refer",
|
||||
"OCM_외래내원일자_영문",
|
||||
"OCM_외래실진료일",
|
||||
"OCM_외래실진료일_refer",
|
||||
"OCM_외래실진료일_과목별",
|
||||
"OCM_외래실진료일수",
|
||||
"OCM_외래실진료일수_Refer",
|
||||
"OCM_외출예상시작일시",
|
||||
"OCM_외출예상종료일시",
|
||||
"OCM_외출외박신청일",
|
||||
"OCM_외출외박종료일",
|
||||
"OCM_응급증상여부_NO",
|
||||
"OCM_응급증상여부_YES",
|
||||
"OCM_의사퇴원예고일시",
|
||||
"OCM_임신주기",
|
||||
"OCM_입실시간",
|
||||
"OCM_입원과",
|
||||
"OCM_입원과_Refer",
|
||||
"OCM_입원과_한글명칭",
|
||||
"OCM_입원내원일시",
|
||||
"OCM_입원내원일시_Refer",
|
||||
"OCM_입원내원일자",
|
||||
"OCM_입원내원일자_Refer",
|
||||
"OCM_입원시간",
|
||||
"OCM_입원시간_Refer",
|
||||
"OCM_입원의사",
|
||||
"OCM_입원의사_Refer",
|
||||
"OCM_입원일시",
|
||||
"OCM_입원일시_Refer",
|
||||
"OCM_입원일시_영문",
|
||||
"OCM_입원일자_낮병동",
|
||||
"OCM_입원전환일시",
|
||||
"OCM_입원전환일시_Refer",
|
||||
"OCM_입원전환일자",
|
||||
"OCM_입원전환일자_Refer",
|
||||
"OCM_입통원구분_입원",
|
||||
"OCM_입통원구분_입원_Refer",
|
||||
"OCM_입통원구분_통원",
|
||||
"OCM_입통원구분_통원_Refer",
|
||||
"OCM_재원일수",
|
||||
"OCM_재원일수_Refer",
|
||||
"OCM_재원일수_낮병동",
|
||||
"OCM_재원일수_단입법",
|
||||
"OCM_적용진료과",
|
||||
"OCM_적용진료의사",
|
||||
"OCM_전과",
|
||||
"OCM_전과내역",
|
||||
"OCM_전과내역_Refer",
|
||||
"OCM_전과일시",
|
||||
"OCM_전과일자",
|
||||
"OCM_조정체중_LAST",
|
||||
"OCM_주상병POA",
|
||||
"OCM_주상병POA_Refer",
|
||||
"OCM_주상병명",
|
||||
"OCM_주상병명_Refer",
|
||||
"OCM_주상병명_영어",
|
||||
"OCM_주상병명_영어_Refer",
|
||||
"OCM_주상병일자",
|
||||
"OCM_주상병일자_Refer",
|
||||
"OCM_주상병코드",
|
||||
"OCM_주상병코드_Refer",
|
||||
"OCM_주상병특정기호",
|
||||
"OCM_주상병특정기호_Refer",
|
||||
"OCM_진단구분_상병명",
|
||||
"OCM_진단구분_상병코드_상병명",
|
||||
"OCM_진단구분_상병코드_상병명_Refer",
|
||||
"OCM_진단서의사_영문",
|
||||
"OCM_진단서의사_영문_Refer",
|
||||
"OCM_진단서의사면허번호",
|
||||
"OCM_진단서의사면허번호_Refer",
|
||||
"OCM_진단서의사명",
|
||||
"OCM_진단서의사명_Refer",
|
||||
"OCM_진단서의사명_외래",
|
||||
"OCM_진단서의사세부전문의번호",
|
||||
"OCM_진단서의사싸인",
|
||||
"OCM_진단서의사싸인_Refer",
|
||||
"OCM_진단서의사전문의번호",
|
||||
"OCM_진단서의사전문의번호_Refer",
|
||||
"OCM_진단서전문과목",
|
||||
"OCM_진단서전문과목_Refer",
|
||||
"OCM_진단서전문그룹과목",
|
||||
"OCM_진단서전문그룹과목_Refer",
|
||||
"OCM_진단서청구진료과목_로그인",
|
||||
"OCM_진료과",
|
||||
"OCM_진료과_Refer",
|
||||
"OCM_진료과_그룹코드",
|
||||
"OCM_진료과_영어",
|
||||
"OCM_진료과_전화번호",
|
||||
"OCM_진료과대외명칭",
|
||||
"OCM_진료과대외명칭_Refer",
|
||||
"OCM_진료과약어명칭",
|
||||
"OCM_진료기간",
|
||||
"OCM_진료기간_Refer",
|
||||
"OCM_진료기간_진료과",
|
||||
"OCM_진료기간_진료과_Refer",
|
||||
"OCM_청구진료과목",
|
||||
"OCM_청구진료과목_Refer",
|
||||
"OCM_체온",
|
||||
"OCM_체온_LAST",
|
||||
"OCM_초진일_발병일",
|
||||
"OCM_초진일_발병일_Refer",
|
||||
"OCM_최초내원일",
|
||||
"OCM_키",
|
||||
"OCM_키_LAST",
|
||||
"OCM_퇴원과",
|
||||
"OCM_퇴원과_Refer",
|
||||
"OCM_퇴원과_한글명칭",
|
||||
"OCM_퇴원분석_수술처치명",
|
||||
"OCM_퇴원시간",
|
||||
"OCM_퇴원시간_Refer",
|
||||
"OCM_퇴원약",
|
||||
"OCM_퇴원약_New",
|
||||
"OCM_퇴원약_Refer",
|
||||
"OCM_퇴원약_명칭",
|
||||
"OCM_퇴원약_용량",
|
||||
"OCM_퇴원약_용법",
|
||||
"OCM_퇴원약_일수",
|
||||
"OCM_퇴원약_코드",
|
||||
"OCM_퇴원약_횟수",
|
||||
"OCM_퇴원예고일시",
|
||||
"OCM_퇴원예고재원일수",
|
||||
"OCM_퇴원예정일시",
|
||||
"OCM_퇴원의사",
|
||||
"OCM_퇴원의사_Refer",
|
||||
"OCM_퇴원일시",
|
||||
"OCM_퇴원일시_Refer",
|
||||
"OCM_퇴원일시_영문",
|
||||
"OCM_퇴원일자_낮병동",
|
||||
"OCM_표준체중_LAST",
|
||||
"OCM_혈압",
|
||||
"OCM_혈압_BPD",
|
||||
"OCM_혈압_BPS",
|
||||
"OCM_혈압_LAST",
|
||||
"OCM_혈액형",
|
||||
"OCM_협진과",
|
||||
"OCM_협진과_협진의",
|
||||
"OCM_호흡",
|
||||
"OCM_호흡_LAST",
|
||||
"PAT_BMI",
|
||||
"PAT_IBW",
|
||||
"PAT_IBW_퍼센트",
|
||||
"PAT_건보_급여_세대주명",
|
||||
"PAT_건보세대주명",
|
||||
"PAT_국적",
|
||||
"PAT_나이",
|
||||
"PAT_나이_00Months_00Days",
|
||||
"PAT_나이_00개월_00일",
|
||||
"PAT_나이_00개월_일",
|
||||
"PAT_나이_개월_00일",
|
||||
"PAT_나이_개월수",
|
||||
"PAT_나이_세",
|
||||
"PAT_도로명주소",
|
||||
"PAT_보호자연락처",
|
||||
"PAT_생년월일",
|
||||
"PAT_생년월일_영문",
|
||||
"PAT_생년월일_영문_일월년도",
|
||||
"PAT_성별",
|
||||
"PAT_성별_나이",
|
||||
"PAT_성별_남",
|
||||
"PAT_성별_여",
|
||||
"PAT_실제생년월일",
|
||||
"PAT_여권번호",
|
||||
"PAT_연락처",
|
||||
"PAT_연락처_뒷4자리",
|
||||
"PAT_영문도로명주소",
|
||||
"PAT_영문이름",
|
||||
"PAT_우편번호",
|
||||
"PAT_이름",
|
||||
"PAT_자택전화번호",
|
||||
"PAT_장애등급",
|
||||
"PAT_주민번호",
|
||||
"PAT_주민번호_Blind",
|
||||
"PAT_주민번호_Dash",
|
||||
"PAT_주민번호_SexTyp_Blind",
|
||||
"PAT_주민번호_SexTyp_BlindStar",
|
||||
"PAT_지번주소",
|
||||
"PAT_차트번호",
|
||||
"PAT_한글성별",
|
||||
"PAT_협력업체",
|
||||
"PAT_휴대전화번호",
|
||||
"PAT_휴대전화번호_뒷4자리",
|
||||
"퇴원약DT",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
namespace SheetMe.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 배치된 컨트롤 1개 — 레거시 XML의 <Object> 노드 1개에 대응.
|
||||
/// 모든 Property 는 <see cref="Props"/>에 원본 순서로 무손실 보존되고,
|
||||
/// 편집 편의를 위해 Location/Size 만 <see cref="Bounds"/>로 리프트한다
|
||||
/// (편집 중 Props 의 Location/Size 는 stale — 쓰기 시 Bounds 값으로 동기화된다).
|
||||
/// </summary>
|
||||
public sealed class ControlElement
|
||||
{
|
||||
#region Properties
|
||||
/// <summary>중립 타입명 — ControlRegistry/LegacyTypeCatalog 의 키. 미지원 타입은 "Placeholder"</summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>컨트롤 이름(name 어트리뷰트) — 레거시 SctObjNam 등가, 문서 내 유일해야 함</summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>원본 AssemblyQualifiedName — 읽기 시 원문 보존, 신규 생성 시 카탈로그에서 채움</summary>
|
||||
public string? LegacyAqn { get; set; }
|
||||
|
||||
/// <summary>원본에 name 어트리뷰트가 있었는지 (왕복 보존)</summary>
|
||||
public bool HasNameAttr { get; set; } = true;
|
||||
|
||||
/// <summary>원본에 children 어트리뷰트가 있었는지 (Control 이면 "Controls")</summary>
|
||||
public bool HasChildrenAttr { get; set; } = true;
|
||||
|
||||
/// <summary>Object 어트리뷰트 DisplaySequence 값 (레거시 writer 는 항상 0 기록)</summary>
|
||||
public int DisplaySequence { get; set; }
|
||||
|
||||
/// <summary>부모 기준 배치(px) — Props 의 Location/Size 리프트</summary>
|
||||
public LayoutRect Bounds { get; set; } = new();
|
||||
|
||||
/// <summary>원본 Props 에 Location 이 있었는지 — 쓰기 시 생략/기록 판단</summary>
|
||||
public bool HasLocation { get; set; }
|
||||
|
||||
/// <summary>원본 Props 에 Size 가 있었는지 — 쓰기 시 생략/기록 판단</summary>
|
||||
public bool HasSize { get; set; }
|
||||
|
||||
/// <summary>전체 속성 가방(원본 순서 보존) — Location/Size 포함(쓰기 시 Bounds 로 동기화)</summary>
|
||||
public PropBag Props { get; set; } = new();
|
||||
|
||||
/// <summary>자식 컨트롤 — 레거시 Controls 컬렉션 순서 그대로(index 0 = 최상위 z-order)</summary>
|
||||
public List<ControlElement> Children { get; set; } = new();
|
||||
|
||||
/// <summary>디자이너 편집 잠금(직렬화 안 함 — 편집 전용 상태)</summary>
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public bool Locked { get; set; }
|
||||
|
||||
/// <summary>디자이너 임시 숨김(직렬화 안 함 — 편집 전용 상태, 레거시 Visible 과 별개)</summary>
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public bool Hidden { get; set; }
|
||||
|
||||
/// <summary>디자이너 그룹 식별자(직렬화 안 함 — 편집 전용 상태)</summary>
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public string? GroupId { get; set; }
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>텍스트 속성(Text) 편의 접근 — 없으면 빈 문자열</summary>
|
||||
public string GetText() => Props.GetText("Text") ?? string.Empty;
|
||||
|
||||
/// <summary>깊은 복제(자식·속성 포함)</summary>
|
||||
public ControlElement Clone()
|
||||
{
|
||||
var clone = new ControlElement
|
||||
{
|
||||
Type = Type,
|
||||
Id = Id,
|
||||
LegacyAqn = LegacyAqn,
|
||||
HasNameAttr = HasNameAttr,
|
||||
HasChildrenAttr = HasChildrenAttr,
|
||||
DisplaySequence = DisplaySequence,
|
||||
Bounds = Bounds.Clone(),
|
||||
HasLocation = HasLocation,
|
||||
HasSize = HasSize,
|
||||
Props = Props.Clone(),
|
||||
Locked = Locked,
|
||||
Hidden = Hidden,
|
||||
GroupId = GroupId,
|
||||
};
|
||||
foreach (var child in Children)
|
||||
{
|
||||
clone.Children.Add(child.Clone());
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace SheetMe.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 서식 문서 — 직렬화(레거시 XML/JSON)의 단위. 페이지 목록과 메타데이터를 갖는다.
|
||||
/// FormId/Title 은 XML 본문이 아닌 DB(E_ShtMst)에서 오는 메타데이터다.
|
||||
/// </summary>
|
||||
public sealed class FormDocument
|
||||
{
|
||||
#region Properties
|
||||
/// <summary>서식 코드 — E_ShtMst.ShtCod (파일 기반일 때는 파일명 유래)</summary>
|
||||
public string FormId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>서식 명칭 — E_ShtMst.ShtKorNam</summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>페이지 목록(순서 = 페이지 순서)</summary>
|
||||
public List<FormPage> Pages { get; set; } = new();
|
||||
|
||||
/// <summary>부가 메타(출처/감사/읽기 경고)</summary>
|
||||
public FormMeta Meta { get; set; } = new();
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>깊은 복제 — Undo 스냅샷용(편집 전용 상태 포함)</summary>
|
||||
public FormDocument Clone()
|
||||
{
|
||||
var clone = new FormDocument
|
||||
{
|
||||
FormId = FormId,
|
||||
Title = Title,
|
||||
Meta = Meta.Clone(),
|
||||
};
|
||||
foreach (var page in Pages)
|
||||
{
|
||||
clone.Pages.Add(page.Clone());
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SheetMe.Core.Models;
|
||||
|
||||
/// <summary>서식 문서 부가 메타 — 출처·감사 정보와 읽기 시 수집된 경고.</summary>
|
||||
public sealed class FormMeta
|
||||
{
|
||||
#region Properties
|
||||
/// <summary>레거시 가져오기 출처 디자인 키 — E_SdgMst.SdgKey</summary>
|
||||
public decimal? SourceSdgKey { get; set; }
|
||||
|
||||
/// <summary>최종 수정자</summary>
|
||||
public string? UpdatedBy { get; set; }
|
||||
|
||||
/// <summary>최종 수정일시(yyyyMMddHHmmss)</summary>
|
||||
public string? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>읽기(파싱) 중 수집된 경고 — 직렬화하지 않음</summary>
|
||||
[JsonIgnore]
|
||||
public List<string> ReadWarnings { get; } = new();
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>복제(경고 제외)</summary>
|
||||
public FormMeta Clone() => new()
|
||||
{
|
||||
SourceSdgKey = SourceSdgKey,
|
||||
UpdatedBy = UpdatedBy,
|
||||
UpdatedAt = UpdatedAt,
|
||||
};
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SheetMe.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 서식 페이지 1장 — 레거시의 MDesignerHost 루트 <Object> 1개에 대응.
|
||||
/// 루트 노드 자체를 <see cref="Root"/>(ControlElement)로 보존해 루트 속성까지 무손실 왕복한다.
|
||||
/// </summary>
|
||||
public sealed class FormPage
|
||||
{
|
||||
#region Properties
|
||||
/// <summary>페이지 루트(MDesignerHost) — 자식이 페이지의 컨트롤들</summary>
|
||||
public ControlElement Root { get; set; } = new();
|
||||
|
||||
/// <summary>페이지의 컨트롤 목록(루트의 자식) — index 0 = 최상위 z-order(레거시 규약)</summary>
|
||||
[JsonIgnore]
|
||||
public List<ControlElement> Controls => Root.Children;
|
||||
|
||||
/// <summary>페이지 너비(px) — 루트 Size 리프트, 레거시 기본 720</summary>
|
||||
[JsonIgnore]
|
||||
public double Width
|
||||
{
|
||||
get => Root.Bounds.W > 0 ? Root.Bounds.W : 720;
|
||||
set => Root.Bounds.W = value;
|
||||
}
|
||||
|
||||
/// <summary>페이지 높이(px) — 루트 Size 리프트, 레거시 기본 856</summary>
|
||||
[JsonIgnore]
|
||||
public double Height
|
||||
{
|
||||
get => Root.Bounds.H > 0 ? Root.Bounds.H : 856;
|
||||
set => Root.Bounds.H = value;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>깊은 복제</summary>
|
||||
public FormPage Clone() => new() { Root = Root.Clone() };
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace SheetMe.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 배치 사각형 — 부모(페이지 루트 또는 컨테이너) 기준 상대 좌표(px, WinForms px = WPF DIP 1:1).
|
||||
/// </summary>
|
||||
public sealed class LayoutRect
|
||||
{
|
||||
#region Properties
|
||||
/// <summary>부모 기준 X(px)</summary>
|
||||
public double X { get; set; }
|
||||
|
||||
/// <summary>부모 기준 Y(px)</summary>
|
||||
public double Y { get; set; }
|
||||
|
||||
/// <summary>너비(px)</summary>
|
||||
public double W { get; set; }
|
||||
|
||||
/// <summary>높이(px)</summary>
|
||||
public double H { get; set; }
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>복제</summary>
|
||||
public LayoutRect Clone() => new() { X = X, Y = Y, W = W, H = H };
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
namespace SheetMe.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 레거시 서식 XML의 Property 값 변형(variant) 기본형.
|
||||
/// 레거시 직렬화기(clsBasicDesignerLoader.WriteValue)가 만들어내는 5가지 형태 + 빈 값(Null)을
|
||||
/// 원형 그대로 보존해 무손실 왕복을 보장한다.
|
||||
/// </summary>
|
||||
public abstract class LegacyPropValue
|
||||
{
|
||||
/// <summary>텍스트 값 — <Property>text</Property> (TypeConverter invariant 문자열)</summary>
|
||||
public sealed class TextValue : LegacyPropValue
|
||||
{
|
||||
/// <summary>속성 문자열 값</summary>
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
public TextValue() { }
|
||||
public TextValue(string value) => Value = value;
|
||||
|
||||
public override LegacyPropValue Clone() => new TextValue(Value);
|
||||
}
|
||||
|
||||
/// <summary>빈 값 — <Property /> 또는 내용 없는 요소 (로더에서 null 로 설정됨)</summary>
|
||||
public sealed class NullValue : LegacyPropValue
|
||||
{
|
||||
public override LegacyPropValue Clone() => new NullValue();
|
||||
}
|
||||
|
||||
/// <summary>중첩 속성 — Content 직렬화 속성의 자식 <Property> 목록 (예: DataBindings)</summary>
|
||||
public sealed class NestedValue : LegacyPropValue
|
||||
{
|
||||
/// <summary>자식 Property 가방</summary>
|
||||
public PropBag Children { get; set; } = new();
|
||||
|
||||
public override LegacyPropValue Clone() => new NestedValue { Children = Children.Clone() };
|
||||
}
|
||||
|
||||
/// <summary>컬렉션 항목 — Content IList 속성의 <Item type="AQN"> 목록 (예: ComboBox Items)</summary>
|
||||
public sealed class ItemsValue : LegacyPropValue
|
||||
{
|
||||
/// <summary>항목 목록</summary>
|
||||
public List<LegacyItem> Items { get; set; } = new();
|
||||
|
||||
public override LegacyPropValue Clone()
|
||||
{
|
||||
var clone = new ItemsValue();
|
||||
foreach (var item in Items)
|
||||
{
|
||||
clone.Items.Add(new LegacyItem { Aqn = item.Aqn, Value = item.Value.Clone() });
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>바이너리 값 — <Binary>base64</Binary> (BinaryFormatter/Byte[] 직렬화 잔재, verbatim 보존)</summary>
|
||||
public sealed class BinaryValue : LegacyPropValue
|
||||
{
|
||||
/// <summary>base64 원문</summary>
|
||||
public string Base64 { get; set; } = string.Empty;
|
||||
|
||||
public override LegacyPropValue Clone() => new BinaryValue { Base64 = Base64 };
|
||||
}
|
||||
|
||||
/// <summary>컴포넌트 참조 — <Reference name="..." /> (같은 호스트 내 사이트 참조)</summary>
|
||||
public sealed class ReferenceValue : LegacyPropValue
|
||||
{
|
||||
/// <summary>참조 대상 컴포넌트 이름</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public override LegacyPropValue Clone() => new ReferenceValue { Name = Name };
|
||||
}
|
||||
|
||||
/// <summary>깊은 복제</summary>
|
||||
public abstract LegacyPropValue Clone();
|
||||
}
|
||||
|
||||
/// <summary>Content IList 속성의 항목 1개 — <Item type="AQN">값</Item></summary>
|
||||
public sealed class LegacyItem
|
||||
{
|
||||
/// <summary>항목 타입의 AssemblyQualifiedName 원문</summary>
|
||||
public string Aqn { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>항목 값(대개 TextValue)</summary>
|
||||
public LegacyPropValue Value { get; set; } = new LegacyPropValue.NullValue();
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Collections;
|
||||
|
||||
namespace SheetMe.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 순서 보존 속성 가방 — 레거시 XML의 <Property> 목록을 원본 순서 그대로 담는다.
|
||||
/// 값 갱신 시 원래 위치를 유지하고, 새 키는 뒤에 추가되어 왕복(diff) 안정성을 보장한다.
|
||||
/// </summary>
|
||||
public sealed class PropBag : IEnumerable<KeyValuePair<string, LegacyPropValue>>
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly List<string> keys = new();
|
||||
private readonly Dictionary<string, LegacyPropValue> map = new(StringComparer.Ordinal);
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>속성 개수</summary>
|
||||
public int Count => keys.Count;
|
||||
|
||||
/// <summary>속성 이름 목록(원본 순서)</summary>
|
||||
public IReadOnlyList<string> Keys => keys;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>속성 값 조회 — 없으면 null</summary>
|
||||
public LegacyPropValue? Get(string name)
|
||||
=> map.TryGetValue(name, out var value) ? value : null;
|
||||
|
||||
/// <summary>텍스트 속성 값 조회 — TextValue 가 아니면 null</summary>
|
||||
public string? GetText(string name)
|
||||
=> Get(name) is LegacyPropValue.TextValue text ? text.Value : null;
|
||||
|
||||
/// <summary>속성 존재 여부</summary>
|
||||
public bool Contains(string name) => map.ContainsKey(name);
|
||||
|
||||
/// <summary>속성 설정 — 기존 키는 위치 유지, 새 키는 뒤에 추가</summary>
|
||||
public void Set(string name, LegacyPropValue value)
|
||||
{
|
||||
if (!map.ContainsKey(name))
|
||||
{
|
||||
keys.Add(name);
|
||||
}
|
||||
map[name] = value;
|
||||
}
|
||||
|
||||
/// <summary>텍스트 속성 설정</summary>
|
||||
public void SetText(string name, string value)
|
||||
=> Set(name, new LegacyPropValue.TextValue(value));
|
||||
|
||||
/// <summary>속성 제거</summary>
|
||||
public bool Remove(string name)
|
||||
{
|
||||
if (!map.Remove(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
keys.Remove(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>지정 키 바로 앞에 새 키 삽입 — 기준 키가 없으면 뒤에 추가</summary>
|
||||
public void InsertBefore(string anchorName, string name, LegacyPropValue value)
|
||||
{
|
||||
if (map.ContainsKey(name))
|
||||
{
|
||||
map[name] = value;
|
||||
return;
|
||||
}
|
||||
var index = keys.IndexOf(anchorName);
|
||||
if (index < 0)
|
||||
{
|
||||
keys.Add(name);
|
||||
}
|
||||
else
|
||||
{
|
||||
keys.Insert(index, name);
|
||||
}
|
||||
map[name] = value;
|
||||
}
|
||||
|
||||
/// <summary>깊은 복제</summary>
|
||||
public PropBag Clone()
|
||||
{
|
||||
var clone = new PropBag();
|
||||
foreach (var key in keys)
|
||||
{
|
||||
clone.Set(key, map[key].Clone());
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
public IEnumerator<KeyValuePair<string, LegacyPropValue>> GetEnumerator()
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
yield return new KeyValuePair<string, LegacyPropValue>(key, map[key]);
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
#endregion
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>SheetMe.Core</RootNamespace>
|
||||
<AssemblyName>SheetMe.Core</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- 레거시 XML 속성값(Font/Color/Point 등) invariant 문자열 정합용 — 로컬 캐시 보유 버전 고정 -->
|
||||
<PackageReference Include="System.Drawing.Common" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user