Add batch cleaning with per-file review and ZIP download
Up to 50 images at once; single-image flow unchanged. Includes a dependency-free stored-ZIP writer. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
28a8151cc0
commit
1a9b726354
5 files changed
+421
-11
No files matched your search
@@ -0,0 +1,281 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher, onDestroy } from "svelte";
|
||||||
|
import { extractMetadata, findings, isClean } from "./metadata.js";
|
||||||
|
import { cleanImage, formatBytes } from "./clean.js";
|
||||||
|
import { createZip } from "./zip.js";
|
||||||
|
import Icon from "./Icon.svelte";
|
||||||
|
|
||||||
|
export let files;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
/** @type {'scanning'|'review'|'cleaning'|'done'} */
|
||||||
|
let stage = "scanning";
|
||||||
|
let items = files.map((file, id) => ({
|
||||||
|
id,
|
||||||
|
file,
|
||||||
|
state: "pending", // pending | scanned | cleaning | done | failed
|
||||||
|
labels: [],
|
||||||
|
clean: false,
|
||||||
|
result: null,
|
||||||
|
}));
|
||||||
|
let zipping = false;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
$: scanned = items.filter((item) => item.state !== "pending").length;
|
||||||
|
$: done = items.filter((item) => item.state === "done" || item.state === "failed").length;
|
||||||
|
$: succeeded = items.filter((item) => item.state === "done");
|
||||||
|
$: dirty = items.filter((item) => !item.clean).length;
|
||||||
|
|
||||||
|
function patch(id, values) {
|
||||||
|
items = items.map((item) => (item.id === id ? { ...item, ...values } : item));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanAll() {
|
||||||
|
for (const item of items) {
|
||||||
|
if (cancelled) return;
|
||||||
|
try {
|
||||||
|
const metadata = await extractMetadata(item.file);
|
||||||
|
patch(item.id, {
|
||||||
|
state: "scanned",
|
||||||
|
clean: isClean(metadata),
|
||||||
|
labels: findings(metadata).filter((f) => f.severity !== "info"),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
patch(item.id, { state: "scanned", clean: false, labels: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage = "review";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanAll() {
|
||||||
|
stage = "cleaning";
|
||||||
|
for (const item of items) {
|
||||||
|
if (cancelled) return;
|
||||||
|
patch(item.id, { state: "cleaning" });
|
||||||
|
const result = await cleanImage(item.file);
|
||||||
|
patch(item.id, { state: result.fallback ? "failed" : "done", result });
|
||||||
|
}
|
||||||
|
stage = "done";
|
||||||
|
dispatch("notify", {
|
||||||
|
tone: items.some((i) => i.state === "failed") ? "err" : "ok",
|
||||||
|
message: items.some((i) => i.state === "failed")
|
||||||
|
? "Some images could not be re-encoded and were skipped."
|
||||||
|
: `Metadata removed from ${items.length} images.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadZip() {
|
||||||
|
zipping = true;
|
||||||
|
try {
|
||||||
|
const used = new Set();
|
||||||
|
const entries = [];
|
||||||
|
for (const item of succeeded) {
|
||||||
|
let name = item.result.filename;
|
||||||
|
for (let n = 2; used.has(name); n++) {
|
||||||
|
name = item.result.filename.replace(/(\.[^.]+)$/, `-${n}$1`);
|
||||||
|
}
|
||||||
|
used.add(name);
|
||||||
|
const data = new Uint8Array(await (await fetch(item.result.url)).arrayBuffer());
|
||||||
|
entries.push({ name, data });
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(createZip(entries));
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "untrace-cleaned.zip";
|
||||||
|
link.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("ZIP failed:", error);
|
||||||
|
dispatch("notify", { tone: "err", message: "Could not build the ZIP. Download the images one by one instead." });
|
||||||
|
} finally {
|
||||||
|
zipping = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
cancelled = true;
|
||||||
|
dispatch("reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
scanAll();
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
cancelled = true;
|
||||||
|
for (const item of items) if (item.result?.url) URL.revokeObjectURL(item.result.url);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="batch" aria-live="polite">
|
||||||
|
<div class="head">
|
||||||
|
<h3>
|
||||||
|
{#if stage === "scanning"}
|
||||||
|
Scanning {scanned} of {items.length}…
|
||||||
|
{:else if stage === "review"}
|
||||||
|
{items.length} images ready, {dirty} with hidden data
|
||||||
|
{:else if stage === "cleaning"}
|
||||||
|
Cleaning {done} of {items.length}…
|
||||||
|
{:else}
|
||||||
|
{succeeded.length} of {items.length} cleaned
|
||||||
|
{/if}
|
||||||
|
</h3>
|
||||||
|
<progress
|
||||||
|
max={items.length}
|
||||||
|
value={stage === "scanning" ? scanned : stage === "review" ? items.length : done}
|
||||||
|
aria-label="Progress"
|
||||||
|
></progress>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="list">
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<li class="item">
|
||||||
|
<div class="name">
|
||||||
|
<span class="file" title={item.file.name}>{item.file.name}</span>
|
||||||
|
<span class="size">{formatBytes(item.file.size)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="state">
|
||||||
|
{#if item.state === "pending"}
|
||||||
|
<span class="status info">Waiting</span>
|
||||||
|
{:else if item.state === "cleaning"}
|
||||||
|
<span class="status info">Cleaning</span>
|
||||||
|
{:else if item.state === "done"}
|
||||||
|
<span class="status ok">Clean</span>
|
||||||
|
<a class="btn btn-ghost dl" href={item.result.url} download={item.result.filename}>
|
||||||
|
<Icon name="download" size="sm" /> Download
|
||||||
|
</a>
|
||||||
|
{:else if item.state === "failed"}
|
||||||
|
<span class="status err">Could not re-encode</span>
|
||||||
|
{:else if item.clean}
|
||||||
|
<span class="status ok">Already clean</span>
|
||||||
|
{:else}
|
||||||
|
{#each item.labels.slice(0, 3) as label}
|
||||||
|
<span class="status {label.severity}">{label.label}</span>
|
||||||
|
{/each}
|
||||||
|
{#if item.labels.length > 3}
|
||||||
|
<span class="more">+{item.labels.length - 3} more</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="action-bar">
|
||||||
|
{#if stage === "done"}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-block"
|
||||||
|
aria-disabled={zipping || succeeded.length === 0}
|
||||||
|
on:click={() => !zipping && succeeded.length > 0 && downloadZip()}
|
||||||
|
>
|
||||||
|
<Icon name="download" size="sm" />
|
||||||
|
{zipping ? "Building ZIP…" : `Download ${succeeded.length} as ZIP`}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-block" on:click={reset}>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Clean more images
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-block"
|
||||||
|
aria-disabled={stage !== "review"}
|
||||||
|
on:click={() => stage === "review" && cleanAll()}
|
||||||
|
>
|
||||||
|
{#if stage === "cleaning"}
|
||||||
|
<span class="spinner" aria-hidden="true"></span>
|
||||||
|
Cleaning…
|
||||||
|
{:else}
|
||||||
|
<Icon name="shield" size="sm" />
|
||||||
|
Clean {items.length} images
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-block" disabled={stage === "cleaning"} on:click={reset}>
|
||||||
|
Choose different images
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.batch {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head h3 {
|
||||||
|
font-size: var(--fs-4);
|
||||||
|
line-height: var(--lh-4);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--space-2);
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
list-style: none;
|
||||||
|
max-height: calc(var(--space-9) * 5);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: var(--border-w) solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.item {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-3);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size,
|
||||||
|
.more {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: var(--fs-1);
|
||||||
|
line-height: var(--lh-1);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl {
|
||||||
|
height: var(--space-7);
|
||||||
|
padding-inline: var(--space-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+29
-10
@@ -11,15 +11,33 @@
|
|||||||
let fileInput;
|
let fileInput;
|
||||||
let depth = 0;
|
let depth = 0;
|
||||||
|
|
||||||
function offer(file) {
|
const MAX_FILES = 50;
|
||||||
if (!file) return;
|
|
||||||
if (!isAccepted(file)) {
|
function offer(list) {
|
||||||
|
const incoming = [...(list || [])].filter(Boolean);
|
||||||
|
if (incoming.length === 0) return;
|
||||||
|
|
||||||
|
const accepted = incoming.filter(isAccepted);
|
||||||
|
if (accepted.length === 0) {
|
||||||
|
const type = incoming[0].type;
|
||||||
dispatch("reject", {
|
dispatch("reject", {
|
||||||
message: `Untrace cannot read ${file.type || "that file type"}. Choose a JPEG, PNG, TIFF, HEIC or AVIF image.`,
|
message: `Untrace cannot read ${type || "that file type"}. Choose JPEG, PNG, TIFF, HEIC or AVIF images.`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch("select", { file });
|
|
||||||
|
const skipped = incoming.length - accepted.length;
|
||||||
|
const capped = accepted.slice(0, MAX_FILES);
|
||||||
|
const over = accepted.length - capped.length;
|
||||||
|
if (skipped > 0 || over > 0) {
|
||||||
|
dispatch("reject", {
|
||||||
|
message:
|
||||||
|
over > 0
|
||||||
|
? `Only the first ${MAX_FILES} images were added.`
|
||||||
|
: `Skipped ${skipped} file${skipped === 1 ? "" : "s"} Untrace cannot read.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dispatch("select", { files: capped });
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDrop(event) {
|
function onDrop(event) {
|
||||||
@@ -27,7 +45,7 @@
|
|||||||
depth = 0;
|
depth = 0;
|
||||||
dragging = false;
|
dragging = false;
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
offer(event.dataTransfer?.files?.[0]);
|
offer(event.dataTransfer?.files);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragEnter(event) {
|
function onDragEnter(event) {
|
||||||
@@ -44,7 +62,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onChange(event) {
|
function onChange(event) {
|
||||||
offer(event.target.files?.[0]);
|
offer(event.target.files);
|
||||||
// Reset so choosing the same file twice still fires a change event.
|
// Reset so choosing the same file twice still fires a change event.
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
}
|
}
|
||||||
@@ -54,7 +72,7 @@
|
|||||||
const item = [...(event.clipboardData?.items || [])].find((entry) =>
|
const item = [...(event.clipboardData?.items || [])].find((entry) =>
|
||||||
entry.type.startsWith("image/")
|
entry.type.startsWith("image/")
|
||||||
);
|
);
|
||||||
if (item) offer(item.getAsFile());
|
if (item) offer([item.getAsFile()]);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -77,10 +95,10 @@
|
|||||||
>
|
>
|
||||||
<span class="glyph"><Icon name="upload" size="lg" /></span>
|
<span class="glyph"><Icon name="upload" size="lg" /></span>
|
||||||
<span class="title">
|
<span class="title">
|
||||||
{dragging ? "Drop to scan it" : "Drop an image, or choose a file"}
|
{dragging ? "Drop to scan" : "Drop images, or choose files"}
|
||||||
</span>
|
</span>
|
||||||
<span class="hint">
|
<span class="hint">
|
||||||
Nothing is uploaded. The image is read and rewritten inside this tab.
|
Nothing is uploaded. Add one image or up to 50 at once.
|
||||||
</span>
|
</span>
|
||||||
<span class="chip-row formats">
|
<span class="chip-row formats">
|
||||||
<span class="chip">JPEG</span>
|
<span class="chip">JPEG</span>
|
||||||
@@ -98,6 +116,7 @@
|
|||||||
<input
|
<input
|
||||||
class="visually-hidden"
|
class="visually-hidden"
|
||||||
type="file"
|
type="file"
|
||||||
|
multiple
|
||||||
accept={ACCEPTED_EXTENSIONS}
|
accept={ACCEPTED_EXTENSIONS}
|
||||||
bind:this={fileInput}
|
bind:this={fileInput}
|
||||||
on:change={onChange}
|
on:change={onChange}
|
||||||
|
|||||||
+17
-1
@@ -8,6 +8,7 @@
|
|||||||
import ReviewPanel from "./ReviewPanel.svelte";
|
import ReviewPanel from "./ReviewPanel.svelte";
|
||||||
import ResultPanel from "./ResultPanel.svelte";
|
import ResultPanel from "./ResultPanel.svelte";
|
||||||
import Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
|
import Batch from "./Batch.svelte";
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@
|
|||||||
let metadata = null;
|
let metadata = null;
|
||||||
let previewUrl = null;
|
let previewUrl = null;
|
||||||
let result = null;
|
let result = null;
|
||||||
|
/** Set when more than one image is added; Batch owns that flow. */
|
||||||
|
let batchFiles = null;
|
||||||
|
|
||||||
$: current = stage === "idle" || stage === "scanning" ? 0 : stage === "done" ? 2 : 1;
|
$: current = stage === "idle" || stage === "scanning" ? 0 : stage === "done" ? 2 : 1;
|
||||||
$: busy = stage === "scanning" || stage === "cleaning";
|
$: busy = stage === "scanning" || stage === "cleaning";
|
||||||
@@ -38,7 +41,11 @@
|
|||||||
|
|
||||||
async function onSelect(event) {
|
async function onSelect(event) {
|
||||||
release();
|
release();
|
||||||
file = event.detail.file;
|
if (event.detail.files.length > 1) {
|
||||||
|
batchFiles = event.detail.files;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
file = event.detail.files[0];
|
||||||
previewUrl = URL.createObjectURL(file);
|
previewUrl = URL.createObjectURL(file);
|
||||||
metadata = null;
|
metadata = null;
|
||||||
stage = "scanning";
|
stage = "scanning";
|
||||||
@@ -75,6 +82,7 @@
|
|||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
release();
|
release();
|
||||||
|
batchFiles = null;
|
||||||
file = null;
|
file = null;
|
||||||
metadata = null;
|
metadata = null;
|
||||||
stage = "idle";
|
stage = "idle";
|
||||||
@@ -86,6 +94,13 @@
|
|||||||
<section class="card tool" id="tool" aria-labelledby="tool-heading">
|
<section class="card tool" id="tool" aria-labelledby="tool-heading">
|
||||||
<h2 class="visually-hidden" id="tool-heading">Clean an image</h2>
|
<h2 class="visually-hidden" id="tool-heading">Clean an image</h2>
|
||||||
|
|
||||||
|
{#if batchFiles}
|
||||||
|
<Batch
|
||||||
|
files={batchFiles}
|
||||||
|
on:notify={(e) => dispatch("notify", e.detail)}
|
||||||
|
on:reset={reset}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
<ol class="stepper">
|
<ol class="stepper">
|
||||||
{#each steps as label, index}
|
{#each steps as label, index}
|
||||||
<li class="step" data-state={stepState(index, current)}>
|
<li class="step" data-state={stepState(index, current)}>
|
||||||
@@ -179,6 +194,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ export const faqs = [
|
|||||||
q: "Does cleaning reduce image quality?",
|
q: "Does cleaning reduce image quality?",
|
||||||
a: "Small PNGs are rewritten as PNG with no quality loss. Everything else is re-encoded as JPEG at quality 0.92, which is visually indistinguishable at normal viewing sizes. The tool shows the size change so you can see exactly what happened.",
|
a: "Small PNGs are rewritten as PNG with no quality loss. Everything else is re-encoded as JPEG at quality 0.92, which is visually indistinguishable at normal viewing sizes. The tool shows the size change so you can see exactly what happened.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
q: "Can I clean several images at once?",
|
||||||
|
a: "Yes. Choose or drop up to 50 images together. Untrace scans them all, shows what each one hides, cleans them one after another in your browser, and lets you download them individually or as one ZIP.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
q: "Which formats are supported?",
|
q: "Which formats are supported?",
|
||||||
a: "JPEG, PNG, TIFF, HEIC and AVIF. The cleaned file is returned as JPEG, or as PNG when the original was a small PNG.",
|
a: "JPEG, PNG, TIFF, HEIC and AVIF. The cleaned file is returned as JPEG, or as PNG when the original was a small PNG.",
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Minimal ZIP writer (stored, no compression). Images are already compressed,
|
||||||
|
* so deflating them gains nothing, and this avoids a dependency.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CRC_TABLE = (() => {
|
||||||
|
const table = new Uint32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
table[n] = c >>> 0;
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function crc32(bytes) {
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
|
||||||
|
}
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function header(size, write) {
|
||||||
|
const view = new DataView(new ArrayBuffer(size));
|
||||||
|
write(view);
|
||||||
|
return new Uint8Array(view.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {{name: string, data: Uint8Array}[]} files @returns {Blob} */
|
||||||
|
export function createZip(files) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const now = new Date();
|
||||||
|
const time = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1);
|
||||||
|
const date = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
const central = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const { name, data } of files) {
|
||||||
|
const nameBytes = encoder.encode(name);
|
||||||
|
const crc = crc32(data);
|
||||||
|
|
||||||
|
const local = header(30, (v) => {
|
||||||
|
v.setUint32(0, 0x04034b50, true);
|
||||||
|
v.setUint16(4, 20, true);
|
||||||
|
v.setUint16(6, 0x0800, true); // UTF-8 names
|
||||||
|
v.setUint16(8, 0, true); // stored
|
||||||
|
v.setUint16(10, time, true);
|
||||||
|
v.setUint16(12, date, true);
|
||||||
|
v.setUint32(14, crc, true);
|
||||||
|
v.setUint32(18, data.length, true);
|
||||||
|
v.setUint32(22, data.length, true);
|
||||||
|
v.setUint16(26, nameBytes.length, true);
|
||||||
|
});
|
||||||
|
parts.push(local, nameBytes, data);
|
||||||
|
|
||||||
|
central.push(
|
||||||
|
header(46, (v) => {
|
||||||
|
v.setUint32(0, 0x02014b50, true);
|
||||||
|
v.setUint16(4, 20, true);
|
||||||
|
v.setUint16(6, 20, true);
|
||||||
|
v.setUint16(8, 0x0800, true);
|
||||||
|
v.setUint16(10, 0, true);
|
||||||
|
v.setUint16(12, time, true);
|
||||||
|
v.setUint16(14, date, true);
|
||||||
|
v.setUint32(16, crc, true);
|
||||||
|
v.setUint32(20, data.length, true);
|
||||||
|
v.setUint32(24, data.length, true);
|
||||||
|
v.setUint16(28, nameBytes.length, true);
|
||||||
|
v.setUint32(42, offset, true);
|
||||||
|
}),
|
||||||
|
nameBytes
|
||||||
|
);
|
||||||
|
|
||||||
|
offset += local.length + nameBytes.length + data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralSize = central.reduce((sum, part) => sum + part.length, 0);
|
||||||
|
const end = header(22, (v) => {
|
||||||
|
v.setUint32(0, 0x06054b50, true);
|
||||||
|
v.setUint16(8, files.length, true);
|
||||||
|
v.setUint16(10, files.length, true);
|
||||||
|
v.setUint32(12, centralSize, true);
|
||||||
|
v.setUint32(16, offset, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Blob([...parts, ...central, end], { type: "application/zip" });
|
||||||
|
}
|
||||||
Reference in new issue
Block a user