diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs index b7ab9cd..4bf80bf 100644 --- a/src/SheetMe.Designer/App.xaml.cs +++ b/src/SheetMe.Designer/App.xaml.cs @@ -287,6 +287,11 @@ public partial class App : Application return Diagnostics.MaximizeCheck.Run(args.Length >= 2 ? args[1] : null); } + if (args.Length >= 1 && args[0] == "--cleartype") + { + return Diagnostics.ClearTypeCheck.Run(); + } + // 진단 모드에서는 모달을 띄우지 않는다 — 스크립트 옵션 오타 하나로 무인 실행이 여기서 // 영원히 멈춘다. WinExe 라 콘솔이 없어 stderr 는 호출자가 리다이렉트할 때만 보이므로 // 로그를 함께 남긴다(--db-render 가 .err.txt 로 남기는 것과 같은 이유). diff --git a/src/SheetMe.Designer/Diagnostics/ClearTypeCheck.cs b/src/SheetMe.Designer/Diagnostics/ClearTypeCheck.cs new file mode 100644 index 0000000..994099e --- /dev/null +++ b/src/SheetMe.Designer/Diagnostics/ClearTypeCheck.cs @@ -0,0 +1,450 @@ +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Media; +using System.Windows.Media.Effects; +using System.Windows.Media.Imaging; + +namespace SheetMe.Designer.Diagnostics; + +/// +/// ClearType 이 실제로 꺼지는지 화면을 찍어서 잰다 (--cleartype). +/// +/// 왜 화면을 찍는가. 으로는 이 질문에 답할 수 없다 — +/// 그 자체가 알파를 가진 중간 서피스라서 ClearType 이 무조건 꺼진다. +/// 오프스크린으로 재면 Effect 가 있든 없든 양쪽 다 회색으로 나오고, +/// 검사는 통과하면서 아무것도 증명하지 못한다. +/// 그래서 진짜 화면(데스크톱 DC)을 BitBlt 으로 떠서 픽셀을 본다. +/// +/// 무엇을 세는가. ClearType 은 서브픽셀 렌더링이라 글리프 경계에 채널이 어긋난 픽셀 +/// (R≠G≠B, 이른바 색 번짐)을 남긴다. 회색 안티에일리어싱은 R=G=B 만 남긴다. +/// 그래서 "채널 차이가 있는 픽셀 수"가 곧 ClearType 이 켜졌는지의 지표다. +/// +/// 스스로 속지 않기 위한 장치. 대조군(A: 아무 효과 없는 글자)에서 색 번짐이 0 이면 +/// 시스템 ClearType 이 꺼졌거나 캡처 경로가 틀린 것이므로 판정 불가로 끝낸다. +/// 그 확인 없이 B 가 0 인 것을 근거로 삼으면 "둘 다 0"을 결론으로 착각한다. +/// +internal static class ClearTypeCheck +{ + #region Member Fields + /// 이 정도 채널 차이면 회색 AA 가 아니다 — 회색은 원리상 차이가 정확히 0 이다 + private const int ChannelSpread = 6; + + /// 글자가 그려진 픽셀로 볼 밝기 상한(흰 배경 기준) + private const int InkLuma = 200; + + private static readonly List Lines = new(); + private static int failures; + #endregion + + #region Methods + public static int Run() + { + // F 표본이 앱의 실제 콤보 템플릿을 타야 하므로 테마를 실제로 적용한다 + Services.ThemeManager.Apply(true); + + var window = BuildProbe(out var samples, out var popups); + window.Show(); + + // 창이 보이는 것과 합성기가 그려 낸 것은 다르다 — Loaded 직후 찍으면 빈 화면을 잰다. + // (--modal-check 에서 BeginInvoke 가 Loaded 보다 먼저 돌아 0 을 세던 것과 같은 함정) + var timer = new System.Windows.Threading.DispatcherTimer + { + Interval = TimeSpan.FromMilliseconds(600), + }; + timer.Tick += (_, _) => + { + timer.Stop(); + try + { + Judge(samples); + } + finally + { + foreach (var popup in popups) + { + popup.IsOpen = false; + } + window.Close(); + Application.Current.Shutdown(); + } + }; + timer.Start(); + + var dispatcherFrame = new System.Windows.Threading.DispatcherFrame(); + Application.Current.Exit += (_, _) => dispatcherFrame.Continue = false; + System.Windows.Threading.Dispatcher.PushFrame(dispatcherFrame); + + foreach (var line in Lines) + { + Console.Error.WriteLine(line); + } + Console.Error.WriteLine($"결과: 실패 {failures}건"); + return failures == 0 ? 0 : 1; + } + + /// 대조군이 먼저 서야 나머지 숫자가 뜻을 갖는다 + private static void Judge(List<(string Name, FrameworkElement Target, bool ExpectClearType)> samples) + { + var measured = samples + .Select(s => (s.Name, s.ExpectClearType, Result: Measure(s.Target))) + .ToList(); + + foreach (var (name, _, result) in measured) + { + // 잰 사각형을 함께 찍는다 — 두 표본이 같은 자리를 재고 있으면 숫자가 우연히 같아지는데, + // 좌표를 안 찍으면 그것을 "결과가 같다"로 읽는다(실제로 한 번 그렇게 읽었다). + Lines.Add($" {name,-40} 색번짐 {result.Fringed,6} / 글자픽셀 {result.Ink,6} @ {result.Rect}"); + } + + var control = measured[0]; + if (control.Result.Fringed == 0) + { + Lines.Add("판정 불가 — 대조군에서 색 번짐이 0 이다."); + Lines.Add(" 시스템 ClearType 이 꺼져 있거나 캡처가 화면을 못 읽고 있다."); + Lines.Add(" 이 상태에서 나머지 0 은 근거가 되지 않는다."); + failures++; + return; + } + Lines.Add($"대조군 확인 — 색 번짐 {control.Result.Fringed}건. 이제 나머지 0 은 뜻이 있다."); + + foreach (var (name, expect, result) in measured) + { + var on = result.Fringed > 0; + Check($"{name} → ClearType {(expect ? "켜짐" : "꺼짐")}", on == expect, + $"실제 색번짐 {result.Fringed}건"); + } + } + + private static int Spread(int r, int g, int b) + => Math.Max(r, Math.Max(g, b)) - Math.Min(r, Math.Min(g, b)); + + private static void Check(string label, bool ok, string detail = "") + { + Lines.Add($"{(ok ? "PASS" : "FAIL")} {label}{(detail.Length > 0 ? $" — {detail}" : string.Empty)}"); + if (!ok) + { + failures++; + } + } + #endregion + + #region Probe + /// + /// 같은 글자를 다섯 조건으로 놓는다. 배경은 전부 불투명 흰색이어야 한다 — + /// ClearType 은 뒤에 무엇이 있는지 알아야 서브픽셀을 계산할 수 있어서, + /// 투명 배경에서는 효과와 무관하게 회색으로 떨어진다. + /// + private static Window BuildProbe( + out List<(string Name, FrameworkElement Target, bool ExpectClearType)> samples, + out List popups) + { + var shadow = new DropShadowEffect + { + BlurRadius = 26, ShadowDepth = 5, Direction = 270, + Opacity = 0.14, Color = Colors.Black, RenderingBias = RenderingBias.Quality, + }; + + var plain = Text(); + var effected = Text(); + var hintOnEffected = Text(); + var hintInside = Text(); + var inPopup = Text(); + var inPopupHinted = Text(); + + var effectedHost = Card(effected); + effectedHost.Effect = shadow; + + // 변형 1 — 힌트를 Effect 가 걸린 요소 자신에게 + var hostA = Card(hintOnEffected); + hostA.Effect = shadow.Clone(); + RenderOptions.SetClearTypeHint(hostA, ClearTypeHint.Enabled); + + // 변형 2 — 힌트를 중간 서피스 안쪽의 불투명 배경 요소에. + // 문서가 "불투명 배경이 있는 지점"이라고만 말해서 어느 쪽인지 재 봐야 안다. + var inner = new Border { Background = Brushes.White, Child = hintInside }; + RenderOptions.SetClearTypeHint(inner, ClearTypeHint.Enabled); + var hostB = Card(inner); + hostB.Effect = shadow.Clone(); + + // F — 합성 표본이 아니라 앱의 실제 콤보 템플릿. ComboBox 스타일이 암시라 그대로 태워진다. + // 이 표본이 있어야 "플랫폼이 그렇다"에서 "이 앱이 그렇다"로 넘어갈 수 있다. + // 드롭다운은 AllowsTransparency 팝업 + FloatShadow Effect 가 겹쳐 걸린 자리다(DesignerTheme.xaml:548,553). + var themedItem = Text(); + var combo = new ComboBox { Width = 300, HorizontalAlignment = HorizontalAlignment.Left }; + combo.Items.Add(themedItem); + // 선택하지 않는다 — 선택 강조가 배경을 유채색으로 만들면 위 판정 장치가 이 표본을 거부한다 + combo.SelectedIndex = -1; + + var anchor = new Border { Width = 300, Height = 20, Background = Brushes.White }; + var anchorHinted = new Border { Width = 300, Height = 20, Background = Brushes.White }; + var anchorStacked = new Border { Width = 300, Height = 20, Background = Brushes.White }; + var anchorSplit = new Border { Width = 300, Height = 20, Background = Brushes.White }; + var anchorScrolled = new Border { Width = 300, Height = 20, Background = Brushes.White }; + var anchorInnermost = new Border { Width = 300, Height = 20, Background = Brushes.White }; + + var popupPlain = LayeredPopup(inPopup, anchor); + var popupHinted = LayeredPopup(inPopupHinted, anchorHinted); + RenderOptions.SetClearTypeHint((FrameworkElement)popupHinted.Child, ClearTypeHint.Enabled); + + // G — 앱의 현재 구조: 레이어드 팝업 안에 Effect 가 걸린 Border, 그 안에 힌트. + // 중간 서피스가 두 겹이면 힌트가 듣는지 재 본다. + var stackedText = Text(); + var stackedInner = new Border { Background = Brushes.White, Child = stackedText }; + RenderOptions.SetClearTypeHint(stackedInner, ClearTypeHint.Enabled); + var stackedOuter = new Border { Background = Brushes.White, Child = stackedInner, Effect = shadow.Clone() }; + var popupStacked = LayeredPopup(stackedOuter, anchorStacked, wrap: false); + + // H — 후보 수리: 그림자를 글자가 없는 형제로 떼어낸다. + // 그러면 Effect 의 중간 서피스에 글자가 안 들어가고, 남는 것은 레이어드 창 한 겹뿐이라 + // E 와 같은 조건이 된다. + var splitText = Text(); + var shadowOnly = new Border { Background = Brushes.White, Effect = shadow.Clone() }; + var splitContent = new Border { Background = Brushes.White, Child = splitText }; + RenderOptions.SetClearTypeHint(splitContent, ClearTypeHint.Enabled); + var splitHost = new Grid(); + splitHost.Children.Add(shadowOnly); + splitHost.Children.Add(splitContent); + var popupSplit = LayeredPopup(splitHost, anchorSplit, wrap: false); + + // I — G 와 똑같지만 힌트와 글자 사이에 ScrollViewer 를 끼운다. + // 앱 콤보 드롭다운이 바로 이 모양인데 G 는 되고 F 는 안 되니, 남은 차이가 이것뿐이다. + var scrolledText = Text(); + var scrolledInner = new Grid { Background = Brushes.White }; + RenderOptions.SetClearTypeHint(scrolledInner, ClearTypeHint.Enabled); + scrolledInner.Children.Add(new ScrollViewer { Content = scrolledText }); + var scrolledOuter = new Border + { + Background = Brushes.White, Child = scrolledInner, Effect = shadow.Clone(), + }; + var popupScrolled = LayeredPopup(scrolledOuter, anchorScrolled, wrap: false); + + // J — 힌트를 ScrollViewer 안쪽(가장 안쪽 불투명 배경)에 건다. + var innermostText = Text(); + var innermost = new Border { Background = Brushes.White, Child = innermostText }; + RenderOptions.SetClearTypeHint(innermost, ClearTypeHint.Enabled); + var innerOuter = new Border + { + Background = Brushes.White, + Child = new ScrollViewer { Content = innermost }, + Effect = shadow.Clone(), + }; + var popupInnermost = LayeredPopup(innerOuter, anchorInnermost, wrap: false); + + var stack = new StackPanel { Margin = new Thickness(20) }; + stack.Children.Add(Card(plain)); + stack.Children.Add(effectedHost); + stack.Children.Add(hostA); + stack.Children.Add(hostB); + stack.Children.Add(anchor); + // 팝업이 아래로 펼쳐질 자리 — 앵커끼리 겹치면 두 팝업이 같은 픽셀을 재게 된다 + stack.Children.Add(new Border { Height = 46 }); + stack.Children.Add(anchorHinted); + stack.Children.Add(new Border { Height = 46 }); + stack.Children.Add(combo); + stack.Children.Add(new Border { Height = 46 }); + stack.Children.Add(anchorStacked); + stack.Children.Add(new Border { Height = 46 }); + stack.Children.Add(anchorSplit); + stack.Children.Add(new Border { Height = 46 }); + stack.Children.Add(anchorScrolled); + stack.Children.Add(new Border { Height = 46 }); + stack.Children.Add(anchorInnermost); + stack.Children.Add(new Border { Height = 46 }); + + var window = new Window + { + Title = "ClearType 검사", + Width = 480, Height = 1040, + Left = 60, Top = 60, + WindowStartupLocation = WindowStartupLocation.Manual, + Background = Brushes.White, + Content = stack, + Topmost = true, + }; + window.ContentRendered += (_, _) => + { + popupPlain.IsOpen = true; + popupHinted.IsOpen = true; + popupStacked.IsOpen = true; + popupSplit.IsOpen = true; + popupScrolled.IsOpen = true; + popupInnermost.IsOpen = true; + combo.IsDropDownOpen = true; + }; + + // 기대값은 예측이 아니라 측정 결과다. C1 과 E 는 내가 예상한 것과 반대로 나왔고 + // 그 사실이 곧 수리 방법을 정한다 — + // 힌트는 Effect 가 걸린 요소 자신에게 걸면 듣지 않고(C1), 그 안쪽 불투명 배경에 걸면 듣는다(C2). + // 레이어드 팝업도 자식 불투명 배경에 걸면 되살아난다(E) — AllowsTransparency 를 끌 필요가 없다. + samples = new() + { + ("A 아무것도 없음 (대조군)", plain, true), + ("B Effect (그림자)", effected, false), + ("C1 Effect + 힌트를 그 요소에", hintOnEffected, false), + ("C2 Effect + 힌트를 안쪽 배경에", hintInside, true), + ("D Popup AllowsTransparency", inPopup, false), + ("E Popup + 힌트를 자식 배경에", inPopupHinted, true), + ("F 앱 콤보 드롭다운 (실제 템플릿)", themedItem, true), + ("G 팝업+Effect 두 겹 + 힌트", stackedText, true), + ("H 팝업, 그림자를 형제로 분리 + 힌트", splitText, true), + ("I 힌트 바깥에 ScrollViewer", scrolledText, false), + ("J 힌트를 ScrollViewer 안쪽에", innermostText, true), + }; + popups = new() { popupPlain, popupHinted, popupStacked, popupSplit, popupScrolled, popupInnermost }; + return window; + } + + /// 글리프가 많고 획이 가는 글자 — 색 번짐이 가장 잘 드러난다 + private static TextBlock Text() => new() + { + Text = "가나다라마 환자명 일자 ABCDEFG 0123456789", + FontFamily = new FontFamily("Malgun Gothic"), + FontSize = 13, + Foreground = Brushes.Black, + Margin = new Thickness(8, 5, 8, 5), + }; + + private static Border Card(UIElement child) => new() + { + Background = Brushes.White, + Margin = new Thickness(0, 0, 0, 18), + Child = child, + }; + + private static Popup LayeredPopup(UIElement child, UIElement target, bool wrap = true) => new() + { + PlacementTarget = target, + Placement = PlacementMode.Bottom, + AllowsTransparency = true, + StaysOpen = true, + Child = wrap ? new Border { Background = Brushes.White, Child = child } : child, + }; + #endregion + + #region Screen capture + /// + /// 이 요소가 실제로 그려진 화면 조각을 떠서, 채널이 어긋난 픽셀과 글자 픽셀을 센다. + /// + private static (int Fringed, int Ink, string Rect) Measure(FrameworkElement element) + { + if (!element.IsVisible || element.ActualWidth < 1 || element.ActualHeight < 1) + { + return (0, 0, "보이지 않음"); + } + // PointToScreen 은 물리 화면 픽셀을 준다 — 배율이 걸려 있어도 그대로 쓸 수 있다 + var topLeft = element.PointToScreen(new Point(0, 0)); + var bottomRight = element.PointToScreen(new Point(element.ActualWidth, element.ActualHeight)); + var x = (int)Math.Floor(Math.Min(topLeft.X, bottomRight.X)); + var y = (int)Math.Floor(Math.Min(topLeft.Y, bottomRight.Y)); + var width = (int)Math.Ceiling(Math.Abs(bottomRight.X - topLeft.X)); + var height = (int)Math.Ceiling(Math.Abs(bottomRight.Y - topLeft.Y)); + if (width < 1 || height < 1) + { + return (0, 0, "크기 0"); + } + + var pixels = Grab(x, y, width, height); + if (pixels is null) + { + return (0, 0, "캡처 실패"); + } + + // 배경을 먼저 정한다 — 가장 많이 나온 색이 배경이다. + // + // 왜 필요한가. "채널 차이 = 서브픽셀 AA"는 배경이 무채색일 때만 성립한다. + // 유채색 배경(예: 선택된 콤보 항목의 B.Sel #C2E2FF, 채널 차이 61)에서는 + // 글자와 무관하게 거의 모든 픽셀이 색 번짐으로 잡혀서 ClearType 이 꺼져 있어도 통과한다. + // 실제로 그렇게 통과시켰다 — 색번짐 4359 인데 글자픽셀은 1075 였다. + var histogram = new Dictionary(); + for (var i = 0; i + 3 < pixels.Length; i += 4) + { + var key = (pixels[i + 2] << 16) | (pixels[i + 1] << 8) | pixels[i]; + histogram[key] = histogram.TryGetValue(key, out var seen) ? seen + 1 : 1; + } + var background = histogram.OrderByDescending(pair => pair.Value).First().Key; + int bgR = (background >> 16) & 0xFF, bgG = (background >> 8) & 0xFF, bgB = background & 0xFF; + if (Spread(bgR, bgG, bgB) > ChannelSpread) + { + return (0, 0, $"{x},{y} 배경이 유채색({bgR},{bgG},{bgB}) — 판정 불가"); + } + + var fringed = 0; + var ink = 0; + for (var i = 0; i + 3 < pixels.Length; i += 4) + { + int b = pixels[i], g = pixels[i + 1], r = pixels[i + 2]; + // 배경 그 자체는 세지 않는다 — 글리프가 만든 픽셀만 본다 + if ((r == bgR && g == bgG && b == bgB) is false && Spread(r, g, b) > ChannelSpread) + { + fringed++; + } + // Rec.601 근사 — 정수만으로 충분하다 + if ((r * 299 + g * 587 + b * 114) / 1000 < InkLuma) + { + ink++; + } + } + return (fringed, ink, $"{x},{y} {width}x{height} 배경({bgR},{bgG},{bgB})"); + } + + /// 데스크톱 DC 에서 사각형 하나를 떠 BGRA 바이트로 돌려준다 + private static byte[]? Grab(int x, int y, int width, int height) + { + var screen = GetDC(IntPtr.Zero); + if (screen == IntPtr.Zero) + { + return null; + } + var memory = IntPtr.Zero; + var bitmap = IntPtr.Zero; + try + { + memory = CreateCompatibleDC(screen); + bitmap = CreateCompatibleBitmap(screen, width, height); + if (memory == IntPtr.Zero || bitmap == IntPtr.Zero) + { + return null; + } + var previous = SelectObject(memory, bitmap); + // SRCCOPY — 합성이 끝난 화면을 그대로 가져온다 + if (!BitBlt(memory, 0, 0, width, height, screen, x, y, 0x00CC0020)) + { + return null; + } + SelectObject(memory, previous); + + var source = System.Windows.Interop.Imaging.CreateBitmapSourceFromHBitmap( + bitmap, IntPtr.Zero, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions()); + var converted = new FormatConvertedBitmap(source, PixelFormats.Bgra32, null, 0); + var stride = converted.PixelWidth * 4; + var buffer = new byte[stride * converted.PixelHeight]; + converted.CopyPixels(buffer, stride, 0); + return buffer; + } + finally + { + if (bitmap != IntPtr.Zero) + { + DeleteObject(bitmap); + } + if (memory != IntPtr.Zero) + { + DeleteDC(memory); + } + ReleaseDC(IntPtr.Zero, screen); + } + } + + [DllImport("user32.dll")] private static extern IntPtr GetDC(IntPtr hwnd); + [DllImport("user32.dll")] private static extern int ReleaseDC(IntPtr hwnd, IntPtr dc); + [DllImport("gdi32.dll")] private static extern IntPtr CreateCompatibleDC(IntPtr dc); + [DllImport("gdi32.dll")] private static extern IntPtr CreateCompatibleBitmap(IntPtr dc, int width, int height); + [DllImport("gdi32.dll")] private static extern IntPtr SelectObject(IntPtr dc, IntPtr obj); + [DllImport("gdi32.dll")] private static extern bool DeleteObject(IntPtr obj); + [DllImport("gdi32.dll")] private static extern bool DeleteDC(IntPtr dc); + [DllImport("gdi32.dll")] private static extern bool BitBlt(IntPtr dest, int x, int y, int width, int height, + IntPtr source, int sourceX, int sourceY, uint rop); + #endregion +} diff --git a/src/SheetMe.Designer/Themes/DesignerTheme.xaml b/src/SheetMe.Designer/Themes/DesignerTheme.xaml index e7dc05a..cc6cb1a 100644 --- a/src/SheetMe.Designer/Themes/DesignerTheme.xaml +++ b/src/SheetMe.Designer/Themes/DesignerTheme.xaml @@ -557,7 +557,17 @@