Skip to content

Commit 383fbdb

Browse files
committed
Add compression stats, drag-and-drop, accept filter, BrotliSharpLib, and Zstandard
Compression stats - Models/File.cs: add OriginalSize and OutputSize properties - App.razor: capture sizes before/after processing in both Compress and Decompress paths; display "X → Y" sizes and a "% smaller/larger" badge (compress mode only) after each file finishes Drag-and-drop file zone - Replace plain <InputFile> with a styled drop zone; InputFile is positioned absolutely at full opacity:0 to capture both clicks and drops; a _dragCount counter (incremented on dragenter, decremented on dragleave) drives the active highlight without flickering on child elements Accept filter - File picker is restricted to the active format's extension in decompress mode (e.g. .gz, .br, .zst); no restriction in compress mode BrotliSharpLib (fixes WASM crash) - System.IO.Compression.BrotliStream requires a native library unavailable in WebAssembly; replaced with BrotliSharpLib 0.3.3 (pure managed C#); added ToBrotliQuality() to map CompressionLevel to Brotli's 0-11 scale Zstandard via ZstdSharp.Port 0.8.7 - Added ZStd to CompressionFormat enum; hostname detection on "zstd"; extension .zst; ToZstdLevel() maps CompressionLevel to zstd's 1-19 scale; ZstdSharp.CompressionStream / DecompressionStream wired into the existing helper methods Tests (15 passing) - Stats assertions added to compress and decompress end-to-end tests - zstd added to the format loop; formatDisplayNames map handles the "zstd" key vs "Zstandard" display name mismatch
1 parent 8356125 commit 383fbdb

5 files changed

Lines changed: 125 additions & 19 deletions

File tree

App.razor

Lines changed: 81 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,22 @@
5050
</div>
5151
}
5252

53-
<div class="mb-3">
53+
<div class="drop-zone mb-3 @(_dragCount > 0 ? "drop-zone--active" : "")"
54+
@ondragenter="@(() => _dragCount++)"
55+
@ondragleave="@(() => _dragCount--)"
56+
@ondrop="@(() => _dragCount = 0)"
57+
@ondragover:preventDefault>
5458
@if (compressionMode == CompressionMode.Compress)
5559
{
56-
<label class="form-label">Choose files to compress to @FormatName</label>
60+
<p class="mb-1 fw-medium">Choose files to compress to @FormatName</p>
5761
}
5862
else
5963
{
60-
<label class="form-label">Choose @FormatName files to decompress</label>
64+
<p class="mb-1 fw-medium">Choose @FormatName files to decompress</p>
6165
}
62-
<InputFile OnChange="OnFilesChange" multiple class="form-control" />
66+
<p class="text-muted small mb-0">or drag and drop here</p>
67+
<InputFile OnChange="OnFilesChange" multiple class="drop-zone-input"
68+
accept="@(compressionMode == CompressionMode.Decompress ? Extension : "")" />
6369
</div>
6470

6571
<div class="mb-3">
@@ -97,32 +103,40 @@
97103
@if(file.Status == "Finished")
98104
{
99105
<span class="text-success">✔ Finished</span>
106+
<span class="text-muted ms-2 small">@FormatBytes(file.OriginalSize)@FormatBytes(file.OutputSize)</span>
107+
@if (compressionMode == CompressionMode.Compress)
108+
{
109+
<span class="@RatioBadgeCss(file.OriginalSize, file.OutputSize) ms-1">@RatioText(file.OriginalSize, file.OutputSize)</span>
110+
}
100111
}
101112
</li>
102113
}
103114
</ul>
104115

105116
@code {
106-
private enum CompressionFormat { GZip, Brotli, Deflate, ZLib }
117+
private enum CompressionFormat { GZip, Brotli, Deflate, ZLib, ZStd }
107118

108119
private CompressionMode compressionMode = CompressionMode.Compress;
109120
private CompressionLevel compressionLevel = CompressionLevel.Optimal;
110121
private List<Models.File> files = new List<Models.File>();
111122
private bool anyFiles => files.Any();
112123

113124
private CompressionFormat _format = CompressionFormat.GZip;
125+
private int _dragCount;
114126
private string FormatName => _format switch
115127
{
116128
CompressionFormat.Brotli => "brotli",
117129
CompressionFormat.Deflate => "deflate",
118130
CompressionFormat.ZLib => "zlib",
131+
CompressionFormat.ZStd => "zstd",
119132
_ => "gzip"
120133
};
121134
private string Extension => _format switch
122135
{
123136
CompressionFormat.Brotli => ".br",
124137
CompressionFormat.Deflate => ".deflate",
125138
CompressionFormat.ZLib => ".zlib",
139+
CompressionFormat.ZStd => ".zst",
126140
_ => ".gz"
127141
};
128142

@@ -134,26 +148,74 @@
134148
_ when host.Contains("brotli") => CompressionFormat.Brotli,
135149
_ when host.Contains("deflate") => CompressionFormat.Deflate,
136150
_ when host.Contains("zlib") => CompressionFormat.ZLib,
151+
_ when host.Contains("zstd") => CompressionFormat.ZStd,
137152
_ => CompressionFormat.GZip
138153
};
139154
}
140155

141-
private Stream CreateCompressStream(Stream output, CompressionLevel level) => _format switch
156+
private Stream CreateCompressStream(Stream output, CompressionLevel level)
142157
{
143-
CompressionFormat.Brotli => new BrotliStream(output, level),
144-
CompressionFormat.Deflate => new DeflateStream(output, level),
145-
CompressionFormat.ZLib => new ZLibStream(output, level),
146-
_ => new GZipStream(output, level)
158+
if (_format == CompressionFormat.Brotli)
159+
{
160+
var s = new BrotliSharpLib.BrotliStream(output, CompressionMode.Compress);
161+
s.SetQuality(ToBrotliQuality(level));
162+
return s;
163+
}
164+
if (_format == CompressionFormat.ZStd)
165+
return new ZstdSharp.CompressionStream(output, ToZstdLevel(level));
166+
return _format switch
167+
{
168+
CompressionFormat.Deflate => new DeflateStream(output, level),
169+
CompressionFormat.ZLib => new ZLibStream(output, level),
170+
_ => new GZipStream(output, level)
171+
};
172+
}
173+
174+
private static int ToBrotliQuality(CompressionLevel level) => level switch
175+
{
176+
CompressionLevel.NoCompression => 0,
177+
CompressionLevel.Fastest => 1,
178+
CompressionLevel.SmallestSize => 11,
179+
_ => 6
180+
};
181+
182+
private static int ToZstdLevel(CompressionLevel level) => level switch
183+
{
184+
CompressionLevel.NoCompression => 1,
185+
CompressionLevel.Fastest => 1,
186+
CompressionLevel.SmallestSize => 19,
187+
_ => 3
147188
};
148189

149190
private Stream CreateDecompressStream(Stream input) => _format switch
150191
{
151-
CompressionFormat.Brotli => new BrotliStream(input, CompressionMode.Decompress),
192+
CompressionFormat.Brotli => new BrotliSharpLib.BrotliStream(input, CompressionMode.Decompress),
152193
CompressionFormat.Deflate => new DeflateStream(input, CompressionMode.Decompress),
153194
CompressionFormat.ZLib => new ZLibStream(input, CompressionMode.Decompress),
195+
CompressionFormat.ZStd => new ZstdSharp.DecompressionStream(input),
154196
_ => new GZipStream(input, CompressionMode.Decompress)
155197
};
156198

199+
private static string FormatBytes(long bytes)
200+
{
201+
if (bytes < 1024) return $"{bytes} B";
202+
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB";
203+
return $"{bytes / (1024.0 * 1024):F1} MB";
204+
}
205+
206+
private static string RatioText(long original, long output)
207+
{
208+
if (original == 0) return "";
209+
var pct = (1.0 - (double)output / original) * 100;
210+
return pct >= 0 ? $"{pct:F0}% smaller" : $"{-pct:F0}% larger";
211+
}
212+
213+
private static string RatioBadgeCss(long original, long output)
214+
{
215+
var pct = original > 0 ? (1.0 - (double)output / original) * 100 : 0;
216+
return pct >= 0 ? "badge bg-success" : "badge bg-warning text-dark";
217+
}
218+
157219
private void OnFilesChange(InputFileChangeEventArgs e)
158220
{
159221
files.Clear();
@@ -181,13 +243,16 @@
181243
var browserFile = file.BrowserFile;
182244
var buffer = new byte[browserFile.Size];
183245
await browserFile.OpenReadStream(long.MaxValue).ReadAsync(buffer);
246+
file.OriginalSize = buffer.Length;
184247
using (var outputStream = new MemoryStream())
185248
{
186249
using (var compressionStream = CreateCompressStream(outputStream, compressionLevel))
187250
{
188251
await compressionStream.WriteAsync(buffer, 0, buffer.Length);
189252
}
190-
await BlazorDownloadFileService.DownloadFile(file.NewName, outputStream.ToArray(), "application/octet-stream");
253+
var outputBytes = outputStream.ToArray();
254+
file.OutputSize = outputBytes.Length;
255+
await BlazorDownloadFileService.DownloadFile(file.NewName, outputBytes, "application/octet-stream");
191256
}
192257

193258
file.Status = "Finished";
@@ -220,14 +285,17 @@
220285
var browserFile = file.BrowserFile;
221286
var buffer = new byte[browserFile.Size];
222287
await browserFile.OpenReadStream(long.MaxValue).ReadAsync(buffer);
288+
file.OriginalSize = buffer.Length;
223289
using (var inputStream = new MemoryStream(buffer))
224290
using (var outputStream = new MemoryStream())
225291
{
226292
using (var compressionStream = CreateDecompressStream(inputStream))
227293
{
228294
await compressionStream.CopyToAsync(outputStream);
229295
}
230-
await BlazorDownloadFileService.DownloadFile(file.NewName, outputStream.ToArray(), "application/octet-stream");
296+
var outputBytes = outputStream.ToArray();
297+
file.OutputSize = outputBytes.Length;
298+
await BlazorDownloadFileService.DownloadFile(file.NewName, outputBytes, "application/octet-stream");
231299
}
232300

233301
file.Status = "Finished";

BlazorDeCompressor.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010

1111
<ItemGroup>
1212
<PackageReference Include="BlazorDownloadFile" Version="2.4.0.2" />
13+
<PackageReference Include="BrotliSharpLib" Version="0.3.3" />
1314
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.2" />
1415
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.2" PrivateAssets="all" />
16+
<PackageReference Include="ZstdSharp.Port" Version="0.8.7" />
1517
</ItemGroup>
1618

1719
<ItemGroup>

Models/File.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,7 @@ public class File
88
public required string OriginalName { get; set; }
99
public string? NewName { get; set; }
1010
public required string Status { get; set; }
11+
public long OriginalSize { get; set; }
12+
public long OutputSize { get; set; }
1113
}
1214
}

e2e/tests/app.spec.js

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,9 @@ test('compress a file end-to-end', async ({ page }) => {
5151
const download = await downloadPromise;
5252
expect(download.suggestedFilename()).toBe('test-input.txt.gz');
5353

54-
// Verify the status shows Finished
5554
await expect(page.getByText('✔ Finished')).toBeVisible({ timeout: 10000 });
55+
await expect(page.locator('.list-group-item .text-muted')).toBeVisible();
56+
await expect(page.locator('.list-group-item .badge').filter({ hasText: /smaller|larger/ })).toBeVisible();
5657

5758
fs.unlinkSync(tmpFile);
5859
});
@@ -76,6 +77,7 @@ test('decompress a file end-to-end', async ({ page }) => {
7677
expect(download.suggestedFilename()).toBe('test-decompress.txt');
7778

7879
await expect(page.getByText('✔ Finished')).toBeVisible({ timeout: 10000 });
80+
await expect(page.locator('.list-group-item .text-muted')).toBeVisible();
7981

8082
fs.unlinkSync(srcFile);
8183
fs.unlinkSync(gzFile);
@@ -85,7 +87,9 @@ test('decompress a file end-to-end', async ({ page }) => {
8587
// which reads window.compressionFormat. We spoof it here via addInitScript.
8688
// Blazor component behaviour (labels, file extension) uses NavigationManager and requires
8789
// a real hostname match, so those paths are covered by manual / deployment testing.
88-
for (const format of ['brotli', 'deflate', 'zlib']) {
90+
const formatDisplayNames = { zstd: 'Zstandard' };
91+
for (const format of ['brotli', 'deflate', 'zlib', 'zstd']) {
92+
const displayName = formatDisplayNames[format] || format;
8993
test.describe(`${format} mode`, () => {
9094
test.beforeEach(async ({ page }) => {
9195
await page.addInitScript((f) => { window.compressionFormat = f; }, format);
@@ -94,12 +98,12 @@ for (const format of ['brotli', 'deflate', 'zlib']) {
9498
});
9599

96100
test(`page title and heading reflect ${format}`, async ({ page }) => {
97-
await expect(page).toHaveTitle(new RegExp(format, 'i'));
98-
await expect(page.getByRole('heading', { name: new RegExp(`Online ${format} de/compressor`, 'i') })).toBeVisible();
101+
await expect(page).toHaveTitle(new RegExp(displayName, 'i'));
102+
await expect(page.getByRole('heading', { name: new RegExp(`Online ${displayName} de/compressor`, 'i') })).toBeVisible();
99103
});
100104

101105
test(`intro paragraph mentions ${format}`, async ({ page }) => {
102-
await expect(page.locator('#intro-paragraph')).toContainText(new RegExp(format, 'i'));
106+
await expect(page.locator('#intro-paragraph')).toContainText(new RegExp(displayName, 'i'));
103107
});
104108
});
105109
}

wwwroot/index.html

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,30 @@
6363
top: 0.5rem;
6464
}
6565

66+
.drop-zone {
67+
position: relative;
68+
border: 2px dashed #dee2e6;
69+
border-radius: 0.5rem;
70+
padding: 1.5rem;
71+
text-align: center;
72+
cursor: pointer;
73+
transition: border-color 0.15s ease, background-color 0.15s ease;
74+
}
75+
76+
.drop-zone--active {
77+
border-color: #0d6efd;
78+
background-color: rgba(13, 110, 253, 0.05);
79+
}
80+
81+
.drop-zone-input {
82+
position: absolute;
83+
inset: 0;
84+
opacity: 0;
85+
cursor: pointer;
86+
width: 100%;
87+
height: 100%;
88+
}
89+
6690

6791
@media (min-width: 1200px) {
6892

@@ -144,7 +168,7 @@ <h1 class="display-4">Online GZIP de/compressor</h1>
144168
<script>
145169
(function () {
146170
const hostname = window.location.hostname;
147-
const formats = ['brotli', 'deflate', 'zlib'];
171+
const formats = ['brotli', 'deflate', 'zlib', 'zstd'];
148172
const detected = formats.find(f => hostname.includes(f)) || 'gzip';
149173
if (!window.compressionFormat) window.compressionFormat = detected;
150174
const format = window.compressionFormat;
@@ -169,6 +193,12 @@ <h1 class="display-4">Online GZIP de/compressor</h1>
169193
desc: 'Use this online ZLib tool to compress and decompress files right in the browser',
170194
heading: 'Online ZLib de/compressor',
171195
para: 'This tool can compress and decompress files using the ZLib algorithm. The files are compressed/decompressed right inside of your browser without transmitting the files to a server.'
196+
},
197+
zstd: {
198+
title: 'Zstandard decompress/compress files from your browser',
199+
desc: 'Use this online Zstandard tool to compress and decompress files right in the browser',
200+
heading: 'Online Zstandard de/compressor',
201+
para: 'This tool can compress and decompress files using the Zstandard algorithm. The files are compressed/decompressed right inside of your browser without transmitting the files to a server.'
172202
}
173203
};
174204

0 commit comments

Comments
 (0)