이진 파일·그림 비교 (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:
parent
de1a43cc70
commit
55d9deb418
@ -55,14 +55,14 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
|
||||
되돌릴 수 없는 일이라 <b>화면 없이 검사할 수 있어야</b> 하기 때문이다 - 겹치는 파일은 덮고,
|
||||
대상에만 있던 파일은 남기고, 두 번 복사해도 같은지를 테스트로 못 박았다.
|
||||
|
||||
### ② 파일 비교 (2026-09-08)
|
||||
### ② 파일 비교 — **완료** (2026-09-08)
|
||||
|
||||
| 상태 | 항목 | 내용 | 확인 |
|
||||
|---|---|---|---|
|
||||
| **완료** | 바꾸기 (Ctrl+H) | 찾기 칸 아래에 바꿀 말 줄이 붙는다(Ctrl+F 로 열면 안 뜬다 - 찾기만 할 때 자리를 뺏지 않는다). `바꾸기` 는 고른 자리가 <b>찾는 말일 때만</b> 바꾸고 다음으로 넘어간다 - 엉뚱한 자리를 덮어쓰는 것보다 한 번 더 누르게 하는 편이 낫다 | `PulseUs → PulseMicros` 2개 바꿈 |
|
||||
| **완료** | 덩어리 단위 반영 | `이 덩어리 반영`(`Alt+Shift+→`). 차이로 잡힌 줄이 이어지는 구간을 한 번에 | 마지막 차이에서 2줄 한 번에 반영 |
|
||||
| **완료** | 인코딩·줄바꿈 변환 저장 | 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 | **④ Git 조작 5가지** — amend · stash 목록 · 커밋 간 비교 · blame · 변경분 추출 | `9956567` |
|
||||
| 2026-09-08 | **③ 3-Way** — 자동 병합 · 충돌만 이동 · 병합 완료 판정 | `85c5e9e` |
|
||||
| 2026-09-08 | **① 폴더 비교** — 폴더째 복사·삭제 · 보기 필터 · 비교 취소 | 아래 |
|
||||
| 2026-09-08 | **① 폴더 비교** — 폴더째 복사·삭제 · 보기 필터 · 비교 취소 | `524a6a7` |
|
||||
| 2026-09-08 | **② 파일 비교** — 바꾸기 · 덩어리 반영 · 저장 형식 · 이진/그림 비교 | `de1a43c` + 아래 |
|
||||
|
||||
191
src/MergeHub/Services/BinaryCompare.cs
Normal file
191
src/MergeHub/Services/BinaryCompare.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
// ================= 이진 파일 비교 =================
|
||||
|
||||
/// <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>
|
||||
|
||||
@ -537,6 +537,88 @@
|
||||
</WrapPanel>
|
||||
</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분할 =====
|
||||
ScrollViewer 로 감싸면 내부 Grid 가 "무한 폭"으로 측정되어 star 열이 콘텐츠 크기로
|
||||
부풀고 오른쪽 패널이 창 밖으로 밀려난다. 셸이 이미 정확한 폭을 주므로 그대로 채운다. -->
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user