Skip to content

Commit fbfbf53

Browse files
geevensinghCopilot
andcommitted
Add .ico support to image diff
Extends the image-diff dispatch from #9 to Windows icon files. WPF's `BitmapDecoder.Create` already routes ICO bytes to the built-in `IconBitmapDecoder`, so most of the lift is detection + frame selection: - `ImageFormat` enum: add `Ico`. - `ImageFormatDetector.DetectByExtension`: `.ico` -> `ImageFormat.Ico`. - `ImageFormatDetector.Detect`: 4-byte magic `00 00 01 00` (icon type). CUR files (`00 00 02 00`) intentionally don't match; the `.cur` extension also stays unsupported. - `WpfImageDecoder`: ICOs embed the same icon at multiple resolutions (16x16, 32x32, 48x48, 256x256). `Frames[0]` is typically the smallest and looks blurry fit-to-canvas, so the decoder now picks the largest frame by pixel area for ICO (tiebreak on bit depth so a 32bpp frame wins over a 4bpp frame at the same size). PNG / JPEG / BMP / GIF all keep `Frames[0]`. - Tests: extension + magic-byte detection, CUR-shaped header correctly rejected, single-frame ICO round-trip, multi-resolution ICO with three frames (16/48/32) verifies that the 48x48 frame is picked regardless of frame order. Synthesises ICOs by hand (ICONDIR + ICONDIRENTRY + PNG payloads) since WPF ships no `IconBitmapEncoder`. - CHANGELOG `[Unreleased]` + README features bullet: list ICO alongside PNG/JPEG/GIF/BMP and note the largest-resolution pick. Verification: `dotnet build -c Release` -> 0 warnings; `dotnet test` -> 1161 passed, 1 skipped. Out of scope: `.cur` (cursor) support, multi-resolution selector UI to let the user compare specific embedded sizes. Closes #16 AI-Local-Session: bd41845c-9c2c-4d85-9096-da614c7d2662 AI-Cloud-Session: 6c878301-7f09-4cfd-80b6-5e34c328c217 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a72b308 commit fbfbf53

7 files changed

Lines changed: 182 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,18 +60,23 @@ body. Keep section headings exact and write notes in Markdown.
6060
language coloring, so changed-line signal isn't masked. Some
6161
pathological constructs may mis-color (notably TypeScript JSX
6262
attributes and Ruby heredocs); patch reports welcome.
63-
- Image diff for binary image blobs. PNG, JPEG, GIF, and BMP files
64-
now render as an actual image-diff view instead of the generic
65-
"Binary file - diff not displayed." placeholder. Three viewing
66-
modes are exposed via toolbar radio buttons that replace the
67-
text-only toggles when an image is shown: **Side-by-side** (two
68-
fit-to-frame panes on a checkerboard background), **Swipe**
63+
- Image diff for binary image blobs. PNG, JPEG, GIF, BMP, and ICO
64+
files now render as an actual image-diff view instead of the
65+
generic "Binary file - diff not displayed." placeholder. Three
66+
viewing modes are exposed via toolbar radio buttons that replace
67+
the text-only toggles when an image is shown: **Side-by-side**
68+
(two fit-to-frame panes on a checkerboard background), **Swipe**
6969
(overlay both images with a draggable divider — drag anywhere
7070
over the canvas), and **Onion-skin** (stack both images with an
71-
opacity slider to blend between them). A header strip shows each
72-
side's dimensions, byte size, and the byte delta (e.g.
73-
`512 x 512 / 18.3 KB -> 1024 x 1024 / 64.1 KB (+45.8 KB)`), with
74-
an `(animated, first frame)` suffix when either side is a
71+
opacity slider to blend between them). For added/deleted files
72+
the single available image fills the full canvas and the mode
73+
controls are hidden, since the three modes have no meaningful
74+
semantics when one side is absent. ICO files render the largest
75+
embedded resolution (a 256x256 frame is preferred over the 16x16
76+
frame when both are present). A header strip shows each side's
77+
dimensions, byte size, and the byte delta (e.g.
78+
`512 x 512 / 18.3 KB -> 1024 x 1024 / 64.1 KB (+45.8 KB)`),
79+
with an `(animated, first frame)` suffix when either side is a
7580
multi-frame GIF. The size gate reuses the existing
7681
`LargeFileThresholdBytes` setting; over-threshold images still
7782
fall back to the binary placeholder. Format and mode state is

DiffViewer.Tests/Services/WpfImageDecoderTests.cs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,45 @@ public void Decode_SingleFrameGif_ReportsFrameCountOne()
137137
result.Metadata.FrameCount.Should().Be(1);
138138
}
139139

140+
[StaFact]
141+
public void Decode_SingleFrameIco_RoundtripsAsIco()
142+
{
143+
var bytes = EncodeIco((32, 32, Colors.Purple));
144+
145+
var result = _decoder.Decode(bytes, "favicon.ico");
146+
147+
result.Image.Should().NotBeNull();
148+
result.Error.Should().BeNull();
149+
result.Metadata.Should().NotBeNull();
150+
result.Metadata!.Format.Should().Be(ImageFormat.Ico);
151+
result.Metadata.Width.Should().Be(32);
152+
result.Metadata.Height.Should().Be(32);
153+
result.Metadata.FrameCount.Should().Be(1);
154+
}
155+
156+
[StaFact]
157+
public void Decode_MultiResolutionIco_PicksLargestFrameByArea()
158+
{
159+
// Three frames at 16x16, 48x48, 32x32 (deliberately out of size
160+
// order to verify the decoder picks by pixel area, not by frame
161+
// index). The rendered bitmap should be the 48x48 one;
162+
// FrameCount reports the total embedded count.
163+
var bytes = EncodeIco(
164+
(16, 16, Colors.Red),
165+
(48, 48, Colors.Green),
166+
(32, 32, Colors.Blue));
167+
168+
var result = _decoder.Decode(bytes, "favicon.ico");
169+
170+
result.Image.Should().NotBeNull();
171+
result.Error.Should().BeNull();
172+
result.Metadata.Should().NotBeNull();
173+
result.Metadata!.Format.Should().Be(ImageFormat.Ico);
174+
result.Metadata.Width.Should().Be(48);
175+
result.Metadata.Height.Should().Be(48);
176+
result.Metadata.FrameCount.Should().Be(3);
177+
}
178+
140179
private static byte[] EncodeSolidPng(int width, int height)
141180
{
142181
var frame = MakeBgra32Frame(width, height, Colors.Red);
@@ -181,6 +220,59 @@ private static byte[] EncodeMultiFrameGif(int width, int height, int frames)
181220
return stream.ToArray();
182221
}
183222

223+
// Build a minimal ICO containing the given frames, each embedded as
224+
// a PNG payload (Vista+ ICO format). We synthesise the ICONDIR +
225+
// ICONDIRENTRY bytes by hand because WPF ships an IconBitmapDecoder
226+
// but no IconBitmapEncoder, so a roundtrip via WPF isn't possible.
227+
private static byte[] EncodeIco(params (int width, int height, Color color)[] frames)
228+
{
229+
var pngBlobs = frames
230+
.Select(f => EncodeSolidPng(f.width, f.height, f.color))
231+
.ToArray();
232+
233+
using var stream = new MemoryStream();
234+
using var writer = new BinaryWriter(stream);
235+
236+
// ICONDIR (6 bytes)
237+
writer.Write((ushort)0); // idReserved
238+
writer.Write((ushort)1); // idType (1 = icon)
239+
writer.Write((ushort)pngBlobs.Length); // idCount
240+
241+
// Each ICONDIRENTRY is 16 bytes; image data follows after all
242+
// entries so initial offset = 6 + 16 * N.
243+
var offset = 6 + 16 * pngBlobs.Length;
244+
for (var i = 0; i < pngBlobs.Length; i++)
245+
{
246+
var (w, h, _) = frames[i];
247+
writer.Write((byte)(w == 256 ? 0 : w)); // bWidth (0 means 256)
248+
writer.Write((byte)(h == 256 ? 0 : h)); // bHeight
249+
writer.Write((byte)0); // bColorCount (0 for >=8bpp)
250+
writer.Write((byte)0); // bReserved
251+
writer.Write((ushort)1); // wPlanes
252+
writer.Write((ushort)32); // wBitCount
253+
writer.Write((uint)pngBlobs[i].Length); // dwBytesInRes
254+
writer.Write((uint)offset); // dwImageOffset
255+
offset += pngBlobs[i].Length;
256+
}
257+
258+
foreach (var blob in pngBlobs)
259+
{
260+
writer.Write(blob);
261+
}
262+
263+
return stream.ToArray();
264+
}
265+
266+
private static byte[] EncodeSolidPng(int width, int height, Color color)
267+
{
268+
var frame = MakeBgra32Frame(width, height, color);
269+
var encoder = new PngBitmapEncoder();
270+
encoder.Frames.Add(BitmapFrame.Create(frame));
271+
using var stream = new MemoryStream();
272+
encoder.Save(stream);
273+
return stream.ToArray();
274+
}
275+
184276
private static BitmapSource MakeBgra32Frame(int width, int height, Color color)
185277
{
186278
var stride = width * 4;

DiffViewer.Tests/Utility/ImageFormatDetectorTests.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public class ImageFormatDetectorTests
1616
private static readonly byte[] Gif87aSignature = "GIF87a"u8.ToArray();
1717
private static readonly byte[] Gif89aSignature = "GIF89a"u8.ToArray();
1818
private static readonly byte[] BmpSignature = "BM"u8.ToArray();
19+
// ICO header: 00 00 (reserved) + 01 00 (type=icon, little-endian).
20+
private static readonly byte[] IcoSignature = { 0x00, 0x00, 0x01, 0x00 };
1921

2022
[Fact]
2123
public void Detect_PngMagic_ReturnsPng()
@@ -37,6 +39,19 @@ public void Detect_Gif89aMagic_ReturnsGif()
3739
public void Detect_BmpMagic_ReturnsBmp()
3840
=> ImageFormatDetector.Detect(BmpSignature, "drawing.bmp").Should().Be(ImageFormat.Bmp);
3941

42+
[Fact]
43+
public void Detect_IcoMagic_ReturnsIco()
44+
=> ImageFormatDetector.Detect(IcoSignature, "favicon.ico").Should().Be(ImageFormat.Ico);
45+
46+
[Fact]
47+
public void Detect_IcoMagicWrongType_DoesNotMatch()
48+
{
49+
// CUR files use 00 00 02 00 (type=cursor). They are out of scope
50+
// in v1, so a CUR-shaped header must not classify as Ico.
51+
var curHeader = new byte[] { 0x00, 0x00, 0x02, 0x00 };
52+
ImageFormatDetector.Detect(curHeader, "pointer.cur").Should().Be(ImageFormat.NotAnImage);
53+
}
54+
4055
[Fact]
4156
public void Detect_MagicBytesWinOverExtension_PngLabelledAsJpg()
4257
{
@@ -88,6 +103,7 @@ public void Detect_UnrecognizedBytesButImageExtension_FallsBackToExtension()
88103
[InlineData("photo.Png", ImageFormat.Png)]
89104
[InlineData("anim.GIF", ImageFormat.Gif)]
90105
[InlineData("draw.BMP", ImageFormat.Bmp)]
106+
[InlineData("favicon.ICO", ImageFormat.Ico)]
91107
public void DetectByExtension_IsCaseInsensitive(string path, ImageFormat expected)
92108
=> ImageFormatDetector.DetectByExtension(path).Should().Be(expected);
93109

@@ -97,6 +113,7 @@ public void DetectByExtension_IsCaseInsensitive(string path, ImageFormat expecte
97113
[InlineData(".jpeg", ImageFormat.Jpeg)]
98114
[InlineData(".gif", ImageFormat.Gif)]
99115
[InlineData(".bmp", ImageFormat.Bmp)]
116+
[InlineData(".ico", ImageFormat.Ico)]
100117
public void DetectByExtension_BareExtensionDotPrefix_Resolves(string path, ImageFormat expected)
101118
=> ImageFormatDetector.DetectByExtension(path).Should().Be(expected);
102119

@@ -108,6 +125,7 @@ public void DetectByExtension_BareExtensionDotPrefix_Resolves(string path, Image
108125
[InlineData(".svg")]
109126
[InlineData(".webp")]
110127
[InlineData(".tiff")]
128+
[InlineData(".cur")]
111129
public void DetectByExtension_UnsupportedOrMissing_ReturnsNotAnImage(string? path)
112130
=> ImageFormatDetector.DetectByExtension(path).Should().Be(ImageFormat.NotAnImage);
113131

DiffViewer/Models/ImageFormat.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ public enum ImageFormat
1818
Jpeg,
1919
Gif,
2020
Bmp,
21+
Ico,
2122
}

DiffViewer/Services/WpfImageDecoder.cs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,15 @@ public ImageDecodeResult Decode(byte[] bytes, string? path)
6060
Error: "Decoder reported zero frames.");
6161
}
6262

63-
var frame = decoder.Frames[0];
63+
// ICOs embed the same icon at multiple resolutions (16x16,
64+
// 32x32, 48x48, 256x256). Frames[0] is typically the
65+
// smallest and looks blurry when fit-to-canvas, so pick the
66+
// largest frame by area for icons. For animated GIFs and
67+
// single-frame formats Frames[0] is correct, so the
68+
// selector is gated on format.
69+
BitmapFrame frame = format == ImageFormat.Ico
70+
? PickLargestFrame(decoder.Frames)
71+
: decoder.Frames[0];
6472
// Freeze so the bitmap can cross threads. Required because
6573
// the production decode runs on a Task.Run worker and the
6674
// bitmap is handed off to the UI thread for binding.
@@ -89,4 +97,28 @@ or InvalidOperationException
8997
Error: $"{ex.GetType().Name}: {ex.Message}");
9098
}
9199
}
100+
101+
// Picks the largest frame by pixel area, with bit depth as a
102+
// tiebreaker so a 256x256 32bpp icon beats a 256x256 4bpp icon.
103+
// WIC exposes Format.BitsPerPixel; for unknown formats it returns
104+
// 0 which sorts last, also fine.
105+
private static BitmapFrame PickLargestFrame(IReadOnlyList<BitmapFrame> frames)
106+
{
107+
BitmapFrame best = frames[0];
108+
long bestArea = (long)best.PixelWidth * best.PixelHeight;
109+
int bestDepth = best.Format.BitsPerPixel;
110+
for (int i = 1; i < frames.Count; i++)
111+
{
112+
var f = frames[i];
113+
long area = (long)f.PixelWidth * f.PixelHeight;
114+
int depth = f.Format.BitsPerPixel;
115+
if (area > bestArea || (area == bestArea && depth > bestDepth))
116+
{
117+
best = f;
118+
bestArea = area;
119+
bestDepth = depth;
120+
}
121+
}
122+
return best;
123+
}
92124
}

DiffViewer/Utility/ImageFormatDetector.cs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,21 @@ internal static class ImageFormatDetector
3636
private static ReadOnlySpan<byte> Gif87aSignature => "GIF87a"u8;
3737
private static ReadOnlySpan<byte> Gif89aSignature => "GIF89a"u8;
3838

39-
// BMP — 2-byte "BM" header. Weakest signature of the four; we still
40-
// accept it on bytes alone because no other common format starts with
41-
// those two ASCII letters.
39+
// BMP — 2-byte "BM" header. Weakest of the magic-byte signatures;
40+
// we still accept it on bytes alone because no other common format
41+
// starts with those two ASCII letters.
4242
private static ReadOnlySpan<byte> BmpSignature => "BM"u8;
4343

44+
// ICO — 4-byte header: 00 00 (reserved) + 01 00 (image type = icon,
45+
// little-endian). CUR files use 02 00 in the type field and are not
46+
// supported. The signature is short and not particularly distinctive,
47+
// but combined with the extension fallback in DetectByExtension this
48+
// is enough for practical use.
49+
private static ReadOnlySpan<byte> IcoSignature => new byte[]
50+
{
51+
0x00, 0x00, 0x01, 0x00,
52+
};
53+
4454
/// <summary>
4555
/// Identify the image format of <paramref name="bytes"/>. Falls back
4656
/// to <paramref name="path"/>'s extension when the byte signature is
@@ -54,6 +64,7 @@ public static ImageFormat Detect(ReadOnlySpan<byte> bytes, string? path)
5464
if (StartsWith(bytes, Gif87aSignature) ||
5565
StartsWith(bytes, Gif89aSignature)) return ImageFormat.Gif;
5666
if (StartsWith(bytes, BmpSignature)) return ImageFormat.Bmp;
67+
if (StartsWith(bytes, IcoSignature)) return ImageFormat.Ico;
5768

5869
return path is null ? ImageFormat.NotAnImage : DetectByExtension(path);
5970
}
@@ -77,6 +88,7 @@ public static ImageFormat DetectByExtension(string? path)
7788
".jpeg" => ImageFormat.Jpeg,
7889
".gif" => ImageFormat.Gif,
7990
".bmp" => ImageFormat.Bmp,
91+
".ico" => ImageFormat.Ico,
8092
_ => ImageFormat.NotAnImage,
8193
};
8294
}

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@ individual hunks.
3131
hunk from the working tree" confirmation (suppressible).
3232
- Live updates when the working tree changes (Ctrl+L; automatically
3333
disabled for commit-vs-commit comparisons).
34-
- **Image diff for PNG, JPEG, GIF, and BMP** files. Binary image
35-
blobs render as an actual image diff (instead of the generic
34+
- **Image diff for PNG, JPEG, GIF, BMP, and ICO** files. Binary
35+
image blobs render as an actual image diff (instead of the generic
3636
binary placeholder) with three modes: side-by-side, swipe (drag
37-
anywhere over the canvas), and onion-skin. A header strip shows
38-
each side's dimensions, byte size, and the byte delta. Reuses
39-
the existing large-file threshold; over-threshold images fall
40-
back to the binary placeholder.
37+
anywhere over the canvas), and onion-skin. Added/deleted images
38+
render full-canvas with the mode controls hidden. ICO files
39+
render the largest embedded resolution. A header strip shows each
40+
side's dimensions, byte size, and the byte delta. Reuses the
41+
existing large-file threshold; over-threshold images fall back to
42+
the binary placeholder.
4143
- Persistent settings and a **recent launch contexts** dropdown (up to 10
4244
entries) so jumping back to "working tree of repo X" or "two commits in
4345
repo Y" is one click.

0 commit comments

Comments
 (0)