diff --git a/src/SheetMe.Designer/Diagnostics/DialogShots.cs b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
index eeb6799..0df7f3a 100644
--- a/src/SheetMe.Designer/Diagnostics/DialogShots.cs
+++ b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
@@ -53,6 +53,13 @@ public static class DialogShots
: window.Background?.ToString() ?? "(null)";
var styled = window.Style is not null ? "스타일적용" : "스타일없음";
lines.Add($"OK {name}-{suffix} 배경={background} {styled}");
+ // 그림은 증거만 만든다 — 잘렸는지는 값으로 단정해야 CI 가 잡는다
+ var violations = OverflowCheck.Inspect(window, out var inspected);
+ lines.Add($" 글자 {inspected}개 검사, 넘침 {violations.Count}건");
+ foreach (var violation in violations)
+ {
+ lines.Add($"FAIL 넘침 {name}-{suffix} {violation}");
+ }
}
catch (Exception ex)
{
@@ -63,6 +70,8 @@ public static class DialogShots
lines.Add(string.Empty);
lines.AddRange(StyleSelfCheck());
+ lines.Add(string.Empty);
+ lines.AddRange(OverflowCheck.SelfCheck());
File.WriteAllText(Path.Combine(outputDirectory, "_report.txt"),
string.Join(Environment.NewLine, lines));
diff --git a/src/SheetMe.Designer/Diagnostics/OverflowCheck.cs b/src/SheetMe.Designer/Diagnostics/OverflowCheck.cs
new file mode 100644
index 0000000..2c6c748
--- /dev/null
+++ b/src/SheetMe.Designer/Diagnostics/OverflowCheck.cs
@@ -0,0 +1,321 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+
+namespace SheetMe.Designer.Diagnostics;
+
+///
+/// 레이아웃이 글자를 잘라먹었는지 단정한다.
+///
+/// 왜 필요한가. 지금 이 저장소에서는 글자가 잘렸다는 이유로 실패할 수 있는 것이 하나도 없다.
+/// 테스트 277건은 Core·Data 만 참조해 XAML 을 한 줄도 로드하지 않고,
+/// --dialog-shots 는 PNG 57장을 만들면서 단정하는 것은 창 배경색과 스타일 존재 여부 둘뿐이다.
+/// 즉 회귀를 잡는 유일한 수단이 사람이 그림 57장을 보는 것이었다.
+/// 실제로 이번 세션에만 "232 로는 이름이 잘린다", "220 이면 마지막 토글이 잘린다",
+/// "0 크기 Grid 가 자식을 폭 0 으로 재서 글자가 사라진다" 세 건이 눈으로만 잡혔다.
+///
+/// 왜 글자만 보는가. WPF 에서 DesiredSize > RenderSize 는 정상인 경우가 많다
+/// (Auto 칸, 클리핑을 의도한 컨테이너, 측정 전 요소). 전부 훑으면 수백 건이 떠서 게이트가 무용지물이 된다.
+/// 잘려서 실제로 손해가 나는 것은 읽어야 할 글자뿐이라 거기만 본다.
+///
+internal static class OverflowCheck
+{
+ #region Member Fields
+ /// 서브픽셀 반올림 오차 — 이보다 작은 초과는 잘린 것이 아니다
+ private const double Slack = 0.75;
+ #endregion
+
+ #region Types
+ internal readonly record struct Violation(string Kind, string Text, string Detail)
+ {
+ public override string ToString() => $"{Kind}: \"{Text}\" — {Detail}";
+ }
+ #endregion
+
+ #region Methods
+ ///
+ /// 이 창의 시각 트리를 훑어 잘린 글자를 찾는다. Measure/Arrange 가 끝난 뒤 불러야 한다.
+ ///
+ /// 를 함께 돌려주는 이유: 위반 0건은
+ /// "깨끗하다"와 "훑기가 깨졌다"를 구분해 주지 않는다. 몇 개를 봤는지 같이 적어야
+ /// 0 이 뜻을 갖는다(--cleartype 의 대조군과 같은 장치다).
+ ///
+ public static List Inspect(Visual root, out int inspected)
+ {
+ var found = new List();
+ inspected = 0;
+ Walk(root, found, ref inspected);
+ return found;
+ }
+
+ ///
+ /// 대조군. 일부러 잘린 글자 넷을 만들어 검사기가 실제로 잡는지 확인한다.
+ ///
+ /// 이것이 없으면 위반 0건이 "화면이 깨끗하다"가 아니라 "검사기가 고장났다"일 수 있고,
+ /// 그 둘은 보고서에서 똑같이 생겼다. 이번 세션에만 그 함정에 두 번 빠졌다
+ /// (--maxrect 가 기본값 0 을 기대값 0 과 비교해 공허하게 통과했고,
+ /// --modal-check 가 Loaded 보다 먼저 재서 언제나 0 을 셌다).
+ ///
+ public static List SelfCheck()
+ {
+ var lines = new List { "[넘침 검사기 자가 점검 — 일부러 잘린 것을 잡는가]" };
+
+ // 대조군은 실제로 손해가 나는 모양이어야 한다. 처음에는 "Width 를 좁게 주고 말줄임표를 끈"
+ // 것을 잘림으로 놓았는데, 실측해 보니 그건 잘리는 게 아니라 자연폭 그대로 그려지는 것이었다
+ // (ActualWidth 가 213.3 으로 나왔다). 대조군이 틀렸던 것이라 모양을 바꿨다.
+ var zeroHost = new Grid { Width = 0, ClipToBounds = true };
+ var zeroText = Text("0 크기 칸에 갇힌 글자", TextTrimming.CharacterEllipsis, tip: false);
+ zeroHost.Children.Add(zeroText);
+
+ var spillHost = new Border { Width = 40, Height = 20, ClipToBounds = true };
+ var spillText = Text("자르는 칸 밖으로 삐져나간 긴 문구", TextTrimming.None, tip: false);
+ spillHost.Child = spillText;
+
+ var trimmedNoTip = Text("말줄임표는 있고 툴팁은 없는 긴 문구", TextTrimming.CharacterEllipsis, tip: false);
+ trimmedNoTip.Width = 40;
+ var trimmedWithTip = Text("툴팁이 있으니 전체를 읽을 수 있다", TextTrimming.CharacterEllipsis, tip: true);
+ trimmedWithTip.Width = 40;
+
+ // 0 폭 칸에 갇힌 글자는 ActualWidth 가 정확히 0 이 되지 않는다 — 말줄임표 글리프 몫으로 9.6 이 남는다.
+ // 그래서 이 대조군의 정답은 '크기0' 이 아니라 두 규칙이 함께 걸리는 것이다.
+ // 이것도 처음에 기대값을 잘못 적었고 실측이 정정했다.
+ // '크기0' 규칙 자체는 남긴다 — 0 크기 Grid 가 자식을 폭 0 으로 재던 실제 버그를 겨눈 것이고,
+ // 합성으로 그 모양을 정확히 만들지 못한다는 사실은 규칙이 틀렸다는 뜻이 아니다.
+ var cases = new (string Expect, TextBlock Element)[]
+ {
+ ("컨테이너넘침,툴팁없이잘림", zeroText),
+ ("컨테이너넘침", spillText),
+ ("툴팁없이잘림", trimmedNoTip),
+ ("통과", trimmedWithTip),
+ };
+
+ var host = new StackPanel();
+ host.Children.Add(zeroHost);
+ host.Children.Add(spillHost);
+ host.Children.Add(trimmedNoTip);
+ host.Children.Add(trimmedWithTip);
+ var window = new Window
+ {
+ Width = 400, Height = 300, Left = -6000, Top = -6000,
+ WindowStartupLocation = WindowStartupLocation.Manual,
+ ShowActivated = false, ShowInTaskbar = false, Content = host,
+ };
+ window.Show();
+ window.UpdateLayout();
+
+ foreach (var (expect, element) in cases)
+ {
+ var hits = Inspect(element, out var seen);
+ var kinds = hits.Count == 0 ? "통과" : string.Join(",", hits.Select(h => h.Kind));
+ var ok = kinds == expect && seen == 1;
+ var block = element;
+ lines.Add($"{(ok ? "PASS" : "FAIL")} 대조군 {expect,-14} → {kinds} (검사 {seen}개)"
+ + $" [보임={block.IsVisible} 폭={block.ActualWidth:0.#} 높이={block.ActualHeight:0.#}"
+ + $" 자연폭={(block.IsVisible ? NaturalWidth(block) : 0):0.#} 줄바꿈={block.TextWrapping}]");
+ }
+ window.Close();
+ return lines;
+ }
+
+ private static TextBlock Text(string text, TextTrimming trimming, bool tip)
+ {
+ var block = new TextBlock
+ {
+ Text = text,
+ FontSize = 13,
+ TextWrapping = TextWrapping.NoWrap,
+ TextTrimming = trimming,
+ HorizontalAlignment = HorizontalAlignment.Left,
+ };
+ if (tip)
+ {
+ block.ToolTip = text;
+ }
+ return block;
+ }
+
+ private static void Walk(DependencyObject node, List found, ref int inspected)
+ {
+ if (node is TextBlock text)
+ {
+ inspected++;
+ Judge(text, found);
+ }
+ var count = VisualTreeHelper.GetChildrenCount(node);
+ for (var i = 0; i < count; i++)
+ {
+ Walk(VisualTreeHelper.GetChild(node, i), found, ref inspected);
+ }
+ // 팝업 내용은 시각 트리에 안 걸리므로 논리 자식으로 따라 들어간다 —
+ // 메뉴·플라이아웃·자동완성이 전부 이 경로에 있다
+ if (node is FrameworkElement { } element)
+ {
+ foreach (var child in LogicalTreeHelper.GetChildren(element))
+ {
+ if (child is System.Windows.Controls.Primitives.Popup { Child: { } popupChild })
+ {
+ Walk(popupChild, found, ref inspected);
+ }
+ }
+ }
+ }
+
+ private static void Judge(TextBlock text, List found)
+ {
+ var content = text.Text;
+ if (!text.IsVisible || content.Length == 0 || IsWhiteSpace(content))
+ {
+ return;
+ }
+ var label = content.Length > 28 ? content[..28] + "…" : content;
+ var owner = OwnerOf(text);
+
+ // ① 보이는데 크기가 0 — 이번 세션에 실제로 낸 버그다(0 크기 Grid 가 자식을 폭 0 으로 잰다).
+ // 이 경우 사용자는 글자가 아예 없는 것으로 본다.
+ if (text.ActualWidth < 0.5 || text.ActualHeight < 0.5)
+ {
+ found.Add(new Violation("크기0", label,
+ $"{owner} ActualWidth={text.ActualWidth:0.#} ActualHeight={text.ActualHeight:0.#}"));
+ return;
+ }
+
+ // ② 세로로 잘림 — 줄바꿈이 일어났는데 높이를 안 준 경우다.
+ if (text.DesiredSize.Height - text.RenderSize.Height > Slack)
+ {
+ found.Add(new Violation("세로잘림", label,
+ $"{owner} 필요 {text.DesiredSize.Height:0.#} > 실제 {text.RenderSize.Height:0.#}"));
+ }
+
+ // ③ 자기를 자르는 조상 밖으로 삐져나갔는가.
+ //
+ // 왜 ActualWidth 로는 못 잡는가. TextTrimming 이 None 이면 TextBlock 은 주어진 Width 를
+ // 지키지 않고 자연폭을 그대로 ActualWidth 로 보고한다(실측: Width=40 인데 ActualWidth=213.3).
+ // 즉 WPF 에서 말줄임표 없는 NoWrap 글자는 잘리는 것이 아니라 옆을 침범한다.
+ // 그래서 "글자 자연폭 > 자기 ActualWidth" 는 이 경우 항상 거짓이고, 대신
+ // 자르는 조상의 사각형과 비교해야 한다.
+ Clipped(text, label, owner, found);
+
+ // ④ 말줄임표가 실제로 걸렸는가 — 이때는 ActualWidth 가 좁혀지므로 자연폭과 비교할 수 있다.
+ if (text.TextWrapping != TextWrapping.NoWrap || text.TextTrimming == TextTrimming.None)
+ {
+ return;
+ }
+ var natural = NaturalWidth(text);
+ var available = text.ActualWidth - text.Padding.Left - text.Padding.Right;
+ if (natural - available <= Slack)
+ {
+ return;
+ }
+ // 말줄임표는 있지만 툴팁이 없으면 전체 문구를 볼 방법이 없다.
+ if (text.ToolTip is null && ToolTipService.GetToolTip(text) is null && !AncestorHasToolTip(text))
+ {
+ found.Add(new Violation("툴팁없이잘림", label,
+ $"{owner} 필요 {natural:0.#} > 가용 {available:0.#}"));
+ }
+ }
+
+ ///
+ /// 글자의 사각형을 자기를 자르는 가장 가까운 조상의 좌표로 옮겨 비교한다.
+ /// 자르는 조상이 없으면 삐져나가도 안 잘리므로(옆과 겹칠 뿐) 위반으로 세지 않는다 —
+ /// 겹침은 다른 종류의 결함이고 이 검사기가 판정할 수 있는 것이 아니다.
+ ///
+ private static void Clipped(TextBlock text, string label, string owner, List found)
+ {
+ var node = (DependencyObject)text;
+ for (var depth = 0; depth < 24; depth++)
+ {
+ node = VisualTreeHelper.GetParent(node);
+ if (node is not FrameworkElement { IsVisible: true } ancestor)
+ {
+ if (node is null)
+ {
+ return;
+ }
+ continue;
+ }
+ // ScrollViewer 안은 스크롤로 볼 수 있으므로 잘린 것이 아니다
+ if (ancestor is ScrollViewer or ScrollContentPresenter)
+ {
+ return;
+ }
+ if (!ancestor.ClipToBounds && ancestor.Clip is null)
+ {
+ continue;
+ }
+ var box = text.TransformToAncestor(ancestor)
+ .TransformBounds(new Rect(0, 0, text.ActualWidth, text.ActualHeight));
+ var overRight = box.Right - ancestor.ActualWidth;
+ var overBottom = box.Bottom - ancestor.ActualHeight;
+ if (overRight > Slack || overBottom > Slack || box.Left < -Slack || box.Top < -Slack)
+ {
+ found.Add(new Violation("컨테이너넘침", label,
+ $"{owner} → {ancestor.GetType().Name}"
+ + $"({ancestor.ActualWidth:0.#}×{ancestor.ActualHeight:0.#}) 밖으로 "
+ + $"우 {Math.Max(0, overRight):0.#} 하 {Math.Max(0, overBottom):0.#}"));
+ }
+ return;
+ }
+ }
+
+ /// 이 글자가 한 줄로 다 그려지려면 몇 픽셀이 필요한가
+ private static double NaturalWidth(TextBlock text)
+ {
+ var typeface = new Typeface(text.FontFamily, text.FontStyle, text.FontWeight, text.FontStretch);
+ // 요소와 같은 서식 모드로 재야 한다 — Ideal 과 Display 는 글립 진행이 달라
+ // 같은 글자도 폭이 다르게 나온다. 이 값은 생성자 인자로만 줄 수 있다.
+ var formatted = new FormattedText(
+ text.Text,
+ CultureInfo.CurrentUICulture,
+ text.FlowDirection,
+ typeface,
+ text.FontSize,
+ Brushes.Black,
+ numberSubstitution: null,
+ TextOptions.GetTextFormattingMode(text),
+ VisualTreeHelper.GetDpi(text).PixelsPerDip);
+ return formatted.WidthIncludingTrailingWhitespace;
+ }
+
+ /// 행 전체에 툴팁이 걸린 경우가 흔하다 — 그러면 글자에 없어도 읽을 수 있다
+ private static bool AncestorHasToolTip(DependencyObject node)
+ {
+ for (var i = 0; i < 6 && node is not null; i++)
+ {
+ node = VisualTreeHelper.GetParent(node);
+ if (node is FrameworkElement { } element
+ && (element.ToolTip is not null || ToolTipService.GetToolTip(element) is not null))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /// 어디서 잘렸는지 — 이름 있는 가장 가까운 조상을 짚어 준다
+ private static string OwnerOf(DependencyObject node)
+ {
+ for (var i = 0; i < 10 && node is not null; i++)
+ {
+ if (node is FrameworkElement { Name.Length: > 0 } named)
+ {
+ return named.Name;
+ }
+ node = VisualTreeHelper.GetParent(node);
+ }
+ return "(이름없음)";
+ }
+
+ private static bool IsWhiteSpace(string value)
+ {
+ foreach (var character in value)
+ {
+ if (!char.IsWhiteSpace(character))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+ #endregion
+}