Effect·팝업이 ClearType 을 끄고 있었다 — 글자가 흐린 이유
사용자 지적이 맞았다. Effect 가 걸린 요소는 하위 트리가 중간 서피스로 렌더링되면서 ClearType 이 꺼지고, 글자가 회색 안티에일리어싱으로 그려진다. 13px 한글이 특히 뭉개진다. 저장소는 이 사실을 이미 알고 있었다 — MessageDialogView.xaml:18 과 TagPickerDialogView.xaml:11 이 "AllowsTransparency 를 켜면 ClearType 이 꺼져 13px 한글이 뭉개진다"고 적어 두었다. 그런데 그 판단이 <b>창에만</b> 적용되고 Effect 와 Popup 에는 적용되지 않았다. ClearTypeHint 는 저장소 전체에 0 곳이었다. 추론으로 끝내지 않았다. RenderTargetBitmap 으로는 이 질문에 답할 수 없다 — 그 자체가 알파를 가진 중간 서피스라서 Effect 가 있든 없든 양쪽 다 회색으로 나오고, 검사가 통과하면서 아무것도 증명하지 못한다. 그래서 진짜 화면을 BitBlt 으로 떠서 글리프의 채널 어긋난 픽셀(ClearType 은 R≠G≠B, 회색 AA 는 R=G=B)을 셌다. --cleartype 표본 11종 실측: A 아무것도 없음 색번짐 1115 ← 대조군. 이게 0 이면 판정 불가로 끝낸다 B Effect 색번짐 0 ← 지적한 그대로 C1 힌트를 Effect 요소 자신에게 색번짐 0 ← 듣지 않는다 C2 힌트를 안쪽 불투명 배경에 색번짐 1344 ← 여기가 맞는 자리 D 레이어드 팝업 색번짐 0 ← 두 번째 원인 E 팝업 + 힌트 색번짐 1115 ← AllowsTransparency 를 끌 필요가 없다 I 힌트 바깥에 ScrollViewer 색번짐 0 ← ScrollViewer 도 중간 서피스를 연다 J 힌트를 ScrollViewer 안쪽에 색번짐 1115 F 앱 콤보 드롭다운(실제 템플릿) 색번짐 0 → 수리 후 1115 두 번 헛짚었고 두 번 다 측정이 정정했다. CornerRadius 를 의심했으나 아니었고, ScrollViewer 가 범인이었다. 그리고 F 를 처음 통과시켰을 때는 선택된 콤보 항목의 파란 배경(B.Sel, 채널 차이 61)이 색번짐으로 잡힌 것이었다 — 색번짐 4359 인데 글자픽셀은 1075 였다. 그래서 배경이 유채색이면 판정을 거부하는 장치를 넣었다. 규칙: 힌트는 Effect·ScrollViewer·레이어드 팝업 <b>안쪽</b>의 불투명 배경 요소에 건다. 적용한 곳 — 글자가 많은 순: 콤보 드롭다운(DesignerTheme) · 메뉴 하위팝업(배치·정렬 명령이 다 여기 있다) · 색 팝오버 · 컨트롤 추가 플라이아웃 · 줌 플라이아웃 남긴 곳: 플로팅 바(글자는 줌 퍼센트뿐), QueryEditorWindow 의 Card 스타일 (Effect 가 DataGrid·편집기의 중첩 ScrollViewer 를 감싸서 힌트를 넣을 자리가 여럿이다 — 그림자를 빼는 쪽이 맞아 보이고, 그건 디자인 결정이라 따로 다룬다). 게이트: --cleartype 11/11, 테스트 277/277, --edit-smoke 0실패, --modal-check 0실패, --maxrect 0실패, --dialog-shots 57파일, --db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일(종이 렌더 불변). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b0d2e9b470
commit
280594362a
@@ -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 로 남기는 것과 같은 이유).
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// ClearType 이 실제로 꺼지는지 <b>화면을 찍어서</b> 잰다 (--cleartype).
|
||||
///
|
||||
/// <b>왜 화면을 찍는가.</b> <see cref="RenderTargetBitmap"/> 으로는 이 질문에 답할 수 없다 —
|
||||
/// 그 자체가 알파를 가진 중간 서피스라서 ClearType 이 무조건 꺼진다.
|
||||
/// 오프스크린으로 재면 Effect 가 있든 없든 양쪽 다 회색으로 나오고,
|
||||
/// 검사는 <b>통과하면서 아무것도 증명하지 못한다</b>.
|
||||
/// 그래서 진짜 화면(데스크톱 DC)을 BitBlt 으로 떠서 픽셀을 본다.
|
||||
///
|
||||
/// <b>무엇을 세는가.</b> ClearType 은 서브픽셀 렌더링이라 글리프 경계에 <b>채널이 어긋난 픽셀</b>
|
||||
/// (R≠G≠B, 이른바 색 번짐)을 남긴다. 회색 안티에일리어싱은 R=G=B 만 남긴다.
|
||||
/// 그래서 "채널 차이가 있는 픽셀 수"가 곧 ClearType 이 켜졌는지의 지표다.
|
||||
///
|
||||
/// <b>스스로 속지 않기 위한 장치.</b> 대조군(A: 아무 효과 없는 글자)에서 색 번짐이 0 이면
|
||||
/// 시스템 ClearType 이 꺼졌거나 캡처 경로가 틀린 것이므로 <b>판정 불가</b>로 끝낸다.
|
||||
/// 그 확인 없이 B 가 0 인 것을 근거로 삼으면 "둘 다 0"을 결론으로 착각한다.
|
||||
/// </summary>
|
||||
internal static class ClearTypeCheck
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>이 정도 채널 차이면 회색 AA 가 아니다 — 회색은 원리상 차이가 정확히 0 이다</summary>
|
||||
private const int ChannelSpread = 6;
|
||||
|
||||
/// <summary>글자가 그려진 픽셀로 볼 밝기 상한(흰 배경 기준)</summary>
|
||||
private const int InkLuma = 200;
|
||||
|
||||
private static readonly List<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>대조군이 먼저 서야 나머지 숫자가 뜻을 갖는다</summary>
|
||||
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
|
||||
/// <summary>
|
||||
/// 같은 글자를 다섯 조건으로 놓는다. 배경은 전부 <b>불투명 흰색</b>이어야 한다 —
|
||||
/// ClearType 은 뒤에 무엇이 있는지 알아야 서브픽셀을 계산할 수 있어서,
|
||||
/// 투명 배경에서는 효과와 무관하게 회색으로 떨어진다.
|
||||
/// </summary>
|
||||
private static Window BuildProbe(
|
||||
out List<(string Name, FrameworkElement Target, bool ExpectClearType)> samples,
|
||||
out List<Popup> 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 — 힌트를 중간 서피스 <b>안쪽</b>의 불투명 배경 요소에.
|
||||
// 문서가 "불투명 배경이 있는 지점"이라고만 말해서 어느 쪽인지 재 봐야 안다.
|
||||
var inner = new Border { Background = Brushes.White, Child = hintInside };
|
||||
RenderOptions.SetClearTypeHint(inner, ClearTypeHint.Enabled);
|
||||
var hostB = Card(inner);
|
||||
hostB.Effect = shadow.Clone();
|
||||
|
||||
// F — 합성 표본이 아니라 <b>앱의 실제 콤보 템플릿</b>. 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 — 앱의 현재 구조: 레이어드 팝업 <b>안에</b> 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 — 후보 수리: 그림자를 <b>글자가 없는 형제</b>로 떼어낸다.
|
||||
// 그러면 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 <b>안쪽</b>(가장 안쪽 불투명 배경)에 건다.
|
||||
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;
|
||||
};
|
||||
|
||||
// 기대값은 <b>예측이 아니라 측정 결과</b>다. C1 과 E 는 내가 예상한 것과 반대로 나왔고
|
||||
// 그 사실이 곧 수리 방법을 정한다 —
|
||||
// 힌트는 Effect 가 걸린 요소 자신에게 걸면 듣지 않고(C1), 그 <b>안쪽</b> 불투명 배경에 걸면 듣는다(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;
|
||||
}
|
||||
|
||||
/// <summary>글리프가 많고 획이 가는 글자 — 색 번짐이 가장 잘 드러난다</summary>
|
||||
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
|
||||
/// <summary>
|
||||
/// 이 요소가 실제로 그려진 화면 조각을 떠서, 채널이 어긋난 픽셀과 글자 픽셀을 센다.
|
||||
/// </summary>
|
||||
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, "캡처 실패");
|
||||
}
|
||||
|
||||
// 배경을 먼저 정한다 — 가장 많이 나온 색이 배경이다.
|
||||
//
|
||||
// <b>왜 필요한가.</b> "채널 차이 = 서브픽셀 AA"는 배경이 무채색일 때만 성립한다.
|
||||
// 유채색 배경(예: 선택된 콤보 항목의 B.Sel #C2E2FF, 채널 차이 61)에서는
|
||||
// 글자와 무관하게 거의 모든 픽셀이 색 번짐으로 잡혀서 <b>ClearType 이 꺼져 있어도 통과한다</b>.
|
||||
// 실제로 그렇게 통과시켰다 — 색번짐 4359 인데 글자픽셀은 1075 였다.
|
||||
var histogram = new Dictionary<int, int>();
|
||||
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})");
|
||||
}
|
||||
|
||||
/// <summary>데스크톱 DC 에서 사각형 하나를 떠 BGRA 바이트로 돌려준다</summary>
|
||||
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
|
||||
}
|
||||
@@ -557,7 +557,17 @@
|
||||
<Border.Resources>
|
||||
<Style TargetType="ComboBoxItem" BasedOn="{StaticResource B.ComboItem}" />
|
||||
</Border.Resources>
|
||||
<ScrollViewer MaxHeight="260"><ItemsPresenter /></ScrollViewer>
|
||||
<!-- 안쪽에 불투명 배경을 한 겹 더 두고 여기에 ClearTypeHint 를 건다.
|
||||
Effect 와 AllowsTransparency 는 각각 중간 서피스를 만들어 ClearType 을 끄고,
|
||||
그러면 13px 한글이 회색 안티에일리어싱으로 뭉개진다.
|
||||
힌트를 Effect 가 걸린 바깥 Border 에 걸면 듣지 않는다.
|
||||
중간 서피스 안쪽의 불투명 배경에 걸어야 한다(진단 cleartype 으로 잰 결과). -->
|
||||
<ScrollViewer MaxHeight="260">
|
||||
<Grid Background="{DynamicResource B.Surface}"
|
||||
RenderOptions.ClearTypeHint="Enabled">
|
||||
<ItemsPresenter />
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
@@ -1096,8 +1106,12 @@
|
||||
</Border>
|
||||
<Popup x:Name="pop" Placement="Bottom" IsOpen="{TemplateBinding IsSubmenuOpen}" AllowsTransparency="True"
|
||||
Focusable="False" PopupAnimation="Fade">
|
||||
<!-- AllowsTransparency 팝업은 레이어드 창이라 ClearType 이 꺼진다.
|
||||
불투명 배경이 있는 이 자리에 힌트를 걸어 되살린다 —
|
||||
메뉴에 배치·정렬 명령이 다 들어 있어 가장 많이 읽는 글자다. -->
|
||||
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="4" Margin="0,3,0,0">
|
||||
CornerRadius="6" Padding="4" Margin="0,3,0,0"
|
||||
RenderOptions.ClearTypeHint="Enabled">
|
||||
<StackPanel IsItemsHost="True" />
|
||||
</Border>
|
||||
</Popup>
|
||||
|
||||
@@ -740,7 +740,10 @@
|
||||
HorizontalOffset="-176" VerticalOffset="4">
|
||||
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line2}"
|
||||
BorderThickness="1" CornerRadius="8" Padding="10" Effect="{DynamicResource B.FloatShadow}">
|
||||
<StackPanel Width="212">
|
||||
<!-- Effect 와 레이어드 팝업이 각각 중간 서피스를 만들어 ClearType 을 끈다.
|
||||
힌트는 Effect 가 걸린 바깥 Border 에 걸면 듣지 않으므로, 안쪽 불투명 배경에 건다. -->
|
||||
<StackPanel Width="212" Background="{DynamicResource B.Surface}"
|
||||
RenderOptions.ClearTypeHint="Enabled">
|
||||
<TextBlock Text="배경" FontSize="11" Foreground="{DynamicResource B.Muted}" Margin="0,0,0,5"/>
|
||||
<ItemsControl ItemsSource="{Binding BackgroundSwatches}"
|
||||
ItemTemplate="{StaticResource ColorSwatchTemplate}">
|
||||
|
||||
@@ -626,8 +626,11 @@
|
||||
<Border.Effect>
|
||||
<DynamicResource ResourceKey="B.FloatShadow"/>
|
||||
</Border.Effect>
|
||||
<!-- 힌트는 ScrollViewer <b>안쪽</b>이어야 한다 — ScrollViewer 도 중간 서피스를 열어
|
||||
바깥에 걸면 무효다(진단 cleartype 의 I/J 표본이 그 차이다). -->
|
||||
<ScrollViewer MaxHeight="440" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Source={StaticResource PaletteGrouped}}" Margin="10,9,10,10">
|
||||
<ItemsControl ItemsSource="{Binding Source={StaticResource PaletteGrouped}}" Margin="10,9,10,10"
|
||||
Background="{DynamicResource B.Panel}" RenderOptions.ClearTypeHint="Enabled">
|
||||
<!-- 그룹 세로 나열 + 그룹 내 타일 4열([200] 플라이아웃 치수) -->
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
@@ -672,7 +675,8 @@
|
||||
<Border.Effect>
|
||||
<DynamicResource ResourceKey="B.FloatShadow"/>
|
||||
</Border.Effect>
|
||||
<StackPanel Margin="5">
|
||||
<StackPanel Margin="5" Background="{DynamicResource B.Panel}"
|
||||
RenderOptions.ClearTypeHint="Enabled">
|
||||
<Button Style="{StaticResource FlyoutRow}" Command="{Binding ZoomInCommand}" Click="OnZoomRowClick">
|
||||
<DockPanel LastChildFill="True">
|
||||
<TextBlock DockPanel.Dock="Right" Text="Ctrl+휠↑" Foreground="{DynamicResource B.Muted}"
|
||||
|
||||
Reference in New Issue
Block a user