초기 커밋: 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
+258
View File
@@ -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",
};
}
+88
View File
@@ -0,0 +1,88 @@
namespace SheetMe.Core.Models;
/// <summary>
/// 배치된 컨트롤 1개 — 레거시 XML의 &lt;Object&gt; 노드 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
}
+40
View File
@@ -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
}
+32
View File
@@ -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
}
+40
View File
@@ -0,0 +1,40 @@
using System.Text.Json.Serialization;
namespace SheetMe.Core.Models;
/// <summary>
/// 서식 페이지 1장 — 레거시의 MDesignerHost 루트 &lt;Object&gt; 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
}
+26
View File
@@ -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>텍스트 값 — &lt;Property&gt;text&lt;/Property&gt; (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>빈 값 — &lt;Property /&gt; 또는 내용 없는 요소 (로더에서 null 로 설정됨)</summary>
public sealed class NullValue : LegacyPropValue
{
public override LegacyPropValue Clone() => new NullValue();
}
/// <summary>중첩 속성 — Content 직렬화 속성의 자식 &lt;Property&gt; 목록 (예: 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 속성의 &lt;Item type="AQN"&gt; 목록 (예: 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>바이너리 값 — &lt;Binary&gt;base64&lt;/Binary&gt; (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>컴포넌트 참조 — &lt;Reference name="..." /&gt; (같은 호스트 내 사이트 참조)</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개 — &lt;Item type="AQN"&gt;값&lt;/Item&gt;</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();
}
+102
View File
@@ -0,0 +1,102 @@
using System.Collections;
namespace SheetMe.Core.Models;
/// <summary>
/// 순서 보존 속성 가방 — 레거시 XML의 &lt;Property&gt; 목록을 원본 순서 그대로 담는다.
/// 값 갱신 시 원래 위치를 유지하고, 새 키는 뒤에 추가되어 왕복(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 표준 직렬화 구조.
/// 셀 데이터(&lt;Cell Row Column&gt;)와 스팬(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 을 &#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
}
@@ -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
}
+14
View File
@@ -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>