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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+