diff --git a/doc/진행상황.md b/doc/진행상황.md index b5a88b5..8a5ae36 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -55,14 +55,14 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘 되돌릴 수 없는 일이라 화면 없이 검사할 수 있어야 하기 때문이다 - 겹치는 파일은 덮고, 대상에만 있던 파일은 남기고, 두 번 복사해도 같은지를 테스트로 못 박았다. -### ② 파일 비교 (2026-09-08) +### ② 파일 비교 — **완료** (2026-09-08) | 상태 | 항목 | 내용 | 확인 | |---|---|---|---| | **완료** | 바꾸기 (Ctrl+H) | 찾기 칸 아래에 바꿀 말 줄이 붙는다(Ctrl+F 로 열면 안 뜬다 - 찾기만 할 때 자리를 뺏지 않는다). `바꾸기` 는 고른 자리가 찾는 말일 때만 바꾸고 다음으로 넘어간다 - 엉뚱한 자리를 덮어쓰는 것보다 한 번 더 누르게 하는 편이 낫다 | `PulseUs → PulseMicros` 2개 바꿈 | | **완료** | 덩어리 단위 반영 | `이 덩어리 반영`(`Alt+Shift+→`). 차이로 잡힌 줄이 이어지는 구간을 한 번에 | 마지막 차이에서 2줄 한 번에 반영 | | **완료** | 인코딩·줄바꿈 변환 저장 | ACTIONS 의 `저장 형식` 두 칸. 기본 "원본 유지"라 지금까지와 똑같이 나간다. UTF-8 / UTF-8(BOM) / CP949 · CRLF / LF | 화면 확인 | -| 대기 | 바이너리·이미지 비교 | 감지해서 안내만 하고 끝난다 | | +| **완료** | 바이너리·이미지 비교 | "줄 단위로 비교할 수 없습니다" 한 줄로 끝나던 것을 답이 있는 화면으로. 크기·SHA-1·처음 갈리는 바이트 위치를 알려 주고, 그림이면 나란히 띄우고 아니면 앞 4KB 를 16진으로 늘어놓는다(다른 줄만 색) | `.bin` 두 개: "왼쪽 17 B · 오른쪽 19 B · 처음 갈리는 곳 0x10" · `.png` 두 개 나란히 | --- @@ -86,4 +86,5 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘 | 2026-09-08 | 저장소 개설 (MergeHub · UbisamPlatform 각각) | `7719094` / `36ee902` | | 2026-09-08 | **④ Git 조작 5가지** — amend · stash 목록 · 커밋 간 비교 · blame · 변경분 추출 | `9956567` | | 2026-09-08 | **③ 3-Way** — 자동 병합 · 충돌만 이동 · 병합 완료 판정 | `85c5e9e` | -| 2026-09-08 | **① 폴더 비교** — 폴더째 복사·삭제 · 보기 필터 · 비교 취소 | 아래 | +| 2026-09-08 | **① 폴더 비교** — 폴더째 복사·삭제 · 보기 필터 · 비교 취소 | `524a6a7` | +| 2026-09-08 | **② 파일 비교** — 바꾸기 · 덩어리 반영 · 저장 형식 · 이진/그림 비교 | `de1a43c` + 아래 | diff --git a/src/MergeHub/Services/BinaryCompare.cs b/src/MergeHub/Services/BinaryCompare.cs new file mode 100644 index 0000000..965df11 --- /dev/null +++ b/src/MergeHub/Services/BinaryCompare.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace MergeHub.Services +{ + /// 16진 나란히 보기의 한 줄. 왼쪽 16바이트와 오른쪽 16바이트를 같은 자리에 놓는다. + public sealed class HexRow + { + public HexRow(long offset, string leftHex, string leftText, string rightHex, string rightText, bool different) + { + Offset = offset; + LeftHex = leftHex; + LeftText = leftText; + RightHex = rightHex; + RightText = rightText; + IsDifferent = different; + } + + public long Offset { get; } + public string OffsetText => Offset.ToString("X8", CultureInfo.InvariantCulture); + + public string LeftHex { get; } + public string LeftText { get; } + public string RightHex { get; } + public string RightText { get; } + + /// 이 16바이트가 서로 다른지. 다른 줄만 눈에 띄면 어디가 갈렸는지 바로 보인다. + public bool IsDifferent { get; } + } + + public sealed class BinaryCompareResult + { + public long LeftSize { get; set; } + public long RightSize { get; set; } + + /// 내용이 같은지. 크기가 같아도 내용이 다를 수 있어 해시로 본다. + public bool Same { get; set; } + + public string LeftHash { get; set; } = string.Empty; + public string RightHash { get; set; } = string.Empty; + + /// 처음으로 갈리는 바이트 위치. 같으면 null. + public long? FirstDifference { get; set; } + + /// 앞부분만 보여 준다. 수십 MB 짜리를 전부 그리면 화면이 굳는다. + public List Rows { get; } = new List(); + + public bool Truncated { get; set; } + + public string Summary => + Same + ? $"내용이 같습니다 · {Describe(LeftSize)}" + : FirstDifference is long at + ? $"다릅니다 · 왼쪽 {Describe(LeftSize)} · 오른쪽 {Describe(RightSize)} · 처음 갈리는 곳 0x{at:X}" + : $"다릅니다 · 왼쪽 {Describe(LeftSize)} · 오른쪽 {Describe(RightSize)}"; + + private static string Describe(long bytes) => + bytes >= 1024 * 1024 ? $"{bytes / 1024.0 / 1024.0:0.#} MB" : + bytes >= 1024 ? $"{bytes / 1024.0:0.#} KB" : + $"{bytes} B"; + } + + /// + /// 이진 파일 비교. + /// + /// 줄 단위로 견줄 수 없다고 알리기만 하면 "그래서 같다는 거야 다르다는 거야"를 알 수 없다. + /// 크기·해시·처음 갈리는 자리를 알려 주고, 앞부분을 16진으로 나란히 보여 준다. + /// + public static class BinaryCompare + { + public const int BytesPerRow = 16; + + /// 기본으로 보여 줄 줄 수. 256줄 = 4KB 면 머리말을 살피기에 넉넉하다. + public const int DefaultMaxRows = 256; + + public static BinaryCompareResult Compare(string leftPath, string rightPath, int maxRows = DefaultMaxRows) + { + var result = new BinaryCompareResult(); + + var left = ReadAll(leftPath); + var right = ReadAll(rightPath); + + result.LeftSize = left.LongLength; + result.RightSize = right.LongLength; + result.LeftHash = Hash(left); + result.RightHash = Hash(right); + result.Same = string.Equals(result.LeftHash, result.RightHash, StringComparison.Ordinal); + + if (!result.Same) result.FirstDifference = FindFirstDifference(left, right); + + var total = Math.Max(left.LongLength, right.LongLength); + var rows = (int)Math.Min(maxRows, (total + BytesPerRow - 1) / BytesPerRow); + + for (var i = 0; i < rows; i++) + { + var offset = (long)i * BytesPerRow; + + var l = Slice(left, offset); + var r = Slice(right, offset); + + result.Rows.Add(new HexRow(offset, Hex(l), Printable(l), Hex(r), Printable(r), !SameBytes(l, r))); + } + + result.Truncated = total > (long)rows * BytesPerRow; + return result; + } + + private static byte[] ReadAll(string path) + { + try + { + return File.Exists(path) ? File.ReadAllBytes(path) : Array.Empty(); + } + catch (IOException) + { + return Array.Empty(); + } + } + + private static string Hash(byte[] bytes) + { + using var sha = SHA1.Create(); + var hash = sha.ComputeHash(bytes); + + var sb = new StringBuilder(hash.Length * 2); + foreach (var b in hash) sb.Append(b.ToString("x2", CultureInfo.InvariantCulture)); + return sb.ToString(); + } + + private static long? FindFirstDifference(byte[] left, byte[] right) + { + var common = Math.Min(left.LongLength, right.LongLength); + + for (long i = 0; i < common; i++) + if (left[i] != right[i]) return i; + + // 앞부분이 같다면 짧은 쪽이 끝나는 자리에서 갈린다. + return left.LongLength == right.LongLength ? (long?)null : common; + } + + private static byte[] Slice(byte[] bytes, long offset) + { + if (offset >= bytes.LongLength) return Array.Empty(); + + var length = (int)Math.Min(BytesPerRow, bytes.LongLength - offset); + var slice = new byte[length]; + Array.Copy(bytes, offset, slice, 0, length); + return slice; + } + + private static bool SameBytes(byte[] a, byte[] b) + { + if (a.Length != b.Length) return false; + for (var i = 0; i < a.Length; i++) if (a[i] != b[i]) return false; + return true; + } + + private static string Hex(byte[] bytes) + { + var sb = new StringBuilder(BytesPerRow * 3); + + for (var i = 0; i < BytesPerRow; i++) + { + if (i > 0) sb.Append(' '); + sb.Append(i < bytes.Length ? bytes[i].ToString("X2", CultureInfo.InvariantCulture) : " "); + } + + return sb.ToString(); + } + + /// 눈으로 알아볼 수 있는 글자만 남기고 나머지는 점으로. 16진만 보면 무슨 파일인지 모른다. + private static string Printable(byte[] bytes) + { + var sb = new StringBuilder(bytes.Length); + foreach (var b in bytes) sb.Append(b >= 0x20 && b < 0x7F ? (char)b : '.'); + return sb.ToString(); + } + + /// 그림 파일인지. 그림이면 16진보다 나란히 띄워 보여 주는 편이 훨씬 빠르다. + public static bool IsImage(string path) + { + var extension = Path.GetExtension(path)?.ToLowerInvariant(); + + return extension is ".png" or ".jpg" or ".jpeg" or ".bmp" or ".gif" or ".ico" or ".tif" or ".tiff"; + } + } +} diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs index cecc7cc..153f1e6 100644 --- a/src/MergeHub/ViewModels/HomeViewModel.cs +++ b/src/MergeHub/ViewModels/HomeViewModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; @@ -1455,7 +1455,7 @@ namespace MergeHub.ViewModels RefreshLanguage(); BaseHeader = $"BASE · {Path.GetFileName(LeftFile)}"; LocalHeader = $"LOCAL · {Path.GetFileName(RightFile)}"; - DiffSummary = "이진 파일 - 줄 단위로 비교할 수 없습니다"; + CompareBinary(LeftFile, RightFile); }); _workspace.AppendLog("이진 파일이라 비교하지 않음"); @@ -1463,6 +1463,9 @@ namespace MergeHub.ViewModels } IsBinaryFile = false; + IsImageFile = false; + HexRows.Clear(); + BinarySummary = string.Empty; string baseText = string.Empty, localText = string.Empty; @@ -2035,6 +2038,60 @@ namespace MergeHub.ViewModels RefreshProblems(); } + // ================= 이진 파일 비교 ================= + + /// 이진 파일 비교 결과 한 줄 요약. "그래서 같다는 거야 다르다는 거야"에 답한다. + [ObservableProperty] private string _binarySummary = string.Empty; + + /// 16진 나란히 보기. 앞부분만 담는다. + public ObservableCollection HexRows { get; } = new ObservableCollection(); + + [ObservableProperty] private bool _isImageFile; + [ObservableProperty] private string _leftImagePath = string.Empty; + [ObservableProperty] private string _rightImagePath = string.Empty; + + /// 이진 파일인데 그림이 아니라 16진으로 봐야 하는 경우. + public bool ShowHexView => IsBinaryFile && !IsImageFile; + + partial void OnIsBinaryFileChanged(bool value) => OnPropertyChanged(nameof(ShowHexView)); + partial void OnIsImageFileChanged(bool value) => OnPropertyChanged(nameof(ShowHexView)); + + /// + /// 이진 파일 두 개를 견준다. + /// + /// 지금까지는 "줄 단위로 비교할 수 없습니다" 한 줄로 끝났다. 그러면 정작 알고 싶은 + /// 같은지 다른지를 모른 채 화면을 닫게 된다. 크기·해시·처음 갈리는 자리를 알려 주고, + /// 그림이면 나란히 띄우고 아니면 앞부분을 16진으로 늘어놓는다. + /// + private void CompareBinary(string left, string right) + { + HexRows.Clear(); + IsImageFile = BinaryCompare.IsImage(left) || BinaryCompare.IsImage(right); + LeftImagePath = File.Exists(left) ? left : string.Empty; + RightImagePath = File.Exists(right) ? right : string.Empty; + + try + { + var result = BinaryCompare.Compare(left, right); + + BinarySummary = result.Truncated + ? $"{result.Summary} · 앞 {result.Rows.Count * BinaryCompare.BytesPerRow}바이트만 표시" + : result.Summary; + + if (!IsImageFile) + { + foreach (var row in result.Rows) HexRows.Add(row); + } + + DiffSummary = result.Same ? "이진 파일 · 내용이 같습니다" : "이진 파일 · 내용이 다릅니다"; + } + catch (Exception ex) + { + BinarySummary = $"이진 파일을 읽지 못했습니다: {ex.Message}"; + DiffSummary = "이진 파일"; + } + } + // ================= 저장 형식 · 덩어리 반영 ================= /// diff --git a/src/MergeHub/Views/HomeView.xaml b/src/MergeHub/Views/HomeView.xaml index a4fbe86..75dd193 100644 --- a/src/MergeHub/Views/HomeView.xaml +++ b/src/MergeHub/Views/HomeView.xaml @@ -537,6 +537,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +