Files
SheetMe/src/SheetMe.Designer/App.xaml.cs
T
MsystechandClaude Opus 5 ba838ae54a 피그마식 스냅 — 여러 선이 동시에, 관련된 것만 잇고, 같은 간격이면 그렇다고 말한다
정렬 스냅 자체는 있었다. 다만 축마다 선 하나만, 그것도 화면 끝에서 끝까지 긋는 무한 점선이라
무엇에 맞춘 것인지가 안 보였다. 판단을 SheetMe.Core 로 옮기고 후보의 출처를 끝까지 들고 간다.

왜 옮겼나. 스냅은 눈으로만 확인되는 종류의 코드다 — 후보 하나, 열거 순서 하나가 바뀌면
붙는 자리가 조용히 달라지고 드래그해 보기 전엔 아무도 모른다. Core/Layout 의 SnapSolver 는
순수 기하이고 단위 테스트 27건이 승자 규칙·가이드 개수·선분 범위·간격 계산을 못 박는다.
SnapEngine 에는 "어떤 사각형이 후보인가"와 좌표 변환만 남았다.

달라진 것들:

· 동시에 맞는 정렬은 전부 보인다. 폭이 같은 형제 옆에 대면 좌·중·우 세 줄이 함께 뜬다.
  보정량은 승자 하나만 쓰되, 그 보정을 적용했을 때 실제로 맞는 후보만 선으로 낸다 —
  |거리|만 같고 부호가 반대인 후보까지 그리면 '붙었다고 그려 놓고 안 붙은' 선이 된다.

· 선이 선분이 됐다. 형제 가이드는 관련된 것들의 합집합 범위까지만, 용지 가이드는 그 용지 전체.
  같은 좌표의 형제 여럿은 한 선으로 합치고 범위만 넓힌다.
  덤으로 대비 문제도 풀렸다 — 예전 분홍(#FF4FA3)은 종이 밖 회색 배경 위에서 2.1:1 로 죽었다.

· 허용 오차가 화면 기준이 됐다. 월드 고정이면 줌 50%에서 두 배로 끈적이고 200%에서 절반으로
  미끄러진다 — 손끝 감각은 화면 거리로 정해진다. 핸들 히트 반경도 같은 병이라 함께 고쳤다
  (줌 300%에서 그림은 8px 인데 판정이 18px 였다). 선 두께도 화면 1px 로 고정한다.

· 승자가 결정적이 됐다. 예전에는 컨트롤 열거 순서가 동률을 갈랐다.
  이제 |거리| → 종류(형제 모서리 > 형제 중심 > 용지 경계 > 용지 중앙) → 좌표 → 모서리 순이다.

· 간격 균등. 형제들이 이미 같은 간격으로 늘어서 있으면 그 리듬에 끼거나 이어 붙는 자리에 붙고,
  같은 값이 된 빈틈 전부에 막대와 수치 칩을 놓는다. 양옆만 재면 "30"이 한 번 뜰 뿐이라
  무엇과 같아졌는지 안 보인다. 같은 축에서 정렬과 동시에 사정권이면 정렬이 이긴다 —
  거리로 겨루게 하면 한 픽셀 차이로 표시가 선↔막대로 튀어 종잡을 수 없다.

· 후보 범위. 컨테이너 자식도 이제 후보다(히트테스트는 재귀하는데 스냅만 최상위를 보던 규칙 불일치).
  대신 끌려가는 컨테이너의 자식은 빼야 한다 — 선택 목록엔 없지만 함께 움직이므로
  빼지 않으면 컨테이너가 제 자식에게 붙어 그 자리에서 굳는다.

· 리사이즈. 후보를 이동 드래그에서만 모으고 있어서, 앱을 켜자마자 핸들을 잡으면 스냅이 없고
  이동을 한 번 한 뒤에는 그때의 낡은 좌표에 붙었다. 리사이즈 시작에도 모으고 놓을 때 버린다.
  모서리 스냅이 '첫 매치'였던 것도 이동과 같은 최근접 규칙으로 통일했다. 가이드도 함께 보인다.

· 정합된 축은 반올림하지 않는다. 형제 폭이 홀수면 중심이 반정수라, 반올림하면 중앙에 맞췄는데
  0.5px 어긋난 채로 놓인다. 무한선일 땐 안 보였지만 선분이 되면 모서리에서 뜬 게 눈에 띈다.
  저장은 LegacyFormat.FormatPair 가 어차피 정수로 쓰므로 포맷은 영향 없다.

성능 가드: 한 줄에 겹치는 형제가 64개 넘으면 간격 계산을 건너뛰고(정렬은 정상),
형제가 2000개 넘으면 간격을 끈다. 후보 정렬은 드래그 시작에 1회, 프레임 경로는 무할당.
가이드 내용이 이전과 같으면 컬렉션을 건드리지 않는다.

신규 진단 --snap-shots — 종이와 오버레이를 겹쳐 드래그 도중을 찍는다.
기존 --render-smoke/--db-render 는 종이만 찍어 오버레이가 안 나왔다. 선이 어디서 어디까지
그어지는지, 몇 개가 동시에 보이는지는 그림으로만 확인된다. 게이트는 함께 남기는 _report.txt 의
수치로 걸고 PNG 는 눈 검증 보조다(픽셀 비교는 테마·글꼴에 흔들려 게이트로 못 쓴다).

게이트: 테스트 257/257(신규 27), --edit-smoke 223건 0실패(스냅 검사 14건 신규),
--query-popup 17/17, --maxrect 10/10, --db-smoke 3000 diff 0·예외 0,
--db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일, --snap-shots 7종 0실패.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 09:23:42 +09:00

379 lines
16 KiB
C#

using System.IO;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using SheetMe.Designer.Services;
using SheetMe.Designer.Views;
namespace SheetMe.Designer;
/// <summary>
/// 서식생성기 애플리케이션 진입점.
/// 명령행: --render-smoke &lt;입력.xml&gt; &lt;출력.png&gt; — 첫 페이지를 오프스크린 렌더해 PNG 저장(검증용) 후 종료.
/// </summary>
public partial class App : Application
{
/// <summary>
/// 기동 — 진단 모드 분기, 아니면 HIS 세션을 확정한 뒤 메인 셸 표시.
/// HIS 기동 규약: <c>SheetMe.Designer.exe "UidCod,ComNum,ShtCod"</c> (콤마 구분 단일 인자).
/// </summary>
/// <summary>Dispatcher 예외 폭주 차단용 — 10초 내 5회면 강제 종료(무한 팝업 루프 방지)</summary>
private readonly Queue<DateTime> recentCrashes = new();
/// <summary>진단 플래그로 기동했는가 — 이 모드에서는 어떤 경로로도 모달을 띄우지 않는다</summary>
private bool diagnosticMode;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// 진단 분기보다 먼저 등록한다 — 스모크 중 발생한 예외도 로그에 남아야 한다
Services.AppLog.Initialize();
DispatcherUnhandledException += OnDispatcherUnhandledException;
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
Services.AppLog.Error("처리되지 않은 예외(도메인)", args.ExceptionObject as Exception ?? new Exception(args.ExceptionObject?.ToString()));
System.Threading.Tasks.TaskScheduler.UnobservedTaskException += (_, args) =>
{
Services.AppLog.Error("관측되지 않은 Task 예외", args.Exception);
args.SetObserved();
};
if (Services.StartupArguments.IsDiagnostic(e.Args))
{
diagnosticMode = true;
// 기본 ShutdownMode 는 OnLastWindowClose 다. 창을 띄웠다 전부 닫는 진단
// (--maxrect, --edit-smoke, --dialog-shots …)은 마지막 창을 닫는 순간 WPF 가 먼저
// Shutdown(0) 을 걸어 버리고, 뒤이은 Shutdown(RunDiagnostic(...)) 인자는 무시된다 —
// 리포트에 '실패 3건' 이 찍혀도 프로세스는 0 을 돌려준다(실제로 그 상태였다).
// 스크립트가 종료코드로 판정하면 실패를 성공으로 읽는다.
ShutdownMode = ShutdownMode.OnExplicitShutdown;
Services.UserSession.InitializeDiagnostic();
Shutdown(RunDiagnostic(e.Args));
return;
}
var launch = Services.StartupArguments.Parse(e.Args);
var config = Services.ConfigService.Current;
if (!Services.SessionBootstrap.TryInitialize(launch, config.ConnectionString, config.DevUidCod, out var error))
{
// 순정 MessageBox 유지 — 테마 대화상자로 바꾸지 말 것.
// (1) 이 시점엔 창이 하나도 없다. 기본 ShutdownMode 는 OnLastWindowClose 라 여기서 Window 를
// 띄우면 그 창이 앱의 유일한 창이 되고, 닫는 순간 WPF 가 자동 종료를 시작해 바로 아래
// Shutdown(2) 의 종료코드가 유실될 수 있다(런처가 실패를 성공으로 읽는다).
// (2) ThemeManager.LoadSaved() 가 아직 아래(:54)라 라이트 사용자에게도 다크 창이 뜬다.
// (3) '앱이 뜨지 못한다'를 알리는 마지막 통로다 — 알림 수단이 앱 상태에 의존하면 안 된다.
MessageBox.Show(error, "서식생성기", MessageBoxButton.OK, MessageBoxImage.Error);
Shutdown(2);
return;
}
Services.ThemeManager.LoadSaved();
// 제목 표시줄은 OS 가 그린다 — 창이 만들어질 때마다 앱 테마에 맞춰 준다.
// 창마다 손으로 넣으면 새 대화상자에서 반드시 빠지므로 클래스 단위로 한 번만 건다.
EventManager.RegisterClassHandler(typeof(Window), Window.LoadedEvent,
new RoutedEventHandler((sender, _) =>
{
if (sender is Window window)
{
Services.WindowChromeTheme.Apply(window, !Services.ThemeManager.IsLight);
}
}));
var main = new MainView();
if (launch?.ShtCod.Length > 0 && main.DataContext is ViewModels.MainViewModel viewModel)
{
// XAML DataContext 는 InitializeComponent 안에서 이미 생성되므로 Show 전에 넣을 수 있다.
// MainView/MainViewModel 생성자를 건드리지 않는 최소 경로.
viewModel.PendingSheetCode = launch.ShtCod;
}
main.Show();
}
/// <summary>
/// UI 스레드 미처리 예외 — 기록 후 계속 진행한다. 편집 중 문서를 예외 하나로 잃지 않게 하려는 것이며,
/// 같은 예외가 반복되면(10초 내 5회) 무한 팝업 루프이므로 강제 종료한다.
/// </summary>
private void OnDispatcherUnhandledException(object sender,
System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
{
var code = Services.AppLog.Error("처리되지 않은 예외(UI)", e.Exception);
// 진단 모드에서는 절대 모달을 띄우지 않는다 — 아래 MessageBox 는 사람이 누르기 전까지 돌아오지 않아
// 무인 실행이 타임아웃까지 매달린다. 이 핸들러가 진단 분기보다 먼저 등록되므로(로그를 남기려고)
// RunDiagnostic 의 "모달 금지" 규칙을 여기서도 지켜야 앞뒤가 맞는다.
if (diagnosticMode)
{
e.Handled = true;
Console.Error.WriteLine($"진단 중 UI 예외 — 오류 코드: {code}");
Shutdown(3);
return;
}
var now = DateTime.UtcNow;
recentCrashes.Enqueue(now);
while (recentCrashes.Count > 0 && (now - recentCrashes.Peek()).TotalSeconds > 10)
{
recentCrashes.Dequeue();
}
// 아래 두 알림은 순정 MessageBox 를 유지한다 — 테마 대화상자로 바꾸지 말 것.
// 여기는 이미 예외가 터진 자리다. WPF 창을 새로 만들면(템플릿 해석·DynamicResource·렌더)
// 그 과정이 다시 던져 같은 핸들러로 재진입한다 — 무한 팝업을 막으려는 코드가 원인이 된다.
// MessageBox 는 Win32 호출이라 WPF 렌더 스택에 의존하지 않는다.
if (recentCrashes.Count >= 5)
{
e.Handled = true;
MessageBox.Show("반복되는 오류로 프로그램을 종료합니다.\n로그를 확인해 주세요.\n오류 코드: " + code,
"서식생성기", MessageBoxButton.OK, MessageBoxImage.Error);
Shutdown(3);
return;
}
// Handled 를 알림보다 먼저 세운다 — 알림이 던지면 '복구 가능한 예외'가 하드 크래시로 바뀐다
e.Handled = true;
MessageBox.Show(
$"오류가 발생했지만 작업은 계속할 수 있습니다.\n저장하지 않은 내용이 있으면 먼저 저장해 주세요.\n\n오류 코드: {code}",
"서식생성기", MessageBoxButton.OK, MessageBoxImage.Warning);
}
/// <summary>진단 플래그 분기 — 종료 코드를 반환한다(호출부가 Shutdown 처리)</summary>
private int RunDiagnostic(string[] args)
{
if (args.Length >= 3 && args[0] == "--render-smoke")
{
return RunRenderSmoke(args[1], args[2]);
}
if (args.Length >= 2 && args[0] == "--edit-smoke")
{
return Diagnostics.EditSmoke.Run(args[1]);
}
if (args.Length >= 2 && args[0] == "--db-smoke")
{
var max = args.Length >= 3 && int.TryParse(args[2], out var n) ? n : 30;
return Diagnostics.DbSmoke.RunReadSmoke(args[1], max);
}
if (args.Length >= 3 && args[0] == "--db-save-smoke")
{
return Diagnostics.DbSmoke.RunSaveSmoke(args[1], args[2]);
}
if (args.Length >= 3 && args[0] == "--db-find")
{
return Diagnostics.DbSmoke.RunFind(args[1], args[2]);
}
if (args.Length >= 3 && args[0] == "--db-columns")
{
return Diagnostics.DbSmoke.RunColumns(args[1], args[2]);
}
if (args.Length >= 3 && args[0] == "--db-row")
{
return Diagnostics.DbSmoke.RunRow(args[1], args[2]);
}
if (args.Length >= 2 && args[0] == "--db-tags")
{
return Diagnostics.DbSmoke.RunTagUsageReport(args[1]);
}
if (args.Length >= 2 && args[0] == "--db-phase3")
{
return Diagnostics.DbSmoke.RunPhase3Report(args[1]);
}
if (args.Length >= 2 && args[0] == "--db-dtf")
{
return Diagnostics.DbSmoke.RunDataTableFieldReport(args[1]);
}
if (args.Length >= 2 && args[0] == "--db-lines")
{
return Diagnostics.DbSmoke.RunLineReport(args[1]);
}
if (args.Length >= 2 && args[0] == "--db-props")
{
return Diagnostics.DbSmoke.RunPropUsageReport(args[1]);
}
if (args.Length >= 2 && args[0] == "--db-shtmst")
{
return Diagnostics.DbSmoke.RunShtMstReport(args[1]);
}
if (args.Length >= 3 && args[0] == "--db-sample")
{
return Diagnostics.DbSmoke.RunSample(args[1], args[2]);
}
if (args.Length >= 3 && args[0] == "--db-word-smoke")
{
return Diagnostics.DbSmoke.RunWordSmoke(args[1], args[2]);
}
if (args.Length >= 3 && args[0] == "--db-spd")
{
return Diagnostics.DbSmoke.RunSpreadDump(args[1], args[2]);
}
if (args.Length >= 2 && args[0] == "--db-colors")
{
return Diagnostics.DbSmoke.RunColorReport(args[1]);
}
if (args.Length >= 2 && args[0] == "--db-pageprops")
{
return Diagnostics.DbSmoke.RunPagePropsReport(args[1]);
}
if (args.Length >= 3 && args[0] == "--db-xml")
{
return Diagnostics.DbSmoke.RunDesignXml(args[1], args[2]);
}
if (args.Length >= 3 && args[0] == "--db-render")
{
return RunDbRenderSmoke(args[1], args[2]);
}
if (args.Length >= 2 && args[0] == "--db-gate")
{
return Diagnostics.DbSmoke.RunGateReport(args[1]);
}
if (args.Length >= 2 && args[0] == "--db-cls")
{
return Diagnostics.DbSmoke.RunClassReport(args[1]);
}
if (args.Length >= 3 && args[0] == "--db-tables")
{
return Diagnostics.DbSmoke.RunTableSearch(args[1], args[2]);
}
if (args.Length >= 2 && args[0] == "--query-popup")
{
return Diagnostics.DbSmoke.RunQueryPopupCheck(args[1]);
}
if (args.Length >= 2 && args[0] == "--db-trial")
{
return Diagnostics.DbSmoke.RunQueryTrial(args[1]);
}
if (args.Length >= 2 && args[0] == "--dialog-shots")
{
return Diagnostics.DialogShots.Run(args[1]);
}
if (args.Length >= 2 && args[0] == "--snap-shots")
{
return Diagnostics.SnapShots.Run(args[1]);
}
if (args.Length >= 1 && args[0] == "--maxrect")
{
return Diagnostics.MaximizeCheck.Run(args.Length >= 2 ? args[1] : null);
}
// 진단 모드에서는 모달을 띄우지 않는다 — 스크립트 옵션 오타 하나로 무인 실행이 여기서
// 영원히 멈춘다. WinExe 라 콘솔이 없어 stderr 는 호출자가 리다이렉트할 때만 보이므로
// 로그를 함께 남긴다(--db-render 가 .err.txt 로 남기는 것과 같은 이유).
Services.AppLog.Warn($"알 수 없는 진단 옵션입니다: {args[0]}");
Console.Error.WriteLine($"알 수 없는 진단 옵션입니다: {args[0]}");
return 2;
}
/// <summary>첫 페이지 오프스크린 렌더 → PNG (렌더 파이프라인 자동 검증)</summary>
private static int RunRenderSmoke(string xmlPath, string pngPath)
{
try
{
var store = new Data.Stores.XmlFileFormStore();
var document = store.LoadFile(xmlPath);
var page = DocumentMapper.CreatePage(document.Pages[0], 0);
var view = new PageView { DataContext = page };
var size = new Size(page.WidthDip, page.HeightDip);
view.Measure(size);
view.Arrange(new Rect(size));
view.UpdateLayout();
var bitmap = new RenderTargetBitmap(
(int)Math.Ceiling(size.Width), (int)Math.Ceiling(size.Height), 96, 96, PixelFormats.Pbgra32);
bitmap.Render(view);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(bitmap));
using var stream = File.Create(pngPath);
encoder.Save(stream);
Console.WriteLine($"렌더 스모크 완료: {pngPath} ({size.Width}x{size.Height}, " +
$"페이지 {document.Pages.Count}, 경고 {document.Meta.ReadWarnings.Count})");
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"렌더 스모크 실패: {ex}");
return 1;
}
}
/// <summary>
/// DB 서식 오프스크린 렌더 → PNG (Spread 격자 주입 경로 포함 검증).
/// 페이지가 여럿이면 <c>이름_p1.png</c>, <c>이름_p2.png</c> … 로 전부 뽑는다 —
/// 운영 서식 128건이 다중 페이지라 첫 장만 봐서는 검증이 되지 않는다.
/// </summary>
private static int RunDbRenderSmoke(string shtCod, string pngPath)
{
try
{
var dataBusiness = new DataBusiness.FormDesignDataBusiness();
var document = dataBusiness.OpenFromDb(shtCod);
if (document is null)
{
File.WriteAllText(pngPath + ".err.txt", $"활성 디자인 없음: {shtCod}");
return 1;
}
var designer = new ViewModels.DesignerViewModel(document, dataBusiness.LoadSpreadGrids(shtCod));
var directory = Path.GetDirectoryName(pngPath) ?? ".";
var stem = Path.GetFileNameWithoutExtension(pngPath);
for (var i = 0; i < designer.Pages.Count; i++)
{
var page = designer.Pages[i];
var view = new PageView { DataContext = page };
var size = new Size(page.WidthDip, page.HeightDip);
view.Measure(size);
view.Arrange(new Rect(size));
view.UpdateLayout();
var bitmap = new RenderTargetBitmap(
(int)Math.Ceiling(size.Width), (int)Math.Ceiling(size.Height), 96, 96, PixelFormats.Pbgra32);
bitmap.Render(view);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(bitmap));
var target = designer.Pages.Count == 1
? pngPath
: Path.Combine(directory, $"{stem}_p{i + 1}.png");
using var stream = File.Create(target);
encoder.Save(stream);
}
Console.WriteLine($"{shtCod}: 페이지 {designer.Pages.Count} 렌더 완료");
return 0;
}
catch (Exception ex)
{
File.WriteAllText(pngPath + ".err.txt", ex.ToString());
return 1;
}
}
}