이진 파일·그림 비교 (Services/BinaryCompare)

"줄 단위로 비교할 수 없습니다" 한 줄로 끝내면 정작 알고 싶은 "같은지 다른지" 를
모른 채 화면을 닫게 된다. 답이 있는 화면으로 바꿨다.

- 크기 · SHA-1 · 처음 갈리는 바이트 위치를 한 줄로 알려 준다. 크기가 같아도 내용이
  다를 수 있어 해시로 본다. 앞부분이 같고 길이만 다르면 짧은 쪽이 끝나는 자리가
  갈리는 지점이다.
- 그림(.png/.jpg/.bmp/.gif/.ico/.tif)이면 나란히 띄운다 - 16진으로 보는 것보다 훨씬 빠르다.
- 그 밖에는 앞 4KB 를 16진으로 나란히 놓고, 16바이트 단위로 다른 줄에만 색을 준다.
  볼 수 있는 글자는 오른쪽 칸에 그대로 적는다 - 16진만 보면 무슨 파일인지 모른다.
  수십 MB 짜리를 전부 그리면 화면이 굳으므로 앞부분만 그리고 잘렸다고 알린다.

BinaryTest 25건 신설(같음·가운데 다름·길이 다름·16진 표기·줄 수 제한·글자 칸·
없는 파일·그림 판별). 전체 330건 통과.

실행 확인: .bin 두 개 -> "다릅니다 · 왼쪽 17 B · 오른쪽 19 B · 처음 갈리는 곳 0x10"
+ 16진 두 줄(0x10 줄만 색). .png 두 개 -> 나란히 표시.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-08 14:50:11 +09:00
parent de1a43cc70
commit 55d9deb418
4 changed files with 336 additions and 5 deletions

View File

@ -55,14 +55,14 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
되돌릴 수 없는 일이라 <b>화면 없이 검사할 수 있어야</b> 하기 때문이다 - 겹치는 파일은 덮고, 되돌릴 수 없는 일이라 <b>화면 없이 검사할 수 있어야</b> 하기 때문이다 - 겹치는 파일은 덮고,
대상에만 있던 파일은 남기고, 두 번 복사해도 같은지를 테스트로 못 박았다. 대상에만 있던 파일은 남기고, 두 번 복사해도 같은지를 테스트로 못 박았다.
### ② 파일 비교 (2026-09-08) ### ② 파일 비교 — **완료** (2026-09-08)
| 상태 | 항목 | 내용 | 확인 | | 상태 | 항목 | 내용 | 확인 |
|---|---|---|---| |---|---|---|---|
| **완료** | 바꾸기 (Ctrl+H) | 찾기 칸 아래에 바꿀 말 줄이 붙는다(Ctrl+F 로 열면 안 뜬다 - 찾기만 할 때 자리를 뺏지 않는다). `바꾸기` 는 고른 자리가 <b>찾는 말일 때만</b> 바꾸고 다음으로 넘어간다 - 엉뚱한 자리를 덮어쓰는 것보다 한 번 더 누르게 하는 편이 낫다 | `PulseUs → PulseMicros` 2개 바꿈 | | **완료** | 바꾸기 (Ctrl+H) | 찾기 칸 아래에 바꿀 말 줄이 붙는다(Ctrl+F 로 열면 안 뜬다 - 찾기만 할 때 자리를 뺏지 않는다). `바꾸기` 는 고른 자리가 <b>찾는 말일 때만</b> 바꾸고 다음으로 넘어간다 - 엉뚱한 자리를 덮어쓰는 것보다 한 번 더 누르게 하는 편이 낫다 | `PulseUs → PulseMicros` 2개 바꿈 |
| **완료** | 덩어리 단위 반영 | `이 덩어리 반영`(`Alt+Shift+→`). 차이로 잡힌 줄이 이어지는 구간을 한 번에 | 마지막 차이에서 2줄 한 번에 반영 | | **완료** | 덩어리 단위 반영 | `이 덩어리 반영`(`Alt+Shift+→`). 차이로 잡힌 줄이 이어지는 구간을 한 번에 | 마지막 차이에서 2줄 한 번에 반영 |
| **완료** | 인코딩·줄바꿈 변환 저장 | ACTIONS 의 `저장 형식` 두 칸. 기본 "원본 유지"라 지금까지와 똑같이 나간다. UTF-8 / UTF-8(BOM) / CP949 · CRLF / LF | 화면 확인 | | **완료** | 인코딩·줄바꿈 변환 저장 | ACTIONS 의 `저장 형식` 두 칸. 기본 "원본 유지"라 지금까지와 똑같이 나간다. UTF-8 / UTF-8(BOM) / CP949 · CRLF / LF | 화면 확인 |
| 대기 | 바이너리·이미지 비교 | 감지해서 안내만 하고 끝난다 | | | **완료** | 바이너리·이미지 비교 | "줄 단위로 비교할 수 없습니다" 한 줄로 끝나던 것을 <b>답이 있는 화면</b>으로. 크기·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 | 저장소 개설 (MergeHub · UbisamPlatform 각각) | `7719094` / `36ee902` |
| 2026-09-08 | **④ Git 조작 5가지** — amend · stash 목록 · 커밋 간 비교 · blame · 변경분 추출 | `9956567` | | 2026-09-08 | **④ Git 조작 5가지** — amend · stash 목록 · 커밋 간 비교 · blame · 변경분 추출 | `9956567` |
| 2026-09-08 | **③ 3-Way** — 자동 병합 · 충돌만 이동 · 병합 완료 판정 | `85c5e9e` | | 2026-09-08 | **③ 3-Way** — 자동 병합 · 충돌만 이동 · 병합 완료 판정 | `85c5e9e` |
| 2026-09-08 | **① 폴더 비교** — 폴더째 복사·삭제 · 보기 필터 · 비교 취소 | 아래 | | 2026-09-08 | **① 폴더 비교** — 폴더째 복사·삭제 · 보기 필터 · 비교 취소 | `524a6a7` |
| 2026-09-08 | **② 파일 비교** — 바꾸기 · 덩어리 반영 · 저장 형식 · 이진/그림 비교 | `de1a43c` + 아래 |

View File

@ -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
{
/// <summary>16진 나란히 보기의 한 줄. 왼쪽 16바이트와 오른쪽 16바이트를 같은 자리에 놓는다.</summary>
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; }
/// <summary>이 16바이트가 서로 다른지. 다른 줄만 눈에 띄면 어디가 갈렸는지 바로 보인다.</summary>
public bool IsDifferent { get; }
}
public sealed class BinaryCompareResult
{
public long LeftSize { get; set; }
public long RightSize { get; set; }
/// <summary>내용이 같은지. 크기가 같아도 내용이 다를 수 있어 해시로 본다.</summary>
public bool Same { get; set; }
public string LeftHash { get; set; } = string.Empty;
public string RightHash { get; set; } = string.Empty;
/// <summary>처음으로 갈리는 바이트 위치. 같으면 <c>null</c>.</summary>
public long? FirstDifference { get; set; }
/// <summary>앞부분만 보여 준다. 수십 MB 짜리를 전부 그리면 화면이 굳는다.</summary>
public List<HexRow> Rows { get; } = new List<HexRow>();
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";
}
/// <summary>
/// 이진 파일 비교.
///
/// 줄 단위로 견줄 수 없다고 알리기만 하면 "그래서 같다는 거야 다르다는 거야"를 알 수 없다.
/// 크기·해시·처음 갈리는 자리를 알려 주고, 앞부분을 16진으로 나란히 보여 준다.
/// </summary>
public static class BinaryCompare
{
public const int BytesPerRow = 16;
/// <summary>기본으로 보여 줄 줄 수. 256줄 = 4KB 면 머리말을 살피기에 넉넉하다.</summary>
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<byte>();
}
catch (IOException)
{
return Array.Empty<byte>();
}
}
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<byte>();
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();
}
/// <summary>눈으로 알아볼 수 있는 글자만 남기고 나머지는 점으로. 16진만 보면 무슨 파일인지 모른다.</summary>
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();
}
/// <summary>그림 파일인지. 그림이면 16진보다 <b>나란히 띄워 보여 주는</b> 편이 훨씬 빠르다.</summary>
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";
}
}
}

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.IO; using System.IO;
@ -1455,7 +1455,7 @@ namespace MergeHub.ViewModels
RefreshLanguage(); RefreshLanguage();
BaseHeader = $"BASE · {Path.GetFileName(LeftFile)}"; BaseHeader = $"BASE · {Path.GetFileName(LeftFile)}";
LocalHeader = $"LOCAL · {Path.GetFileName(RightFile)}"; LocalHeader = $"LOCAL · {Path.GetFileName(RightFile)}";
DiffSummary = "이진 파일 - 줄 단위로 비교할 수 없습니다"; CompareBinary(LeftFile, RightFile);
}); });
_workspace.AppendLog("이진 파일이라 비교하지 않음"); _workspace.AppendLog("이진 파일이라 비교하지 않음");
@ -1463,6 +1463,9 @@ namespace MergeHub.ViewModels
} }
IsBinaryFile = false; IsBinaryFile = false;
IsImageFile = false;
HexRows.Clear();
BinarySummary = string.Empty;
string baseText = string.Empty, localText = string.Empty; string baseText = string.Empty, localText = string.Empty;
@ -2035,6 +2038,60 @@ namespace MergeHub.ViewModels
RefreshProblems(); RefreshProblems();
} }
// ================= 이진 파일 비교 =================
/// <summary>이진 파일 비교 결과 한 줄 요약. "그래서 같다는 거야 다르다는 거야"에 답한다.</summary>
[ObservableProperty] private string _binarySummary = string.Empty;
/// <summary>16진 나란히 보기. 앞부분만 담는다.</summary>
public ObservableCollection<HexRow> HexRows { get; } = new ObservableCollection<HexRow>();
[ObservableProperty] private bool _isImageFile;
[ObservableProperty] private string _leftImagePath = string.Empty;
[ObservableProperty] private string _rightImagePath = string.Empty;
/// <summary>이진 파일인데 그림이 아니라 16진으로 봐야 하는 경우.</summary>
public bool ShowHexView => IsBinaryFile && !IsImageFile;
partial void OnIsBinaryFileChanged(bool value) => OnPropertyChanged(nameof(ShowHexView));
partial void OnIsImageFileChanged(bool value) => OnPropertyChanged(nameof(ShowHexView));
/// <summary>
/// 이진 파일 두 개를 견준다.
///
/// 지금까지는 "줄 단위로 비교할 수 없습니다" 한 줄로 끝났다. 그러면 정작 알고 싶은
/// <b>같은지 다른지</b>를 모른 채 화면을 닫게 된다. 크기·해시·처음 갈리는 자리를 알려 주고,
/// 그림이면 나란히 띄우고 아니면 앞부분을 16진으로 늘어놓는다.
/// </summary>
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 = "이진 파일";
}
}
// ================= 저장 형식 · 덩어리 반영 ================= // ================= 저장 형식 · 덩어리 반영 =================
/// <summary> /// <summary>

View File

@ -537,6 +537,88 @@
</WrapPanel> </WrapPanel>
</Border> </Border>
<!--
이진 파일을 열었을 때. 코드 패널 위를 덮는다.
"줄 단위로 비교할 수 없습니다" 한 줄로 끝내면 정작 알고 싶은 <b>같은지 다른지</b>를
모른 채 화면을 닫게 된다. 그림이면 나란히 띄우고, 아니면 앞부분을 16진으로 늘어놓는다.
-->
<Border Grid.Row="2" Padding="10" Panel.ZIndex="10"
Visibility="{Binding IsBinaryFile, Converter={StaticResource BoolToVis}}"
Background="{DynamicResource Ubisam.Brush.ContentBackground}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}" BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Margin="0,0,0,8">
<TextBlock Text="이진 파일" FontWeight="Bold" FontSize="13"
Foreground="{DynamicResource Ubisam.Brush.Accent}"/>
<TextBlock Text="{Binding BinarySummary}" FontSize="12" Margin="0,2,0,0"
TextWrapping="Wrap"/>
</StackPanel>
<!-- 그림: 나란히 띄운다. 16진으로 보는 것보다 훨씬 빠르다. -->
<Grid Grid.Row="1" Visibility="{Binding IsImageFile, Converter={StaticResource BoolToVis}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" BorderThickness="1" Padding="6"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{Binding BaseHeader}" Style="{StaticResource Muted}"
FontSize="11" TextTrimming="CharacterEllipsis" Margin="0,0,0,6"/>
<Image Source="{Binding LeftImagePath}" Stretch="Uniform"
RenderOptions.BitmapScalingMode="HighQuality"/>
</DockPanel>
</Border>
<Border Grid.Column="2" BorderThickness="1" Padding="6"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{Binding LocalHeader}" Style="{StaticResource Muted}"
FontSize="11" TextTrimming="CharacterEllipsis" Margin="0,0,0,6"/>
<Image Source="{Binding RightImagePath}" Stretch="Uniform"
RenderOptions.BitmapScalingMode="HighQuality"/>
</DockPanel>
</Border>
</Grid>
<!-- 그림이 아니면 16진 나란히 보기. 다른 줄만 색을 준다. -->
<DataGrid Grid.Row="1" ItemsSource="{Binding HexRows}"
Visibility="{Binding ShowHexView, Converter={StaticResource BoolToVis}}"
AutoGenerateColumns="False" IsReadOnly="True"
HeadersVisibility="Column" GridLinesVisibility="None"
FontFamily="Consolas" FontSize="11"
Background="Transparent" BorderThickness="1"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<Style.Triggers>
<DataTrigger Binding="{Binding IsDifferent}" Value="True">
<Setter Property="Background"
Value="{Binding Source=Changed, Converter={StaticResource HighlightByName}, ConverterParameter=Changed}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</DataGrid.RowStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="위치" Binding="{Binding OffsetText}" Width="Auto"/>
<DataGridTextColumn Header="왼쪽 (16진)" Binding="{Binding LeftHex}" Width="Auto"/>
<DataGridTextColumn Header="왼쪽" Binding="{Binding LeftText}" Width="Auto"/>
<DataGridTextColumn Header="오른쪽 (16진)" Binding="{Binding RightHex}" Width="Auto"/>
<DataGridTextColumn Header="오른쪽" Binding="{Binding RightText}" Width="*"/>
</DataGrid.Columns>
</DataGrid>
</Grid>
</Border>
<!-- ===== 본문 4분할 ===== <!-- ===== 본문 4분할 =====
ScrollViewer 로 감싸면 내부 Grid 가 "무한 폭"으로 측정되어 star 열이 콘텐츠 크기로 ScrollViewer 로 감싸면 내부 Grid 가 "무한 폭"으로 측정되어 star 열이 콘텐츠 크기로
부풀고 오른쪽 패널이 창 밖으로 밀려난다. 셸이 이미 정확한 폭을 주므로 그대로 채운다. --> 부풀고 오른쪽 패널이 창 밖으로 밀려난다. 셸이 이미 정확한 폭을 주므로 그대로 채운다. -->