diff --git a/src/SheetMe.Core/Catalog/PrintFilter.cs b/src/SheetMe.Core/Catalog/PrintFilter.cs new file mode 100644 index 0000000..27e049c --- /dev/null +++ b/src/SheetMe.Core/Catalog/PrintFilter.cs @@ -0,0 +1,71 @@ +using SheetMe.Core.Models; + +namespace SheetMe.Core.Catalog; + +/// +/// 이 컨트롤이 인쇄·미리보기에 나오는가. +/// +/// 왜 필요한가. 레거시 런타임은 인쇄에서 이렇게 걸러낸다 — +/// If Me.Visible = False OrElse mbPrintOutPut = False Then Return False +/// (그 사실이 주석에 인용까지 되어 있다). +/// 그런데 우리 미리보기는 그 두 키를 아무도 읽지 않았다. 대신 디자이너 전용 플래그 +/// (ControlElement.Hidden, [JsonIgnore], 주석에 "레거시 Visible 과 별개")만 걸렀다. +/// 그래서 인스펙터에서 '인쇄 출력'을 꺼도 미리보기에 그대로 나왔다 — +/// 저장 전 검증 수단이 거짓말을 하고 있었다. +/// +/// 키 이름이 타입마다 다르다. 라벨만 소문자 visible 이다 +/// (Label.vb 가 Shadows Property visibleControl.Visible 을 가리고 직렬화기가 그림자를 쓴다). +/// 운영에서 소문자 58,864건(99.7%) 대 대문자 196건이다. +/// 그래서 타입이 선언한 키를 카탈로그에서 찾아 그것만 읽는다 — +/// 둘 다 관용으로 읽으면, 대문자 Visible=False 인 라벨이 인쇄에서 빠진다. +/// 레거시는 그 라벨을 그대로 인쇄하므로 그건 우리가 만드는 새 차이다. +/// +/// 모르는 값은 인쇄한다. 키가 없거나 해석할 수 없으면 나온다 — +/// 내용을 조용히 빼는 것이 조용히 넣는 것보다 나쁘다(빠진 것은 아무도 못 본다). +/// +public static class PrintFilter +{ + #region Methods + /// 인쇄·미리보기에 나오는가 + public static bool IsPrinted(ControlElement control) + => IsOn(control, VisibleKeyOf(control.Type)) && IsOn(control, "PrintOutPut"); + + /// + /// 이 타입이 실제로 쓰는 '표시' 키. 카탈로그가 정답을 갖고 있으므로 하드코딩하지 않는다 — + /// 타입이 늘거나 키가 바뀌면 여기가 따라온다. + /// + public static string VisibleKeyOf(string type) + { + var descriptor = ControlRegistry.Find(type); + if (descriptor is null) + { + return "Visible"; + } + foreach (var property in descriptor.Properties) + { + if (string.Equals(property.Key, "visible", StringComparison.Ordinal) + || string.Equals(property.Key, "Visible", StringComparison.Ordinal)) + { + return property.Key; + } + } + return "Visible"; + } + + /// + /// 레거시가 쓰는 불리언 표기는 WinForms TypeConverter 의 "True"/"False" 다. + /// 운영 데이터에는 대소문자·공백이 섞여 있어 관용으로 읽는다. + /// + private static bool IsOn(ControlElement control, string key) + { + var raw = control.Props.GetText(key)?.Trim(); + if (raw is null || raw.Length == 0) + { + return true; + } + return !(raw.Equals("False", StringComparison.OrdinalIgnoreCase) + || raw.Equals("0", StringComparison.Ordinal) + || raw.Equals("No", StringComparison.OrdinalIgnoreCase)); + } + #endregion +} diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index 082da2e..f69bf19 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -51,6 +51,65 @@ public static class EditSmoke && designer.Document.Pages[0].Controls.Count == 3); Check("배치: 신규가 선택됨", designer.Selection.Items.Count == 1); + // 팔레트 즉시 배치가 같은 자리에 겹치지 않는가. + // 전에는 (Controls.Count % 8) * 16 이라 9번째가 1번째와 정확히 같은 자리에 떨어졌고, + // 하나 지우고 다시 놓으면 지운 것과 같은 자리로 갔다. + // 별도 문서에서 잰다 — 위 3개가 든 문서를 쓰면 뒤 검사들의 인덱스가 밀린다. + { + var stack = new DesignerViewModel(new FormDesignDataBusiness().CreateNew()); + var before = stack.Pages[0].Controls.Count; + for (var i = 0; i < 20; i++) + { + stack.AddPaletteItemAtCenter("Label"); + } + var placed = stack.Pages[0].Controls.Skip(0) + .Where(c => c.Type == "Label").Select(c => (c.X, c.Y)).ToList(); + Check("즉시 배치: 20개가 서로 다른 자리", placed.Distinct().Count() == placed.Count, + $"고유 {placed.Distinct().Count()} / 전체 {placed.Count} (기존 {before}개)"); + + // 지우고 다시 놓아도 지운 자리로 돌아가지 않는다 + var last = stack.Pages[0].Controls.First(c => c.Type == "Label"); + var lastSpot = (last.X, last.Y); + stack.Selection.SetSingle(last); + stack.DeleteSelection(); + stack.AddPaletteItemAtCenter("Label"); + var again = stack.Pages[0].Controls.First(c => c.Type == "Label"); + Check("즉시 배치: 지운 자리로 돌아가지 않는다", (again.X, again.Y) != lastSpot, + $"지운 자리 {lastSpot}, 새로 놓인 자리 ({again.X},{again.Y})"); + } + + // 미리보기가 인쇄 필터를 실제로 쓰는가. + // PrintFilter 단위 테스트는 판정이 옳다는 것만 말한다 — 렌더 경로가 그 판정을 부르는지는 + // 다른 문제이고, 이 앱에서 "코드는 맞아 보이는데 화면은 다른" 일이 여러 번 있었다. + { + var paper = new DesignerViewModel(new FormDesignDataBusiness().CreateNew()); + paper.AddControlAt("Label", new Point(60, 60)); + paper.AddControlAt("Label", new Point(60, 120)); + paper.AddControlAt("TextBox", new Point(60, 180)); + var page = paper.Pages[0]; + var all = ((System.Windows.Controls.Canvas)Services.PrintService.BuildPageVisual(page)).Children.Count; + + // 라벨은 소문자 visible, 텍스트박스는 대문자 Visible — 키를 바꿔 쓰면 안 걸려야 한다 + var lowerLabel = page.Controls.First(c => c.Type == "Label"); + lowerLabel.Model.Props.SetText("visible", "False"); + var afterLabel = ((System.Windows.Controls.Canvas)Services.PrintService.BuildPageVisual(page)).Children.Count; + + var box = page.Controls.First(c => c.Type == "TextBox"); + box.Model.Props.SetText("PrintOutPut", "False"); + var afterBox = ((System.Windows.Controls.Canvas)Services.PrintService.BuildPageVisual(page)).Children.Count; + + var wrongKey = page.Controls.Last(c => c.Type == "Label"); + wrongKey.Model.Props.SetText("Visible", "False"); + var afterWrongKey = ((System.Windows.Controls.Canvas)Services.PrintService.BuildPageVisual(page)).Children.Count; + + Check("미리보기: 라벨 소문자 visible=False 를 뺀다", afterLabel == all - 1, + $"전 {all} → 후 {afterLabel}"); + Check("미리보기: PrintOutPut=False 를 뺀다", afterBox == all - 2, + $"전 {all} → 후 {afterBox}"); + Check("미리보기: 라벨의 대문자 Visible 은 무시한다", afterWrongKey == all - 2, + $"전 {all} → 후 {afterWrongKey} (레거시는 이 라벨을 인쇄한다)"); + } + // 2) 클릭 선택 — 컨트롤 중심(핸들 반경 밖) var textBox = Find("TextBox"); var clickPoint = new Point(textBox.X + textBox.Width / 2, textBox.Y + textBox.Height / 2); diff --git a/src/SheetMe.Designer/Services/PrintService.cs b/src/SheetMe.Designer/Services/PrintService.cs index 9ca47c1..56d51fa 100644 --- a/src/SheetMe.Designer/Services/PrintService.cs +++ b/src/SheetMe.Designer/Services/PrintService.cs @@ -66,8 +66,15 @@ public static class PrintService foreach (var control in page.Controls) { - // 숨김 + 데이터소스(MDataTable — 런타임 비가시)는 인쇄/미리보기에서 제외 - if (control.Model.Hidden || control is DataTableViewModel) + // 인쇄/미리보기에서 빠지는 것 셋: + // ① 디자이너 임시 숨김(Hidden — 저장되지 않는 편집 전용 상태) + // ② 데이터소스(MDataTable — 런타임 비가시) + // ③ 레거시 런타임이 인쇄에서 빼는 것: Visible=False 또는 PrintOutPut=False. + // ③ 을 오래 빠뜨려서 '인쇄 출력'을 꺼도 미리보기에 그대로 나왔다 — + // 저장 전 검증 수단이 거짓말을 하고 있었다. + // 캔버스는 계속 다 보여 준다(편집 중인 것을 못 보면 고칠 수 없다) — 여기만 거른다. + if (control.Model.Hidden || control is DataTableViewModel + || !SheetMe.Core.Catalog.PrintFilter.IsPrinted(control.Model)) { continue; } diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs index d99cd5c..465d77c 100644 --- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs @@ -65,6 +65,12 @@ public sealed class DesignerViewModel : ViewModelBase private const double PageGap = 28; private const double WorldPad = 24; private static readonly List clipboard = new(); + /// 한 계단에 몇 번 내려간 뒤 옆으로 비키는가 — 종이를 벗어나기 전에 접는다 + private const int PlacementLaneSteps = 12; + + /// 팔레트 즉시 배치 계단 — 지우고 다시 놓아도 같은 자리로 돌아가지 않게 단조 증가한다 + private int placementStep; + private double zoom = 1.0; private FormDocument document; private PageViewModel? selectedPage; @@ -1153,8 +1159,18 @@ public sealed class DesignerViewModel : ViewModelBase { return; } - var offset = (page.Controls.Count % 8) * 16; - AddControlAt(type, new Point(page.WidthDip / 2 + offset, page.OffsetY + page.HeightDip / 3 + offset)); + // 겹침 방지 오프셋은 단조 증가하는 자체 카운터로 센다. + // + // 전에는 (page.Controls.Count % 8) * 16 이었다. 두 가지가 잘못이다 — + // ① % 8 이라 9번째가 1번째와 정확히 같은 자리에 떨어진다. + // ② Controls.Count 를 쓰므로 하나 지우고 다시 놓으면 지운 것과 같은 자리로 간다. + // 계단은 종이를 벗어나기 전에 접는다 — 접히더라도 방금 놓은 것 위로는 가지 않는다. + var step = placementStep++; + var lane = step / PlacementLaneSteps; + var offset = (step % PlacementLaneSteps) * 16; + AddControlAt(type, new Point( + page.WidthDip / 2 + offset + (lane * 12), + page.OffsetY + page.HeightDip / 3 + offset)); } /// 팔레트 드롭 — 월드 좌표에 신규 컨트롤 생성(모델 index 0 = 최상위) diff --git a/tests/SheetMe.Core.Tests/PrintFilterTests.cs b/tests/SheetMe.Core.Tests/PrintFilterTests.cs new file mode 100644 index 0000000..672a241 --- /dev/null +++ b/tests/SheetMe.Core.Tests/PrintFilterTests.cs @@ -0,0 +1,94 @@ +using SheetMe.Core.Catalog; +using SheetMe.Core.Models; + +namespace SheetMe.Core.Tests; + +/// +/// 인쇄·미리보기 필터 고정. +/// +/// 이 판정이 틀리면 미리보기가 사용자에게 거짓말을 한다 — 인쇄에서 빠질 것을 보여 주거나, +/// 나올 것을 감춘다. 저장 전에 결과를 확인할 유일한 수단이라 틀린 값이 곧 잘못된 서식이 된다. +/// +[TestClass] +public sealed class PrintFilterTests +{ + #region Methods + private static ControlElement Make(string type, params (string Key, string Value)[] props) + { + var control = new ControlElement { Type = type, Id = type + "1" }; + foreach (var (key, value) in props) + { + control.Props.SetText(key, value); + } + return control; + } + + [TestMethod] + public void 아무_설정도_없으면_인쇄된다() + { + // 키가 없을 때의 기본값은 True 다(레거시 컨트롤 필드 선언 기준). + // 여기서 false 를 내면 서식 대부분이 빈 종이로 나온다. + Assert.IsTrue(PrintFilter.IsPrinted(Make("Label"))); + Assert.IsTrue(PrintFilter.IsPrinted(Make("TextBox"))); + } + + [TestMethod] + public void 인쇄_출력을_끄면_빠진다() + { + Assert.IsFalse(PrintFilter.IsPrinted(Make("TextBox", ("PrintOutPut", "False")))); + Assert.IsFalse(PrintFilter.IsPrinted(Make("Label", ("PrintOutPut", "False")))); + } + + [TestMethod] + public void 라벨은_소문자_visible_을_본다() + { + // Label.vb 가 Shadows Property visible 로 Control.Visible 을 가리고 직렬화기가 그림자를 쓴다. + // 운영에서 소문자 58,864건(99.7%) 대 대문자 196건이다. + Assert.AreEqual("visible", PrintFilter.VisibleKeyOf("Label")); + Assert.IsFalse(PrintFilter.IsPrinted(Make("Label", ("visible", "False")))); + } + + [TestMethod] + public void 라벨의_대문자_Visible_은_인쇄를_막지_못한다() + { + // 레거시 런타임이 읽지 않는 키다. 관용으로 둘 다 읽으면 레거시가 인쇄하는 라벨을 + // 우리만 빼먹는 새 차이가 생긴다 — 그건 호환이 아니다. + Assert.IsTrue(PrintFilter.IsPrinted(Make("Label", ("Visible", "False")))); + } + + [TestMethod] + public void 라벨이_아닌_타입은_대문자_Visible_을_본다() + { + Assert.AreEqual("Visible", PrintFilter.VisibleKeyOf("TextBox")); + Assert.IsFalse(PrintFilter.IsPrinted(Make("TextBox", ("Visible", "False")))); + // 그쪽에서는 소문자가 무시된다 + Assert.IsTrue(PrintFilter.IsPrinted(Make("TextBox", ("visible", "False")))); + } + + [TestMethod] + public void 대소문자와_공백을_관용으로_읽는다() + { + Assert.IsFalse(PrintFilter.IsPrinted(Make("TextBox", ("PrintOutPut", " false ")))); + Assert.IsFalse(PrintFilter.IsPrinted(Make("TextBox", ("PrintOutPut", "FALSE")))); + Assert.IsFalse(PrintFilter.IsPrinted(Make("TextBox", ("PrintOutPut", "0")))); + } + + [TestMethod] + public void 해석할_수_없는_값은_인쇄한다() + { + // 내용을 조용히 빼는 것이 조용히 넣는 것보다 나쁘다 — 빠진 것은 아무도 못 본다. + Assert.IsTrue(PrintFilter.IsPrinted(Make("TextBox", ("PrintOutPut", "알수없는값")))); + Assert.IsTrue(PrintFilter.IsPrinted(Make("TextBox", ("PrintOutPut", "")))); + } + + [TestMethod] + public void 모르는_타입도_판정을_거부하지_않는다() + { + // 카탈로그에 없는 타입(Spread·MSign 처럼 미지원으로 살아남은 242건)이 들어와도 + // 예외를 던지지 않고 대문자 Visible 규약으로 판정한다. + Assert.AreEqual("Visible", PrintFilter.VisibleKeyOf("Spread")); + Assert.IsTrue(PrintFilter.IsPrinted(Make("Spread"))); + Assert.IsFalse(PrintFilter.IsPrinted(Make("Spread", ("PrintOutPut", "False")))); + } + #endregion +}