diff --git a/src/SheetMe.Core/Serialization/LegacyMask.cs b/src/SheetMe.Core/Serialization/LegacyMask.cs
new file mode 100644
index 0000000..b0f7d7e
--- /dev/null
+++ b/src/SheetMe.Core/Serialization/LegacyMask.cs
@@ -0,0 +1,78 @@
+using System.Text;
+
+namespace SheetMe.Core.Serialization;
+
+///
+/// WinForms MaskedTextBox 마스크를 화면 표시 문자열로 변환한다.
+///
+/// 레거시는 마스크 패턴을 그대로 그리지 않고, 입력 자리마다 프롬프트 문자(기본 _)를 찍고
+/// 리터럴은 그대로 둔다. 예: 0000년 90월 90일 → ____년 __월 __일.
+/// 마스크 원문을 그대로 보여주면 디자이너가 값이 이미 입력된 것으로 오해한다.
+///
+/// 운영 실측(활성 디자인 전수): MaskedTextBox 475개 / Mask 보유 460개 / 고유 44종.
+/// 등장 지시자는 0(2,675) 9(687) #(12) 와 이스케이프 \(2) 뿐이고
+/// 문자류 지시자(L/?/&/C/A/a)와 대소문자 변환자(< > |)는 쓰이지 않는다.
+/// PromptChar 속성을 가진 컨트롤은 0개라 전부 기본값이다. 그래도 전 지시자를 구현해 둔다.
+///
+public static class LegacyMask
+{
+ #region Member Fields
+ /// WinForms 기본 프롬프트 문자
+ public const char DefaultPromptChar = '_';
+ #endregion
+
+ #region Methods
+ ///
+ /// 마스크 → 표시 문자열. 입력 자리는 , 그 외는 리터럴로 남긴다.
+ ///
+ /// 레거시 Mask 속성 값
+ /// 프롬프트 문자 — 레거시 PromptChar 속성(기본 _)
+ public static string ToPromptDisplay(string? mask, char promptChar = DefaultPromptChar)
+ {
+ if (string.IsNullOrEmpty(mask))
+ {
+ return string.Empty;
+ }
+
+ var result = new StringBuilder(mask.Length);
+ for (var i = 0; i < mask.Length; i++)
+ {
+ var c = mask[i];
+ switch (c)
+ {
+ // 이스케이프 — 다음 문자를 리터럴로. 마스크 끝에 홀로 오면 리터럴 백슬래시로 둔다.
+ case '\\':
+ if (i + 1 < mask.Length)
+ {
+ result.Append(mask[++i]);
+ }
+ else
+ {
+ result.Append(c);
+ }
+ break;
+
+ // 입력 자리 지시자 — 숫자/문자/영숫자 계열 모두 한 자리를 소비한다
+ case '0' or '9' or '#' // 숫자 계열
+ or 'L' or '?' // 문자 계열
+ or '&' or 'C' // 임의 문자 계열
+ or 'A' or 'a': // 영숫자 계열
+ result.Append(promptChar);
+ break;
+
+ // 대소문자 변환자 — 자리를 차지하지 않고 표시에도 나타나지 않는다
+ case '<' or '>' or '|':
+ break;
+
+ // 그 외(구분자 . , : / $ 와 한글·공백 등)는 리터럴.
+ // WinForms 는 . , : / $ 를 문화권 구분자로 치환하지만, 운영 마스크는 보이는 그대로
+ // 쓰이고 레거시 화면도 그렇게 나오므로 원문을 유지한다.
+ default:
+ result.Append(c);
+ break;
+ }
+ }
+ return result.ToString();
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Controls/ControlTemplates.xaml b/src/SheetMe.Designer/Controls/ControlTemplates.xaml
index d47c3da..6852c97 100644
--- a/src/SheetMe.Designer/Controls/ControlTemplates.xaml
+++ b/src/SheetMe.Designer/Controls/ControlTemplates.xaml
@@ -66,7 +66,7 @@
-
+
diff --git a/src/SheetMe.Designer/ViewModels/Controls/TypedControlViewModels.cs b/src/SheetMe.Designer/ViewModels/Controls/TypedControlViewModels.cs
index ac49621..eba135a 100644
--- a/src/SheetMe.Designer/ViewModels/Controls/TypedControlViewModels.cs
+++ b/src/SheetMe.Designer/ViewModels/Controls/TypedControlViewModels.cs
@@ -28,9 +28,17 @@ public sealed class TextBoxViewModel : ControlViewModel
/// 마스크 입력 (레거시 MaskedTextBox)
public sealed class MaskedTextBoxViewModel : ControlViewModel
{
- /// 입력 마스크
+ /// 입력 마스크 원문 — 인스펙터 편집용
public string Mask => Prop("Mask") ?? string.Empty;
+ ///
+ /// 빈 값일 때 캔버스에 그릴 문자열 — 레거시와 동일하게 입력 자리를 프롬프트 문자로 표시한다
+ /// (예: 0000년 90월 90일 → ____년 __월 __일).
+ /// 마스크 원문을 그대로 그리면 값이 입력된 것으로 오해하게 된다.
+ ///
+ public string MaskDisplay => LegacyMask.ToPromptDisplay(
+ Mask, (Prop("PromptChar") is { Length: > 0 } p) ? p[0] : LegacyMask.DefaultPromptChar);
+
public MaskedTextBoxViewModel(ControlElement model) : base(model) { }
}
diff --git a/tests/SheetMe.Core.Tests/LegacyMaskTests.cs b/tests/SheetMe.Core.Tests/LegacyMaskTests.cs
new file mode 100644
index 0000000..8def566
--- /dev/null
+++ b/tests/SheetMe.Core.Tests/LegacyMaskTests.cs
@@ -0,0 +1,95 @@
+using SheetMe.Core.Serialization;
+
+namespace SheetMe.Core.Tests;
+
+/// WinForms MaskedTextBox 마스크 → 표시 문자열 변환 테스트.
+[TestClass]
+public sealed class LegacyMaskTests
+{
+ /// 운영 실사용 마스크 상위 항목 — 2026-08-11 활성 디자인 전수 census 근거
+ [TestMethod]
+ [DataRow("0000년 90월 90일", "____년 __월 __일")] // 198건 — 최다
+ [DataRow("0000-00-00", "____-__-__")] // 51건
+ [DataRow("90:90", "__:__")] // 36건
+ [DataRow("90시90분", "__시__분")] // 22건
+ [DataRow("0000.00.00.", "____.__.__.")] // 21건
+ [DataRow("90월 90일", "__월 __일")] // 15건 — 연속 공백 보존
+ [DataRow("000-9000-0000", "___-____-____")] // 6건
+ [DataRow("( 90 : 00 )", "( __ : __ )")]
+ [DataRow("####/##/## ##:##", "____/__/__ __:__")] // # 지시자
+ [DataRow("0000년 90월 90일 90시 90분", "____년 __월 __일 __시 __분")]
+ public void ToPromptDisplay_ProductionMasks(string mask, string expected)
+ {
+ Assert.AreEqual(expected, LegacyMask.ToPromptDisplay(mask));
+ }
+
+ /// 이스케이프 — 백슬래시 다음 문자는 리터럴(운영에 '2\000년' 형태가 존재)
+ [TestMethod]
+ [DataRow(@"2\000년", "20__년")]
+ [DataRow(@"2\000년 90월 90일", "20__년 __월 __일")]
+ [DataRow(@"\9\0", "90")]
+ public void ToPromptDisplay_EscapedLiterals(string mask, string expected)
+ {
+ Assert.AreEqual(expected, LegacyMask.ToPromptDisplay(mask));
+ }
+
+ /// 문자·영숫자 지시자도 한 자리를 소비한다(운영 미사용이나 규약상 지원)
+ [TestMethod]
+ [DataRow("LLL", "___")]
+ [DataRow("???", "___")]
+ [DataRow("AAA", "___")]
+ [DataRow("aaa", "___")]
+ [DataRow("&&&", "___")]
+ [DataRow("CCC", "___")]
+ public void ToPromptDisplay_CharacterDirectives(string mask, string expected)
+ {
+ Assert.AreEqual(expected, LegacyMask.ToPromptDisplay(mask));
+ }
+
+ /// 대소문자 변환자는 자리를 차지하지 않고 표시에도 나오지 않는다
+ [TestMethod]
+ [DataRow(">LLL", "___")]
+ [DataRow("LL|LL", "____")]
+ public void ToPromptDisplay_CaseModifiersAreInvisible(string mask, string expected)
+ {
+ Assert.AreEqual(expected, LegacyMask.ToPromptDisplay(mask));
+ }
+
+ /// PromptChar 지정 — 운영엔 0건이지만 속성이 있으면 반영해야 한다
+ [TestMethod]
+ public void ToPromptDisplay_HonorsPromptChar()
+ {
+ Assert.AreEqual("0000년 00월 00일", LegacyMask.ToPromptDisplay("0000년 90월 90일", '0'));
+ Assert.AreEqual("####-##-##", LegacyMask.ToPromptDisplay("0000-00-00", '#'));
+ Assert.AreEqual(" - - ", LegacyMask.ToPromptDisplay("0000-00-00", ' '));
+ }
+
+ /// 빈 마스크·null 안전
+ [TestMethod]
+ public void ToPromptDisplay_EmptyInputs()
+ {
+ Assert.AreEqual(string.Empty, LegacyMask.ToPromptDisplay(null));
+ Assert.AreEqual(string.Empty, LegacyMask.ToPromptDisplay(string.Empty));
+ }
+
+ /// 끝에 홀로 남은 백슬래시는 리터럴로 — 인덱스 초과로 죽지 않아야 한다
+ [TestMethod]
+ public void ToPromptDisplay_TrailingBackslashIsLiteral()
+ {
+ Assert.AreEqual(@"__\", LegacyMask.ToPromptDisplay(@"00\"));
+ }
+
+ ///
+ /// 표시 길이는 마스크의 시각 길이와 같아야 한다(변환자·이스케이프 제외).
+ /// 길이가 달라지면 캔버스에서 필드 폭이 어긋나 보인다.
+ ///
+ [TestMethod]
+ [DataRow("0000년 90월 90일")]
+ [DataRow("0000.00.00.")]
+ [DataRow("( 90 : 00 )")]
+ public void ToPromptDisplay_PreservesVisibleLength(string mask)
+ {
+ Assert.AreEqual(mask.Length, LegacyMask.ToPromptDisplay(mask).Length);
+ }
+}